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