diff --git a/nobilis b/nobilis
index 8dc0b7c..5ad1d70 160000
--- a/nobilis
+++ b/nobilis
@@ -1 +1 @@
-Subproject commit 8dc0b7c3769a004951648167b3662bc5fee8d71a
+Subproject commit 5ad1d70d457b1e680bf3032a63ce0d36d89ad3a7
diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css
index ab4fa08..60ca6b2 100644
--- a/src/renderer/src/app.css
+++ b/src/renderer/src/app.css
@@ -4177,3 +4177,106 @@ video.lightbox-media {
.directory-server {
flex: 0 1 11em;
}
+
+/* --- room search --------------------------------------------------------
+ A dialog you type into and leave, so it sits high in the window rather than
+ centred: the list grows downward, and a box centred on its own height jumps
+ as results arrive. */
+.room-search {
+ width: 640px;
+ max-width: calc(100vw - var(--spacing-xl));
+ max-height: 72vh;
+ margin-top: 8vh;
+ align-self: flex-start;
+ display: flex;
+ flex-direction: column;
+ background: var(--surface-container);
+ border: 1px solid var(--outline-medium);
+ border-radius: var(--corner-radius);
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
+ overflow: hidden;
+}
+
+.room-search-head {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-s);
+ padding: var(--spacing-s) var(--spacing-m);
+ border-bottom: 1px solid var(--outline-light);
+ color: var(--surface-variant-text);
+}
+
+/* The search field is the dialog's whole purpose, so it is drawn as the
+ surface rather than as a control sitting on one. */
+.room-search-input {
+ flex: 1;
+ border: none;
+ background: none;
+ color: var(--surface-text);
+ font-size: var(--font-size-large);
+}
+
+.room-search-input:focus {
+ outline: none;
+}
+
+.room-search-servers {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-s);
+ padding: var(--spacing-xs) var(--spacing-m);
+ border-bottom: 1px solid var(--outline-light);
+}
+
+.room-search-servers > span {
+ flex: 1;
+}
+
+.room-search-server {
+ flex: 0 0 14em;
+}
+
+.room-search-results {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding: var(--spacing-xs) 0;
+}
+
+.room-search-row {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-s);
+ padding: var(--spacing-xs) var(--spacing-m);
+}
+
+.room-search-row:hover {
+ background: var(--outline-light);
+}
+
+/* min-width:0 so the ellipsis in the children has something to bite on - a
+ flex child is as wide as its content without it, and a long topic pushes
+ the join button off the edge instead of being cut. */
+.room-search-text {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.room-search-title {
+ display: flex;
+ align-items: baseline;
+ gap: var(--spacing-s);
+ min-width: 0;
+}
+
+.room-search-detail,
+.room-search-title > .ellipsis {
+ min-width: 0;
+}
+
+.room-search-empty,
+.room-search-more {
+ margin: var(--spacing-m);
+}
diff --git a/src/renderer/src/components/JoinPanel.tsx b/src/renderer/src/components/JoinPanel.tsx
index e609d34..cde5c6d 100644
--- a/src/renderer/src/components/JoinPanel.tsx
+++ b/src/renderer/src/components/JoinPanel.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { Icon } from './Icon'
+import { RoomSearch } from './RoomSearch'
import { useChat, useStore } from '../state/hooks'
import { classes, resolveMediaUrl } from '../lib/util'
import type { Account, DiscordFriend } from '../../../shared/wire'
@@ -512,129 +513,29 @@ function MatrixJoin({ account }: { account: Account }): JSX.Element {
.catch((e: Error) => store.toast('error', e.message))
}
/>
-
+
)
}
-interface PublicRoom {
- roomId: string
- name: string
- alias: string
- topic: string
- members: number
- avatarUrl?: string
- joined: boolean
-}
-
/**
- * The public room directory - what Element calls exploring rooms.
+ * The way into the room search, which is a dialog rather than a section here.
*
- * Two fields rather than one, because a directory is per homeserver: without
- * the second, this can only ever find rooms our own server happens to know
- * about, and the ordinary case for finding a community is having been told
- * which server it lives on. Blank means ours.
- *
- * Searching is explicit rather than as-you-type: each keystroke would be a
- * request to somebody else's server, and a directory search is heavy enough
- * that servers rate-limit it.
+ * A search that spans every homeserver returns a great many rooms, and a list
+ * that long inside a page of small fields would push everything else off the
+ * bottom. It also is not really part of joining by address - it is how you
+ * find the address in the first place.
*/
-function MatrixRoomDirectory({ account }: { account: Account }): JSX.Element {
- const store = useStore()
- const [query, setQuery] = useState('')
- const [server, setServer] = useState('')
- const [rooms, setRooms] = useState(null)
- const [next, setNext] = useState('')
- const [busy, setBusy] = useState(false)
-
- const search = (since = ''): void => {
- setBusy(true)
- void window.moho
- .rpc<{ rooms: PublicRoom[]; next: string }>('searchMatrixRooms', {
- accountId: account.id,
- query,
- server,
- since
- })
- .then((result) => {
- // A page appends; a fresh search replaces. Same call either way, so
- // the difference has to be made here.
- setRooms((current) => (since && current ? [...current, ...result.rooms] : result.rooms))
- setNext(result.next)
- })
- .catch((e: Error) => store.toast('error', e.message))
- .finally(() => setBusy(false))
- }
-
+function FindRoomButton({ account }: { account: Account }): JSX.Element {
+ const [open, setOpen] = useState(false)
return (
)
}
diff --git a/src/renderer/src/components/RoomSearch.tsx b/src/renderer/src/components/RoomSearch.tsx
new file mode 100644
index 0000000..59a761d
--- /dev/null
+++ b/src/renderer/src/components/RoomSearch.tsx
@@ -0,0 +1,224 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { Avatar } from './Avatar'
+import { Icon, IconButton } from './Icon'
+import { useStore } from '../state/hooks'
+import type { Account } from '../../../shared/wire'
+
+interface PublicRoom {
+ roomId: string
+ name: string
+ alias: string
+ topic: string
+ members: number
+ avatarUrl?: string
+ via: string
+ joined: boolean
+}
+
+interface Answer {
+ rooms: PublicRoom[]
+ next: Record
+ servers: string[]
+ refused: string[]
+}
+
+/** Long enough that a typed word is one search, short enough to feel live. */
+const TYPING_SETTLE_MS = 450
+
+/**
+ * Finding a room, across every homeserver at once.
+ *
+ * A dialog over the window rather than a page inside the join panel, because
+ * this is a thing you do *to* get somewhere rather than somewhere you are:
+ * you open it, you type, you arrive, and it goes away. Element's spotlight is
+ * the same shape and for the same reason.
+ *
+ * One list, not one list per server. A room directory belongs to a homeserver,
+ * so the honest presentation would be a section each - but somebody searching
+ * for a room by name does not know which server it is on, and that is exactly
+ * the thing a per-server view asks them to know first. So the servers are
+ * asked together, the answers are merged, and where a room lives is printed
+ * beside it as a fact about the room rather than as a heading to navigate.
+ */
+export function RoomSearch({ account, onClose }: { account: Account; onClose: () => void }): JSX.Element {
+ const store = useStore()
+ const [query, setQuery] = useState('')
+ const [extraServer, setExtraServer] = useState('')
+ const [answer, setAnswer] = useState(null)
+ const [busy, setBusy] = useState(false)
+ const [joining, setJoining] = useState>({})
+ const inputRef = useRef(null)
+ // Which search the answer in flight belongs to. A slow server answering a
+ // query somebody has already typed past would otherwise replace the results
+ // for what they are actually looking at now.
+ const generation = useRef(0)
+
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent): void => {
+ if (e.key === 'Escape') onClose()
+ }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ }, [onClose])
+
+ const servers = useMemo(
+ () => (extraServer.trim() ? [extraServer.trim()] : []),
+ [extraServer]
+ )
+
+ const search = (since?: Record): void => {
+ const mine = ++generation.current
+ setBusy(true)
+ void window.moho
+ .rpc('searchMatrixRooms', {
+ accountId: account.id,
+ query,
+ servers,
+ limit: 40,
+ ...(since ? { since } : {})
+ })
+ .then((result) => {
+ if (generation.current !== mine) return
+ // A page adds to what is there; a fresh search replaces it. The
+ // merged list is re-sorted either way, since a later page from one
+ // server can be busier than an earlier page from another.
+ setAnswer((current) =>
+ since && current
+ ? {
+ ...result,
+ rooms: [...current.rooms, ...result.rooms].sort((a, b) => b.members - a.members)
+ }
+ : result
+ )
+ })
+ .catch((e: Error) => {
+ if (generation.current === mine) store.toast('error', e.message)
+ })
+ .finally(() => {
+ if (generation.current === mine) setBusy(false)
+ })
+ }
+
+ // Searching as it is typed, once typing stops. Every keystroke would be a
+ // request to a handful of other people's servers, which is both rude and
+ // rate-limited.
+ useEffect(() => {
+ const t = setTimeout(() => search(), TYPING_SETTLE_MS)
+ return () => clearTimeout(t)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [query, extraServer, account.id])
+
+ const join = (room: PublicRoom): void => {
+ setJoining((j) => ({ ...j, [room.roomId]: true }))
+ void window.moho
+ .rpc('joinMatrixRoom', {
+ accountId: account.id,
+ // An alias where there is one: it carries its own server and survives
+ // the room being upgraded. A bare room id needs to be told where to
+ // ask, which is the server that listed it.
+ roomIdOrAlias: room.alias || room.roomId,
+ via: room.alias ? [] : [room.via]
+ })
+ .then(() => onClose())
+ .catch((e: Error) => {
+ setJoining((j) => ({ ...j, [room.roomId]: false }))
+ store.toast('error', e.message)
+ })
+ }
+
+ const rooms = answer?.rooms ?? []
+ const morePages = Object.keys(answer?.next ?? {}).length > 0
+
+ return createPortal(
+
+
+ {/* The empty string is our own server, which has no name here -
+ counted, not listed, so the line does not start with a gap. */}
+ Searching {answer ? answer.servers.length : '…'} homeserver
+ {answer && answer.servers.length === 1 ? '' : 's'}
+ {answer?.refused.length ? ` — ${answer.refused.join(', ')} did not answer` : ''}
+
+ setExtraServer(e.target.value)}
+ />
+
+ {room.members.toLocaleString()} members
+ {/* Where it lives, said once, beside the room rather than
+ over a section of them. */}
+ {room.via ? ` · ${room.via}` : ' · this server'}
+ {room.topic ? ` · ${room.topic}` : ''}
+
+
+
+
+ ))}
+
+ {!busy && rooms.length === 0 && (
+
+ Nothing matched. A server only lists the rooms it has been asked to publish, so a room
+ can exist and not be here — joining by address still works.
+