diff --git a/scripts/tray-check.mjs b/scripts/tray-check.mjs index fc51ef7..1f8f5f5 100644 --- a/scripts/tray-check.mjs +++ b/scripts/tray-check.mjs @@ -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() } diff --git a/src/main/index.ts b/src/main/index.ts index b025308..c4983b6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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, diff --git a/src/main/tray.ts b/src/main/tray.ts index 67ca8e5..c038c55 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -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 } diff --git a/src/preload/index.ts b/src/preload/index.ts index 43c2867..67a2b15 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7d3681f..dcdd8ff 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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('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('mutedBuffers', []) + const [trayHidden] = usePref('hiddenBuffers', []) + const [trayMutedGroups] = usePref('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('ui.activeBufferId', '') const [savedGroupId] = usePref('ui.activeGroupId', '') diff --git a/src/renderer/src/lib/traycount.test.ts b/src/renderer/src/lib/traycount.test.ts new file mode 100644 index 0000000..83a8f83 --- /dev/null +++ b/src/renderer/src/lib/traycount.test.ts @@ -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 => + ({ 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 }) + }) +}) diff --git a/src/renderer/src/lib/traycount.ts b/src/renderer/src/lib/traycount.ts new file mode 100644 index 0000000..89547c9 --- /dev/null +++ b/src/renderer/src/lib/traycount.ts @@ -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 } +} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 05f3039..e5b1790 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -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