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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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[]))
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user