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 <[email protected]>
This commit is contained in:
+1
-1
Submodule nobilis updated: 7822d32115...f3a92e64f1
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<div className="download-row">
|
||||
@@ -101,7 +106,7 @@ function Row({ transfer }: { transfer: DccTransfer }): JSX.Element {
|
||||
</div>
|
||||
<div className="small muted download-detail">
|
||||
<span className="download-from">
|
||||
{transfer.outgoing ? 'to' : 'from'} {transfer.from}
|
||||
{isExport ? 'export of' : transfer.outgoing ? 'to' : 'from'} {transfer.from}
|
||||
</span>
|
||||
{/* 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)}
|
||||
</span>
|
||||
</div>
|
||||
{running && <TransferBar transfer={transfer} />}
|
||||
{(running || paused) && <TransferBar transfer={transfer} />}
|
||||
</div>
|
||||
|
||||
<div className="download-actions">
|
||||
@@ -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) && (
|
||||
<IconButton
|
||||
name={paused ? 'play_arrow' : 'pause'}
|
||||
title={paused ? 'Carry on with this export' : 'Put this export down for now'}
|
||||
onClick={() => void store.pauseTransfer(transfer.id, !paused)}
|
||||
/>
|
||||
)}
|
||||
{(running || paused || offered || waiting) && (
|
||||
<IconButton
|
||||
name="close"
|
||||
title={offered ? 'Decline this file' : waiting ? 'Withdraw this offer' : 'Cancel this transfer'}
|
||||
title={
|
||||
offered
|
||||
? 'Decline this file'
|
||||
: waiting
|
||||
? 'Withdraw this offer'
|
||||
: isExport
|
||||
? 'Stop this export - what has been written is kept'
|
||||
: 'Cancel this transfer'
|
||||
}
|
||||
className="calling"
|
||||
onClick={() => 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':
|
||||
|
||||
@@ -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(
|
||||
<div className="lightbox-backdrop" onClick={onCancel}>
|
||||
<div className="reason-prompt" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
|
||||
<div className="reason-prompt-head">
|
||||
<Icon name="download" size={18} />
|
||||
<span>Export {title}</span>
|
||||
</div>
|
||||
<p className="small muted">
|
||||
Written as a folder of HTML, the way it looks here, into your downloads.
|
||||
</p>
|
||||
|
||||
<label className="export-choice">
|
||||
<input type="radio" checked={everything} onChange={() => setEverything(true)} />
|
||||
<span>Everything</span>
|
||||
</label>
|
||||
<label className="export-choice">
|
||||
<input type="radio" checked={!everything} onChange={() => setEverything(false)} />
|
||||
<span>A range</span>
|
||||
</label>
|
||||
|
||||
{!everything && (
|
||||
<div className="export-range">
|
||||
<label>
|
||||
<span className="small muted">From</span>
|
||||
<input type="date" value={from} max={to} onChange={(e) => setFrom(e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="small muted">To</span>
|
||||
<input type="date" value={to} min={from} onChange={(e) => setTo(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="export-choice">
|
||||
<input type="checkbox" checked={media} onChange={(e) => setMedia(e.target.checked)} />
|
||||
<span>
|
||||
Download pictures and video
|
||||
<span className="small muted"> — slower, but the export still reads offline</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{shortfall && (
|
||||
<p className="small warn-text">
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="reason-prompt-actions">
|
||||
<button type="button" className="button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
disabled={!ready}
|
||||
onClick={() => onConfirm({ since, until }, { media })}
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="rail-tile rail-cog"
|
||||
title={busy > 0 ? `Accounts and settings - ${busy} file(s) arriving` : 'Accounts and settings'}
|
||||
title={busy > 0 ? `Accounts and settings - ${busyLabel}` : 'Accounts and settings'}
|
||||
aria-label="Accounts and settings"
|
||||
// Opened by left click, unlike the tiles above it - it is a menu
|
||||
// button, not a thing being acted upon.
|
||||
@@ -877,7 +894,9 @@ function RailMenu(): JSX.Element {
|
||||
{/* Where a minimised transfer goes, so putting one away does not look
|
||||
like losing it. A count rather than a dot, because the number is
|
||||
the thing you are checking when you look. */}
|
||||
{busy > 0 && <span className="rail-downloads">{busy}</span>}
|
||||
{busy > 0 && (
|
||||
<span className={exporting > 0 ? 'rail-downloads exporting' : 'rail-downloads'}>{busy}</span>
|
||||
)}
|
||||
</button>
|
||||
{menu && (
|
||||
<ContextMenu
|
||||
|
||||
@@ -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<void> => 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<boolean> {
|
||||
for (;;) {
|
||||
const s = await rpc<Status>('exportStatus', { id })
|
||||
if (s.cancelled || s.done) return false
|
||||
if (!s.paused) return true
|
||||
await sleep(500)
|
||||
}
|
||||
}
|
||||
|
||||
type Rpc = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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<Message[]>('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<string> {
|
||||
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<string>()
|
||||
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'
|
||||
? `<div><video controls src="${src}"></video></div>`
|
||||
: `<div><a href="${src}"><img loading="lazy" alt="${label}" src="${src}"></a></div>`
|
||||
}
|
||||
}
|
||||
|
||||
// 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 ? ` <a href="${escapeHtml(e.url)}">${escapeHtml(e.url)}</a>` : ''
|
||||
cards += `<blockquote>${bits.join('<br>')}${link}</blockquote>`
|
||||
}
|
||||
|
||||
const system = m.kind && m.kind !== 'chat' ? ' system' : ''
|
||||
const who = m.isAction ? `* ${m.from}` : m.from
|
||||
const edited = m.edited ? ' <span class="dim">(edited)</span>' : ''
|
||||
return (
|
||||
`<div class="msg${system}" id="m-${escapeHtml(m.id)}">` +
|
||||
`<time datetime="${when.toISOString()}">${escapeHtml(stamp)}</time>` +
|
||||
`<span class="who">${escapeHtml(who)}</span>` +
|
||||
`<span class="what">${body}${edited}${cards}${media}</span>` +
|
||||
`</div>\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<Message[]>('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()}`
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
try {
|
||||
await window.moho.rpc('leaveVoiceChannel', { accountId })
|
||||
|
||||
+11
-1
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user