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:
+1
-1
Submodule nobilis updated: e56e3a9b7e...caf56c7e73
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user