Merge branch 'development'
This commit is contained in:
@@ -74,6 +74,27 @@ degrade to text and `javascript:`/`data:` URLs, inline event handlers and `<scri
|
||||
survive. Local media nobilis has fetched is served through a dedicated scheme that resolves only
|
||||
inside the known cache directories, rather than by disabling `webSecurity`.
|
||||
|
||||
#### No plugins, and no scripting
|
||||
|
||||
moho does not have a plugin API or a scripting language, and will not grow one. This is a
|
||||
decision rather than a gap, and it is the same decision as everything above: the client does not
|
||||
execute code.
|
||||
|
||||
It is the one place moho deliberately falls short of the clients it is otherwise measured
|
||||
against — HexChat has Perl and Python, WeeChat has Lua, Python, Perl and Ruby, mIRC has its own
|
||||
language — so a comparison that counts scripting will always find moho missing it, and that is
|
||||
the intended answer rather than a backlog item.
|
||||
|
||||
The reasoning is that a scripting host is an execution surface pointed at exactly the data that
|
||||
is fully attacker-controlled. Every mitigation above — the fixed preload surface, the tag and
|
||||
attribute whitelist, the media scheme — exists to keep message content from ever becoming
|
||||
something that runs. A plugin API hands that back in one step, and does it with more privilege
|
||||
than the renderer has: scripts want the filesystem, the network and the message stream, which is
|
||||
the whole of what an attacker would ask for. The daemon/client split makes this cheaper to hold
|
||||
to than it would otherwise be, since anything that genuinely needs to automate moho can speak the
|
||||
wire protocol in `Wire protocol` below as a separate process, with its own permissions and its
|
||||
own blast radius.
|
||||
|
||||
### Global hotkey on Wayland
|
||||
|
||||
The show/hide hotkey (`Control+Shift+M` by default, configurable) uses Electron's
|
||||
|
||||
+1
-1
Submodule nobilis updated: 7822d32115...f3a92e64f1
@@ -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 && <BufferFace buffer={buffer} />}
|
||||
<span className="main-header-title ellipsis">{headerTitle}</span>
|
||||
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 ? (
|
||||
<ConversationMenu buffer={buffer} />
|
||||
) : (
|
||||
<span className="main-header-title ellipsis">{headerTitle}</span>
|
||||
)}
|
||||
|
||||
{/* The face, the search and the call button all act on the open
|
||||
conversation, which the mentions page is not showing - leaving
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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<string[]>('hiddenBuffers', [])
|
||||
const [assignment, setAssignment] = useMapPref<string>('channelCategory')
|
||||
|
||||
const { menu, open, close } = useContextMenu()
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [oldestHeld, setOldestHeld] = useState<number | undefined>()
|
||||
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={classes('header-nameplate', menu && 'open')}
|
||||
title={`${name} — options`}
|
||||
aria-haspopup="menu"
|
||||
onClick={(e) => open(e)}
|
||||
>
|
||||
{buffer.kind === 'dm' || buffer.avatarUrl ? (
|
||||
<Avatar
|
||||
name={buffer.name}
|
||||
url={buffer.avatarUrl}
|
||||
size={24}
|
||||
status={dmStatus(buffer, presence, buffers)}
|
||||
/>
|
||||
) : null}
|
||||
<span className="main-header-title ellipsis">{name}</span>
|
||||
</button>
|
||||
|
||||
{menu && <ContextMenu x={menu.x} y={menu.y} entries={entries} onClose={close} />}
|
||||
|
||||
{exporting && (
|
||||
<ExportDialog
|
||||
title={name}
|
||||
service={account?.service}
|
||||
oldestHeld={oldestHeld}
|
||||
onCancel={() => setExporting(false)}
|
||||
onConfirm={(range, opts) => {
|
||||
setExporting(false)
|
||||
void store.exportConversation(buffer.id, range, opts)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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,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[]))
|
||||
]
|
||||
}
|
||||
@@ -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