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.
This commit is contained in:
2026-09-02 17:11:56 -04:00
parent 12a90b2660
commit 760e637876
4 changed files with 341 additions and 113 deletions
+1 -1
Submodule nobilis updated: 8dc0b7c376...5ad1d70d45
+103
View File
@@ -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);
}
+13 -112
View File
@@ -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))
}
/>
<MatrixRoomDirectory account={account} />
<FindRoomButton account={account} />
<CreateMatrixRoom account={account} />
</div>
)
}
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<PublicRoom[] | null>(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 (
<div className="field">
<span className="small muted">Find a public room</span>
<div className="field-row">
<input
className="text-field"
placeholder="search the directory"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && search()}
/>
<input
className="text-field directory-server"
placeholder="server (optional)"
value={server}
onChange={(e) => setServer(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && search()}
/>
<button type="button" className="button" disabled={busy} onClick={() => search()}>
{busy ? 'Searching…' : 'Search'}
</button>
</div>
{rooms && rooms.length === 0 && (
<p className="small muted">
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.
</p>
)}
{rooms && rooms.length > 0 && (
<div className="channel-browser">
{rooms.map((room) => (
<button
key={room.roomId}
type="button"
className="channel-browser-row"
title={room.topic || room.alias || room.name}
disabled={room.joined}
onClick={() =>
void window.moho
.rpc('joinMatrixRoom', {
accountId: account.id,
// The address where there is one: an alias survives a room
// being upgraded, and a bare room id needs the server to
// already know somebody in it.
roomIdOrAlias: room.alias || room.roomId
})
.catch((e: Error) => store.toast('error', e.message))
}
>
<span className="ellipsis channel-browser-name">
{room.name || room.alias || room.roomId}
</span>
<span className="small muted channel-browser-users">
{room.joined ? 'joined' : room.members.toLocaleString()}
</span>
<span className="small muted ellipsis channel-browser-topic">{room.topic}</span>
</button>
))}
</div>
)}
{next && (
<button type="button" className="button" disabled={busy} onClick={() => search(next)}>
Show more
</button>
)}
<button type="button" className="button" onClick={() => setOpen(true)}>
<Icon name="search" size={15} /> Search every homeserver
</button>
{open && <RoomSearch account={account} onClose={() => setOpen(false)} />}
</div>
)
}
+224
View File
@@ -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<string, string>
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<Answer | null>(null)
const [busy, setBusy] = useState(false)
const [joining, setJoining] = useState<Record<string, boolean>>({})
const inputRef = useRef<HTMLInputElement>(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<string, string>): void => {
const mine = ++generation.current
setBusy(true)
void window.moho
.rpc<Answer>('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(
<div className="modal-scrim" onClick={onClose}>
<div
className="room-search"
role="dialog"
aria-modal="true"
aria-label="Find a room"
onClick={(e) => e.stopPropagation()}
>
<div className="room-search-head">
<Icon name="search" size={18} />
<input
ref={inputRef}
autoFocus
className="room-search-input"
placeholder="Search public rooms"
value={query}
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)}
/>
</div>
<div className="room-search-results">
{rooms.map((room) => (
<div key={room.roomId} className="room-search-row">
<Avatar name={room.name || room.alias || room.roomId} url={room.avatarUrl} size={32} />
<div className="room-search-text">
<div className="room-search-title">
<span className="ellipsis">{room.name || room.alias || room.roomId}</span>
{room.alias && <span className="small muted ellipsis">{room.alias}</span>}
</div>
<div className="small muted room-search-detail ellipsis">
{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}` : ''}
</div>
</div>
<button
type="button"
className="button"
disabled={room.joined || joining[room.roomId]}
onClick={() => join(room)}
>
{room.joined ? 'Joined' : joining[room.roomId] ? 'Joining…' : 'Join'}
</button>
</div>
))}
{!busy && rooms.length === 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.
</p>
)}
{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>
)}
</div>
</div>
</div>,
document.body
)
}