The tray's number is the window's: counted by the rail's rule - muted and hidden conversations out, a direct message by each unread message, anywhere else by its mentions - and told to the tray whenever it changes, instead of a tally of its own that drifted

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
2026-10-09 20:08:34 -04:00
co-authored by Claude Sonnet 5.5
parent 07f4fc2ef6
commit 1e314f85b6
8 changed files with 141 additions and 2 deletions
+24
View File
@@ -156,6 +156,30 @@ try {
check(now.status === 'idle' && !now.auto, 'a chosen Idle stays until Online is chosen', JSON.stringify(now))
await tray('t.click(["Status", "Online"])')
await waitFor('x.status === "online"', 'online again')
// The tray's number is the window's: it follows what is waiting, by the rail's rule.
const stage = (patch) => page.evaluate(`(() => { const s = window.__mohoShots.state(); const id = s.buffers.find((b) => b.kind === 'channel' && b.id.startsWith('checker')).id; window.__chan = id; window.__mohoShots.patch({ buffers: s.buffers.map((b) => (b.id === id ? { ...b, ...(${JSON.stringify(patch)}) } : b)) }); return id })()`)
const counted = async (want, what) => {
await until(main, `(() => { const s = globalThis.__mohoTray.state(); return s.mentions === ${want.mentions} && s.dms === ${want.dms} })()`, what)
check(true, what)
}
await tray('t.update({ unread: 0, dms: 0, mentions: 0 })')
const chan = await stage({ unread: 33, mentions: 2, highlight: true })
await counted({ mentions: 2, dms: 0 }, 'two mentions in a busy channel make the tray say two, not thirty-three')
check((await tray('t.state().latest')) === chan, 'and the menu opens that channel')
// Said the way another window would say it, through main.
const chanId = await page.evaluate('window.__chan')
await tray(`t.pref('mutedBuffers', [${JSON.stringify(chanId)}])`)
await counted({ mentions: 0, dms: 0 }, 'muting it takes it off the tray, as it does the rail')
await tray("t.pref('mutedBuffers', [])")
await counted({ mentions: 2, dms: 0 }, 'and unmuting puts it back')
await stage({ unread: 0, mentions: 0, highlight: false })
await counted({ mentions: 0, dms: 0 }, 'reading it somewhere else clears the tray as it clears the window')
await page.evaluate(`(() => { const s = window.__mohoShots.state(); window.__mohoShots.patch({ buffers: [...s.buffers, { id: 'checker@localhost|bobdm', accountId: s.accounts[0].id, kind: 'dm', name: 'bob', lastActivityTs: 1, unread: 3, highlight: false, mentions: 0 }] }); return true })()`)
await counted({ mentions: 0, dms: 3 }, 'a direct message counts for each unread message in it')
check(/3 direct messages/.test(await tray('t.tooltip()')), 'and the tooltip says so', await tray('t.tooltip()'))
await page.evaluate(`(() => { const s = window.__mohoShots.state(); window.__mohoShots.patch({ buffers: s.buffers.filter((b) => b.id !== 'checker@localhost|bobdm') }); return true })()`)
await counted({ mentions: 0, dms: 0 }, 'and the tray is empty again')
} finally {
irc.close()
}
+13 -2
View File
@@ -679,7 +679,7 @@ function refreshTrayMenu(): void {
{
toggleWindow,
openLatest: () => {
const id = notifier.latestUnread()
const id = lastTray.latest ?? notifier.latestUnread()
if (id) openConversation(id)
},
openSettings: () => tellWindow('settings'),
@@ -788,6 +788,14 @@ function wireIpc(): void {
ipcMain.on(IPC.activity, () => idleWatcher.activity())
// What is waiting, as the window counts it. The tray's number is the window's
// own: counted here from notifications it drifted from what the window showed.
ipcMain.on(IPC.trayUnread, (_e, count: { dms: number; mentions: number; latest: string | null }) => {
const dms = Math.max(0, Number(count?.dms) || 0)
const mentions = Math.max(0, Number(count?.mentions) || 0)
updateTray({ unread: dms + mentions, dms, mentions, latest: typeof count?.latest === 'string' ? count.latest : null })
})
ipcMain.handle(IPC.markBufferRead, (_e, bufferId: string) => notifier.clear(bufferId))
// Every window draws its own title bar, so these act on whichever window
@@ -1260,7 +1268,8 @@ app.whenReady().then(() => {
notifier = new Notifier(
prefs,
updateTray,
// The tray's number is the window's, not this tally's - see IPC.trayUnread.
() => {},
openConversation,
() => mainWindow?.webContents ?? null,
(bufferId) => popoutState().watched.includes(bufferId),
@@ -1320,6 +1329,8 @@ app.whenReady().then(() => {
update: updateTray,
idle: (v: boolean | null) => idleWatcher.forceForTesting(v),
lastActivity: () => idleWatcher.lastActivityAt(),
state: () => lastTray,
pref: (key: string, value: unknown) => applyPref(key, value),
icon: () => lastTrayIcon,
tooltip: () => lastTrayTooltip,
menu: () => lastTrayMenu,
+2
View File
@@ -15,6 +15,8 @@ export interface TrayState {
dms: number
/** Of those, everything else: somebody mentioning this account in a channel. */
mentions: number
/** The conversation the menu's first line opens, where the window has said. */
latest?: string | null
}
export const NOTHING_WAITING: TrayState = { unread: 0, dms: 0, mentions: 0 }
+5
View File
@@ -99,6 +99,11 @@ const api = {
ipcRenderer.send(IPC.trayStatus, statuses, offered, shown, invisibleName)
},
/** Says what is waiting, counted the way the rail counts it, for the tray's number. */
setTrayUnread(count: { dms: number; mentions: number; latest: string | null }): void {
ipcRenderer.send(IPC.trayUnread, count)
},
onMaximizeChange(cb: (maximized: boolean) => void): () => void {
const handler = (_e: unknown, v: boolean): void => cb(v)
ipcRenderer.on(IPC.maximizeChanged, handler)
+17
View File
@@ -8,6 +8,7 @@ import { MessageList } from './components/MessageList'
import { MentionsInbox } from './components/MentionsInbox'
import { MentionsPage } from './components/MentionsPage'
import { INVITE_PREFIX, MENTIONS_GROUP_ID } from './lib/groups'
import { trayCount } from './lib/traycount'
import { Composer } from './components/Composer'
import { MembershipGate } from './components/MembershipGate'
import { NickList } from './components/NickList'
@@ -89,6 +90,22 @@ export default function App(): JSX.Element {
else delete document.documentElement.dataset.reduceMotion
}, [density, reduceMotion])
const [userListFolded, setUserListFolded] = usePref<boolean>('ui.userListFolded', false)
// What the tray says is waiting, which is whatever the rail would add up to:
// the same rule, from the same lists, said to the main process whenever the
// answer changes. Told only when it has, since most changes to the buffers
// are not to what is waiting.
const [trayMuted] = usePref<string[]>('mutedBuffers', [])
const [trayHidden] = usePref<string[]>('hiddenBuffers', [])
const [trayMutedGroups] = usePref<string[]>('mutedGroups', [])
const lastTray = useRef('')
useEffect(() => {
const count = trayCount(allBuffers, trayMuted, trayHidden, trayMutedGroups)
const said = JSON.stringify(count)
if (said === lastTray.current) return
lastTray.current = said
window.moho.setTrayUnread(count)
}, [allBuffers, trayMuted, trayHidden, trayMutedGroups])
const [savedBufferId] = usePref<string>('ui.activeBufferId', '')
const [savedGroupId] = usePref<string>('ui.activeGroupId', '')
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { trayCount } from './traycount'
import type { BufferEntry } from '../state/store'
const buf = (over: Partial<BufferEntry>): BufferEntry =>
({ id: 'a|#x', accountId: 'a', kind: 'channel', name: '#x', lastActivityTs: 0, groupId: 'g', unread: 0, highlight: false, mentions: 0, ...over }) as BufferEntry
describe('the number on the tray', () => {
it('is nothing when nothing is waiting', () => {
expect(trayCount([buf({}), buf({ id: 'a|#y', unread: 40 })], [], [], [])).toEqual({ dms: 0, mentions: 0, latest: null })
})
it('counts a channel by its mentions and not by its messages', () => {
expect(trayCount([buf({ unread: 33, mentions: 2, highlight: true })], [], [], [])).toEqual({ dms: 0, mentions: 2, latest: 'a|#x' })
})
it('counts a direct message by each unread message in it', () => {
expect(trayCount([buf({ id: 'a|bob', kind: 'dm', unread: 3 })], [], [], [])).toEqual({ dms: 3, mentions: 0, latest: 'a|bob' })
})
it('does not count what is muted or hidden, mentions included', () => {
const rows = [buf({ id: 'a|#m', mentions: 4 }), buf({ id: 'a|#h', mentions: 5 }), buf({ id: 'a|gm', mentions: 6, groupId: 'muted-group' })]
expect(trayCount(rows, ['a|#m'], ['a|#h'], ['muted-group'])).toEqual({ dms: 0, mentions: 0, latest: null })
expect(trayCount([buf({ mentions: 2, serverMuted: true })], [], [], [])).toEqual({ dms: 0, mentions: 0, latest: null })
})
it('names the most recently active conversation that has something, for the tray to open', () => {
const rows = [buf({ id: 'a|old', mentions: 1, lastActivityTs: 10 }), buf({ id: 'a|new', mentions: 1, lastActivityTs: 20 }), buf({ id: 'a|quiet', lastActivityTs: 99 })]
expect(trayCount(rows, [], [], []).latest).toBe('a|new')
})
it('adds the two kinds together and keeps them apart', () => {
const rows = [buf({ id: 'a|bob', kind: 'dm', unread: 2 }), buf({ mentions: 3 })]
expect(trayCount(rows, [], [], [])).toMatchObject({ dms: 2, mentions: 3 })
})
})
+42
View File
@@ -0,0 +1,42 @@
import { countsTowardRail, isDirectMessage } from './groups'
import type { BufferEntry } from '../state/store'
/** What the tray is told is waiting. */
export interface TrayCount {
/** Unread direct messages, counted by the message. */
dms: number
/** Mentions in everywhere that is not a direct message, counted by the mention. */
mentions: number
/** The conversation to open from the tray: the most recently active one with something counted. */
latest: string | null
}
/**
* The number on the tray, worked out the way the rail's is.
*
* The tray used to keep a count of its own in the main process, from the
* notifications it was sent and a handful of "this was read" messages, and the
* two drifted: a read on another device cleared the window and not the tray, a
* mention in the conversation already open raised the tray and was never
* counted by the window, and a room muted afterwards stayed in the tray's
* number. It also counted conversations where the rail counts messages.
*
* So the window - which already knows every one of those things, because it is
* what draws them - says what is waiting, by the same rule as the rail: a
* muted or hidden conversation does not count, a direct message counts for each
* unread message in it, and anywhere else counts for its mentions.
*/
export function trayCount(buffers: BufferEntry[], muted: string[], hidden: string[], mutedGroups: string[]): TrayCount {
let dms = 0
let mentions = 0
let latest: BufferEntry | null = null
for (const b of buffers) {
if (!countsTowardRail(b, muted, hidden, mutedGroups)) continue
const waiting = isDirectMessage(b) ? b.unread : b.mentions
if (waiting <= 0) continue
if (isDirectMessage(b)) dms += waiting
else mentions += waiting
if (!latest || b.lastActivityTs > latest.lastActivityTs) latest = b
}
return { dms, mentions, latest: latest?.id ?? null }
}
+2
View File
@@ -57,6 +57,8 @@ export const IPC = {
trayCommand: 'moho:tray:command',
/** window -> main: the status each connected account is at. */
trayStatus: 'moho:tray:status',
/** window -> main: what is waiting, for the number on the tray. */
trayUnread: 'moho:tray:unread',
/** window -> main: something was used in this window, so the person is here. */
activity: 'moho:activity'
} as const