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(
+
,
+ 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