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.
This commit is contained in:
Salastil committed 2026-09-02 21:17:06 -04:00
1 parent f5433a76e5
commit dee8cbb196
3 files changed
+182 -39

No files matched your search

+1 -1
Submodule nobilis updated: 534dfca260...d3c6278783.
+48 -5
View File
@@ -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);
}
+133 -33
View File
@@ -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<string, string>
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<Answer | null>(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<Set<string>>(new Set())
const [joining, setJoining] = useState<Record<string, boolean>>({})
const inputRef = useRef<HTMLInputElement>(null)
const resultsRef = useRef<HTMLDivElement>(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()}
/>
<IconButton name="close" title="Close" onClick={onClose} />
</div>
<div className="room-search-servers small muted">
<span>
{/* 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` : ''}
</span>
<input
className="text-field room-search-server"
placeholder="add a homeserver"
value={extraServer}
onChange={(e) => setExtraServer(e.target.value)}
/>
<IconButton name="close" title="Close" onClick={onClose} />
</div>
<div className="room-search-results">
{/* 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. */}
<div className="room-search-servers small muted">
{!answer && <span className="muted">Asking every homeserver…</span>}
{answer?.servers.map((s) => {
const off = disabled.has(s.server)
return (
<button
key={s.server}
type="button"
aria-pressed={!off}
className={classes('server-chip', s.error && 'failed', off && 'off')}
title={
s.error
? `${s.server}: ${s.error}`
: `${s.server} — ${s.rooms} rooms. Click to ${off ? 'include' : 'hide'}.`
}
onClick={() =>
setDisabled((current) => {
const next = new Set(current)
if (!next.delete(s.server)) next.add(s.server)
return next
})
}
>
{s.server}
<span className="server-chip-count">
{s.error ? refusalLabel(s.error) : s.rooms}
</span>
</button>
)
})}
</div>
<div className="room-search-results" ref={resultsRef} onScroll={onScroll}>
{rooms.map((room) => (
<div key={room.roomId} className="room-search-row">
<Avatar name={room.name || room.alias || room.roomId} url={room.avatarUrl} size={32} />
@@ -197,7 +292,13 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: ()
</div>
))}
{!busy && rooms.length === 0 && (
{!busy && rooms.length === 0 && (answer?.rooms.length ?? 0) > 0 && (
<p className="small muted room-search-empty">
Every server with results is switched off above.
</p>
)}
{!busy && (answer?.rooms.length ?? 0) === 0 && (
<p className="small muted room-search-empty">
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 && <p className="small muted room-search-empty">Searching…</p>}
{morePages && rooms.length > 0 && (
<button
type="button"
className="button room-search-more"
disabled={busy}
onClick={() => search(answer?.next)}
>
Show more
</button>
{/* 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 && (
<p className="small muted room-search-empty">
<span className="spinner" /> Loading more…
</p>
)}
</div>
</div>