Open a thread beside the room, and explore public rooms

A thread gets a side panel, not a buffer. It is not a place you go - it
is an aside inside a conversation you are already reading, and the buffer
list would fill with fragments that appear and go quiet. Element makes
the same call, and somebody moving between the two should not have to
learn two shapes. It stands in the member list's column and closes it
while open: two columns beside a conversation leaves the conversation too
narrow to read.

Two ways in. A message that started a thread carries the count, drawn
from what is loaded - which undercounts a thread older than the window,
and says "at least this many" rather than "none". A message that is in
one says so where the reply preview would be, and opens it.

The panel has its own box. Typing there answers the thread while the
room's composer still answers the room, which is the whole reason to open
one.

And the room directory: a search field with an optional homeserver
beside it, because a directory is per server and the ordinary way to find
a community is to have been told which server it is on. Explicit search
rather than as-you-type - each keystroke would be a request to somebody
else's server, and directories are rate-limited.
This commit is contained in:
2026-09-02 15:41:59 -04:00
parent f5f74081fc
commit 12a90b2660
9 changed files with 483 additions and 14 deletions
+1 -1
Submodule nobilis updated: cefc913f65...8dc0b7c376
+14 -1
View File
@@ -9,6 +9,7 @@ import { MENTIONS_GROUP_ID } from './lib/groups'
import { Composer } from './components/Composer'
import { MembershipGate } from './components/MembershipGate'
import { NickList } from './components/NickList'
import { ThreadPanel } from './components/ThreadPanel'
import { ConversationTools } from './components/ConversationTools'
import { AccountsPanel } from './components/AccountsPanel'
import { SettingsPanel } from './components/settings/SettingsPanel'
@@ -58,6 +59,8 @@ export default function App(): JSX.Element {
*/
const onMentionsPage = activePanel === '' && activeGroupId === MENTIONS_GROUP_ID
const openThread = useChat((s) => s.openThread)
// A stale restored id is harmless: it simply resolves to no buffer and the
// empty state shows, exactly as it would for "".
const showNickList =
@@ -166,7 +169,17 @@ export default function App(): JSX.Element {
)}
</div>
{showNickList && (
{/* A thread stands where the member list would, and closes it while
it is open: both are a column beside the conversation, and two of
them at once leaves the conversation itself too narrow to read. */}
{openThread && (
<>
<div className="divider-v" />
<ThreadPanel />
</>
)}
{!openThread && showNickList && (
<>
<div className="divider-v" />
<div className="nicklist-pane">
+92
View File
@@ -4085,3 +4085,95 @@ video.lightbox-media {
.message-row:hover .read-markers {
opacity: 0.25;
}
/* --- threads ------------------------------------------------------------
A thread beside the room, in the column the member list otherwise holds.
Wider than that column: this one holds conversation, and a message list
squeezed to a name list's width wraps every line to three. */
.thread-pane {
width: 360px;
max-width: 45vw;
flex: none;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--surface-container);
}
.thread-header {
display: flex;
align-items: center;
gap: var(--spacing-s);
padding: var(--spacing-s) var(--spacing-m);
border-bottom: 1px solid var(--outline-light);
}
.thread-title {
flex: 1;
font-weight: 600;
}
.thread-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--spacing-s) 0;
}
.thread-empty {
padding: var(--spacing-m);
text-align: center;
}
.thread-composer {
display: flex;
align-items: flex-end;
gap: var(--spacing-xs);
padding: var(--spacing-s);
border-top: 1px solid var(--outline-light);
}
.thread-composer textarea {
flex: 1;
resize: none;
max-height: 8em;
padding: var(--spacing-s);
border: 1px solid var(--outline-medium);
border-radius: var(--corner-radius);
background: var(--surface-container-high);
color: var(--surface-text);
font: inherit;
}
/* The way in, from the message a thread grew out of. Quiet until pointed at:
a busy room has one of these on every few messages, and a row of buttons
competing with the text would make the room harder to read than the
threads are worth. */
.thread-open,
.reply-preview.thread-link {
display: inline-flex;
align-items: center;
gap: var(--spacing-xxs);
margin-top: 2px;
padding: 1px var(--spacing-xs);
border: 1px solid var(--outline-light);
border-radius: var(--corner-radius-small);
color: var(--surface-variant-text);
background: transparent;
text-align: left;
max-width: 100%;
}
.thread-open:hover,
.reply-preview.thread-link:hover {
border-color: var(--outline-medium);
color: var(--surface-text);
background: var(--surface-container-high);
}
/* The homeserver whose directory to ask. Narrower than the search box beside
it: a hostname is short and predictable, and the words being searched for
are neither. */
.directory-server {
flex: 0 1 11em;
}
+122
View File
@@ -512,11 +512,133 @@ function MatrixJoin({ account }: { account: Account }): JSX.Element {
.catch((e: Error) => store.toast('error', e.message))
}
/>
<MatrixRoomDirectory 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.
*
* 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.
*/
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))
}
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>
)}
</div>
)
}
/**
* This backend runs on a user token, not a bot, so joining a server means
* accepting an invite exactly as a human would. There's no guild-wide member
@@ -135,9 +135,27 @@ export function MessageList(): JSX.Element {
const [blockedNicks] = usePref<string[]>('blockedNicks', [])
const readers = useChat((s) => s.readersByBuffer)[bufferId]
const accountId = buffers.find((b) => b.id === bufferId)?.accountId || ''
const all = messagesByBuffer[bufferId] || []
/**
* How many replies each thread has, by the message it grew from.
*
* Counted from what is loaded rather than asked for: Matrix does send a
* summary with the root, but only on the sync that carried it, so a room
* read back from scrollback would show nothing. This undercounts a thread
* whose replies are older than the loaded window, which is the honest
* failure - it says "at least this many", never "none" for a live one.
*/
const threadReplies = useMemo(() => {
const counts: Record<string, number> = {}
for (const m of all) {
if (m.replyTo?.thread) counts[m.replyTo.id] = (counts[m.replyTo.id] ?? 0) + 1
}
return counts
}, [all])
const messages = useMemo(() => {
const allowed: Record<string, boolean> = {
join: showJoin,
@@ -418,6 +436,7 @@ export function MessageList(): JSX.Element {
mediaLoop={mediaLoop}
contentSniffing={contentSniffing}
readers={showReaders ? readers?.[msg.id] : undefined}
threadReplies={threadReplies[msg.id]}
/>
</div>
))}
+49 -10
View File
@@ -93,6 +93,14 @@ interface Props {
* reader has turned the markers off.
*/
readers?: MessageReader[]
/**
* How many replies this message has started, where it has started any.
* Drawn as the way into the thread, which is the only way into one for a
* root that nobody has answered inside the visible log.
*/
threadReplies?: number
/** Whether this row is being drawn inside the thread panel itself. */
inThread?: boolean
}
/** How many faces fit before the row starts costing more than it says. */
@@ -136,7 +144,9 @@ export function MessageRow({
mediaAutoplay,
mediaLoop,
contentSniffing,
readers
readers,
threadReplies,
inThread
}: Props): JSX.Element {
const store = useStore()
const { menu, open, close } = useContextMenu()
@@ -442,15 +452,30 @@ export function MessageRow({
</span>
)}
{message.replyTo && (
<div className="reply-preview small muted">
<Icon name="reply" size={13} />
<span className="reply-from" style={{ color: nickColor(message.replyTo.from) }}>
{message.replyTo.from}
</span>
<span className="ellipsis">{message.replyTo.body}</span>
</div>
)}
{/* A reply names the message it answers; a threaded message names
the thread it is in. The second is a button, because a thread is
somewhere you can go - unless this is already the thread, where
it would only reopen what is on screen. */}
{message.replyTo &&
(message.replyTo.thread && !inThread ? (
<button
type="button"
className="reply-preview thread-link small muted"
onClick={() => void store.openThreadPanel(bufferId, message.replyTo!.id)}
>
<Icon name="forum" size={13} />
<span>In thread</span>
{message.replyTo.body && <span className="ellipsis">{message.replyTo.body}</span>}
</button>
) : (
<div className="reply-preview small muted">
<Icon name={message.replyTo.thread ? 'forum' : 'reply'} size={13} />
<span className="reply-from" style={{ color: nickColor(message.replyTo.from) }}>
{message.replyTo.from}
</span>
<span className="ellipsis">{message.replyTo.body}</span>
</div>
))}
{editing ? (
<input
@@ -627,6 +652,20 @@ export function MessageRow({
{timeLabel}
</span>
)}
{/* The way into a thread from the message it grew out of. A count
rather than a label, because the number is what makes somebody
open it - "3 replies" is an invitation and "thread" is furniture. */}
{!inThread && !!threadReplies && (
<button
type="button"
className="thread-open small"
onClick={() => void store.openThreadPanel(bufferId, message.id)}
>
<Icon name="forum" size={13} />
{threadReplies === 1 ? '1 reply' : `${threadReplies} replies`}
</button>
)}
</div>
{/* Who has read this far. Element puts these against the last message
+110
View File
@@ -0,0 +1,110 @@
import { useMemo, useRef, useState } from 'react'
import { MessageRow } from './MessageRow'
import { IconButton } from './Icon'
import { useChat, usePref, useStore } from '../state/hooks'
import type { ChatMessage } from '../state/store'
/**
* A thread, beside the room it belongs to.
*
* A side panel rather than its own buffer, because a thread is not a place
* you go - it is an aside inside a conversation you are already reading, and
* putting it in the buffer list would fill that list with fragments that
* appear and go quiet. Element makes the same call, and somebody moving
* between the two clients should not have to learn two shapes.
*
* What it shows is merged from two places: what the server said the thread
* contains, and anything that has arrived in the room since. The second is
* what makes a live thread move without re-asking.
*/
export function ThreadPanel(): JSX.Element | null {
const thread = useChat((s) => s.openThread)
const messagesByBuffer = useChat((s) => s.messagesByBuffer)
const buffers = useChat((s) => s.buffers)
const [comfy] = usePref<'classic' | 'comfy' | 'bubbles'>('ui.messageMode', 'comfy')
const [relativeTimestamps] = usePref<boolean>('ui.relativeTimestamps', false)
const store = useStore()
const [draft, setDraft] = useState('')
const inputRef = useRef<HTMLTextAreaElement>(null)
const service = buffers.find((b) => b.id === thread?.bufferId)?.id.split(':')[0]
const messages = useMemo((): ChatMessage[] => {
if (!thread) return []
const live = (messagesByBuffer[thread.bufferId] || []).filter(
(m) => m.id === thread.rootId || (m.replyTo?.thread && m.replyTo.id === thread.rootId)
)
// By id, so a message that is both fetched and live appears once. The
// live copy wins: it is the one carrying edits and reactions that have
// happened since the fetch.
const byId = new Map<string, ChatMessage>()
for (const m of [...thread.messages, ...live]) byId.set(m.id, m)
return [...byId.values()].sort((a, b) => a.ts - b.ts)
}, [thread, messagesByBuffer])
if (!thread) return null
const send = (): void => {
const body = draft.trim()
if (!body) return
setDraft('')
void store.sendToThread(body)
}
return (
<div className="thread-pane">
<div className="thread-header">
<span className="thread-title ellipsis">Thread</span>
<span className="small muted">{Math.max(0, messages.length - 1)} replies</span>
<IconButton name="close" title="Close thread" onClick={() => store.closeThreadPanel()} />
</div>
<div className="thread-body">
{messages.map((msg, i) => (
<MessageRow
key={msg.id}
message={msg}
bufferId={thread.bufferId}
service={service}
grouped={false}
mode={comfy === 'bubbles' ? 'comfy' : comfy}
lastInRun={i === messages.length - 1}
relativeTimestamps={relativeTimestamps}
mediaAutoplay={false}
mediaLoop={false}
contentSniffing={false}
inThread
/>
))}
{thread.loading && messages.length === 0 && (
<div className="muted small thread-empty">Reading the thread…</div>
)}
{!thread.loading && messages.length === 0 && (
<div className="muted small thread-empty">
This thread is not in local history and the server did not return it.
</div>
)}
</div>
{/* Its own box, not the room's. Typing here answers the thread, and the
room's composer still answers the room - which is the whole reason
somebody opens one of these. */}
<div className="thread-composer">
<textarea
ref={inputRef}
rows={1}
value={draft}
placeholder="Reply in thread"
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}}
/>
<IconButton name="send" title="Send" onClick={send} />
</div>
</div>
)
}
+68 -2
View File
@@ -73,6 +73,14 @@ export function sameIrcNetwork(a: string, b: string): boolean {
return !!na && !!nb && na.id === nb.id
}
/** A thread, as it is being read: what was fetched, and whether it still is. */
export interface OpenThread {
bufferId: string
rootId: string
messages: ChatMessage[]
loading: boolean
}
export type ActivePanel = '' | 'accounts' | 'settings' | 'join' | 'downloads'
export interface ChatState {
@@ -87,6 +95,14 @@ export interface ChatState {
* cannot leave a copy of themselves behind on an older message.
*/
readersByBuffer: Record<string, Record<string, MessageReader[]>>
/**
* The thread being read, if any.
*
* One at a time, and held here rather than in the panel's own state so that
* opening a thread from a message survives the panel being closed and
* reopened, and so the composer can send into it.
*/
openThread: OpenThread | null
linkUp: boolean
accounts: Account[]
buffers: BufferEntry[]
@@ -251,6 +267,7 @@ const INITIAL: ChatState = {
dividerTsByBuffer: {},
typingByBuffer: {},
readersByBuffer: {},
openThread: null,
matrixPermissions: {},
replyingTo: null,
toasts: [],
@@ -1086,6 +1103,52 @@ export class ChatStore {
})
}
/**
* Opens a thread and reads it from the service.
*
* The panel shows what is stored the moment it opens and fills in behind
* that, because a thread whose replies are already in scrollback should not
* blank out while the server confirms them.
*/
async openThreadPanel(bufferId: string, rootId: string): Promise<void> {
const known = (this.state.messagesByBuffer[bufferId] || []).filter(
(m) => m.id === rootId || (m.replyTo?.thread && m.replyTo.id === rootId)
)
this.set({ openThread: { bufferId, rootId, messages: known, loading: true } })
try {
const result = (await window.moho.rpc('fetchThread', { bufferId, rootId })) as {
messages?: ChatMessage[]
}
// Somebody may have closed it, or opened a different one, while this
// was in flight - answering into that would swap the thread under them.
const open = this.state.openThread
if (!open || open.bufferId !== bufferId || open.rootId !== rootId) return
this.set({ openThread: { bufferId, rootId, messages: result.messages ?? known, loading: false } })
} catch {
const open = this.state.openThread
if (open && open.bufferId === bufferId && open.rootId === rootId) {
this.set({ openThread: { ...open, loading: false } })
}
}
}
closeThreadPanel(): void {
this.set({ openThread: null })
}
/** Says something into a thread, rather than into the room around it. */
async sendToThread(body: string): Promise<void> {
const open = this.state.openThread
if (!open || !body.trim()) return
const root = open.messages.find((m) => m.id === open.rootId)
await this.dispatchSend(open.bufferId, body, {
id: open.rootId,
from: root?.from ?? '',
body: root?.body ?? '',
thread: true
})
}
private handleEvent(frame: NobilisEvent): void {
const { event, data } = frame
switch (event) {
@@ -1783,7 +1846,7 @@ export class ChatStore {
private async dispatchSend(
bufferId: string,
body: string,
reply: { id: string; from: string; body: string } | null,
reply: { id: string; from: string; body: string; thread?: boolean } | null,
attachmentPath?: string
): Promise<void> {
if (!body.trim() && !attachmentPath) return
@@ -1841,7 +1904,10 @@ export class ChatStore {
...(attachmentPath
? { attachmentPath, uploadHost: await uploadHost(account?.service, attachmentPath) }
: {}),
...(replyToId ? { replyToId } : {})
...(replyToId ? { replyToId } : {}),
// Into the thread rather than at the message: the daemon needs to be
// told which, because Matrix says them with the same field.
...(reply?.thread ? { thread: true } : {})
})
// Success alone doesn't resolve the echo - only the real message event
// does, since that's what carries nobilis's own id and timestamp.
+8
View File
@@ -263,6 +263,14 @@ export interface ReplyPreview {
id: string
from: string
body: string
/**
* Whether `id` names a thread rather than one message being answered.
*
* The same field carries both because the protocol says both the same way.
* What differs is what can be done with it: a reply points at something, a
* thread is somewhere, and only the second can be opened and continued.
*/
thread?: boolean
}
/**