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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', '')
|
||||
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user