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