From bfd892ade11e2bddfcd5ba36fd113db6eeac7efa Mon Sep 17 00:00:00 2001 From: Salastil Date: Fri, 28 Aug 2026 01:08:01 -0400 Subject: [PATCH] Ask before leaving a server, and mean the delay Issue #20. Leaving is now offered from the rail tile's own menu, behind a confirmation whose Leave button is greyed for ten seconds. The delay is the point rather than decoration. Leaving is one click in a context menu and permanent in effect - rejoining needs an invite, and for somewhere you were invited to years ago there may be nobody left to ask. A button that is simply there gets pressed by the hand that was already moving; one that arrives a few seconds later is pressed by somebody who read the sentence above it. Cancel works throughout, and so does Escape, so the wait never traps anyone - it only slows down the one direction that matters. Co-Authored-By: Claude Opus 5 --- nobilis | 2 +- src/renderer/src/app.css | 39 +++++++++++ src/renderer/src/components/LeaveConfirm.tsx | 69 ++++++++++++++++++++ src/renderer/src/components/ServerRail.tsx | 40 +++++++++++- src/renderer/src/state/store.ts | 34 +++++++++- 5 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/components/LeaveConfirm.tsx diff --git a/nobilis b/nobilis index e56e3a9..caf56c7 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit e56e3a9b7e1a95b947b440ee58dc7d1ab3af4b0d +Subproject commit caf56c7e731bda5c555484f2c46a91c888cfbd7e diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index 39c7952..7c30350 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -875,6 +875,45 @@ img.composer-emoji { color: var(--warning); } +/* Confirming something that cannot be undone. Sits on the same backdrop the + lightbox uses, so there is one way for this app to say "deal with this + first". */ +.leave-confirm { + max-width: 420px; + padding: var(--spacing-l); + border-radius: var(--corner-radius); + background: var(--surface-container-high); + border: 1px solid var(--outline-medium); + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.6); +} + +.leave-confirm-head { + display: flex; + align-items: center; + gap: var(--spacing-s); + font-size: var(--font-size-large); + font-weight: 600; + margin-bottom: var(--spacing-s); +} + +.leave-confirm p { + color: var(--surface-variant-text); + margin-bottom: var(--spacing-l); +} + +.leave-confirm-actions { + display: flex; + justify-content: flex-end; + gap: var(--spacing-s); +} + +/* The one button in the app that destroys something. Not --error, which is a + pale text colour and reads as decorative when used as a fill. */ +.button.danger { + background: #c9403c; + color: #fff; +} + /* --- incoming calls ---------------------------------------------------- */ /* Bottom left, above the account plaque, which is where Discord puts it and diff --git a/src/renderer/src/components/LeaveConfirm.tsx b/src/renderer/src/components/LeaveConfirm.tsx new file mode 100644 index 0000000..f6dcee5 --- /dev/null +++ b/src/renderer/src/components/LeaveConfirm.tsx @@ -0,0 +1,69 @@ +import { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { Icon } from './Icon' + +/** + * Confirming something that cannot be undone from here. + * + * The delay is the point. Leaving a server is one click in a context menu and + * permanent in effect - rejoining needs an invite, and for somewhere you were + * invited to years ago there may be nobody left to ask. A button that is + * simply *there* gets pressed by the hand that was already moving; one that + * arrives a few seconds later is pressed by somebody who read the sentence + * above it. + * + * Cancel is available throughout and is what Escape does, so the delay never + * traps anyone - it only slows down the one direction that matters. + */ +const COUNTDOWN_SECONDS = 10 + +export function LeaveConfirm(props: { + name: string + /** What is lost, in the words of the service it belongs to. */ + detail: string + onLeave: () => void + onCancel: () => void +}): JSX.Element { + const { name, detail, onLeave, onCancel } = props + const [left, setLeft] = useState(COUNTDOWN_SECONDS) + + useEffect(() => { + if (left <= 0) return + const t = setTimeout(() => setLeft((n) => n - 1), 1000) + return () => clearTimeout(t) + }, [left]) + + useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') onCancel() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onCancel]) + + return createPortal( +
+
e.stopPropagation()} role="dialog" aria-modal="true"> +
+ + Leave {name}? +
+

{detail}

