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 <[email protected]>
This commit is contained in:
2026-08-28 01:08:01 -04:00
co-authored by Claude Opus 5
parent 2745d135ff
commit bfd892ade1
5 changed files with 181 additions and 3 deletions
+1 -1
Submodule nobilis updated: e56e3a9b7e...caf56c7e73
+39
View File
@@ -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
@@ -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(
<div className="lightbox-backdrop" onClick={onCancel}>
<div className="leave-confirm" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
<div className="leave-confirm-head">
<Icon name="warning" size={20} color="var(--warning)" />
<span>Leave {name}?</span>
</div>
<p className="small">{detail}</p>
<div className="leave-confirm-actions">
<button type="button" className="button" onClick={onCancel} autoFocus>
Cancel
</button>
<button
type="button"
className="button danger"
disabled={left > 0}
onClick={onLeave}
>
{left > 0 ? `Leave in ${left}` : 'Leave'}
</button>
</div>
</div>
</div>,
document.body
)
}
+39 -1
View File
@@ -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<RailGroup | null>(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 && (
<LeaveConfirm
name={leaving.name}
detail={
leaving.kind === 'space'
? 'You will leave every room in this space that you joined through it. Getting back in needs a fresh invite unless the space is public.'
: "You won't be able to rejoin without a new invite, and its channels and history will go with it."
}
onCancel={() => setLeaving(null)}
onLeave={() => {
void store.leaveGroup(leaving.id)
setLeaving(null)
}}
/>
)}
<div className="rail-divider" />
<RailMenu />
</nav>
+33 -1
View File
@@ -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<void> {
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