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:
+1
-1
Submodule nobilis updated: 8dc0b7c376...5ad1d70d45
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user