A conversation's name is the way into its menu, and to exporting it

The last piece of #114, and the way in to all of it.

The header had a name that was only a label. It is now the conversation's
picture and name together, and clicking it opens the menu its row in the
list carries - plus Export. Same options on purpose: somebody with the
conversation open should not have to go and find its row in a list to mute
it. Export is not added to the list's right-click menu, which is already
long and would have grown for every user to serve the few who export.

The menu itself moved to lib/buffermenu.ts rather than being copied. A
hundred lines of entries in two places is the kind of thing that agrees
until somebody edits one of them - the same failure this session opened by
fixing, one regex at a time, in a comment that claimed two lists were the
same list. The move is mechanical: the list's row passes the values it
already had.

The state behind the header's copy is read from the same preference hooks
the list uses - `pinnedBuffers`, `mutedBuffers`, `hiddenBuffers`,
`channelCategory` - so there is one answer to what is pinned or hidden
rather than two to keep in step.

Verified against a daemon on its own socket, driving the RPCs directly: a
folder with index.html and media in it, the body part cleaned up after
sealing, the transfer row reading kind=export and done, pause and resume,
cancel refusing further writes, a second export of the same conversation on
the same day landing beside the first rather than on top of it, and a title
of "../../escape" staying inside the downloads folder.

Closes #114.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-11 23:42:17 -04:00
co-authored by Claude Opus 5
parent a68752e0ad
commit ad06b4e852
5 changed files with 425 additions and 124 deletions
+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
+36
View File
@@ -5370,6 +5370,42 @@ video.lightbox-media {
position: relative;
}
/* The conversation's name at the top of the window, which is also the way
into its menu. Styled as the heading it already was until it is reached for:
a header that looked like a row of buttons would be busier than the thing it
is heading. */
.header-nameplate {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
padding: 3px 6px;
margin: -3px 0;
border: 0;
border-radius: 7px;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.header-nameplate:hover,
.header-nameplate.open {
background: var(--surface-container-high);
}
.header-nameplate:focus-visible {
outline: 2px solid var(--primary);
outline-offset: -2px;
}
.header-nameplate .main-header-title {
flex: 1;
min-width: 0;
}
/* --- Exporting a conversation ----------------------------------------- */
.export-choice {
+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)
}}
/>
)}
</>
)
}
+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[]))
]
}