From fa595257396ebd92d7b0376ae6960c376e43a554 Mon Sep 17 00:00:00 2001 From: Salastil Date: Sat, 22 Aug 2026 23:05:18 -0400 Subject: [PATCH] Move Accounts and Settings to a rail cog, and add a status picker The cog sits at the foot of the server rail under a divider, opening Accounts and Settings. Those belong there rather than in the channel list, which is about whichever server is selected; the two buttons that were in its footer are gone. That footer now shows who you are on the selected account - avatar, display name and a status dot - and opens the status picker: Online, Idle, Do Not Disturb. Status is per account because that is what the underlying protocols carry, so the picker acts on the account whose channels are on screen. Do Not Disturb is the exception and applies to every account at once, since it is a statement about the person rather than about a connection. It also silences notifications, enforced in main alongside the existing mute rules so it keeps working with the window closed - and main learns each account's status by watching the account list the renderer already asks for, rather than needing a channel of its own. --- nobilis | 2 +- src/main/index.ts | 10 +- src/main/notifications.ts | 13 +++ src/renderer/src/app.css | 106 +++++++++++++++++++++ src/renderer/src/components/BufferList.tsx | 12 +-- src/renderer/src/components/ServerRail.tsx | 44 +++++++++ src/renderer/src/components/UserFooter.tsx | 95 ++++++++++++++++++ src/renderer/src/state/store.ts | 17 ++++ src/shared/wire.ts | 5 + 9 files changed, 292 insertions(+), 12 deletions(-) create mode 100644 src/renderer/src/components/UserFooter.tsx diff --git a/nobilis b/nobilis index 1d8b2ca..94e801f 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit 1d8b2cac32bc2655cbbd2281c5d61f14d5a942a8 +Subproject commit 94e801f0247e1821eefdb305e40050eb80b14295 diff --git a/src/main/index.ts b/src/main/index.ts index aef0620..ad457b4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -221,7 +221,15 @@ function applyHotkey(accelerator: string): void { function wireIpc(): void { ipcMain.handle(IPC.rpc, async (_e, method: string, params: Record) => { try { - return { ok: true, result: await client.request(method, params) } + const result = await client.request(method, params) + // Main needs each account's status to enforce Do Not Disturb, and the + // renderer already asks for the account list whenever it changes - + // watching the answer keeps the two in step without a second channel or + // a poll of its own. + if (method === 'listAccounts' && Array.isArray(result)) { + notifier.trackAccounts(result as { id: string; status?: string }[]) + } + return { ok: true, result } } catch (err) { // Surfaced as a value rather than a rejection so the renderer sees // nobilis's own error text (which is often the actionable part - "account diff --git a/src/main/notifications.ts b/src/main/notifications.ts index 0441c86..e969395 100644 --- a/src/main/notifications.ts +++ b/src/main/notifications.ts @@ -35,6 +35,8 @@ export class Notifier { /** Source path -> transcoded PNG, so a repeat sender is converted once. */ private transcoded = new Map() + /** Accounts currently set to Do Not Disturb. */ + private dndAccounts = new Set() constructor( private prefs: Prefs, @@ -44,6 +46,11 @@ export class Notifier { private renderer: () => WebContents | null = () => null ) {} + /** Mirrors each account's status, so DND can be enforced here. */ + trackAccounts(accounts: { id: string; status?: string }[]): void { + this.dndAccounts = new Set(accounts.filter((a) => a.status === 'dnd').map((a) => a.id)) + } + trackBuffer(buffer: ChatBuffer, removed: boolean): void { if (removed) this.buffers.delete(buffer.id) else this.buffers.set(buffer.id, buffer) @@ -60,6 +67,12 @@ export class Notifier { * all; see isMuted for why. */ private isMuted(accountId: string, bufferId: string): boolean { + // Do Not Disturb is a statement about the person rather than any one + // connection, so it silences every account regardless of which one it was + // set from. Held in main so it keeps working with the window closed, + // which is the whole point of the setting. + if (this.dndAccounts.size > 0) return true + const muted = this.prefs.get('mutedBuffers', []) if (muted.includes(bufferId)) return true diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index 251014f..ef851fa 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -1877,3 +1877,109 @@ video.lightbox-media { .rail-tile.muted .rail-face { opacity: 0.45; } + +/* The rail's entries scroll; the cog below stays put. */ +.rail-scroll { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-xs); + flex: 1; + min-height: 0; + width: 100%; + overflow-y: auto; + scrollbar-width: none; +} + +.rail-scroll::-webkit-scrollbar { + display: none; +} + +.rail-divider { + width: 24px; + height: 1px; + flex: none; + margin: var(--spacing-xs) 0; + background: var(--outline-strong); +} + +.rail-cog { + flex: none; + margin-bottom: var(--spacing-xxs); +} + +/* --- user footer ------------------------------------------------------ */ + +.user-footer { + display: flex; + align-items: center; + gap: var(--spacing-s); + width: 100%; + flex: none; + padding: var(--spacing-xs) var(--spacing-s); + background: none; + border: none; + text-align: left; + color: var(--surface-variant-text); +} + +.user-footer:hover { + background: var(--surface-hover); + color: var(--surface-text); +} + +/* Reserves the row's height before an account is selected, so the channel + list doesn't shift when one arrives. */ +.user-footer.empty { + height: 44px; + pointer-events: none; +} + +.user-avatar { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + flex: none; +} + +.user-avatar img, +.user-avatar .avatar-fallback { + width: 28px; + height: 28px; + border-radius: 50%; + object-fit: cover; + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-size-small); + font-weight: 600; + color: var(--primary-text); +} + +/* The status dot overlaps the avatar's corner, so it needs a ring to stay + legible against whatever the picture happens to be. */ +.user-status-dot { + position: absolute; + right: -1px; + bottom: -1px; + width: 10px; + height: 10px; + border-radius: 50%; + border: 2px solid var(--surface-container); +} + +.user-identity { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + line-height: 1.2; +} + +.user-name { + font-weight: 500; + color: var(--surface-text); +} diff --git a/src/renderer/src/components/BufferList.tsx b/src/renderer/src/components/BufferList.tsx index 7f5da48..c615a48 100644 --- a/src/renderer/src/components/BufferList.tsx +++ b/src/renderer/src/components/BufferList.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react' import { Icon, IconButton, MaskIcon } from './Icon' import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu' +import { UserFooter } from './UserFooter' import { useChat, useIdSetPref, usePref, useStore } from '../state/hooks' import { dmGroup, @@ -151,16 +152,7 @@ export function BufferList(): JSX.Element {
-
- - -
+
) } diff --git a/src/renderer/src/components/ServerRail.tsx b/src/renderer/src/components/ServerRail.tsx index 8fb76cb..164bb4e 100644 --- a/src/renderer/src/components/ServerRail.tsx +++ b/src/renderer/src/components/ServerRail.tsx @@ -234,6 +234,8 @@ export function ServerRail(): JSX.Element | null { return ( ) } + +/** + * The cog at the foot of the rail: the way into Accounts and Settings, which + * used to sit in the channel list's own footer. They belong here because they + * are about the app rather than about whichever server is selected. + */ +function RailMenu(): JSX.Element { + const store = useStore() + const { menu, open, close } = useContextMenu() + return ( + <> + + {menu && ( + store.setActivePanel('accounts') }, + { label: 'Settings', icon: 'settings', onClick: () => store.setActivePanel('settings') } + ]} + onClose={close} + /> + )} + + ) +} diff --git a/src/renderer/src/components/UserFooter.tsx b/src/renderer/src/components/UserFooter.tsx new file mode 100644 index 0000000..ea4d957 --- /dev/null +++ b/src/renderer/src/components/UserFooter.tsx @@ -0,0 +1,95 @@ +import { ContextMenu, useContextMenu } from './ContextMenu' +import { Icon, MaskIcon } from './Icon' +import { useChat, useStore } from '../state/hooks' +import { nickColor, resolveMediaUrl, serviceIcon } from '../lib/util' +import type { Account } from '../../../shared/wire' + +/** + * Who you are on the account you are currently looking at, at the foot of the + * channel list - and the way to change how you are presenting. + * + * Per account rather than global because that is what the underlying status + * is: each protocol carries its own, and only Do Not Disturb reaches across + * all of them (by silencing notifications, which is this app's own doing + * rather than anything a server is told). + */ + +export type Status = 'online' | 'idle' | 'dnd' + +const STATUSES: { id: Status; label: string; glyph: string; note?: string }[] = [ + { id: 'online', label: 'Online', glyph: 'circle' }, + { id: 'idle', label: 'Idle', glyph: 'dark_mode' }, + { + id: 'dnd', + label: 'Do Not Disturb', + glyph: 'do_not_disturb_on', + note: 'Silences notifications everywhere' + } +] + +export function statusColor(status: string): string { + if (status === 'idle') return 'var(--warning)' + if (status === 'dnd') return 'var(--error)' + return 'var(--success)' +} + +export function UserFooter({ account }: { account?: Account }): JSX.Element { + const store = useStore() + const accounts = useChat((s) => s.accounts) + const { menu, open, close } = useContextMenu() + + // With no account selected there is nobody to show, but the footer still + // has to exist so the layout doesn't jump when one arrives. + if (!account) return
+ + const status = (account.status || 'online') as Status + const service = serviceIcon(account.service) + const name = account.displayName || account.id + + const setStatus = (next: Status): void => { + // DND is a statement about the person, not the connection, so it goes to + // every account at once. The others are per-account, matching where the + // user set them from. + const targets = next === 'dnd' || status === 'dnd' ? accounts : [account] + for (const a of targets) void store.setAccountStatus(a.id, next) + } + + return ( + <> + + + {menu && ( + ({ + label: s.id === status ? `${s.label} ✓` : s.label, + icon: s.glyph, + onClick: () => setStatus(s.id) + }))} + onClose={close} + /> + )} + + ) +} diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/state/store.ts index b0a464d..7f57a15 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/state/store.ts @@ -248,6 +248,23 @@ export class ChatStore { if (!this.state.activeGroupId) this.set({ activeGroupId: groups[0].id }) } + /** + * Sets how an account presents itself. + * + * Refreshes the account list rather than patching state locally: nobilis + * reports whether it could actually apply the status, and for a + * disconnected account or a protocol with no presence concept the answer is + * no - showing it as set anyway would be a lie. + */ + async setAccountStatus(accountId: string, status: 'online' | 'idle' | 'dnd'): Promise { + try { + await window.moho.rpc('setAccountStatus', { accountId, status }) + await this.refreshAccounts() + } catch (e) { + this.toast('error', `Couldn't set status: ${(e as Error).message}`) + } + } + selectGroup(groupId: string): void { if (groupId === this.state.activeGroupId) return this.set({ activeGroupId: groupId }) diff --git a/src/shared/wire.ts b/src/shared/wire.ts index 8de4997..d0ac63f 100644 --- a/src/shared/wire.ts +++ b/src/shared/wire.ts @@ -5,6 +5,11 @@ export interface Account { id: string service: 'irc' | 'discord' | 'sockchat' | 'matrix' | 'jabber' | 'slack' displayName: string + /** + * How the user is presenting: "online" | "idle" | "dnd". Distinct from + * `state`, which is whether the connection is up. + */ + status?: string state: string autojoin: string hasNickservPassword: boolean