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.
This commit is contained in:
2026-08-22 23:05:18 -04:00
parent 89f7e529b0
commit fa59525739
9 changed files with 292 additions and 12 deletions
+1 -1
Submodule nobilis updated: 1d8b2cac32...94e801f024
+9 -1
View File
@@ -221,7 +221,15 @@ function applyHotkey(accelerator: string): void {
function wireIpc(): void {
ipcMain.handle(IPC.rpc, async (_e, method: string, params: Record<string, unknown>) => {
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
+13
View File
@@ -35,6 +35,8 @@ export class Notifier {
/** Source path -> transcoded PNG, so a repeat sender is converted once. */
private transcoded = new Map<string, string>()
/** Accounts currently set to Do Not Disturb. */
private dndAccounts = new Set<string>()
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<string[]>('mutedBuffers', [])
if (muted.includes(bufferId)) return true
+106
View File
@@ -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);
}
+2 -10
View File
@@ -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 {
</div>
<div className="divider-h" />
<div className="bufferlist-footer">
<button type="button" className="footer-button" onClick={() => store.setActivePanel('accounts')}>
<Icon name="manage_accounts" size={16} />
Accounts
</button>
<button type="button" className="footer-button" onClick={() => store.setActivePanel('settings')}>
<Icon name="settings" size={16} />
Settings
</button>
</div>
<UserFooter account={groupAccount} />
</div>
)
}
@@ -234,6 +234,8 @@ export function ServerRail(): JSX.Element | null {
return (
<nav className="server-rail" aria-label="Servers">
{/* Only the entries scroll; the cog stays pinned to the foot. */}
<div className="rail-scroll">
{ordered.map((g) => {
const t = totals.get(g.id)
return (
@@ -264,6 +266,48 @@ export function ServerRail(): JSX.Element | null {
/>
)
})}
</div>
<div className="rail-divider" />
<RailMenu />
</nav>
)
}
/**
* 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 (
<>
<button
type="button"
className="rail-tile rail-cog"
title="Accounts and settings"
aria-label="Accounts and settings"
// Opened by left click, unlike the tiles above it - it is a menu
// button, not a thing being acted upon.
onClick={open}
>
<span className="rail-face">
<Icon name="settings" size={20} />
</span>
</button>
{menu && (
<ContextMenu
x={menu.x}
y={menu.y}
entries={[
{ label: 'Accounts', icon: 'manage_accounts', onClick: () => store.setActivePanel('accounts') },
{ label: 'Settings', icon: 'settings', onClick: () => store.setActivePanel('settings') }
]}
onClose={close}
/>
)}
</>
)
}
@@ -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 <div className="user-footer empty" />
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 (
<>
<button type="button" className="user-footer" onClick={open} title={`${name} — ${STATUSES.find((s) => s.id === status)?.label}`}>
<span className="user-avatar">
{account.avatarUrl ? (
<img src={resolveMediaUrl(account.avatarUrl)} alt="" />
) : service.mark ? (
<MaskIcon src={service.mark} size={18} />
) : (
<span className="avatar-fallback" style={{ background: nickColor(name) }}>
{name.slice(0, 1).toUpperCase()}
</span>
)}
<span className="user-status-dot" style={{ background: statusColor(status) }} />
</span>
<span className="user-identity">
<span className="ellipsis user-name">{name}</span>
<span className="ellipsis small muted">
{STATUSES.find((s) => s.id === status)?.label}
</span>
</span>
<Icon name="expand_less" size={16} />
</button>
{menu && (
<ContextMenu
x={menu.x}
y={menu.y}
entries={STATUSES.map((s) => ({
label: s.id === status ? `${s.label} ✓` : s.label,
icon: s.glyph,
onClick: () => setStatus(s.id)
}))}
onClose={close}
/>
)}
</>
)
}
+17
View File
@@ -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<void> {
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 })
+5
View File
@@ -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