From 760e63787685447f1579e36ade7f413fd4a2faee Mon Sep 17 00:00:00 2001 From: Salastil Date: Wed, 2 Sep 2026 17:11:56 -0400 Subject: [PATCH] Search for rooms in a dialog, not a dropdown The directory search was two fields in the join page, which made finding a room a thing you did inside a form. It is a dialog now: it opens over the window, takes the focus, searches as you type once typing stops, and closes when you arrive somewhere. Element's spotlight is the same shape and for the same reason. One list rather than a section per homeserver. The honest structure would be per server, since that is what a directory is - but the whole point is finding a room whose server you do not know, and a per-server view asks you to know it first. Where a room lives is printed beside it instead, along with its member count and topic. There is still a field for adding a homeserver by hand, because knowing the server is the one case the merged search cannot improve on. A server that does not answer is named in the line above the results. A search in flight is tagged, so a slow server answering a query somebody has already typed past cannot replace what they are looking at now. --- nobilis | 2 +- src/renderer/src/app.css | 103 ++++++++++ src/renderer/src/components/JoinPanel.tsx | 125 ++---------- src/renderer/src/components/RoomSearch.tsx | 224 +++++++++++++++++++++ 4 files changed, 341 insertions(+), 113 deletions(-) create mode 100644 src/renderer/src/components/RoomSearch.tsx 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 (
Find a public room -
- setQuery(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && search()} - /> - setServer(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && search()} - /> - -
- - {rooms && rooms.length === 0 && ( -

- Nothing matched. A server only lists rooms it has been asked to publish, so a room can - exist and not be here — joining by address still works. -

- )} - - {rooms && rooms.length > 0 && ( -
- {rooms.map((room) => ( - - ))} -
- )} - - {next && ( - - )} + + {open && setOpen(false)} />}
) } 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( +
+
e.stopPropagation()} + > +
+ + setQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && search()} + /> + +
+ +
+ + {/* 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)} + /> +
+ +
+ {rooms.map((room) => ( +
+ +
+
+ {room.name || room.alias || room.roomId} + {room.alias && {room.alias}} +
+
+ {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. +

+ )} + + {busy &&

Searching…

} + + {morePages && rooms.length > 0 && ( + + )} +
+
+
, + document.body + ) +}