From d06770972baee8ae6b719c2bd3c605ef6a75416c Mon Sep 17 00:00:00 2001 From: Salastil Date: Fri, 11 Sep 2026 23:04:21 -0400 Subject: [PATCH 1/3] Say that the client does not execute code, and will not Scripting came up as the one place moho falls short of the clients it is measured against - HexChat has Perl and Python, WeeChat four languages, mIRC its own - and the answer is that it is a decision rather than a gap. Written into the security notes rather than anywhere else, because it is the same decision as the rest of that section. The fixed preload surface, the tag and attribute whitelist and the media scheme all exist to stop attacker-controlled message content from becoming something that runs. A plugin API hands that back in one step, and with more privilege than the renderer has. Recorded so the next feature comparison reads it as answered instead of filing it, and so somebody asking for it gets the reasoning rather than a maybe. Co-Authored-By: Claude Opus 5 --- ARCHITECTURE.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 67fb7a1..2d68e4d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -74,6 +74,27 @@ degrade to text and `javascript:`/`data:` URLs, inline event handlers and ` Date: Fri, 11 Sep 2026 23:37:22 -0400 Subject: [PATCH 2/3] The window's half of writing a conversation to disk The loop, the dialog, and the two places an export shows up while it runs. Not yet reachable - the way in is the header menu, which is next. `lib/exporter.ts` is the loop, and it lives here because the formatting does: `formatMessage` is what a message becomes on screen, and an export is meant to be that rather than a second opinion about it. Three passes - reach back through the range, read and render it forward, seal it - with everything that is not formatting happening on the far side of an RPC. Reaching back uses the same `getBacklog` path scrolling up uses, which is what makes it correct per service without a second implementation. It stops on the range being covered, on a page that reached no further back, or on a budget - the budget is not a guess about how long conversations are, it is a guard against a service that answers every request with the same page. Points worth naming: - Attachments prefer their local cached `path` over the remote `url`. For anything behind Tor, a Matrix access token or E2EE the remote URL is not fetchable at all, so this is the difference between an export with pictures and one without. - The dialog says when it cannot deliver what was asked. Sneedchat cannot be asked for history older than what is held, and an export that quietly came back short would read as a conversation that did not happen. - The downloads row counts an export in messages, not bytes, and says "so far" while the total is still unknown - the range's size is not known until the reaching back has finished. - The cog says what is actually happening. "3 file(s) arriving" while a conversation is being written to disk is a sentence about the wrong thing, and a paused export still counts, since it is waiting on a decision rather than finished. - Pause is offered on exports and not on DCC, because an export between pages can be put down and a socket cannot. Towards #114. Co-Authored-By: Claude Opus 5 --- nobilis | 2 +- src/renderer/src/app.css | 61 ++++ .../src/components/DownloadsPanel.tsx | 43 ++- src/renderer/src/components/ExportDialog.tsx | 123 ++++++++ src/renderer/src/components/ServerRail.tsx | 25 +- src/renderer/src/lib/exporter.ts | 290 ++++++++++++++++++ src/renderer/src/state/store.ts | 48 +++ src/shared/wire.ts | 12 +- 8 files changed, 595 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/components/ExportDialog.tsx create mode 100644 src/renderer/src/lib/exporter.ts diff --git a/nobilis b/nobilis index 7822d32..f3a92e6 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit 7822d321156eb7a6db4d4c6fa868715008cf4cb0 +Subproject commit f3a92e64f183ecedf528d567f6eef98d0bd4dc58 diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index 0a86941..14b8140 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -5340,11 +5340,72 @@ video.lightbox-media { font-variant-numeric: tabular-nums; } +/* An export running behind whatever is on screen. A slow pulse rather than a + second badge: the number is already the thing being read, and what this adds + is that something is still happening rather than merely queued. */ +.rail-downloads.exporting { + animation: rail-exporting 2s ease-in-out infinite; +} + +@keyframes rail-exporting { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.55; + } +} + +/* Somebody who has asked for less movement gets the badge and not the pulse; + the count says the same thing either way. */ +@media (prefers-reduced-motion: reduce) { + .rail-downloads.exporting { + animation: none; + } +} + /* The cog holds the badge, the way the tiles hold theirs. */ .rail-cog { position: relative; } +/* --- Exporting a conversation ----------------------------------------- */ + +.export-choice { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 6px 0; + cursor: pointer; +} + +.export-choice input { + margin-top: 2px; + flex: none; +} + +.export-range { + display: flex; + gap: 12px; + margin: 4px 0 10px 24px; +} + +.export-range label { + display: flex; + flex-direction: column; + gap: 2px; +} + +.export-range input { + padding: 4px 6px; + border: 1px solid var(--outline-variant); + border-radius: 6px; + background: var(--surface-container-lowest); + color: var(--on-surface); + font: inherit; +} + /* --- Whispers --------------------------------------------------------- */ /* Said privately rather than to the room. Highlighted like a mention, because diff --git a/src/renderer/src/components/DownloadsPanel.tsx b/src/renderer/src/components/DownloadsPanel.tsx index 5cb4371..dac49b0 100644 --- a/src/renderer/src/components/DownloadsPanel.tsx +++ b/src/renderer/src/components/DownloadsPanel.tsx @@ -88,6 +88,11 @@ function Row({ transfer }: { transfer: DccTransfer }): JSX.Element { // Only an offer somebody made to us is a question we can answer. const offered = transfer.state === 'offered' && !transfer.outgoing const waiting = transfer.state === 'offered' && transfer.outgoing + // An export can be put down between pages and picked back up. A DCC socket + // cannot, which is why this is offered on one and not the other rather than + // on everything that happens to be running. + const isExport = transfer.kind === 'export' + const paused = transfer.state === 'paused' return (
@@ -101,7 +106,7 @@ function Row({ transfer }: { transfer: DccTransfer }): JSX.Element {
- {transfer.outgoing ? 'to' : 'from'} {transfer.from} + {isExport ? 'export of' : transfer.outgoing ? 'to' : 'from'} {transfer.from} {/* Titled as well as shown, because the whole of a long failure is worth being able to read even where it has been wrapped. */} @@ -109,7 +114,7 @@ function Row({ transfer }: { transfer: DccTransfer }): JSX.Element { {describe(transfer)}
- {running && } + {(running || paused) && }
@@ -121,10 +126,25 @@ function Row({ transfer }: { transfer: DccTransfer }): JSX.Element { {/* Only while there is something to stop. A finished row has nothing this button could do, and one that did nothing would still look like it might delete the file. */} - {(running || offered || waiting) && ( + {isExport && (running || paused) && ( + void store.pauseTransfer(transfer.id, !paused)} + /> + )} + {(running || paused || offered || waiting) && ( void store.cancelTransfer(transfer.id)} /> @@ -135,6 +155,8 @@ function Row({ transfer }: { transfer: DccTransfer }): JSX.Element { } function markFor(t: DccTransfer): string { + if (t.kind === 'export' && t.state !== 'done' && t.state !== 'failed') return 'description' + if (t.state === 'paused') return 'pause_circle' if (t.state === 'done') return 'check_circle' if (t.state === 'failed') return 'error' if (t.state === 'declined') return 'block' @@ -145,6 +167,17 @@ function markFor(t: DccTransfer): string { /** The one line that says where this transfer got to. */ function describe(t: DccTransfer): string { + // An export is counted in messages, not bytes. Its `size` is how many the + // range holds and may still be zero while the history is being reached back + // through - which is why "so far" is said rather than a proportion of a + // total that is not known yet. + if (t.kind === 'export') { + const done = t.received.toLocaleString() + if (t.state === 'done') return `${done} messages` + if (t.state === 'failed') return t.error ? `Failed - ${t.error}` : 'Failed' + const where = t.size > 0 ? `${done} of ${t.size.toLocaleString()} messages` : `${done} messages so far` + return t.state === 'paused' ? `${where} · paused` : where + } switch (t.state) { case 'receiving': case 'sending': { @@ -152,6 +185,8 @@ function describe(t: DccTransfer): string { const moved = `${humanSize(t.received)} of ${humanSize(t.size)}` return rate ? `${moved} · ${rate}` : moved } + case 'paused': + return `${humanSize(t.received)} of ${humanSize(t.size)} · paused` case 'offered': return `${humanSize(t.size)} · ${t.outgoing ? 'waiting for them to accept' : 'waiting for an answer'}` case 'done': diff --git a/src/renderer/src/components/ExportDialog.tsx b/src/renderer/src/components/ExportDialog.tsx new file mode 100644 index 0000000..5cded53 --- /dev/null +++ b/src/renderer/src/components/ExportDialog.tsx @@ -0,0 +1,123 @@ +import { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { Icon } from './Icon' +import { canReachBack, type ExportRange } from '../lib/exporter' + +/** + * What to write to disk, and how far back. + * + * Defaults to everything, because that is what somebody usually means by + * exporting a conversation; the range is for the one who knows they want + * March. Dates rather than date-times: an export is measured in days, and two + * clock fields to answer "last month" is four more than the question needs. + * + * The one thing this has to be honest about is what cannot be had. + * `canReachBack` is the list of services that will give out history older than + * what is already here - Sneedchat will not - and an export that quietly came + * back short would read as a conversation that did not happen. + */ +export function ExportDialog({ + title, + service, + oldestHeld, + onConfirm, + onCancel +}: { + title: string + service?: string + /** Unix seconds of the oldest message held locally, if there is one. */ + oldestHeld?: number + onConfirm: (range: ExportRange, opts: { media: boolean }) => void + onCancel: () => void +}): JSX.Element { + const [everything, setEverything] = useState(true) + const [from, setFrom] = useState('') + const [to, setTo] = useState(() => new Date().toISOString().slice(0, 10)) + const [media, setMedia] = useState(true) + + useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') onCancel() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onCancel]) + + const since = everything || !from ? 0 : Math.floor(new Date(`${from}T00:00:00`).getTime() / 1000) + // The end of the chosen day rather than its start - somebody asking for a + // range ending today means today included, and midnight would drop it. + const until = everything || !to ? Math.floor(Date.now() / 1000) : Math.floor(new Date(`${to}T23:59:59`).getTime() / 1000) + const ready = everything || (!!to && (!from || since < until)) + + // Only worth saying where it would actually bite: a range that starts after + // what is already held needs nothing fetched. + const wouldReachBack = !everything && !!oldestHeld && since > 0 && since < oldestHeld + const shortfall = wouldReachBack && !canReachBack(service) + + return createPortal( +
+
e.stopPropagation()} role="dialog" aria-modal="true"> +
+ + Export {title} +
+

+ Written as a folder of HTML, the way it looks here, into your downloads. +

+ + + + + {!everything && ( +
+ + +
+ )} + + + + {shortfall && ( +

+ This service cannot be asked for history older than what moho already holds, so + anything before {new Date((oldestHeld ?? 0) * 1000).toLocaleDateString()} will not be + in the export. +

+ )} + +
+ + +
+
+
, + document.body + ) +} diff --git a/src/renderer/src/components/ServerRail.tsx b/src/renderer/src/components/ServerRail.tsx index 4e97741..bb42c61 100644 --- a/src/renderer/src/components/ServerRail.tsx +++ b/src/renderer/src/components/ServerRail.tsx @@ -858,14 +858,31 @@ function RailMenu(): JSX.Element { const store = useStore() const { menu, open, close } = useContextMenu() const transfers = useChat((s) => s.transfers) - const busy = transfers.filter((t) => t.state === 'offered' || t.state === 'receiving').length + // What is going on behind whatever is on screen. Exports count alongside + // files because they are the same thing to somebody who started one and + // wandered off - and a paused one still counts, since it is waiting on a + // decision rather than finished. + const running = transfers.filter( + (t) => t.state === 'offered' || t.state === 'receiving' || t.state === 'paused' + ) + const busy = running.length + const exporting = running.filter((t) => t.kind === 'export').length + // Said in the words of whatever is actually happening: "3 file(s) arriving" + // while a conversation is being written to disk is a sentence about the + // wrong thing. + const busyLabel = + exporting === busy + ? `${busy} export${busy === 1 ? '' : 's'} running` + : exporting > 0 + ? `${busy} in progress, ${exporting} of them export${exporting === 1 ? '' : 's'}` + : `${busy} file${busy === 1 ? '' : 's'} arriving` return ( <> {menu && ( => new Promise((r) => setTimeout(r, ms)) + +/** + * Services that can be asked for history older than what is held. + * + * Sneedchat is the one that cannot: its history frame carries what the room + * chose to send and there is no way to ask for more, so an export of a range + * older than the local scrollback is the local scrollback. Saying so is the + * point of this list - an export that quietly came back short would look like + * a conversation that did not happen. + */ +const CAN_REACH_BACK = ['irc', 'matrix', 'discord', 'kick'] + +export function canReachBack(service: string | undefined): boolean { + return !!service && CAN_REACH_BACK.includes(service) +} + +/** What the daemon says about whether to keep going. */ +interface Status { + cancelled: boolean + paused: boolean + done: boolean +} + +/** + * Waits out a pause, and reports whether the job is still wanted. + * + * Polled rather than pushed because the loop is here and the flag is there; + * an export between pages is a thing that can simply stop asking for a while. + */ +async function alive(rpc: Rpc, id: string): Promise { + for (;;) { + const s = await rpc('exportStatus', { id }) + if (s.cancelled || s.done) return false + if (!s.paused) return true + await sleep(500) + } +} + +type Rpc = (method: string, params?: Record) => Promise + +/** + * Pulls older pages until the range's start is covered. + * + * Uses the same `getBacklog` path scrolling up uses, which is what makes this + * correct per service without a second implementation: Discord's snowflake + * paging, Kick's cursor, IRC's CHATHISTORY and Matrix's `/messages` token are + * each already behind it, with their own pacing. + * + * Stops on any of: the range covered, a page that reached no further back + * (the conversation's own beginning), or the page budget. The budget is not a + * guess about conversations - it is a guard against a service that answers + * every request with the same page, which would otherwise loop forever. + */ +async function reachBack( + rpc: Rpc, + target: ExportTarget, + range: ExportRange, + id: string, + onProgress: (pages: number) => void +): Promise { + if (!canReachBack(target.service)) return + let oldest = Number.MAX_SAFE_INTEGER + const held = await rpc<{ count: number; oldestHeld: number | null }>('countMessageRange', { + bufferId: target.bufferId, + after: 0, + until: range.until + }) + oldest = held.oldestHeld ?? range.until + if (oldest <= range.since) return + + for (let page = 0; page < MAX_BACKFILL_PAGES; page++) { + if (!(await alive(rpc, id))) return + const rows = await rpc('getBacklog', { + bufferId: target.bufferId, + before: oldest, + limit: 200 + }) + if (!rows.length) return + const min = Math.min(...rows.map((m) => m.ts)) + // No further back than last time means the service has nothing older - + // the conversation's own beginning, or the point its history stops. + if (min >= oldest) return + oldest = min + onProgress(page + 1) + if (min <= range.since) return + await sleep(PAGE_PACE_MS) + } +} + +/** + * One message, as the row it is on screen. + * + * Deliberately the same `formatMessage` the live log uses, with the same + * options, so the export is the conversation rather than a description of it. + * The media is rewritten to point inside the folder, which is the one way the + * exported copy differs from the live one - and has to, or the page would go + * blank the day the host expires it. + */ +async function renderMessage( + rpc: Rpc, + id: string, + m: Message, + service: string | undefined, + localMedia: boolean +): Promise { + const when = new Date(m.ts * 1000) + const stamp = when.toISOString().replace('T', ' ').slice(0, 19) + const body = formatMessage(m.body ?? '', { + isSneedchat: service === 'sneedchat', + ircFormatting: service === 'irc' ? 'render' : 'strip' + }) + + // Anything the live view would have drawn as a picture or a video, from all + // three places one can come from: linked in the body, attached to the + // message, or carried by an embed. Fetched one at a time through the daemon, + // which paces them - a conversation's worth of pictures is a burst at one + // host. + // + // `path` before `url` for an attachment, deliberately. The local cache file + // is the only route to media behind Tor, a Matrix access token or E2EE + // decryption, and for those the remote URL is not fetchable at all - so + // preferring it is the difference between an export with pictures in it and + // one without. + let media = '' + if (localMedia) { + const wanted: { url: string; kind: string }[] = [] + for (const item of extractMedia(m.body ?? '')) wanted.push({ url: item.url, kind: item.kind }) + for (const a of m.attachments ?? []) { + const src = a.path || a.url + if (src && a.kind !== 'file') wanted.push({ url: src, kind: a.kind }) + } + const seen = new Set() + for (const item of wanted) { + if (seen.has(item.url)) continue + seen.add(item.url) + const answer = await rpc<{ path: string }>('fetchExportMedia', { id, url: item.url }) + const src = escapeHtml(answer.path) + const label = escapeHtml(fileNameOf(item.url)) + media += + item.kind === 'video' + ? `
` + : `
${label}
` + } + } + + // An embed is a card the live view draws around a link. Kept as text rather + // than recreated: the point of the export is the conversation, and a title + // and description say what the card said. + let cards = '' + for (const e of m.embeds ?? []) { + const bits = [e.title, e.description].filter(Boolean).map((t) => escapeHtml(String(t))) + if (!bits.length) continue + const link = e.url ? ` ${escapeHtml(e.url)}` : '' + cards += `
${bits.join('
')}${link}
` + } + + const system = m.kind && m.kind !== 'chat' ? ' system' : '' + const who = m.isAction ? `* ${m.from}` : m.from + const edited = m.edited ? ' (edited)' : '' + return ( + `
` + + `` + + `${escapeHtml(who)}` + + `${body}${edited}${cards}${media}` + + `
\n` + ) +} + +/** + * Runs one export start to finish. + * + * Throws only for a failure worth surfacing; a cancel ends it quietly, because + * somebody pressing cancel already knows what happened. The folder is left + * either way - a half-finished export is sometimes exactly what was wanted. + */ +export async function runExport( + rpc: Rpc, + target: ExportTarget, + range: ExportRange, + into: string, + opts: { media: boolean } = { media: true } +): Promise<{ id: string; path: string; messages: number }> { + const started = await rpc<{ id: string; path: string }>('beginExport', { + accountId: target.accountId, + bufferId: target.bufferId, + title: target.title, + into + }) + const id = started.id + + try { + await reachBack(rpc, target, range, id, () => {}) + + const held = await rpc<{ count: number }>('countMessageRange', { + bufferId: target.bufferId, + after: range.since, + until: range.until + }) + const total = held.count + + let after = range.since + let done = 0 + for (;;) { + if (!(await alive(rpc, id))) return { id, path: started.path, messages: done } + const rows = await rpc('getMessageRange', { + bufferId: target.bufferId, + after, + until: range.until, + limit: RENDER_PAGE + }) + if (!rows.length) break + + let html = '' + for (const m of rows) html += await renderMessage(rpc, id, m, target.service, opts.media) + done += rows.length + await rpc('appendExport', { id, html, done, total }) + + const last = rows[rows.length - 1].ts + // A page that did not advance would loop forever. Only possible if more + // than one page of messages share a timestamp to the second, which IRC + // during a netsplit can genuinely manage. + if (last <= after) break + after = last + } + + const subtitle = describeRange(range, done) + await rpc('finishExport', { id, title: target.title, subtitle }) + return { id, path: started.path, messages: done } + } catch (e) { + await rpc('failExport', { id, reason: e instanceof Error ? e.message : String(e) }).catch(() => {}) + throw e + } +} + +/** The line under the title, saying what was asked for and what was found. */ +export function describeRange(range: ExportRange, messages: number): string { + const day = (ts: number): string => new Date(ts * 1000).toLocaleDateString() + const span = range.since > 0 ? `${day(range.since)} to ${day(range.until)}` : `up to ${day(range.until)}` + const count = messages === 1 ? '1 message' : `${messages.toLocaleString()} messages` + return `${span} · ${count} · exported ${new Date().toLocaleString()}` +} diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/state/store.ts index 46411ab..8620b6a 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/state/store.ts @@ -23,6 +23,7 @@ import type { } from '../../../shared/wire' import { buildSmilieIndex, type SmilieEntry, type SmilieIndex } from '../lib/format' import { bufferDisplayName, isImageFile, resolveMediaUrl } from '../lib/util' +import { runExport } from '../lib/exporter' import { DM_GROUP_ID, isDirectMessage } from '../lib/groups' import { ircNetworkFor } from '../lib/networks' import { parseDeepLink, type IrcLink } from '../../../shared/deeplink' @@ -1487,6 +1488,53 @@ export class ChatStore { } } + /** Puts an export down between pages, or picks it back up. */ + async pauseTransfer(id: string, paused: boolean): Promise { + try { + await window.moho.rpc('pauseTransfer', { id, paused }) + } catch (e) { + this.toast('error', `Couldn't ${paused ? 'pause' : 'resume'} that: ${(e as Error).message}`) + } + } + + /** + * Writes a conversation to disk as a folder of HTML. + * + * The loop is in the window because the formatting is - see lib/exporter.ts. + * What this adds is where it goes (the downloads folder somebody chose, or + * the platform's own) and saying how it went, since the export itself + * reports progress through the transfer list like any other long job. + */ + async exportConversation( + bufferId: string, + range: { since: number; until: number }, + opts: { media: boolean } + ): Promise { + const buffer = this.state.buffers.find((b) => b.id === bufferId) + if (!buffer) return + const account = this.accountFor(bufferId) + try { + const prefs = await window.moho.prefs.getAll() + const into = + (prefs['downloads.directory'] as string | undefined) || (await window.moho.defaultDownloadDir()) + const answer = await runExport( + (method, params) => window.moho.rpc(method, params), + { + bufferId, + accountId: buffer.accountId, + service: account?.service, + title: bufferDisplayName(buffer.name) + }, + range, + into, + opts + ) + this.toast('info', `Exported ${answer.messages.toLocaleString()} messages to ${answer.path}`) + } catch (e) { + this.toast('error', `Export failed: ${(e as Error).message}`) + } + } + async leaveVoice(accountId: string): Promise { try { await window.moho.rpc('leaveVoiceChannel', { accountId }) diff --git a/src/shared/wire.ts b/src/shared/wire.ts index 9e5ea5e..70709ff 100644 --- a/src/shared/wire.ts +++ b/src/shared/wire.ts @@ -190,6 +190,16 @@ export interface VoiceMember { export interface DccTransfer { id: string accountId: string + /** + * What this row is: a file over DCC, or a conversation being written to + * disk. They share a list because they are the same thing to somebody + * waiting - a long job with a size, a rate and a way to call it off - and + * differ only in how they are described. + * + * Absent on a row from a daemon older than exports, which can only ever + * have been a DCC file. + */ + kind?: 'dcc' | 'export' /** * Which way it is going. Sending and receiving differ in almost nothing * else, so they share a list - what somebody wants to see is their @@ -213,7 +223,7 @@ export interface DccTransfer { received: number /** Bytes a second over the last interval, while it is running. */ rate: number - state: 'offered' | 'receiving' | 'sending' | 'done' | 'declined' | 'failed' + state: 'offered' | 'receiving' | 'sending' | 'paused' | 'done' | 'declined' | 'failed' /** Where it landed, once it has. */ path?: string | null /** Why it failed, or why it was turned down. */ From ad06b4e852aa67ad0bf90ca4a1d585e5132d9a36 Mon Sep 17 00:00:00 2001 From: Salastil Date: Fri, 11 Sep 2026 23:42:17 -0400 Subject: [PATCH 3/3] A conversation's name is the way into its menu, and to exporting it The last piece of #114, and the way in to all of it. The header had a name that was only a label. It is now the conversation's picture and name together, and clicking it opens the menu its row in the list carries - plus Export. Same options on purpose: somebody with the conversation open should not have to go and find its row in a list to mute it. Export is not added to the list's right-click menu, which is already long and would have grown for every user to serve the few who export. The menu itself moved to lib/buffermenu.ts rather than being copied. A hundred lines of entries in two places is the kind of thing that agrees until somebody edits one of them - the same failure this session opened by fixing, one regex at a time, in a comment that claimed two lists were the same list. The move is mechanical: the list's row passes the values it already had. The state behind the header's copy is read from the same preference hooks the list uses - `pinnedBuffers`, `mutedBuffers`, `hiddenBuffers`, `channelCategory` - so there is one answer to what is pinned or hidden rather than two to keep in step. Verified against a daemon on its own socket, driving the RPCs directly: a folder with index.html and media in it, the body part cleaned up after sealing, the transfer row reading kind=export and done, pause and resume, cancel refusing further writes, a second export of the same conversation on the same day landing beside the first rather than on top of it, and a title of "../../escape" staying inside the downloads folder. Closes #114. Co-Authored-By: Claude Opus 5 --- src/renderer/src/App.tsx | 16 +- src/renderer/src/app.css | 36 ++++ src/renderer/src/components/BufferList.tsx | 152 +++----------- .../src/components/ConversationMenu.tsx | 151 ++++++++++++++ src/renderer/src/lib/buffermenu.ts | 194 ++++++++++++++++++ 5 files changed, 425 insertions(+), 124 deletions(-) create mode 100644 src/renderer/src/components/ConversationMenu.tsx create mode 100644 src/renderer/src/lib/buffermenu.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 174d2a0..57bc36a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -26,7 +26,7 @@ import { DiscordModal } from './components/DiscordModal' import { FileDrop } from './components/FileDrop' import { TransferPanel } from './components/TransferPanel' import { Icon, IconButton } from './components/Icon' -import { BufferFace } from './components/BufferFace' +import { ConversationMenu } from './components/ConversationMenu' import { useActiveBuffer, useChat, usePref, usePrefsReady, useStore } from './state/hooks' import { bufferDisplayName } from './lib/util' import type { BufferEntry } from './state/store' @@ -124,10 +124,16 @@ export default function App(): JSX.Element { onClick={() => setSidebarFolded(!sidebarFolded)} /> {/* A conversation is headed by whoever it is with, the same way - its row in the list is - the picture and the status together, - not a bare name. */} - {activePanel === '' && !onMentionsPage && buffer && } - {headerTitle} + its row in the list is - the picture and the name together, + and both are the way into its menu. The row in the list is + somewhere you go to find a conversation; this is where you + already are when you want to do something to the one you are + reading. */} + {activePanel === '' && !onMentionsPage && buffer ? ( + + ) : ( + {headerTitle} + )} {/* The face, the search and the call button all act on the open conversation, which the mentions page is not showing - leaving diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index 14b8140..7b3b47a 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -5370,6 +5370,42 @@ video.lightbox-media { position: relative; } +/* The conversation's name at the top of the window, which is also the way + into its menu. Styled as the heading it already was until it is reached for: + a header that looked like a row of buttons would be busier than the thing it + is heading. */ +.header-nameplate { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; + padding: 3px 6px; + margin: -3px 0; + border: 0; + border-radius: 7px; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.header-nameplate:hover, +.header-nameplate.open { + background: var(--surface-container-high); +} + +.header-nameplate:focus-visible { + outline: 2px solid var(--primary); + outline-offset: -2px; +} + +.header-nameplate .main-header-title { + flex: 1; + min-width: 0; +} + /* --- Exporting a conversation ----------------------------------------- */ .export-choice { diff --git a/src/renderer/src/components/BufferList.tsx b/src/renderer/src/components/BufferList.tsx index 51e075a..3562024 100644 --- a/src/renderer/src/components/BufferList.tsx +++ b/src/renderer/src/components/BufferList.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Icon, MaskIcon } from './Icon' -import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu' +import { ContextMenu, useContextMenu } from './ContextMenu' +import { bufferMenuEntries } from '../lib/buffermenu' import { UserFooter } from './UserFooter' import { VoiceChannels } from './VoiceChannels' import { VoicePanel } from './VoicePanel' @@ -24,8 +25,7 @@ import { bufferKindGlyph, classes, resolveMediaUrl, - serviceIcon, - serviceLabel + serviceIcon } from '../lib/util' import type { Account, Member } from '../../../shared/wire' import { Avatar } from './Avatar' @@ -672,14 +672,6 @@ export function dmStatus( * and is honest about it: you are in it because you are in the guild, so there * is nothing to leave and this only hides it. */ -function leaveLabel(buffer: BufferEntry, service?: string): string { - if (buffer.kind === 'dm') return service === 'discord' ? 'Close conversation' : 'Leave conversation' - if (service === 'matrix') return 'Leave room' - if (service === 'irc') return 'Leave channel' - if (service === 'discord') return 'Remove from list' - return 'Close' -} - function ConnectionDot({ state }: { state: string }): JSX.Element { const color = state === 'connected' @@ -809,114 +801,36 @@ function BufferRow({ // you want to reach, so it is where reaching them should be offered. const canCall = account?.service === 'discord' && buffer.kind === 'dm' - const entries: MenuEntry[] = [ - ...(canCall - ? ([ - inCall - ? { label: 'Hang up', icon: 'call_end', danger: true, onClick: onHangUp } - : { label: 'Call', icon: 'call', onClick: onCall } - ] as MenuEntry[]) - : []), - ...(canCall ? ([{ separator: true }] as MenuEntry[]) : []), - // Above pin and mute because it is the one that opens something: the two - // below change how this row behaves, and this one goes somewhere. - poppedOut - ? { label: 'Close its window', icon: 'close_fullscreen', onClick: onDock } - : { label: 'Open in a new window', icon: 'open_in_new', onClick: onPopOut }, - // A Kick channel is a stream as well as a chat, and the stream is not - // something this client shows - so the way to watch it belongs on the - // row, next to the way to open its chat in a window. - // Which space this room belongs to. A space is the server's own grouping, - // unlike the categories above it, so this writes to the room rather than - // to this window - and a room can be in more than one, which is why the - // one it is in now is offered for removal rather than swapped silently. - ...(spaces && spaces.length > 0 && buffer.kind !== 'server' - ? ([ - ...spaces - .filter((space) => space.id !== buffer.groupId) - .map((space) => ({ - label: `Add to ${space.name}`, - icon: 'move_to_inbox', - onClick: () => void onSpace?.(space.id, true) - })), - ...(spaces.some((space) => space.id === buffer.groupId) - ? [ - { - label: `Remove from ${spaces.find((s) => s.id === buffer.groupId)?.name ?? 'this space'}`, - icon: 'outbox', - onClick: () => void onSpace?.(buffer.groupId ?? '', false) - } - ] - : []), - { separator: true } - ] as MenuEntry[]) - : []), - // Watching is offered only while there is something to watch: an offline - // channel's playlist is a signed URL to nothing, and an entry that fails - // a few seconds after being pressed is worse than one that isn't there. - ...(onWatch && (live || watched) - ? ([ - watched - ? { label: 'Stop watching', icon: 'stop_circle', danger: true, onClick: onStopWatching! } - : { label: 'Watch the stream', icon: 'live_tv', onClick: onWatch } - ] as MenuEntry[]) - : []), - ...(onOpenInBrowser - ? ([{ label: 'Open in browser', icon: 'public', onClick: onOpenInBrowser }] as MenuEntry[]) - : []), - { separator: true }, - // Which channels an account rejoins on connect is a property of the - // channels, so it is set on one - it used to be a comma-separated field - // in the add-account form, typed once and never seen again. - ...(onToggleAutojoin - ? ([ - { - label: autojoins ? 'Do not join automatically' : 'Join automatically', - icon: autojoins ? 'bookmark_remove' : 'bookmark_add', - onClick: onToggleAutojoin - } - ] as MenuEntry[]) - : []), - { label: pinned ? 'Unpin' : 'Pin', icon: 'push_pin', onClick: onTogglePin }, - // A mute made on the account itself is not this window's to undo, and an - // "Unmute" that quietly did nothing would be worse than no entry at all - - // so it says where the mute is, and where to go and take it off. - buffer.serverMuted - ? { - label: account ? `Muted on ${serviceLabel(account.service)}` : 'Muted on this account', - icon: 'notifications_off', - disabled: true, - onClick: () => {} - } - : { label: muted ? 'Unmute' : 'Mute', icon: muted ? 'notifications' : 'notifications_off', onClick: onToggleMute }, - { separator: true }, - // Filing is a drag onto the heading now, not an entry per heading. That - // list grew with the number of headings and had no ceiling - on a server - // with twenty categories the menu was taller than the screen, which made - // every other entry on it unreachable. - // - // Taking a channel back out has no gesture, since the channels under no - // heading are drawn without one to drop onto - so it stays here, as the - // one entry it always was, and only where there is something to undo. - ...(filed - ? ([ - { separator: true }, - { label: 'Remove from category', icon: 'folder_off', onClick: () => onFile('') }, - { separator: true } - ] as MenuEntry[]) - : []), - { label: 'Hide', icon: 'visibility_off', onClick: onHide }, - ...(buffer.kind === 'server' - ? [] - : ([ - { - label: leaveLabel(buffer, account?.service), - icon: 'close', - danger: true, - onClick: onClose - } - ] as MenuEntry[])) - ] + // The same entries the header's nameplate opens - see lib/buffermenu.ts for + // why they live there rather than here. + const entries = bufferMenuEntries({ + buffer, + account, + muted, + pinned, + filed, + inCall, + poppedOut, + canCall, + live, + watched, + autojoins, + spaces, + onTogglePin, + onToggleMute, + onToggleAutojoin, + onSpace, + onWatch, + onStopWatching, + onOpenInBrowser, + onHide, + onClose, + onCall, + onHangUp, + onFile, + onPopOut, + onDock + }) return ( <> diff --git a/src/renderer/src/components/ConversationMenu.tsx b/src/renderer/src/components/ConversationMenu.tsx new file mode 100644 index 0000000..f5aee41 --- /dev/null +++ b/src/renderer/src/components/ConversationMenu.tsx @@ -0,0 +1,151 @@ +import { useState } from 'react' +import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu' +import { ExportDialog } from './ExportDialog' +import { Avatar } from './Avatar' +import { bufferMenuEntries } from '../lib/buffermenu' +import { dmStatus } from './BufferList' +import { bufferDisplayName, classes } from '../lib/util' +import { useChat, useStore } from '../state/hooks' +import { useIdSetPref, useMapPref, usePref } from '../state/hooks' +import type { BufferEntry } from '../state/store' + +/** + * The conversation's own name at the top of the window, and the menu behind it. + * + * Two things wanted a home and this is both. The header had a name that was + * only a label, and export needed somewhere to live that was not the buffer + * list's right-click menu - which is already long, and would have been made + * longer for every user to serve the few who export anything. + * + * The menu is the same one the row carries, built by the same code (see + * lib/buffermenu.ts), with export added. Same options on purpose: somebody who + * has the conversation open should not have to go and find its row in a list + * to mute it. + * + * The state behind it is read from the same preference hooks the list uses, so + * there is one answer to what is pinned or hidden rather than two that have to + * be kept in step. + */ +export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Element { + const store = useStore() + const accounts = useChat((s) => s.accounts) + const groups = useChat((s) => s.groups) + const voiceSessions = useChat((s) => s.voiceSessions) + const popouts = useChat((s) => s.popouts) + const live = useChat((s) => s.kickStreams[buffer.id]?.live ?? false) + const presence = useChat((s) => s.presenceByBuffer) + const buffers = useChat((s) => s.buffers) + const watched = useChat((s) => s.watching?.bufferId === buffer.id) + + const [, togglePin, isPinned] = useIdSetPref('pinnedBuffers') + const [, toggleMute, isMuted] = useIdSetPref('mutedBuffers') + const [hidden, setHidden] = usePref('hiddenBuffers', []) + const [assignment, setAssignment] = useMapPref('channelCategory') + + const { menu, open, close } = useContextMenu() + const [exporting, setExporting] = useState(false) + const [oldestHeld, setOldestHeld] = useState() + + const account = accounts.find((a) => a.id === buffer.accountId) + const name = bufferDisplayName(buffer.name) + + const entries: MenuEntry[] = [ + ...bufferMenuEntries({ + buffer, + account, + muted: isMuted(buffer.id), + pinned: isPinned(buffer.id), + filed: !!assignment[buffer.id], + inCall: voiceSessions.some((s) => s.bufferId === buffer.id), + poppedOut: popouts.open.includes(buffer.id), + canCall: account?.service === 'discord' && buffer.kind === 'dm', + live, + watched, + autojoins: store.autojoins(buffer.accountId, name), + spaces: groups.filter((g) => g.kind === 'space' && g.accountId === buffer.accountId), + onTogglePin: () => togglePin(buffer.id), + onToggleMute: () => { + toggleMute(buffer.id) + if (account?.service === 'matrix') { + void window.moho + .rpc('setMatrixRoomMuted', { bufferId: buffer.id, muted: !isMuted(buffer.id) }) + .catch((e: Error) => store.toast('error', e.message)) + } + }, + onToggleAutojoin: + buffer.kind === 'channel' && account?.service === 'irc' + ? () => store.toggleAutojoin(buffer.accountId, name) + : undefined, + onSpace: (spaceId, child) => + void store.setMatrixSpaceChild(spaceId, buffer.id, child).catch((e: Error) => store.toast('error', e.message)), + onWatch: buffer.accountId.startsWith('kick:') ? () => store.watchStream(buffer.id) : undefined, + onStopWatching: () => store.stopWatching(), + onOpenInBrowser: buffer.accountId.startsWith('kick:') + ? () => void window.moho.openExternal(`https://kick.com/${name}`) + : undefined, + onHide: () => { + if (!hidden.includes(buffer.id)) setHidden([...hidden, buffer.id]) + }, + onClose: () => void store.closeBuffer(buffer.id), + onCall: () => void store.callBuffer(buffer.id), + onHangUp: () => void store.leaveVoice(buffer.accountId), + onFile: (categoryId) => setAssignment(buffer.id, categoryId), + onPopOut: () => store.popOut(buffer.id), + onDock: () => store.dock(buffer.id) + }), + { separator: true }, + // Last, and on its own. It is the one entry here that starts something + // long rather than changing a setting, and the only one that writes + // outside moho. + { + label: 'Export…', + icon: 'download', + onClick: () => { + // Asked before the dialog opens so it can say what it will not be able + // to reach, rather than finding out halfway through. + void window.moho + .rpc<{ oldestHeld: number | null }>('countMessageRange', { bufferId: buffer.id, after: 0 }) + .then((a) => setOldestHeld(a.oldestHeld ?? undefined)) + .catch(() => setOldestHeld(undefined)) + .finally(() => setExporting(true)) + } + } + ] + + return ( + <> + + + {menu && } + + {exporting && ( + setExporting(false)} + onConfirm={(range, opts) => { + setExporting(false) + void store.exportConversation(buffer.id, range, opts) + }} + /> + )} + + ) +} diff --git a/src/renderer/src/lib/buffermenu.ts b/src/renderer/src/lib/buffermenu.ts new file mode 100644 index 0000000..c15e510 --- /dev/null +++ b/src/renderer/src/lib/buffermenu.ts @@ -0,0 +1,194 @@ +import type { MenuEntry } from '../components/ContextMenu' +import { serviceLabel } from './util' +import type { Account, BufferGroup } from '../../../shared/wire' +import type { BufferEntry } from '../state/store' + +/** + * The menu a conversation carries, in one place because two things open it. + * + * It began inline in the buffer list's own row, which was right while the row + * was the only way to reach it. The header's nameplate opens the same menu + * now, and a second copy of a hundred lines of entries is the kind of thing + * that agrees with the first until somebody edits one of them. + * + * A bag of values rather than a component's props: the two callers know + * different amounts about a conversation - the row knows it is being dragged, + * the header knows it is the one on screen - and what they share is exactly + * what is below. + */ +export interface BufferMenuInput { + buffer: BufferEntry + account?: Account + muted: boolean + pinned: boolean + filed: boolean + inCall: boolean + poppedOut: boolean + canCall: boolean + /** Kick only: whether the channel is on air, and whether it is the one playing here. */ + live: boolean + watched: boolean + autojoins?: boolean + spaces?: BufferGroup[] + onTogglePin: () => void + onToggleMute: () => void + onToggleAutojoin?: () => void + onSpace?: (spaceId: string, child: boolean) => void + onWatch?: () => void + onStopWatching?: () => void + onOpenInBrowser?: () => void + onHide: () => void + onClose: () => void + onCall: () => void + onHangUp: () => void + onFile: (categoryId: string) => void + onPopOut: () => void + onDock: () => void +} + +/** What leaving is called, which depends on what is being left. */ +export function leaveLabel(buffer: BufferEntry, service?: string): string { + if (buffer.kind === 'dm') return service === 'discord' ? 'Close conversation' : 'Leave conversation' + if (service === 'matrix') return 'Leave room' + if (service === 'irc') return 'Leave channel' + if (service === 'discord') return 'Remove from list' + return 'Close' +} + +export function bufferMenuEntries({ + buffer, + account, + muted, + pinned, + filed, + inCall, + poppedOut, + canCall, + live, + watched, + autojoins, + spaces, + onTogglePin, + onToggleMute, + onToggleAutojoin, + onSpace, + onWatch, + onStopWatching, + onOpenInBrowser, + onHide, + onClose, + onCall, + onHangUp, + onFile, + onPopOut, + onDock +}: BufferMenuInput): MenuEntry[] { + return [ + ...(canCall + ? ([ + inCall + ? { label: 'Hang up', icon: 'call_end', danger: true, onClick: onHangUp } + : { label: 'Call', icon: 'call', onClick: onCall } + ] as MenuEntry[]) + : []), + ...(canCall ? ([{ separator: true }] as MenuEntry[]) : []), + // Above pin and mute because it is the one that opens something: the two + // below change how this row behaves, and this one goes somewhere. + poppedOut + ? { label: 'Close its window', icon: 'close_fullscreen', onClick: onDock } + : { label: 'Open in a new window', icon: 'open_in_new', onClick: onPopOut }, + // A Kick channel is a stream as well as a chat, and the stream is not + // something this client shows - so the way to watch it belongs on the + // row, next to the way to open its chat in a window. + // Which space this room belongs to. A space is the server's own grouping, + // unlike the categories above it, so this writes to the room rather than + // to this window - and a room can be in more than one, which is why the + // one it is in now is offered for removal rather than swapped silently. + ...(spaces && spaces.length > 0 && buffer.kind !== 'server' + ? ([ + ...spaces + .filter((space) => space.id !== buffer.groupId) + .map((space) => ({ + label: `Add to ${space.name}`, + icon: 'move_to_inbox', + onClick: () => void onSpace?.(space.id, true) + })), + ...(spaces.some((space) => space.id === buffer.groupId) + ? [ + { + label: `Remove from ${spaces.find((s) => s.id === buffer.groupId)?.name ?? 'this space'}`, + icon: 'outbox', + onClick: () => void onSpace?.(buffer.groupId ?? '', false) + } + ] + : []), + { separator: true } + ] as MenuEntry[]) + : []), + // Watching is offered only while there is something to watch: an offline + // channel's playlist is a signed URL to nothing, and an entry that fails + // a few seconds after being pressed is worse than one that isn't there. + ...(onWatch && (live || watched) + ? ([ + watched + ? { label: 'Stop watching', icon: 'stop_circle', danger: true, onClick: onStopWatching! } + : { label: 'Watch the stream', icon: 'live_tv', onClick: onWatch } + ] as MenuEntry[]) + : []), + ...(onOpenInBrowser + ? ([{ label: 'Open in browser', icon: 'public', onClick: onOpenInBrowser }] as MenuEntry[]) + : []), + { separator: true }, + // Which channels an account rejoins on connect is a property of the + // channels, so it is set on one - it used to be a comma-separated field + // in the add-account form, typed once and never seen again. + ...(onToggleAutojoin + ? ([ + { + label: autojoins ? 'Do not join automatically' : 'Join automatically', + icon: autojoins ? 'bookmark_remove' : 'bookmark_add', + onClick: onToggleAutojoin + } + ] as MenuEntry[]) + : []), + { label: pinned ? 'Unpin' : 'Pin', icon: 'push_pin', onClick: onTogglePin }, + // A mute made on the account itself is not this window's to undo, and an + // "Unmute" that quietly did nothing would be worse than no entry at all - + // so it says where the mute is, and where to go and take it off. + buffer.serverMuted + ? { + label: account ? `Muted on ${serviceLabel(account.service)}` : 'Muted on this account', + icon: 'notifications_off', + disabled: true, + onClick: () => {} + } + : { label: muted ? 'Unmute' : 'Mute', icon: muted ? 'notifications' : 'notifications_off', onClick: onToggleMute }, + { separator: true }, + // Filing is a drag onto the heading now, not an entry per heading. That + // list grew with the number of headings and had no ceiling - on a server + // with twenty categories the menu was taller than the screen, which made + // every other entry on it unreachable. + // + // Taking a channel back out has no gesture, since the channels under no + // heading are drawn without one to drop onto - so it stays here, as the + // one entry it always was, and only where there is something to undo. + ...(filed + ? ([ + { separator: true }, + { label: 'Remove from category', icon: 'folder_off', onClick: () => onFile('') }, + { separator: true } + ] as MenuEntry[]) + : []), + { label: 'Hide', icon: 'visibility_off', onClick: onHide }, + ...(buffer.kind === 'server' + ? [] + : ([ + { + label: leaveLabel(buffer, account?.service), + icon: 'close', + danger: true, + onClick: onClose + } + ] as MenuEntry[])) + ] +}