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 ` 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 0a86941..7b3b47a 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -5340,11 +5340,108 @@ 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; } +/* 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 { + 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/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/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 && ( 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[])) + ] +} diff --git a/src/renderer/src/lib/exporter.ts b/src/renderer/src/lib/exporter.ts new file mode 100644 index 0000000..d2ccb71 --- /dev/null +++ b/src/renderer/src/lib/exporter.ts @@ -0,0 +1,290 @@ +import { escapeHtml, extractMedia, formatMessage } from './format' +import { fileNameOf } from './util' +import type { Message } from '../../../shared/wire' + +/** + * Writing a conversation to disk, from this side of the split. + * + * The window renders and the daemon writes - see nobilis/src/export.rs for the + * other half and why the line is drawn there. The short of it: the HTML a + * message becomes is made here, by `formatMessage`, and an export is meant to + * be what was on screen rather than a second opinion about it. So the loop + * lives here, beside the formatter, and everything that is not formatting + * happens on the far side of an RPC. + * + * Three passes, in order, because each needs the one before it: + * + * 1. **Reach back.** The range may run older than anything held locally, so + * older pages are pulled until the start of the range is covered or the + * service stops giving them out. + * 2. **Read and render.** The range is walked forward in pages, each turned + * into HTML and handed over. + * 3. **Seal it.** `index.html` is written last, so an unfinished export is + * visibly unfinished. + */ + +/** How long to wait between asking a service for another page of history. */ +const PAGE_PACE_MS = 400 + +/** How many pages back to ask for before giving up on reaching the start. */ +const MAX_BACKFILL_PAGES = 400 + +/** How many messages to render at once. */ +const RENDER_PAGE = 200 + +export interface ExportRange { + /** Unix seconds, exclusive. Zero means "from the beginning". */ + since: number + /** Unix seconds, inclusive. */ + until: number +} + +export interface ExportTarget { + bufferId: string + accountId: string + service?: string + title: string +} + +const sleep = (ms: number): Promise => 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. */