Merge branch 'development'
@@ -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
|
||||
|
||||
@@ -17,6 +17,25 @@ Each section has two parts:
|
||||
that made it, including dependency updates
|
||||
(`node scripts/changelog-deps.mjs <previous tag>` 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 797 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 771 B |
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -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,
|
||||
|
||||
@@ -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
|
||||
? `<mask id="m"><rect width="64" height="64" fill="#fff"/><circle cx="46" cy="18" r="19" fill="#000"/></mask>`
|
||||
: `<mask id="m"><rect width="64" height="64" fill="#fff"/><circle cx="21" cy="27" r="4" fill="#000"/><circle cx="32" cy="27" r="4" fill="#000"/><circle cx="43" cy="27" r="4" fill="#000"/></mask>`
|
||||
const size = label && label.length > 1 ? 24 : 30
|
||||
const text = badge
|
||||
? `<circle cx="46" cy="18" r="16" fill="${BADGE}"/><text x="46" y="${18 + size * 0.36}" font-family="DejaVu Sans" font-weight="bold" font-size="${size}" text-anchor="middle" fill="#fff">${label}</text>`
|
||||
: ''
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${SIZE}" height="${SIZE}" viewBox="0 0 64 64">${mask}<path d="${BUBBLE}" fill="${ink}" mask="url(#m)"/>${text}</svg>`
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -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)
|
||||
@@ -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<string, string> = {}
|
||||
/** 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<boolean>('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<boolean>('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<string, string>, 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<string>('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<string, unknown>).__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()
|
||||
|
||||
@@ -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<string, unknown>): { n: Notifier; alerts: [number, boolean][] } {
|
||||
const alerts: [number, boolean][] = []
|
||||
function notifier(settings: Record<string, unknown>, dnd: string[] = []): { n: Notifier; alerts: TrayState[] } {
|
||||
const alerts: TrayState[] = []
|
||||
const prefs = { get: <T>(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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<boolean>('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<string[]>('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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof trayMenu>): 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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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<string, string>, 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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
@@ -100,8 +101,8 @@ export function StatusPopout({
|
||||
>
|
||||
<span className={classes('status-dot', presenceClass(c.dot))} />
|
||||
<span className="status-choice-text">
|
||||
<span>{c.label}</span>
|
||||
{c.hint && <span className="small muted">{c.hint}</span>}
|
||||
<span>{statusName(account.service, c.id)}</span>
|
||||
{c.hint && <span className="small muted">{account.service === 'matrix' ? 'Your presence is set to offline' : c.hint}</span>}
|
||||
</span>
|
||||
{c.id === status && <Icon name="check" size={16} className="status-check" />}
|
||||
</button>
|
||||
|
||||
@@ -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)}`}
|
||||
>
|
||||
<Avatar name={name} url={account.avatarUrl} size={28} status={status} accountId={account.id} />
|
||||
<span className="user-identity">
|
||||
<span className="ellipsis user-name">{name}</span>
|
||||
<span className="ellipsis small muted">{watching ? 'Watching, signed out' : label(status)}</span>
|
||||
<span className="ellipsis small muted">{watching ? 'Watching, signed out' : label(status, account.service)}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferMenuEntries } from './buffermenu'
|
||||
|
||||
const noop = (): void => {}
|
||||
|
||||
function entries(over: Record<string, unknown>) {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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<ChatState>): 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<string, string> = {}
|
||||
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<void> {
|
||||
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)))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||