+
+ + +
+
+
, + document.body + ) +} diff --git a/src/renderer/src/components/ServerRail.tsx b/src/renderer/src/components/ServerRail.tsx index 119219b..fdc8fce 100644 --- a/src/renderer/src/components/ServerRail.tsx +++ b/src/renderer/src/components/ServerRail.tsx @@ -2,6 +2,7 @@ import { useRef, useState } from 'react' import { ContextMenu, useContextMenu } from './ContextMenu' import { Icon, IconButton, MaskIcon } from './Icon' import { Avatar } from './Avatar' +import { LeaveConfirm } from './LeaveConfirm' import { useChat, usePref, useStore } from '../state/hooks' import { bufferDisplayName, classes, nickColor, resolveMediaUrl, serviceIcon } from '../lib/util' import { @@ -57,6 +58,8 @@ interface TileProps { mergeTarget: boolean muted: boolean onToggleMute: () => void + /** Absent where there is nothing to leave - an account's own entry. */ + onLeave?: () => void onSelect: () => void onDragStart: () => void onDragOver: (merge: boolean) => void @@ -181,7 +184,21 @@ function RailTile(props: TileProps): JSX.Element { label: muted ? `Unmute ${group.name}` : `Mute ${group.name}`, icon: muted ? 'notifications_active' : 'notifications_off', onClick: props.onToggleMute - } + }, + // Only where there is something to leave. An account's own entry + // stands for the connection itself, which is disconnected from + // the accounts pane rather than left. + ...(props.onLeave + ? [ + { separator: true } as const, + { + label: group.kind === 'space' ? 'Leave space' : 'Leave server', + icon: 'logout', + danger: true, + onClick: props.onLeave + } as const + ] + : []) ]} onClose={close} /> @@ -275,6 +292,8 @@ export function ServerRail(): JSX.Element | null { const [over, setOver] = useState('') // The tile a drop would merge into, as opposed to land beside. const [merging, setMerging] = useState('') + /** The rail entry a leave has been asked about, pending confirmation. */ + const [leaving, setLeaving] = useState(null) const beginDrag = (id: string): void => { draggingRef.current = id @@ -385,6 +404,9 @@ export function ServerRail(): JSX.Element | null { ) } active={g.id === activeGroupId} + onLeave={ + g.kind === 'guild' || g.kind === 'space' ? () => setLeaving(g) : undefined + } unread={t?.unread ?? 0} highlight={t?.highlight ?? false} // Direct messages and pinned lead the rail by definition, so @@ -525,6 +547,22 @@ export function ServerRail(): JSX.Element | null { /> )} + {leaving && ( + setLeaving(null)} + onLeave={() => { + void store.leaveGroup(leaving.id) + setLeaving(null) + }} + /> + )} +
diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/state/store.ts index 0ecc3de..d20082c 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/state/store.ts @@ -322,6 +322,20 @@ export class ChatStore { * downloading. Replaced by id rather than appended, then re-sorted so a * late arrival lands in the right place instead of at the end. */ + /** + * Takes a rail entry away, after leaving what it stood for. + * + * Also moves off it if it was the one being read: the pane below would + * otherwise be showing the channels of somewhere that no longer exists. + */ + private removeGroup(groupId: string): void { + const groups = this.state.groups.filter((g) => g.id !== groupId) + this.set({ groups }) + if (this.state.activeGroupId === groupId) { + this.set({ activeGroupId: groups[0]?.id ?? '', activeBufferId: '' }) + } + } + private upsertGroup(group: BufferGroup): void { const rest = this.state.groups.filter((g) => g.id !== group.id) const groups = [...rest, group].sort( @@ -423,6 +437,21 @@ export class ChatStore { } } + /** + * Leaves a guild or space for real. + * + * The daemon takes the rail entry away once the server has agreed, so + * nothing is removed here on optimism - leaving is the one action where + * showing it as done before it is would be worse than a moment's delay. + */ + async leaveGroup(groupId: string): Promise { + try { + await window.moho.rpc('leaveGroup', { groupId }) + } catch (e) { + this.toast('error', `Couldn't leave: ${(e as Error).message}`) + } + } + /** Asks the log to scroll to a message, and to stop following the tail. */ setJumpTarget(messageId: string): void { this.set({ jumpTarget: messageId }) @@ -616,7 +645,10 @@ export class ChatStore { break case 'bufferGroupChange': - this.upsertGroup(data as BufferGroup) + // A leave arrives as a removal rather than a new shape. Passing that + // to the upsert would add a rail entry with no name and no service. + if (data.removed) this.removeGroup(data.id as string) + else this.upsertGroup(data as BufferGroup) break // The call itself came up or went away. Separate from membership