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 + ) +}