From dee8cbb196d117f6e96c7d1aa3e58b645fa09fbb Mon Sep 17 00:00:00 2001 From: Salastil Date: Wed, 2 Sep 2026 21:17:06 -0400 Subject: [PATCH] Homeservers are named tags you can switch on and off The dialog said "Searching 3 homeservers", which named none of them and sat on a line with a text field that capped it at about four before the rest were pushed out of sight. The servers now have a row of their own, each named, with the number of rooms it contributed - or why it gave none: refused, rate limited, timed out, no directory. Each one is a switch. Searching everywhere and searching two particular servers are both things somebody wants, and the difference is the whole reason the list is merged in the first place. Switching one off filters what is already in hand rather than searching again: the rooms are here, and a round trip to hide rows somebody is looking at would make an instant thing slow. They default to on, so a server that turns up later is included without having to be found and enabled. Scrolling to the end loads the next page. A button there was a second decision about the thing somebody is already doing, which is looking further down the list. Appended pages are deduplicated by room id. The daemon can only deduplicate within one answer - it does not know what this window is already showing - so a page from one server could repeat rooms another server had listed earlier. --- nobilis | 2 +- src/renderer/src/app.css | 53 ++++++- src/renderer/src/components/RoomSearch.tsx | 166 +++++++++++++++++---- 3 files changed, 182 insertions(+), 39 deletions(-) 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… +

)}