Merge branch 'development'

This commit is contained in:
2026-09-11 23:42:31 -04:00
13 changed files with 1041 additions and 133 deletions
+21
View File
@@ -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
+11 -5
View File
@@ -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
+97
View File
@@ -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
+33 -119
View File
@@ -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)
}}
/>
)}
</>
)
}
+39 -4
View File
@@ -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
)
}
+22 -3
View File
@@ -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
+194
View File
@@ -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[]))
]
}
+290
View File
@@ -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()}`
}
+48
View File
@@ -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
View File
@@ -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. */