diff --git a/nobilis b/nobilis index 534dfca..d3c6278 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit 534dfca260ddb7a76f77ecb433f67e4a77f697e8 +Subproject commit d3c62787838907e2f1258561b0e1d1d3542cf158 diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index f7397e4..1b9fcee 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -4220,16 +4220,56 @@ video.lightbox-media { outline: none; } +/* A row of its own, so the servers have the width to be read and to grow - + sharing a line with a text field capped them at about four before the rest + were pushed out of sight, which defeats the point of naming them. */ .room-search-servers { display: flex; + flex-wrap: wrap; align-items: center; - gap: var(--spacing-s); + gap: var(--spacing-xs); padding: var(--spacing-xs) var(--spacing-m); border-bottom: 1px solid var(--outline-light); } -.room-search-servers > span { - flex: 1; +.server-chip { + display: inline-flex; + align-items: center; + gap: var(--spacing-xxs); + padding: 1px var(--spacing-xs); + border: 1px solid var(--outline-light); + border-radius: var(--corner-radius-small); + background: none; + color: var(--surface-variant-text); + font: inherit; + cursor: pointer; +} + +.server-chip:hover { + border-color: var(--outline-medium); + color: var(--surface-text); +} + +/* Switched off: still legible, still countable, plainly not counted. Struck + through rather than merely faded, because faded is what disabled means and + this is a choice somebody made rather than something unavailable. */ +.server-chip.off { + opacity: 0.55; + text-decoration: line-through; + border-style: dashed; +} + +/* A server that would not answer is marked rather than dropped: it is why a + room somebody expected is not in the list, and its absence from the line + would read as the room not existing. */ +.server-chip.failed { + border-color: var(--error); + color: var(--error); +} + +.server-chip-count { + opacity: 0.7; + font-variant-numeric: tabular-nums; } .room-search-server { @@ -4276,8 +4316,11 @@ video.lightbox-media { min-width: 0; } -.room-search-empty, -.room-search-more { +.room-search-empty { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-s); margin: var(--spacing-m); } diff --git a/src/renderer/src/components/RoomSearch.tsx b/src/renderer/src/components/RoomSearch.tsx index 59a761d..737b3d2 100644 --- a/src/renderer/src/components/RoomSearch.tsx +++ b/src/renderer/src/components/RoomSearch.tsx @@ -3,6 +3,7 @@ import { createPortal } from 'react-dom' import { Avatar } from './Avatar' import { Icon, IconButton } from './Icon' import { useStore } from '../state/hooks' +import { classes } from '../lib/util' import type { Account } from '../../../shared/wire' interface PublicRoom { @@ -16,16 +17,41 @@ interface PublicRoom { joined: boolean } +/** One homeserver that was asked, and what came of asking it. */ +interface SearchedServer { + server: string + /** How many rooms it contributed that no earlier server had already listed. */ + rooms: number + /** Present instead of results when it would not answer, and why. */ + error?: string +} + interface Answer { rooms: PublicRoom[] next: Record - servers: string[] - refused: string[] + servers: SearchedServer[] } /** Long enough that a typed word is one search, short enough to feel live. */ const TYPING_SETTLE_MS = 450 +/** + * Two words for why a homeserver gave us nothing, with the full reason in the + * tooltip. + * + * "No answer" was the label for all of them, and it is wrong for most: a + * server that refuses to publish its directory has answered very clearly, and + * treating that as silence sends somebody looking for a network fault that + * does not exist. + */ +function refusalLabel(error: string): string { + if (/M_FORBIDDEN|not allowed/i.test(error)) return 'refused' + if (/M_LIMIT_EXCEEDED|too many requests/i.test(error)) return 'rate limited' + if (/timed out|timeout/i.test(error)) return 'timed out' + if (/M_UNRECOGNIZED|404/i.test(error)) return 'no directory' + return 'no answer' +} + /** * Finding a room, across every homeserver at once. * @@ -47,8 +73,15 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: () const [extraServer, setExtraServer] = useState('') const [answer, setAnswer] = useState(null) const [busy, setBusy] = useState(false) + /** + * Servers switched off. Off rather than on, so a server that turns up later + * - one added by hand, or one this account gained a room on - is included + * without having to be found and enabled. + */ + const [disabled, setDisabled] = useState>(new Set()) const [joining, setJoining] = useState>({}) const inputRef = useRef(null) + const resultsRef = 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. @@ -83,14 +116,32 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: () // 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 - ) + setAnswer((current) => { + if (!since || !current) return result + // By room id, keeping what is already on screen. A page from one + // server can contain rooms another server listed on an earlier + // page - the daemon can only deduplicate within a single answer, + // since it does not know what this window is already showing - so + // without this "show more" appended rooms that were already there. + const seen = new Set(current.rooms.map((room) => room.roomId)) + const added = result.rooms.filter((room) => !seen.has(room.roomId)) + // A page only asks the servers that had somewhere to continue from, + // so its answer names fewer servers than the search did. Merging + // rather than replacing keeps the ones that finished on the first + // page - they are still part of what is being shown, and a switch + // that vanished when somebody pressed "show more" would be worse + // than a stale count. + const totals = new Map(current.servers.map((s) => [s.server, s])) + for (const s of result.servers) { + const before = totals.get(s.server) + totals.set(s.server, before ? { ...s, rooms: before.rooms + s.rooms } : s) + } + return { + ...result, + servers: [...totals.values()].sort((a, b) => a.server.localeCompare(b.server)), + rooms: [...current.rooms, ...added].sort((a, b) => b.members - a.members) + } + }) }) .catch((e: Error) => { if (generation.current === mine) store.toast('error', e.message) @@ -127,7 +178,21 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: () }) } - const rooms = answer?.rooms ?? [] + /** + * Asks for the next page as the end of this one comes into view. + * + * A screenful ahead rather than at the very bottom, so the next rooms are + * usually there before somebody reaches where they would have been. Guarded + * on `busy` because scrolling fires continuously and each of these is a + * request to several homeservers. + */ + const onScroll = (): void => { + const el = resultsRef.current + if (!el || busy || !answer || Object.keys(answer.next).length === 0) return + if (el.scrollTop + el.clientHeight >= el.scrollHeight - el.clientHeight) search(answer.next) + } + + const rooms = (answer?.rooms ?? []).filter((room) => !disabled.has(room.via)) const morePages = Object.keys(answer?.next ?? {}).length > 0 return createPortal( @@ -150,26 +215,56 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: () onChange={(e) => 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)} /> +
-
+ {/* Which servers, by name, on their own row so a dozen of them fit - + and each one a switch, because "search everywhere" and "search + these two" are both things somebody wants and the difference is + the whole reason the list is merged. + + Turning one off filters what is already here rather than searching + again: the results are in hand, and a round trip to hide rows + somebody is looking at would make an instant thing slow. */} +
+ {!answer && Asking every homeserver…} + {answer?.servers.map((s) => { + const off = disabled.has(s.server) + return ( + + ) + })} +
+ +
{rooms.map((room) => (
@@ -197,7 +292,13 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: ()
))} - {!busy && rooms.length === 0 && ( + {!busy && rooms.length === 0 && (answer?.rooms.length ?? 0) > 0 && ( +

+ Every server with results is switched off above. +

+ )} + + {!busy && (answer?.rooms.length ?? 0) === 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. @@ -206,15 +307,14 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: () {busy &&

Searching…

} - {morePages && rooms.length > 0 && ( - + {/* Scrolling asks for the next page; this only says so. A button + at the end of a list is a second decision to make about the + thing somebody is already doing, which is looking further down + the list. */} + {morePages && rooms.length > 0 && busy && ( +

+ Loading more… +

)}