diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e54bb3..9364fcc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -270,6 +270,12 @@ jobs: - name: layout check run: xvfb-run -a npm run test:ui + # The tray cannot be looked at, so this reads what main gave it - icon, + # tooltip, menu - and presses its entries, including Restart moho, which + # has to bring back both the window and a daemon of its own. + - name: tray check + run: xvfb-run -a npm run test:tray + # MOHO_BUILD is computed inside the npm script - a shell assignment on # the electron-builder line - so it cannot be injected from out here. # The only way to control what the artifact is called is to guarantee diff --git a/CHANGELOG.md b/CHANGELOG.md index 85c4a48..efeebe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,25 @@ Each section has two parts: that made it, including dependency updates (`node scripts/changelog-deps.mjs ` lists those). +## [1.0.0-rc.4] + +### Major features + +- **A rebuilt tray:** an icon drawn for the system's colour scheme with the number of conversations waiting on it, a tooltip that says what is waiting, and a menu of what is new, Show/Hide, Status, Settings, Restart moho and Quit - Restart moho brings back both the window and the daemon +- **Status by service:** the plaque and the tray offer only the statuses each service has - Discord all four, IRC away and back, Matrix online and offline, Kick and Sneedchat none - and Do not disturb silences desktop notifications on every service, and is Discord's real mode there + +### Fixes, patches and changes + +- Reworked the tray icon and menu, and dropped Restart daemon and Stop daemon from it ([moho `24106e5`](https://github.com/Moho-Chat/moho/commit/24106e5)) +- Do not disturb now silences an account's desktop notifications and the taskbar flash; the tray no longer has a switch of its own for it ([moho `53fb0f3`](https://github.com/Moho-Chat/moho/commit/53fb0f3)) +- Idle from the tray reaches Discord and IRC and leaves Matrix, Kick and Sneedchat as they were; a service is no longer asked for a status it does not have ([moho `c1bcb72`](https://github.com/Moho-Chat/moho/commit/c1bcb72), [nobilis `a8441ab`](https://github.com/Moho-Chat/nobilis/commit/a8441ab)) +- Fixed Matrix's rate limit on changing status showing as M_LIMIT_EXCEEDED: a limited request now waits the time the server names, and a longer wait is applied when it is over ([moho `0dfff0d`](https://github.com/Moho-Chat/moho/commit/0dfff0d), [nobilis `43135c1`](https://github.com/Moho-Chat/nobilis/commit/43135c1)) +- Fixed the window being pulled back to the open conversation's server whenever you chose another: the daemon link coming up is acted on once, a refresh no longer follows the open conversation, and the tray is told only what changed ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a)) +- Matrix's invisible is called Offline in the plaque, its popout and the tray, which is what it sets ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a)) +- A room muted on a Matrix account can now be unmuted from the menu, including a mute set from Element ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a), [nobilis `59db82c`](https://github.com/Moho-Chat/nobilis/commit/59db82c)) +- Discord requests now give up on a connection that has gone quiet, a history catch-up always releases its place, and the gateway logs what it receives and why a message is dropped ([moho `b151ef8`](https://github.com/Moho-Chat/moho/commit/b151ef8), [nobilis `a34821e`](https://github.com/Moho-Chat/nobilis/commit/a34821e)) +- Fixed Discord reconnecting every few seconds after being told it is rate limited: it now waits out the minute Discord asks for ([nobilis `59db82c`](https://github.com/Moho-Chat/nobilis/commit/59db82c)) + ## [1.0.0-rc.3] ### Major features diff --git a/nobilis b/nobilis index baa37ab..f392d70 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit baa37abde4a4e261bc96cd2683d4e18e4e691745 +Subproject commit f392d70c8d0175c3d206a4c0565d5d955cb2c906 diff --git a/package-lock.json b/package-lock.json index f785fba..5a234e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "moho", - "version": "1.0.0-rc.3", + "version": "1.0.0-rc.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "moho", - "version": "1.0.0-rc.3", + "version": "1.0.0-rc.4", "license": "GPL-3.0-or-later", "dependencies": { "@electron-toolkit/preload": "^3.0.1", diff --git a/package.json b/package.json index eb35974..7b798e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moho", - "version": "1.0.0-rc.3", + "version": "1.0.0-rc.4", "description": "Unified desktop chat client for IRC, Discord, Sneedchat and Matrix", "author": "Salastil", "license": "GPL-3.0-or-later", @@ -21,6 +21,7 @@ "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json", "test": "vitest run", "test:ui": "electron-vite build && node scripts/ui-check.mjs", + "test:tray": "electron-vite build && node scripts/tray-check.mjs", "shots": "electron-vite build && node scripts/ui-shots.mjs", "pack": "npm run daemon && npm run build && MOHO_BUILD=$(git rev-parse --short HEAD)$(git diff --quiet HEAD || echo -dirty) electron-builder --linux AppImage", "pack:deb": "npm run daemon && npm run build && MOHO_BUILD=$(git rev-parse --short HEAD)$(git diff --quiet HEAD || echo -dirty) electron-builder --linux deb", diff --git a/resources/icons/tray-alert.png b/resources/icons/tray-alert.png deleted file mode 100644 index 3267894..0000000 Binary files a/resources/icons/tray-alert.png and /dev/null differ diff --git a/resources/icons/tray-dark-1.png b/resources/icons/tray-dark-1.png new file mode 100644 index 0000000..8285996 Binary files /dev/null and b/resources/icons/tray-dark-1.png differ diff --git a/resources/icons/tray-dark-2.png b/resources/icons/tray-dark-2.png new file mode 100644 index 0000000..d94dcbd Binary files /dev/null and b/resources/icons/tray-dark-2.png differ diff --git a/resources/icons/tray-dark-3.png b/resources/icons/tray-dark-3.png new file mode 100644 index 0000000..f832cee Binary files /dev/null and b/resources/icons/tray-dark-3.png differ diff --git a/resources/icons/tray-dark-4.png b/resources/icons/tray-dark-4.png new file mode 100644 index 0000000..2592bfa Binary files /dev/null and b/resources/icons/tray-dark-4.png differ diff --git a/resources/icons/tray-dark-5.png b/resources/icons/tray-dark-5.png new file mode 100644 index 0000000..20b7ad0 Binary files /dev/null and b/resources/icons/tray-dark-5.png differ diff --git a/resources/icons/tray-dark-6.png b/resources/icons/tray-dark-6.png new file mode 100644 index 0000000..6de1372 Binary files /dev/null and b/resources/icons/tray-dark-6.png differ diff --git a/resources/icons/tray-dark-7.png b/resources/icons/tray-dark-7.png new file mode 100644 index 0000000..01c3d5a Binary files /dev/null and b/resources/icons/tray-dark-7.png differ diff --git a/resources/icons/tray-dark-8.png b/resources/icons/tray-dark-8.png new file mode 100644 index 0000000..3189a5d Binary files /dev/null and b/resources/icons/tray-dark-8.png differ diff --git a/resources/icons/tray-dark-9.png b/resources/icons/tray-dark-9.png new file mode 100644 index 0000000..98280ad Binary files /dev/null and b/resources/icons/tray-dark-9.png differ diff --git a/resources/icons/tray-dark-more.png b/resources/icons/tray-dark-more.png new file mode 100644 index 0000000..d5dc0b7 Binary files /dev/null and b/resources/icons/tray-dark-more.png differ diff --git a/resources/icons/tray-dark.png b/resources/icons/tray-dark.png new file mode 100644 index 0000000..e8a40ca Binary files /dev/null and b/resources/icons/tray-dark.png differ diff --git a/resources/icons/tray-light-1.png b/resources/icons/tray-light-1.png new file mode 100644 index 0000000..ce5c51c Binary files /dev/null and b/resources/icons/tray-light-1.png differ diff --git a/resources/icons/tray-light-2.png b/resources/icons/tray-light-2.png new file mode 100644 index 0000000..2fb816b Binary files /dev/null and b/resources/icons/tray-light-2.png differ diff --git a/resources/icons/tray-light-3.png b/resources/icons/tray-light-3.png new file mode 100644 index 0000000..0324393 Binary files /dev/null and b/resources/icons/tray-light-3.png differ diff --git a/resources/icons/tray-light-4.png b/resources/icons/tray-light-4.png new file mode 100644 index 0000000..a1b1d42 Binary files /dev/null and b/resources/icons/tray-light-4.png differ diff --git a/resources/icons/tray-light-5.png b/resources/icons/tray-light-5.png new file mode 100644 index 0000000..8e6d50f Binary files /dev/null and b/resources/icons/tray-light-5.png differ diff --git a/resources/icons/tray-light-6.png b/resources/icons/tray-light-6.png new file mode 100644 index 0000000..7d56655 Binary files /dev/null and b/resources/icons/tray-light-6.png differ diff --git a/resources/icons/tray-light-7.png b/resources/icons/tray-light-7.png new file mode 100644 index 0000000..ea1085c Binary files /dev/null and b/resources/icons/tray-light-7.png differ diff --git a/resources/icons/tray-light-8.png b/resources/icons/tray-light-8.png new file mode 100644 index 0000000..97303e3 Binary files /dev/null and b/resources/icons/tray-light-8.png differ diff --git a/resources/icons/tray-light-9.png b/resources/icons/tray-light-9.png new file mode 100644 index 0000000..6cb6535 Binary files /dev/null and b/resources/icons/tray-light-9.png differ diff --git a/resources/icons/tray-light-more.png b/resources/icons/tray-light-more.png new file mode 100644 index 0000000..cc880cd Binary files /dev/null and b/resources/icons/tray-light-more.png differ diff --git a/resources/icons/tray-light.png b/resources/icons/tray-light.png new file mode 100644 index 0000000..8094b5a Binary files /dev/null and b/resources/icons/tray-light.png differ diff --git a/resources/icons/tray.png b/resources/icons/tray.png deleted file mode 100644 index 7075519..0000000 Binary files a/resources/icons/tray.png and /dev/null differ diff --git a/scripts/lib/harness.mjs b/scripts/lib/harness.mjs index 1a65502..b7d61d4 100644 --- a/scripts/lib/harness.mjs +++ b/scripts/lib/harness.mjs @@ -207,7 +207,7 @@ function moveAside(pid) { * The built app, against a scratch home, with a debugging port. Returns the * page and a `stop()` that takes everything down again. */ -export async function launchApp({ env = {} } = {}) { +export async function launchApp({ env = {}, args = [] } = {}) { const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'moho-ui-')) const run = path.join(scratch, 'run') fs.mkdirSync(run, { mode: 0o700 }) @@ -216,7 +216,7 @@ export async function launchApp({ env = {} } = {}) { // A CI runner's kernel refuses Chromium's sandbox (Ubuntu 24.04 restricts // the unprivileged namespaces it needs); there it runs without, as an // ordinary Chromium in a container does. - const flags = [`--remote-debugging-port=${port}`, ...(process.env.CI ? ['--no-sandbox'] : [])] + const flags = [`--remote-debugging-port=${port}`, ...(process.env.CI ? ['--no-sandbox'] : []), ...args] const app = spawn(electronPath, [root, ...flags], { cwd: root, detached: true, diff --git a/scripts/make-tray-icons.mjs b/scripts/make-tray-icons.mjs new file mode 100644 index 0000000..6f33dc8 --- /dev/null +++ b/scripts/make-tray-icons.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +// Draws the tray icons into resources/icons/tray-*.png. +// +// A tray cannot be recoloured by the desktop the way a themed icon can, so +// there is one set for a dark panel (a light bubble) and one for a light panel +// (a dark bubble), and main picks by the system's colour scheme. Each set is +// the bubble alone, and the bubble with a red badge carrying the number of +// conversations waiting - 1 to 9, then 9+ - because a tray is the one place +// still in view when a notification has faded, and "something" is a poorer +// answer than "three things". +// +// Run when the artwork changes; the PNGs are committed. Needs rsvg-convert. +import { execFileSync } from 'node:child_process' +import { mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const out = join(dirname(fileURLToPath(import.meta.url)), '..', 'resources', 'icons') +const SIZE = 64 +// The app's mention red, so the badge is the same colour as the one in the window. +const BADGE = '#e5484d' + +const BUBBLE = 'M14 8h36a8 8 0 0 1 8 8v22a8 8 0 0 1-8 8H34L22 58V46h-8a8 8 0 0 1-8-8V16a8 8 0 0 1 8-8z' + +function svg(ink, label) { + const badge = label !== null + // The badge sits in a gap cut from the bubble, so it reads against any panel + // without a ring of a colour that panel may not be. + // Without one, three dots are cut from it, which is what makes the shape a + // conversation and not a label. + const mask = badge + ? `` + : `` + const size = label && label.length > 1 ? 24 : 30 + const text = badge + ? `${label}` + : '' + return `${mask}${text}` +} + +mkdirSync(out, { recursive: true }) +const labels = [null, '1', '2', '3', '4', '5', '6', '7', '8', '9', '9+'] +for (const [name, ink] of [['light', '#f1f3f5'], ['dark', '#25282e']]) { + for (const label of labels) { + const file = join(out, `tray-${name}${label === null ? '' : `-${label === '9+' ? 'more' : label}`}.png`) + const tmp = `${file}.svg` + writeFileSync(tmp, svg(ink, label)) + execFileSync('rsvg-convert', ['-w', String(SIZE), '-h', String(SIZE), '-o', file, tmp]) + rmSync(tmp) + } +} +console.log('tray icons written to', out) diff --git a/scripts/tray-check.mjs b/scripts/tray-check.mjs new file mode 100644 index 0000000..40d1c81 --- /dev/null +++ b/scripts/tray-check.mjs @@ -0,0 +1,197 @@ +#!/usr/bin/env node +/** + * Checks the tray against the running app: what its icon, tooltip and menu say + * for what is waiting, what its entries do, and that "Restart moho" brings back + * moho and a daemon of its own. + * + * A tray cannot be photographed, so this reads what main last gave it through a + * hook that exists only under MOHO_UI_SHOTS, and presses its entries the same + * way. Main is reached over the Node inspector the app is started with. + * + * npm run test:tray (builds first; needs the daemon built too) + */ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import { cdp, fakeIrc, freePort, launchApp, openIrcChannel, sleep, until } from './lib/harness.mjs' + +let failures = 0 +const check = (ok, what, detail = '') => { + console.log(`${ok ? 'ok ' : 'FAIL'} ${what}${ok ? '' : detail ? ` - ${detail}` : ''}`) + if (!ok) failures++ +} + +async function mainProcess(inspectPort) { + for (let i = 0; i < 60; i++) { + try { + const list = await (await fetch(`http://127.0.0.1:${inspectPort}/json/list`)).json() + const target = list.find((t) => t.webSocketDebuggerUrl) + if (target) return cdp(target.webSocketDebuggerUrl) + } catch { + /* not up yet */ + } + await sleep(500) + } + throw new Error('main never offered its inspector') +} + +/** The nobilis processes that belong to this run, by the runtime directory in their environment. */ +function daemons(scratch) { + const out = [] + for (const entry of fs.readdirSync('/proc')) { + if (!/^\d+$/.test(entry)) continue + try { + const exe = fs.readlinkSync(`/proc/${entry}/exe`) + if (!exe.endsWith('/nobilis')) continue + if (fs.readFileSync(`/proc/${entry}/environ`, 'utf8').includes(`XDG_RUNTIME_DIR=${scratch}/run`)) out.push(Number(entry)) + } catch { + /* gone, or not ours */ + } + } + return out +} + +// A run that hangs is a failure, and must not leave an app behind it. +setTimeout(() => { + console.log('FAIL timed out') + process.exit(1) +}, 150000).unref() + +const inspectPort = await freePort() +const { page, stop, port } = await launchApp({ env: { MOHO_UI_SHOTS: '1' }, args: [`--inspect=${inspectPort}`] }) +let scratchDir = '' +try { + const main = await mainProcess(inspectPort) + const tray = (expr) => main.evaluate(`(() => { const t = globalThis.__mohoTray; return ${expr} })()`) + await until(main, 'globalThis.__mohoTray', 'the tray') + scratchDir = await main.evaluate('process.env.HOME') + + // Nothing waiting. + await tray('t.update({ unread: 0, dms: 0, mentions: 0 })'.replace('t.', 't.')) + check(/^tray-(light|dark)\.png$/.test(await tray('t.icon()')), 'idle: the plain bubble', await tray('t.icon()')) + check((await tray('t.menu()[0].label')) === 'Nothing new', 'idle: the menu says nothing is new') + check((await tray('t.tooltip()')) === 'moho', 'idle: the tooltip is just moho') + + // Something waiting: the count is on the icon, and the menu says what. + await tray('t.update({ unread: 3, dms: 2, mentions: 1 })') + check(/^tray-(light|dark)-3\.png$/.test(await tray('t.icon()')), 'waiting: the icon carries the count', await tray('t.icon()')) + check((await tray('t.tooltip()')) === 'moho - 2 direct messages, 1 mention', 'waiting: the tooltip says what', await tray('t.tooltip()')) + check((await tray('t.menu()[0].label')) === '2 direct messages, 1 mention - open', 'waiting: the menu says what', await tray('t.menu()[0].label')) + await tray('t.update({ unread: 40, dms: 40, mentions: 0 })') + check(/-more\.png$/.test(await tray('t.icon()')), 'a great many: the icon says 9+') + await tray('t.update({ unread: 0, dms: 0, mentions: 0 })') + + // The entries, and that none of them is daemon plumbing. + const labels = await tray('t.menu().map((i) => i.label ?? "-")') + check(!labels.some((l) => /daemon/i.test(l)), 'the menu offers no daemon plumbing', labels.join(' | ')) + check(labels.includes('Restart moho'), 'the menu offers Restart moho') + + // Settings opens in the window. + await tray('t.click(["Settings"])') + await until(page, 'window.__mohoShots.state().activePanel === "settings"', 'Settings to open') + check(true, 'Settings opens the settings page') + + // A status goes to the window to carry out. + await page.evaluate('window.__trayGot = []; window.moho.onTrayCommand((c, a) => window.__trayGot.push([c, a])); true') + await tray('t.click(["Status", "Do not disturb"])') + await until(page, 'window.__trayGot.length', 'the status to reach the window') + check(JSON.stringify(await page.evaluate('window.__trayGot[0]')) === '["status","dnd"]', 'a status is handed to the window') + + // There is no notifications switch of its own: Do not disturb is that. + check(!labels.some((l) => /notification/i.test(l)), 'the menu has no notifications switch of its own', labels.join(' | ')) + + // With an IRC account connected - which has away and back, and no invisible - + // the menu offers what IRC can be set to, and the daemon takes it. + const irc = await fakeIrc({ people: ['bob'], onJoin: (channel, _nick, send) => send(`:bob!b@h PRIVMSG ${channel} :hello there`) }) + try { + await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#lobby'], waitFor: 'hello there' }) + await until(main, 'globalThis.__mohoTray.menu().find((i) => i.label === "Status").submenu.length === 3', 'the menu to offer only IRC\'s statuses') + const offered = await tray('t.menu().find((i) => i.label === "Status").submenu.map((i) => i.label)') + check(JSON.stringify(offered) === '["Online","Idle","Do not disturb"]', 'IRC alone: no Invisible is offered', JSON.stringify(offered)) + const statusOf = () => page.evaluate('window.moho.rpc("listAccounts", {}).then((a) => a.find((x) => x.id.startsWith("checker")).status)') + await tray('t.click(["Status", "Idle"])') + await until(page, 'window.moho.rpc("listAccounts", {}).then((a) => a.find((x) => x.id.startsWith("checker")).status === "idle")', 'IRC to go idle') + check(true, 'Idle from the tray sets the IRC account idle') + await tray('t.click(["Status", "Do not disturb"])') + await until(page, 'window.moho.rpc("listAccounts", {}).then((a) => a.find((x) => x.id.startsWith("checker")).status === "dnd")', 'IRC to go to Do not disturb') + check((await statusOf()) === 'dnd', 'Do not disturb from the tray sets it') + } finally { + irc.close() + } + + // The tray being redrawn - which it is whenever something unread changes - must + // not move the window: somebody who has chosen another server stays on it. The + // redraw tells the window the link is up, and taking each for a reconnection + // sent the pane back to the server of whatever conversation was open. + const away = await page.evaluate('window.__mohoShots.state().activeGroupId') + await page.evaluate('window.__mohoShots.store.selectGroup("~mentions")') + for (let i = 0; i < 6; i++) { + await tray(`t.update({ unread: ${i}, dms: ${i}, mentions: 0 })`) + await sleep(200) + } + await sleep(1500) + const stayed = await page.evaluate('window.__mohoShots.state().activeGroupId') + check(stayed === '~mentions', 'a redrawn tray leaves the chosen server chosen', `${away} -> ${stayed}`) + await tray('t.update({ unread: 0, dms: 0, mentions: 0 })') + + // Restart brings back moho and a daemon of its own. + const before = daemons(scratchDir) + const pidBefore = await main.evaluate('process.pid') + check(before.length === 1, 'one daemon is running', String(before)) + // Not waited for: the app quits under this call, and the inspector with it. + void tray('t.click(["Restart moho"])').catch(() => {}) + // And the inspector let go, because a process that is being inspected waits + // for the inspector to disconnect before it will exit. + await sleep(500) + main.close() + let pidAfter = pidBefore + let again = null + for (let i = 0; i < 80 && pidAfter === pidBefore; i++) { + await sleep(500) + try { + again = await mainProcess(inspectPort) + pidAfter = await again.evaluate('process.pid') + } catch { + /* between the two */ + } + } + check(pidAfter !== pidBefore, 'moho came back as a new process', `${pidBefore} -> ${pidAfter}`) + await sleep(4000) + const after = daemons(scratchDir) + check(after.length === 1 && !before.includes(after[0]), 'with a daemon of its own, the old one gone', `${before} -> ${after}`) + // Whatever it started has to go with this run. + try { + for (const pid of daemons(scratchDir)) process.kill(pid, 'SIGTERM') + } catch { + /* gone */ + } +} catch (e) { + console.log('FAIL', e.message) + failures++ +} finally { + // The relaunched copy is not the one the harness started, so it is not in the + // group stop() ends. It is found by the debugging port it was told to use - + // it shows no environment to be found by - and what it started goes when it + // does. Ended first, and twice since it may still be starting, because it + // writes into the directory stop() is about to remove. + for (let i = 0; i < 2; i++) { + await sleep(1500) + try { + execFileSync('pkill', ['-9', '-f', `remote-debugging-port=${port}`]) + } catch { + /* none left */ + } + for (const entry of fs.readdirSync('/proc')) { + if (!/^\d+$/.test(entry) || Number(entry) === process.pid || !scratchDir) continue + try { + if (fs.readFileSync(`/proc/${entry}/environ`, 'utf8').includes(`HOME=${scratchDir}\0`)) process.kill(Number(entry), 'SIGKILL') + } catch { + /* gone, or not ours to read */ + } + } + } + await sleep(1500) + await stop().catch(() => {}) + if (scratchDir) fs.rmSync(scratchDir, { recursive: true, force: true }) +} +console.log(failures ? `${failures} failed` : 'all passed') +process.exit(failures ? 1 : 0) diff --git a/src/main/index.ts b/src/main/index.ts index d5721fa..115df9d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -11,18 +11,21 @@ import { globalShortcut, ipcMain, nativeImage, + nativeTheme, net, session, Menu, screen, shell, - Tray + Tray, + type MenuItemConstructorOptions } from 'electron' import { electronApp, is, optimizer } from '@electron-toolkit/utils' import { defaultSocketPath, NobilisClient } from './nobilis-client' import { NobilisProcess } from './nobilis-process' import { Prefs } from './prefs' import { Notifier } from './notifications' +import { NOTHING_WAITING, trayIconFile, trayMenu, trayTooltip, type TrayState, type TrayStatus } from './tray' import { browserLogin, LOGIN_FLOWS } from './browser-login' import { EDIT_ACTIONS, IPC, POPOUT_FLAG, UI_SHOTS_FLAG, type EditAction, type EditMenuRequest, type PopoutState } from '../shared/ipc' import { clearnetLinks } from '../shared/clearnet' @@ -190,6 +193,51 @@ function sniffImage(head: Buffer): string | null { } /** Every window with a renderer in it, main and popped-out conversations alike. */ +/** + * Sets a preference and tells whoever holds a copy of it. + * + * `except` is the window that made the change, which already knows and has + * drawn it. The tray's own entries come through here too, so a setting flipped + * from the tray moves its switch in Settings. + */ +function applyPref(key: string, value: unknown, except?: Electron.WebContents): void { + prefs.set(key, value) + // The badge and the flash are decided from these as they stand. + if (key === 'notifications.badge' || key === 'notifications.flash') updateTray(lastTray) + // Every window keeps its own cache of these, so a setting changed in one + // is stale in the others until they are told. That is not cosmetic once + // there are several windows: muting a conversation, or switching the log + // to compact, would apply to whichever window happened to be asked. + for (const w of liveWindows()) { + if (w.webContents !== except) w.webContents.send(IPC.prefsChanged, key, value) + } + // Pins and mutes feed the tray/notification rules, which live here. + if (key === 'pinnedBuffers' || key === 'mutedBuffers') notifier.publish() + if (key === 'hotkey.toggle') applyHotkey(String(value)) +} + +/** + * Brings up a conversation: a click on its notification, or on the tray's + * "open" entry. + * + * If it has a window of its own, that window is what the click asked for. + * Raising the main one and switching it would move somebody away from whatever + * they were reading in order to show them a conversation that was already open + * on their screen. + */ +function openConversation(bufferId: string): void { + const popout = popouts.get(bufferId) + if (popout && !popout.isDestroyed()) { + if (popout.isMinimized()) popout.restore() + popout.show() + popout.focus() + return + } + mainWindow?.show() + mainWindow?.focus() + mainWindow?.webContents.send(IPC.activateBuffer, bufferId) +} + function liveWindows(): BrowserWindow[] { const all = mainWindow ? [mainWindow, ...popouts.values()] : [...popouts.values()] return all.filter((w) => !w.isDestroyed()) @@ -353,6 +401,9 @@ function createWindow(): void { pendingDeepLink = null mainWindow?.webContents.send(IPC.deepLink, url) }) + // The tray's first action is to show or hide this window, and says which. + mainWindow.on('show', refreshTrayMenu) + mainWindow.on('hide', refreshTrayMenu) mainWindow.on('maximize', () => mainWindow?.webContents.send(IPC.maximizeChanged, true)) mainWindow.on('unmaximize', () => mainWindow?.webContents.send(IPC.maximizeChanged, false)) mainWindow.on('closed', () => { @@ -562,61 +613,112 @@ function toggleWindow(): void { } } -function trayIcon(hasAlert: boolean): Electron.NativeImage { - const file = resourcePath('icons', hasAlert ? 'tray-alert.png' : 'tray.png') - const img = nativeImage.createFromPath(file) +function trayIcon(unread: number): Electron.NativeImage { + lastTrayIcon = trayIconFile(nativeTheme.shouldUseDarkColors, unread) + const img = nativeImage.createFromPath(resourcePath('icons', lastTrayIcon)) // A missing icon file would otherwise produce an invisible tray entry the // user can never click; fall back to the app icon so the entry still exists. return img.isEmpty() ? nativeImage.createFromPath(resourcePath('icons', 'moho.png')) : img } -function createTray(): void { - tray = new Tray(trayIcon(false)) - tray.setToolTip('moho') - tray.setContextMenu( - Menu.buildFromTemplate([ - { label: 'Show/hide', click: toggleWindow }, - { label: 'Restart daemon', click: () => nobilis.restart() }, - { - label: 'Stop daemon', - // Deliberately separate from Quit: stopping the daemon disconnects - // every account, which is worth asking for explicitly rather than - // making it a side effect of closing a window. - click: () => { - void stopDaemon() - } +/** What the window last said each connected account's status is. */ +let accountStatus: Record = {} +/** The status the menu shows as checked, and the ones it offers: what the connected accounts can be set to. */ +let trayStatus: TrayStatus | null = null +let trayOffered: TrayStatus[] = [] +let trayInvisibleName = 'Invisible' +const isTrayStatus = (v: unknown): v is TrayStatus => v === 'online' || v === 'idle' || v === 'dnd' || v === 'invisible' + +/** + * Asks the window to do something, showing it first: the tray's entries that + * are about moho itself - settings, and a status set across every account - + * are the window's to carry out, since it is what holds the accounts. + */ +function tellWindow(command: 'settings' | 'status', arg?: string): void { + if (!mainWindow) createWindow() + if (command === 'settings') { + mainWindow?.show() + mainWindow?.focus() + } + mainWindow?.webContents.send(IPC.trayCommand, command, arg) +} + +/** + * Starts moho again, daemon and all. + * + * Quitting already takes the daemon down cleanly - QUIT to every network, then + * gone - and the new process starts its own, so restarting is quit-and-relaunch. + * An AppImage has to be relaunched by the path it was started from: its + * executable is a temporary mount that disappears when it exits. + */ +function restartMoho(): void { + const appImage = process.env.APPIMAGE + app.relaunch(appImage ? { execPath: appImage, args: process.argv.slice(1) } : undefined) + app.quit() +} + +/** What the tray last offered and showed, kept for the UI harness to read. */ +let lastTrayMenu: MenuItemConstructorOptions[] = [] +let lastTrayIcon = '' +let lastTrayTooltip = 'moho' + +function refreshTrayMenu(): void { + if (!tray) return + lastTrayMenu = trayMenu( + { + state: lastTray, + windowVisible: !!mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible(), + status: trayStatus, + offered: trayOffered, + invisibleName: trayInvisibleName + }, + { + toggleWindow, + openLatest: () => { + const id = notifier.latestUnread() + if (id) openConversation(id) }, - { type: 'separator' }, - { - label: 'Quit', - click: () => { - app.quit() - } - } - ]) + openSettings: () => tellWindow('settings'), + setStatus: (status) => { + trayStatus = status + tellWindow('status', status) + }, + restart: restartMoho, + quit: () => app.quit() + } ) + tray.setContextMenu(Menu.buildFromTemplate(lastTrayMenu)) +} + +function createTray(): void { + tray = new Tray(trayIcon(0)) + tray.setToolTip('moho') + refreshTrayMenu() tray.on('click', toggleWindow) + // The icon is drawn for the panel it sits on, so it is drawn again when the + // system changes colour scheme. + nativeTheme.on('updated', () => updateTray(lastTray)) } /** What the notifier last said, kept so a change of setting can redraw without waiting for the next message. */ -let lastUnread = 0 -let lastAlert = false +let lastTray: TrayState = NOTHING_WAITING -function updateTray(unreadCount: number, hasAlert: boolean): void { - lastUnread = unreadCount - lastAlert = hasAlert - // The unread badge: the tray's alert dot and count, and the launcher's - // count where the desktop has one. Off, the tray is just the tray. +function updateTray(state: TrayState): void { + lastTray = state + // The unread badge: the tray's count and the launcher's count where the + // desktop has one. Off, the tray is just the tray. const badge = prefs.get('notifications.badge', true) - app.setBadgeCount(badge ? unreadCount : 0) + app.setBadgeCount(badge ? state.unread : 0) // The taskbar flash: asking for attention while the window is not in front. const flash = prefs.get('notifications.flash', true) if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.flashFrame(flash && hasAlert && !mainWindow.isFocused()) + mainWindow.flashFrame(flash && state.unread > 0 && trayStatus !== 'dnd' && !mainWindow.isFocused()) } + refreshTrayMenu() if (!tray) return - tray.setImage(trayIcon(badge && hasAlert)) - tray.setToolTip(badge && unreadCount > 0 ? `moho - ${unreadCount} unread` : 'moho') + tray.setImage(trayIcon(badge ? state.unread : 0)) + lastTrayTooltip = trayTooltip(state, badge) + tray.setToolTip(lastTrayTooltip) send(IPC.link, client.linkUp) } @@ -667,21 +769,17 @@ function wireIpc(): void { }) ipcMain.handle(IPC.prefsGetAll, () => prefs.all()) - ipcMain.handle(IPC.prefsSet, (e, key: string, value: unknown) => { - prefs.set(key, value) - // The badge and the flash are decided from these as they stand. - if (key === 'notifications.badge' || key === 'notifications.flash') updateTray(lastUnread, lastAlert) - // Every window keeps its own cache of these, so a setting changed in one - // is stale in the others until they are told. That is not cosmetic once - // there are several windows: muting a conversation, or switching the log - // to compact, would apply to whichever window happened to be asked. - // Not echoed to the window that set it - it already knows, and has drawn. - for (const w of liveWindows()) { - if (w.webContents !== e.sender) w.webContents.send(IPC.prefsChanged, key, value) - } - // Pins and mutes feed the tray/notification rules, which live here. - if (key === 'pinnedBuffers' || key === 'mutedBuffers') notifier.publish() - if (key === 'hotkey.toggle') applyHotkey(String(value)) + ipcMain.handle(IPC.prefsSet, (e, key: string, value: unknown) => applyPref(key, value, e.sender)) + + // The window says what status its accounts are at, for the tray's menu to check. + ipcMain.on(IPC.trayStatus, (_e, statuses: Record, offered: string[], shown: string | null, invisibleName: string) => { + accountStatus = statuses && typeof statuses === 'object' ? statuses : {} + trayOffered = Array.isArray(offered) ? offered.filter(isTrayStatus) : [] + trayStatus = isTrayStatus(shown) ? shown : null + trayInvisibleName = invisibleName === 'Offline' ? 'Offline' : 'Invisible' + refreshTrayMenu() + // Do not disturb is asking for quiet, including from the taskbar. + updateTray(lastTray) }) ipcMain.handle(IPC.markBufferRead, (_e, bufferId: string) => notifier.clear(bufferId)) @@ -1140,24 +1238,10 @@ app.whenReady().then(() => { notifier = new Notifier( prefs, updateTray, - (bufferId) => { - // If this conversation has a window of its own, that window is what the - // click asked for. Raising the main one and switching it would move - // somebody away from whatever they were reading in order to show them a - // conversation that was already open on their screen. - const popout = popouts.get(bufferId) - if (popout && !popout.isDestroyed()) { - if (popout.isMinimized()) popout.restore() - popout.show() - popout.focus() - return - } - mainWindow?.show() - mainWindow?.focus() - mainWindow?.webContents.send(IPC.activateBuffer, bufferId) - }, + openConversation, () => mainWindow?.webContents ?? null, - (bufferId) => popoutState().watched.includes(bufferId) + (bufferId) => popoutState().watched.includes(bufferId), + (accountId) => accountStatus[accountId] === 'dnd' ) client.on('link', (up) => { @@ -1204,6 +1288,25 @@ app.whenReady().then(() => { createWindow() createTray() applyHotkey(prefs.get('hotkey.toggle')) + // For the UI harness, which has no way to look inside a tray: what it shows + // and offers, and the entries themselves to be clicked. Only when it asks. + if (process.env.MOHO_UI_SHOTS === '1') { + ;(globalThis as Record).__mohoTray = { + update: updateTray, + icon: () => lastTrayIcon, + tooltip: () => lastTrayTooltip, + menu: () => lastTrayMenu, + click: (path: string[]) => { + let items: MenuItemConstructorOptions[] = lastTrayMenu + let item: MenuItemConstructorOptions | undefined + for (const label of path) { + item = items.find((i) => i.label === label || (i.label ?? '').startsWith(label)) + items = (item?.submenu as MenuItemConstructorOptions[]) ?? [] + } + item?.click?.({ checked: !item.checked } as never, undefined, {} as never) + } + } + } app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() diff --git a/src/main/notifications.test.ts b/src/main/notifications.test.ts index 724e62b..b32dd8c 100644 --- a/src/main/notifications.test.ts +++ b/src/main/notifications.test.ts @@ -19,13 +19,14 @@ vi.mock('electron', () => { }) import { Notifier } from './notifications' +import type { TrayState } from './tray' const payload = { accountId: 'a', bufferId: 'b', title: 'alice', body: 'hi' } -function notifier(settings: Record): { n: Notifier; alerts: [number, boolean][] } { - const alerts: [number, boolean][] = [] +function notifier(settings: Record, dnd: string[] = []): { n: Notifier; alerts: TrayState[] } { + const alerts: TrayState[] = [] const prefs = { get: (key: string, fallback: T): T => (key in settings ? (settings[key] as T) : fallback) } - const n = new Notifier(prefs as never, (count, alert) => alerts.push([count, alert]), () => {}) + const n = new Notifier(prefs as never, (state) => alerts.push(state), () => {}, () => null, () => false, (account) => dnd.includes(account)) return { n, alerts } } @@ -44,7 +45,7 @@ describe('notification settings', () => { const { n, alerts } = notifier({ 'notifications.desktop': false }) await n.handle(payload) expect(shown).toHaveLength(0) - expect(alerts.at(-1)?.[0]).toBe(1) + expect(alerts.at(-1)?.unread).toBe(1) }) it('makes them silent when the sound is off', async () => { @@ -52,3 +53,47 @@ describe('notification settings', () => { expect(shown[0].silent).toBe(true) }) }) + +describe('what the tray is told is waiting', () => { + const buffer = (id: string, kind: string): never => ({ id, kind }) as never + + it('counts direct messages and mentions apart, and lights for both', async () => { + const { n, alerts } = notifier({ 'notifications.desktop': false }) + n.trackBuffer(buffer('dm', 'dm'), false) + n.trackBuffer(buffer('chan', 'channel'), false) + await n.handle({ ...payload, bufferId: 'dm' }) + await n.handle({ ...payload, bufferId: 'chan' }) + expect(alerts.at(-1)).toEqual({ unread: 2, dms: 1, mentions: 1 }) + }) + + it('names the newest waiting conversation, and none once they are read', async () => { + const { n } = notifier({ 'notifications.desktop': false }) + expect(n.latestUnread()).toBeNull() + await n.handle({ ...payload, bufferId: 'first' }) + await n.handle({ ...payload, bufferId: 'second' }) + expect(n.latestUnread()).toBe('second') + n.clear('second') + expect(n.latestUnread()).toBe('first') + n.clearAll() + expect(n.latestUnread()).toBeNull() + }) +}) + +describe('Do not disturb', () => { + beforeEach(() => { + shown.length = 0 + }) + + it('shows no notification for an account that is set to it, but still counts the conversation', async () => { + const { n, alerts } = notifier({}, ['a']) + await n.handle(payload) + expect(shown).toHaveLength(0) + expect(alerts.at(-1)?.unread).toBe(1) + }) + + it('leaves the other accounts alone', async () => { + const { n } = notifier({}, ['somebody-else']) + await n.handle(payload) + expect(shown).toHaveLength(1) + }) +}) diff --git a/src/main/notifications.ts b/src/main/notifications.ts index 9df75c6..0de3093 100644 --- a/src/main/notifications.ts +++ b/src/main/notifications.ts @@ -4,6 +4,7 @@ import path from 'node:path' import { Notification, nativeImage, net } from 'electron' import type { WebContents } from 'electron' import type { Prefs } from './prefs' +import type { TrayState } from './tray' import type { Buffer as ChatBuffer } from '../shared/wire' /** @@ -48,7 +49,7 @@ export class Notifier { constructor( private prefs: Prefs, - private onAlertChange: (unreadCount: number, hasAlert: boolean) => void, + private onAlertChange: (state: TrayState) => void, private onActivate: (bufferId: string) => void, /** The renderer, borrowed only to decode formats nativeImage cannot. */ private renderer: () => WebContents | null = () => null, @@ -60,7 +61,16 @@ export class Notifier { * them what they can already see, and a tray badge counting it as unread * is simply wrong. */ - private isWatched: (bufferId: string) => boolean = () => false + private isWatched: (bufferId: string) => boolean = () => false, + /** + * Whether this account has been set to Do not disturb. + * + * That is the one switch for silence: an account that says so gets no popup + * and no sound, however its conversations behave. What it is sent still + * counts as unread - the tray and the badge keep their numbers, as Discord's + * own do - and is there to be read when it is looked at. + */ + private isDnd: (accountId: string) => boolean = () => false ) {} trackBuffer(buffer: ChatBuffer, removed: boolean): void { @@ -103,6 +113,8 @@ export class Notifier { this.publish() } + if (this.isDnd(payload.accountId)) return + // Switched off in Settings: the conversation still counts as unread above, // it just does not pop up. if (!this.prefs.get('notifications.desktop', true)) return @@ -288,24 +300,25 @@ export class Notifier { } /** - * Whether the tray should show something is waiting. + * What the tray should say is waiting. * - * A direct message counts, and this is the change: the tray used to light up - * only for *pinned* buffers, on the reasoning that a DM had already had its - * desktop notification and the tray was for things important enough to pin. - * In practice that made the icon nearly inert - somebody messages you, and - * the one place still on screen after the notification has faded says - * nothing, unless you happened to have pinned that exact conversation. - * - * Pinned buffers still count, so a channel worth pinning can still raise it. - * There is no count here: a tray icon is around 22 pixels, which is room for - * "yes" and not for a number. + * Everything held here was worth a notification - a direct message, or + * somebody mentioning this account - so all of it lights the tray, and it + * says how many conversations and of which kind. It used to light only for + * direct messages and pinned conversations, on the reasoning that a mention + * had had its notification and the tray was for what mattered more; the + * effect was an icon that stayed dark while somebody was asking for you. */ publish(): void { - const pinned = this.prefs.get('pinnedBuffers', []) - const hasAlert = [...this.unread].some( - (id) => this.buffers.get(id)?.kind === 'dm' || pinned.includes(id) - ) - this.onAlertChange(this.unread.size, hasAlert) + let dms = 0 + for (const id of this.unread) if (this.buffers.get(id)?.kind === 'dm') dms++ + this.onAlertChange({ unread: this.unread.size, dms, mentions: this.unread.size - dms }) + } + + /** The conversation that has been waiting for you the shortest time. */ + latestUnread(): string | null { + let latest: string | null = null + for (const id of this.unread) latest = id + return latest } } diff --git a/src/main/tray.test.ts b/src/main/tray.test.ts new file mode 100644 index 0000000..ac5b89d --- /dev/null +++ b/src/main/tray.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest' +import { trayIconFile, trayMenu, trayTooltip, waitingSummary, NOTHING_WAITING, type TrayActions } from './tray' + +const acts = (): TrayActions => ({ + toggleWindow: vi.fn(), + openLatest: vi.fn(), + openSettings: vi.fn(), + setStatus: vi.fn(), + restart: vi.fn(), + quit: vi.fn() +}) + +describe('the tray icon', () => { + it('is a light bubble on a dark system and a dark one on a light system', () => { + expect(trayIconFile(true, 0)).toBe('tray-light.png') + expect(trayIconFile(false, 0)).toBe('tray-dark.png') + }) + + it('carries the count to nine and says more after that', () => { + expect(trayIconFile(true, 1)).toBe('tray-light-1.png') + expect(trayIconFile(true, 9)).toBe('tray-light-9.png') + expect(trayIconFile(false, 10)).toBe('tray-dark-more.png') + expect(trayIconFile(false, 400)).toBe('tray-dark-more.png') + }) +}) + +describe('what is said about what is waiting', () => { + it('names direct messages and mentions apart, and in the singular for one', () => { + expect(waitingSummary({ unread: 3, dms: 2, mentions: 1 })).toBe('2 direct messages, 1 mention') + expect(waitingSummary({ unread: 1, dms: 1, mentions: 0 })).toBe('1 direct message') + expect(waitingSummary({ unread: 4, dms: 0, mentions: 4 })).toBe('4 mentions') + }) + + it('leaves the tooltip plain with nothing waiting or the badge off', () => { + expect(trayTooltip(NOTHING_WAITING, true)).toBe('moho') + expect(trayTooltip({ unread: 2, dms: 1, mentions: 1 }, false)).toBe('moho') + expect(trayTooltip({ unread: 2, dms: 1, mentions: 1 }, true)).toBe('moho - 1 direct message, 1 mention') + }) +}) + +describe('the menu', () => { + const model = { state: NOTHING_WAITING, windowVisible: true, status: 'idle' as const, offered: [], invisibleName: 'Invisible' } + const labels = (items: ReturnType): string[] => items.map((i) => i.label ?? '-') + + it('offers no daemon plumbing, and a restart that is of moho as a whole', () => { + const l = labels(trayMenu(model, acts())) + expect(l).not.toContain('Restart daemon') + expect(l).not.toContain('Stop daemon') + expect(l).toContain('Restart moho') + }) + + it('says nothing is new, and offers nothing to open, when that is so', () => { + const [first] = trayMenu(model, acts()) + expect(first.label).toBe('Nothing new') + expect(first.enabled).toBe(false) + }) + + it('says what is waiting and opens it', () => { + const a = acts() + const [first] = trayMenu({ ...model, state: { unread: 2, dms: 1, mentions: 1 } }, a) + expect(first.label).toBe('1 direct message, 1 mention - open') + first.click?.({} as never, undefined, {} as never) + expect(a.openLatest).toHaveBeenCalled() + }) + + it('has no notifications switch of its own: Do not disturb is that', () => { + expect(labels(trayMenu(model, acts())).join('|')).not.toMatch(/notification/i) + const status = trayMenu(model, acts()).find((i) => i.label === 'Status') + expect((status?.submenu as { label: string }[]).map((s) => s.label)).toContain('Do not disturb') + }) + + it('offers only what the connected accounts can be set to', () => { + const labelsOf = (offered: ('online' | 'idle' | 'dnd' | 'invisible')[]): string[] => + (trayMenu({ ...model, offered }, acts()).find((i) => i.label === 'Status')?.submenu as { label: string }[]).map((s) => s.label) + expect(labelsOf(['online', 'dnd'])).toEqual(['Online', 'Do not disturb']) + expect(labelsOf(['online', 'idle', 'dnd', 'invisible'])).toHaveLength(4) + expect(labelsOf([])).toHaveLength(4) + }) + + it('calls invisible offline where only Matrix has it', () => { + const sub = trayMenu({ ...model, offered: ['online', 'invisible'], invisibleName: 'Offline' }, acts()).find((i) => i.label === 'Status')?.submenu as { label: string }[] + expect(sub.map((s) => s.label)).toEqual(['Online', 'Offline']) + }) + + it('asks for the window to be shown or hidden as it stands', () => { + expect(labels(trayMenu({ ...model, windowVisible: true }, acts()))).toContain('Hide moho') + expect(labels(trayMenu({ ...model, windowVisible: false }, acts()))).toContain('Show moho') + }) + + it('checks the status the accounts are at', () => { + const status = trayMenu(model, acts()).find((i) => i.label === 'Status') + const sub = status?.submenu as { label: string; checked: boolean }[] + expect(sub.filter((s) => s.checked).map((s) => s.label)).toEqual(['Idle']) + const none = trayMenu({ ...model, status: null }, acts()).find((i) => i.label === 'Status')?.submenu as { checked: boolean }[] + expect(none.some((s) => s.checked)).toBe(false) + }) +}) diff --git a/src/main/tray.ts b/src/main/tray.ts new file mode 100644 index 0000000..67ca8e5 --- /dev/null +++ b/src/main/tray.ts @@ -0,0 +1,107 @@ +/** + * What the tray says and offers, apart from the Electron objects that show it. + * + * Kept as plain functions of plain data so that what the icon, the tooltip and + * the menu say about a given set of unread conversations can be checked + * without a desktop to look at. + */ +import type { MenuItemConstructorOptions } from 'electron' + +/** What is waiting, as the notifier counts it. */ +export interface TrayState { + /** Conversations with something unread that was worth a notification. */ + unread: number + /** Of those, direct messages. */ + dms: number + /** Of those, everything else: somebody mentioning this account in a channel. */ + mentions: number +} + +export const NOTHING_WAITING: TrayState = { unread: 0, dms: 0, mentions: 0 } + +export type TrayStatus = 'online' | 'idle' | 'dnd' | 'invisible' + +/** + * The icon file for a panel and a count. + * + * `dark` is the system's colour scheme, and a dark system wants the light + * bubble. The count is drawn into the icon up to nine; past that it says "9+", + * since a tray icon is about 22 pixels and has no room for a second digit. + */ +export function trayIconFile(dark: boolean, unread: number): string { + const ink = dark ? 'light' : 'dark' + if (unread <= 0) return `tray-${ink}.png` + return `tray-${ink}-${unread > 9 ? 'more' : unread}.png` +} + +const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}` + +/** "2 direct messages, 1 mention" - what is waiting, in the words a person would use. */ +export function waitingSummary(s: TrayState): string { + const parts: string[] = [] + if (s.dms > 0) parts.push(plural(s.dms, 'direct message', 'direct messages')) + if (s.mentions > 0) parts.push(plural(s.mentions, 'mention', 'mentions')) + return parts.join(', ') +} + +export function trayTooltip(s: TrayState, badge: boolean): string { + return badge && s.unread > 0 ? `moho - ${waitingSummary(s)}` : 'moho' +} + +export interface TrayActions { + toggleWindow: () => void + openLatest: () => void + openSettings: () => void + setStatus: (status: TrayStatus) => void + restart: () => void + quit: () => void +} + +export interface TrayModel { + state: TrayState + windowVisible: boolean + /** The status the accounts are at, where the window has said. */ + status: TrayStatus | null + /** What the connected accounts can be set to; none reported yet means all of them. */ + offered: TrayStatus[] + /** What invisible is called: Matrix, which has only offline presence, says offline. */ + invisibleName: string +} + +const STATUSES: { id: TrayStatus; label: string }[] = [ + { id: 'online', label: 'Online' }, + { id: 'idle', label: 'Idle' }, + { id: 'dnd', label: 'Do not disturb' }, + { id: 'invisible', label: 'Invisible' } +] + +/** + * The right-click menu: what is new, how to get to moho, how to say what you + * are doing - which includes Do not disturb, the one switch for silencing it - + * and how to leave. Daemon plumbing - restart it, stop it - is + * deliberately not here; it is a thing for Settings, and "restart moho" + * covers what somebody reaching for it from a tray actually wants. + */ +export function trayMenu(model: TrayModel, act: TrayActions): MenuItemConstructorOptions[] { + const waiting = model.state.unread > 0 + return [ + waiting + ? { label: `${waitingSummary(model.state)} - open`, click: act.openLatest } + : { label: 'Nothing new', enabled: false }, + { type: 'separator' }, + { label: model.windowVisible ? 'Hide moho' : 'Show moho', click: act.toggleWindow }, + { + label: 'Status', + submenu: STATUSES.filter((s) => model.offered.length === 0 || model.offered.includes(s.id)).map((s) => ({ + label: s.id === 'invisible' ? model.invisibleName : s.label, + type: 'radio' as const, + checked: model.status === s.id, + click: () => act.setStatus(s.id) + })) + }, + { label: 'Settings', click: act.openSettings }, + { type: 'separator' }, + { label: 'Restart moho', click: act.restart }, + { label: 'Quit', click: act.quit } + ] +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 3a7afe6..8fe447a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -83,6 +83,22 @@ const api = { return () => ipcRenderer.off(IPC.activateBuffer, handler) }, + /** The tray menu asking this window to open Settings, or to set a status on every account. */ + onTrayCommand(cb: (command: 'settings' | 'status', arg?: string) => void): () => void { + const handler = (_e: unknown, command: 'settings' | 'status', arg?: string): void => cb(command, arg) + ipcRenderer.on(IPC.trayCommand, handler) + return () => ipcRenderer.off(IPC.trayCommand, handler) + }, + + /** + * Says what status each connected account is at, which statuses any of them + * can be set to, and the one to show as checked: for the tray menu, and for + * main to stay quiet for an account on Do not disturb. + */ + setAccountStatuses(statuses: Record, offered: string[], shown: string | null, invisibleName: string): void { + ipcRenderer.send(IPC.trayStatus, statuses, offered, shown, invisibleName) + }, + 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/components/BufferList.tsx b/src/renderer/src/components/BufferList.tsx index 81eb914..b462e3d 100644 --- a/src/renderer/src/components/BufferList.tsx +++ b/src/renderer/src/components/BufferList.tsx @@ -313,6 +313,14 @@ export function BufferList(): JSX.Element { .catch((e: Error) => store.toast('error', e.message)) } }} + onUnmuteAccount={ + accounts.find((a) => a.id === b.accountId)?.service === 'matrix' + ? () => + void window.moho + .rpc('setMatrixRoomMuted', { bufferId: b.id, muted: false }) + .catch((e: Error) => store.toast('error', e.message)) + : undefined + } onToggleAutojoin={ b.kind === 'channel' && accounts.find((a) => a.id === b.accountId)?.service === 'irc' ? () => store.toggleAutojoin(b.accountId, bufferDisplayName(b.name)) @@ -829,6 +837,8 @@ interface BufferRowProps { onSelect: () => void onTogglePin: () => void onToggleMute: () => void + /** Matrix rooms muted on the account: take that mute off. */ + onUnmuteAccount?: () => void /** IRC channels only: rejoin this on every connect, or stop doing so. */ onToggleAutojoin?: () => void /** Kick channels only: watch the stream where streams are watched. */ @@ -878,6 +888,7 @@ function BufferRow({ onSelect, onTogglePin, onToggleMute, + onUnmuteAccount, onToggleAutojoin, autojoins, onOpenInBrowser, @@ -982,6 +993,7 @@ function BufferRow({ spaces, onTogglePin, onToggleMute, + onUnmuteAccount, onToggleAutojoin, onSpace, onWatch, diff --git a/src/renderer/src/components/ConversationMenu.tsx b/src/renderer/src/components/ConversationMenu.tsx index ec757f9..59d8a42 100644 --- a/src/renderer/src/components/ConversationMenu.tsx +++ b/src/renderer/src/components/ConversationMenu.tsx @@ -75,6 +75,13 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme .catch((e: Error) => store.toast('error', e.message)) } }, + onUnmuteAccount: + account?.service === 'matrix' + ? () => + void window.moho + .rpc('setMatrixRoomMuted', { bufferId: buffer.id, muted: false }) + .catch((e: Error) => store.toast('error', e.message)) + : undefined, onToggleAutojoin: buffer.kind === 'channel' && account?.service === 'irc' ? () => store.toggleAutojoin(buffer.accountId, name) diff --git a/src/renderer/src/components/StatusPopout.tsx b/src/renderer/src/components/StatusPopout.tsx index bf95f0e..9659578 100644 --- a/src/renderer/src/components/StatusPopout.tsx +++ b/src/renderer/src/components/StatusPopout.tsx @@ -6,6 +6,7 @@ import { useEscapeLayer } from '../lib/layers' import { presenceClass } from '../lib/presence' import { classes } from '../lib/util' import type { Account } from '../../../shared/wire' +import { statusName, supportsStatus } from '../lib/status' import type { Status } from './UserFooter' /** The statuses that can be chosen, with the dot each is drawn with. Signing out is separate, below them. */ @@ -89,7 +90,7 @@ export function StatusPopout({ aria-label="Set your status" style={pos ? { left: pos.left, bottom: pos.bottom } : { visibility: 'hidden' }} > - {CHOICES.map((c) => ( + {CHOICES.filter((c) => supportsStatus(account.service, c.id)).map((c) => ( diff --git a/src/renderer/src/components/UserFooter.tsx b/src/renderer/src/components/UserFooter.tsx index fe23556..07d5d06 100644 --- a/src/renderer/src/components/UserFooter.tsx +++ b/src/renderer/src/components/UserFooter.tsx @@ -4,6 +4,7 @@ import { Icon } from './Icon' import { Avatar } from './Avatar' import { useStore, useChat } from '../state/hooks' import { presenceLabel } from '../lib/presence' +import { statusName } from '../lib/status' import { serviceLabel } from '../lib/util' import type { Account } from '../../../shared/wire' @@ -34,8 +35,9 @@ export function effectiveStatus(account: Account): Status | 'connecting' { return (account.status as Status) || 'online' } -function label(status: Status | 'connecting'): string { - return status === 'connecting' ? 'Connecting…' : presenceLabel(status) +function label(status: Status | 'connecting', service: string): string { + if (status === 'connecting') return 'Connecting…' + return status === 'invisible' ? statusName(service, 'invisible') : presenceLabel(status) } export function UserFooter({ account }: { account?: Account }): JSX.Element { @@ -71,12 +73,12 @@ export function UserFooter({ account }: { account?: Account }): JSX.Element { aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(!open)} - title={detail ? `${name} — ${label(status)}\n${detail}` : `${name} — ${label(status)}`} + title={detail ? `${name} — ${label(status, account.service)}\n${detail}` : `${name} — ${label(status, account.service)}`} > {name} - {watching ? 'Watching, signed out' : label(status)} + {watching ? 'Watching, signed out' : label(status, account.service)} diff --git a/src/renderer/src/lib/buffermenu.test.ts b/src/renderer/src/lib/buffermenu.test.ts new file mode 100644 index 0000000..df9358a --- /dev/null +++ b/src/renderer/src/lib/buffermenu.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest' +import { bufferMenuEntries } from './buffermenu' + +const noop = (): void => {} + +function entries(over: Record) { + return bufferMenuEntries({ + buffer: { id: 'b', accountId: 'matrix:@me:x', name: '!r:x', kind: 'channel', serverMuted: true }, + account: { id: 'matrix:@me:x', service: 'matrix' }, + muted: false, + pinned: false, + filed: false, + inCall: false, + poppedOut: false, + canCall: false, + live: false, + watched: false, + onTogglePin: noop, + onToggleMute: noop, + onHide: noop, + onClose: noop, + onCall: noop, + onHangUp: noop, + onFile: noop, + onPopOut: noop, + onDock: noop, + ...over + } as never) as { label?: string; disabled?: boolean; onClick?: () => void }[] +} + +describe('a mute held on the account', () => { + it('can be taken off where the service lets it be, and that is what the entry does', () => { + const unmute = vi.fn() + const toggle = vi.fn() + const entry = entries({ onUnmuteAccount: unmute, onToggleMute: toggle }).find((e) => e.label === 'Unmute') + expect(entry).toBeDefined() + expect(entry?.disabled).toBeFalsy() + entry?.onClick?.() + expect(unmute).toHaveBeenCalled() + // Not the window's own mute, which would only have added a second one. + expect(toggle).not.toHaveBeenCalled() + }) + + it('says where it is where it cannot be taken off from here', () => { + const entry = entries({}).find((e) => (e.label ?? '').startsWith('Muted on')) + expect(entry?.label).toBe('Muted on Matrix') + expect(entry?.disabled).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/buffermenu.ts b/src/renderer/src/lib/buffermenu.ts index 6c69d4a..df86937 100644 --- a/src/renderer/src/lib/buffermenu.ts +++ b/src/renderer/src/lib/buffermenu.ts @@ -32,6 +32,8 @@ export interface BufferMenuInput { spaces?: BufferGroup[] onTogglePin: () => void onToggleMute: () => void + /** Takes off a mute the account itself holds, where the service lets it be taken off from here. */ + onUnmuteAccount?: () => void /** * Filing the conversation on the account rather than in this window - * Matrix's room tags. Absent for every service that has no such idea, which @@ -86,6 +88,7 @@ export function bufferMenuEntries({ spaces, onTogglePin, onToggleMute, + onUnmuteAccount, onTag, onToggleAutojoin, onSpace, @@ -204,16 +207,20 @@ export function bufferMenuEntries({ } ] as MenuEntry[]) : []), - // A mute made on the account itself is not this window's to undo, and an - // "Unmute" that quietly did nothing would be worse than no entry at all - - // so it says where the mute is, and where to go and take it off. + // A mute made on the account itself - Matrix keeps it on the server, as a + // push rule - is the account's to take off, and where the service lets it + // be taken off from here that is what this does. Where it does not, the + // entry says where the mute is rather than offering an "Unmute" that quietly + // did nothing. buffer.serverMuted - ? { - label: account ? `Muted on ${serviceLabel(account.service)}` : 'Muted on this account', - icon: 'notifications_off', - disabled: true, - onClick: () => {} - } + ? onUnmuteAccount + ? { label: 'Unmute', icon: 'notifications', onClick: onUnmuteAccount } + : { + label: account ? `Muted on ${serviceLabel(account.service)}` : 'Muted on this account', + icon: 'notifications_off', + disabled: true, + onClick: () => {} + } : { label: muted ? 'Unmute' : 'Mute', icon: muted ? 'notifications' : 'notifications_off', onClick: onToggleMute }, { separator: true }, // Filing is a drag onto the heading now, not an entry per heading. That diff --git a/src/renderer/src/lib/status.test.ts b/src/renderer/src/lib/status.test.ts new file mode 100644 index 0000000..1d19b86 --- /dev/null +++ b/src/renderer/src/lib/status.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { statusesFor, statusName, supportsStatus } from './status' + +describe('the statuses a service has', () => { + it('gives Discord all four', () => { + expect(statusesFor('discord')).toEqual(['online', 'idle', 'dnd', 'invisible']) + }) + + it('gives IRC away and back, and no invisible', () => { + expect(statusesFor('irc')).toEqual(['online', 'idle', 'dnd']) + }) + + it('gives Matrix offline presence as invisible and no idle', () => { + expect(statusesFor('matrix')).toEqual(['online', 'dnd', 'invisible']) + }) + + it('gives Kick and Sneedchat only the one that is moho\'s own', () => { + expect(statusesFor('kick')).toEqual(['online', 'dnd']) + expect(statusesFor('sneedchat')).toEqual(['online', 'dnd']) + expect(supportsStatus('sneedchat', 'idle')).toBe(false) + }) +}) + +describe('what a status is called', () => { + it('calls Matrix\'s invisible what it is, offline presence', () => { + expect(statusName('matrix', 'invisible')).toBe('Offline') + expect(statusName('discord', 'invisible')).toBe('Invisible') + expect(statusName('matrix', 'dnd')).toBe('Do not disturb') + }) +}) diff --git a/src/renderer/src/lib/status.ts b/src/renderer/src/lib/status.ts new file mode 100644 index 0000000..0fef71b --- /dev/null +++ b/src/renderer/src/lib/status.ts @@ -0,0 +1,44 @@ +/** + * Which statuses a service has, kept in step with `status_supported` in the + * daemon, which also refuses what a service has no state for. + * + * Offered by what each can actually express rather than by what could be faked + * from something nearby: Discord has all four; IRC has away and back - idle and + * online; Matrix has online and offline presence, so invisible is real and idle + * is not; Kick and Sneedchat have none. Do not disturb is online with moho's own + * desktop notifications silenced, which any service can have, so it is on all of + * them - and only Discord has a real state for it, which also holds back its + * phone notifications. + */ +export type Choosable = 'online' | 'idle' | 'dnd' | 'invisible' + +export const ALL_STATUSES: Choosable[] = ['online', 'idle', 'dnd', 'invisible'] + +export function supportsStatus(service: string, status: Choosable): boolean { + switch (status) { + case 'online': + case 'dnd': + return true + case 'idle': + return service === 'discord' || service === 'irc' + case 'invisible': + return service === 'discord' || service === 'matrix' + } +} + +/** What an account of this service can be set to, in the order they are listed. */ +export function statusesFor(service: string): Choosable[] { + return ALL_STATUSES.filter((s) => supportsStatus(service, s)) +} + +/** + * What a status is called on this service. + * + * Matrix has no invisible mode: what is sent for it is offline presence, which + * is what it is, so that is what it says there. Everything else is called what + * the others call it. + */ +export function statusName(service: string, status: Choosable): string { + if (status === 'invisible' && service === 'matrix') return 'Offline' + return { online: 'Online', idle: 'Idle', dnd: 'Do not disturb', invisible: 'Invisible' }[status] +} diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/state/store.ts index 53c0e90..cdcf703 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/state/store.ts @@ -22,6 +22,7 @@ import type { VoiceSession } from '../../../shared/wire' import { addToast, type ToastAction, type ToastItem } from '../lib/toasts' +import { ALL_STATUSES, statusesFor, supportsStatus } from '../lib/status' import { buildSmilieIndex, type SmilieEntry, type SmilieIndex } from '../lib/format' import { bufferDisplayName, isChatKind, isImageFile, resolveMediaUrl } from '../lib/util' import { runExport } from '../lib/exporter' @@ -1179,6 +1180,31 @@ export class ChatStore { private set(patch: Partial): void { this.state = { ...this.state, ...patch } for (const l of this.listeners) l() + if (patch.accounts && !this.state.pinnedBufferId) this.reportTrayStatus() + } + + /** + * Tells main what status each connected account is at, and which statuses + * any of them can be set to. The tray menu offers those and checks the one of + * the account with the most to choose from - Discord's rather than a + * service that has no idle to be at - and main stays quiet, with no popup and + * no sound, for an account that is on Do not disturb. + */ + /** What main was last told, so an account list that changed in nothing that matters is not told again. */ + private lastTrayReport = '' + + private reportTrayStatus(): void { + const statuses: Record = {} + const connected = this.state.accounts.filter((a) => a.state === 'connected') + for (const a of connected) if (a.status) statuses[a.id] = a.status + const offered = ALL_STATUSES.filter((s) => connected.some((a) => supportsStatus(a.service, s))) + const widest = [...connected].sort((a, b) => statusesFor(b.service).length - statusesFor(a.service).length)[0] + // Invisible is what Discord calls it; where only Matrix has it, it is offline. + const invisibleName = connected.some((a) => supportsStatus(a.service, 'invisible') && a.service !== 'matrix') ? 'Invisible' : 'Offline' + const report = JSON.stringify([statuses, offered, widest?.status ?? null, invisibleName]) + if (report === this.lastTrayReport) return + this.lastTrayReport = report + window.moho.setAccountStatuses(statuses, offered, widest?.status ?? null, invisibleName) } // --- lifecycle ------------------------------------------------------ @@ -1244,8 +1270,14 @@ export class ChatStore { window.moho.onScreenPick(async (sources) => (await this.askForScreenSource(sources))?.id ?? null) window.moho.onLinkChange((up) => { + // Only the link coming up is news. Main says it again whenever the tray + // is redrawn, and taking each of those for a reconnection refreshed + // everything and put the pane back on the open conversation's server - + // which is how a Discord account retrying every few seconds kept pulling + // somebody off the IRC network they had just chosen. + const wasUp = this.state.linkUp this.set({ linkUp: up }) - if (up) { + if (up && !wasUp) { void this.refreshAll() void this.refreshNetSettings() } @@ -1256,6 +1288,11 @@ export class ChatStore { // window gets them instead; main routes them there. if (!this.state.pinnedBufferId) { window.moho.onActivateBuffer((id) => void this.selectBuffer(id)) + // The tray's menu: Settings, and a status for every account. + window.moho.onTrayCommand((command, arg) => { + if (command === 'settings') this.setActivePanel('settings') + else if (arg === 'online' || arg === 'idle' || arg === 'dnd' || arg === 'invisible') void this.setStatusEverywhere(arg) + }) window.moho.onDeepLink((url) => this.followDeepLink(url)) } @@ -1344,7 +1381,10 @@ export class ChatStore { ]) // Not awaited: GitHub being slow is no reason to hold the window up. void this.checkForRelease() - if (this.state.activeBufferId) await this.selectBuffer(this.state.activeBufferId) + // The open conversation is read again, and subscribed to again, where it is. + // Not followed to its server: a refresh is not somebody choosing it, and + // the rail may have been moved to another since. + if (this.state.activeBufferId) await this.selectBuffer(this.state.activeBufferId, false) } /** @@ -2119,7 +2159,9 @@ export class ChatStore { * left alone - choosing a status is not a request to connect it. */ async setStatusEverywhere(status: 'online' | 'idle' | 'dnd' | 'invisible', statusText?: string): Promise { - const connected = this.state.accounts.filter((a) => a.state === 'connected') + // Only the accounts of a service that has this status: an idle for the + // others is not asked of them, and they stay as they were. + const connected = this.state.accounts.filter((a) => a.state === 'connected' && supportsStatus(a.service, status)) await Promise.all(connected.map((a) => this.setAccountStatus(a.id, status, a.service === 'discord' || a.service === 'matrix' ? statusText : undefined))) } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 5e18398..df05cd3 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -52,7 +52,11 @@ export const IPC = { /** main -> window: a right click landed on somewhere text is typed. */ editMenu: 'moho:editMenu', deepLink: 'moho:deepLink', - popoutsChanged: 'moho:popout:changed' + popoutsChanged: 'moho:popout:changed', + /** main -> window: something from the tray menu that the window carries out. */ + trayCommand: 'moho:tray:command', + /** window -> main: the status each connected account is at. */ + trayStatus: 'moho:tray:status' } as const /**