13 Commits
Author SHA1 Message Date
SalastilandClaude Sonnet 5.5 9b14536112 moho 1.0.0-rc.5
Fifth release candidate: GIFs on every service with a picker, idle detection, YouTube and GIF cards on every service including over Tor, the tray's number counted by the window, mentions-only counts that survive a mute, and Discord mutes set on the account.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 16:37:23 -04:00
SalastilandClaude Sonnet 5.5 5d826a785d Mirror script: MIRROR_GITEA reaches Gitea by its own address for the files Cloudflare will not take
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 16:33:00 -04:00
SalastilandClaude Sonnet 5.5 99e03116e9 Mirror script: a file over Cloudflare's 100 MB limit stays on GitHub only, said plainly, instead of stopping the mirror before nobilis
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 16:28:04 -04:00
SalastilandClaude Sonnet 5.5 ee0120ff5e A deploy tidies first: old AppImages, debug and cross builds, and a daemon build directory past 8 GB are removed, so a checkout stops growing to a hundred gigabytes
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 16:12:52 -04:00
SalastilandClaude Sonnet 5.5 7803cc38a6 A mute stops the interruptions, not the count: a muted channel still shows its mentions in the list, the rail, the switcher and the tray; a muted direct message shows none; and muting on Discord is written to the account as it is on Matrix
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 13:33:05 -04:00
SalastilandClaude Sonnet 5.5 987f31cf77 The number beside a conversation is what is addressed to you: mentions in a channel, each unread message in a direct message - the bold name says there is activity, a count of it does not
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 13:21:56 -04:00
SalastilandClaude Sonnet 5.5 336335a8a3 Scrolling a long unread run no longer snaps back: the place being read is found through the bar laid over the top of the log, not read off the bar, so a card or picture settling above does not put the view back where the bar appeared
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 13:17:22 -04:00
SalastilandClaude Sonnet 5.5 638b02e07f nobilis: link cards survive edits and reach backlog on every service
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 23:07:05 -04:00
SalastilandClaude Sonnet 5.5 8064e79bc3 nobilis: YouTube cards for accounts on Tor, from the video's page when oEmbed is refused
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 22:57:05 -04:00
SalastilandClaude Sonnet 5.5 1e314f85b6 The tray's number is the window's: counted by the rail's rule - muted and hidden conversations out, a direct message by each unread message, anywhere else by its mentions - and told to the tray whenever it changes, instead of a tally of its own that drifted
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 20:08:34 -04:00
SalastilandClaude Sonnet 5.5 07f4fc2ef6 The window does not load the forum's own files itself: the daemon fetches them through the account's route, and asked for from the reader's address the host refuses them and Tor is bypassed
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 19:06:23 -04:00
SalastilandClaude Sonnet 5.5 55248eb583 GIFs: a linked GIF is drawn as the picture - silent, looping, without the player - on every service, a GIF picker with trending, categories and search, and Klipy/Tenor/Giphy pages are never probed (#331)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 17:55:10 -04:00
SalastilandClaude Sonnet 5.5 23c87dd353 Idle detection: how long since anything was used, from the system where it will say and moho's own windows where it will not; a setting for it and for how long; the plaque says Idle (away) and keeps what was chosen checked (#332)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 17:35:58 -04:00
37 changed files with 1581 additions and 82 deletions
+14
View File
@@ -276,6 +276,20 @@ jobs:
- name: tray check
run: xvfb-run -a npm run test:tray
# GIFs: that a message carrying one is drawn as the picture, that a page
# nothing could read stays a link, and that the picker finds and sends.
# Needs ffmpeg, for the pictures it plays.
- name: gif check
run: |
sudo apt-get install -y --no-install-recommends ffmpeg
xvfb-run -a npm run test:gif
# Scrolling through a long unread run: that the view stays where it is
# read when something above it changes size, with the "new messages" bar
# up over the top of the log.
- name: scroll check
run: xvfb-run -a npm run test:scroll
# 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
+20
View File
@@ -17,6 +17,26 @@ Each section has two parts:
that made it, including dependency updates
(`node scripts/changelog-deps.mjs <previous tag>` lists those).
## [1.0.0-rc.5]
### Major features
- **GIFs on every service:** a linked GIF is drawn as the picture - silent, looping, without the player - whichever service it came from, and a GIF picker with trending, categories and search sends one from the composer. Klipy, Tenor and Giphy pages are never probed for what they are.
- **Idle detection:** moho notices when nothing has been used for a while, from the system where it can say and from its own windows where it cannot, and puts an account that has an idle on Idle and back. It has a setting for it and for how long, and the plaque says Idle (away) while keeping what you chose checked.
- **Link cards on every service:** a YouTube link gets a titled card with its channel on IRC, Matrix, Kick and Sneedchat as it does on Discord - including through Tor, which YouTube refuses the usual way - and one added in an edit, read back from history, or posted before cards existed gets its card too.
### Fixes, patches and changes
- Added idle detection with a setting for it and for how long ([moho `23c87dd`](https://github.com/Moho-Chat/moho/commit/23c87dd), [nobilis `35ea0b7`](https://github.com/Moho-Chat/nobilis/commit/35ea0b7))
- Added GIFs drawn as pictures on every service, and a GIF picker ([moho `55248eb`](https://github.com/Moho-Chat/moho/commit/55248eb), [nobilis `046fbb4`](https://github.com/Moho-Chat/nobilis/commit/046fbb4))
- Fixed a Sneedchat picture from the forum's uploads host - an AVIF, or an MP4 - not drawing: its domain is taken off and it is fetched through the account's route, clearnet or Tor ([moho `07f4fc2`](https://github.com/Moho-Chat/moho/commit/07f4fc2), [nobilis `ca29a63`](https://github.com/Moho-Chat/nobilis/commit/ca29a63))
- Fixed the tray's number drifting from the client's: it is now counted by the window, by the rail's rule, and told to the tray whenever it changes ([moho `1e314f8`](https://github.com/Moho-Chat/moho/commit/1e314f8))
- Fixed YouTube cards never appearing for an account on Tor: YouTube answers every Tor exit with a 403 on its card lookup, so the video's own page is read when that happens ([moho `8064e79`](https://github.com/Moho-Chat/moho/commit/8064e79), [nobilis `340342b`](https://github.com/Moho-Chat/nobilis/commit/340342b))
- Fixed link cards being lost when a message was edited, and missing from Matrix history, Discord catch-up and older messages ([moho `638b02e`](https://github.com/Moho-Chat/moho/commit/638b02e), [nobilis `dab394f`](https://github.com/Moho-Chat/nobilis/commit/dab394f))
- Fixed scrolling through a long run of unread messages snapping back to where the "new messages" bar appeared ([moho `336335a`](https://github.com/Moho-Chat/moho/commit/336335a))
- The number beside a conversation is what is addressed to you - mentions in a channel, each unread message in a direct message - and the bold name is the sign of activity ([moho `987f31c`](https://github.com/Moho-Chat/moho/commit/987f31c))
- A muted channel keeps its mention count in the list, the rail, the switcher and the tray, and stops the popups; muting a Discord channel or direct message is now set on the account, so a phone is quiet too ([moho `7803cc3`](https://github.com/Moho-Chat/moho/commit/7803cc3), [nobilis `b0fa250`](https://github.com/Moho-Chat/nobilis/commit/b0fa250))
## [1.0.0-rc.4]
### Major features
+1 -1
Submodule nobilis updated: 58dfa5894d...f3f872349a
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "moho",
"version": "1.0.0-rc.4",
"version": "1.0.0-rc.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "moho",
"version": "1.0.0-rc.4",
"version": "1.0.0-rc.5",
"license": "GPL-3.0-or-later",
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "moho",
"version": "1.0.0-rc.4",
"version": "1.0.0-rc.5",
"description": "Unified desktop chat client for IRC, Discord, Sneedchat and Matrix",
"author": "Salastil",
"license": "GPL-3.0-or-later",
@@ -22,13 +22,16 @@
"test": "vitest run",
"test:ui": "electron-vite build && node scripts/ui-check.mjs",
"test:tray": "electron-vite build && node scripts/tray-check.mjs",
"test:gif": "electron-vite build && node scripts/gif-check.mjs",
"test:scroll": "electron-vite build && node scripts/scroll-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",
"pack:linux": "npm run daemon && npm run build && MOHO_BUILD=$(git rev-parse --short HEAD)$(git diff --quiet HEAD || echo -dirty) electron-builder --linux AppImage deb",
"pack:dir": "npm run daemon && npm run build && MOHO_BUILD=$(git rev-parse --short HEAD)$(git diff --quiet HEAD || echo -dirty) electron-builder --linux dir",
"daemon": "./scripts/build-daemon.sh",
"deploy": "npm run pack && ./scripts/install-desktop.sh",
"tidy": "./scripts/tidy.sh",
"deploy": "./scripts/tidy.sh && npm run pack && ./scripts/install-desktop.sh",
"daemon:win": "./scripts/build-daemon-win.sh",
"pack:win": "npm run daemon:win && npm run build && MOHO_BUILD=$(git rev-parse --short HEAD)$(git diff --quiet HEAD || echo -dirty) electron-builder --win nsis"
},
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env node
/**
* Checks GIFs against the running app: that one a message carries is drawn as
* the picture and not as its address, that a link to a page nothing could read
* is still a link, and that the picker finds, filters and sends.
*
* What it stands in for, and why: Discord is not here, so the picker's list is
* left for it (a fixture only a harness window reads) and the embeds are put on
* messages directly. What is real is everything the window does with them, and
* the send, which goes through the daemon to a fake IRC server and is read back
* off the wire.
*
* npm run test:gif (builds first; needs the daemon built too)
*/
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fakeIrc, launchApp, openIrcChannel, sleep, until, click } from './lib/harness.mjs'
let failures = 0
const check = (ok, what, detail = '') => {
console.log(`${ok ? 'ok ' : 'FAIL'} ${what}${ok ? '' : detail ? ` - ${detail}` : ''}`)
if (!ok) failures++
}
setTimeout(() => {
console.log('FAIL timed out')
process.exit(1)
}, 120000).unref()
// A GIF and a video to be served, so what is drawn has something to load: made
// with ffmpeg, a second of a flat colour. The window only plays https, blob and
// data media, so they are handed to it as the last two.
const media = fs.mkdtempSync(path.join(os.tmpdir(), 'moho-gif-'))
execFileSync('ffmpeg', ['-v', 'error', '-f', 'lavfi', '-i', 'color=c=0x3a8fe0:s=96x54:d=1:r=10', '-pix_fmt', 'yuv420p', '-y', path.join(media, 'cat.mp4')])
execFileSync('ffmpeg', ['-v', 'error', '-f', 'lavfi', '-i', 'color=c=0xe0a43a:s=96x54:d=1:r=10', '-y', path.join(media, 'giphy.gif')])
const MP4 = fs.readFileSync(path.join(media, 'cat.mp4')).toString('base64')
const GIF = fs.readFileSync(path.join(media, 'giphy.gif')).toString('base64')
const sent = []
const irc = await fakeIrc({
people: ['bob'],
onJoin: (channel, _nick, send) => send(`:bob!b@h PRIVMSG ${channel} :hello there`),
onLine: (line) => {
if (/^PRIVMSG/i.test(line)) sent.push(line)
}
})
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
let home = ''
try {
home = await page.evaluate('window.moho.diagnostics?.home ?? ""').catch(() => '')
await until(page, 'window.__mohoShots', 'the harness hook')
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#lobby'], waitFor: 'hello there' })
// The same GIF, as a message that carries it.
const SIZE = (a, b, label) =>
'data:image/svg+xml;utf8,' +
encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="480" height="270"><rect width="480" height="270" fill="${a}"/><text x="240" y="150" font-size="60" fill="${b}" text-anchor="middle">${label}</text></svg>`)
await page.evaluate(`(() => {
const shots = window.__mohoShots
const s = shots.state()
const id = s.activeBufferId
const now = Math.floor(Date.now() / 1000)
const bytes = Uint8Array.from(atob('${MP4}'), (c) => c.charCodeAt(0))
const MP4_URL = URL.createObjectURL(new Blob([bytes], { type: 'video/mp4' }))
const GIF_URL = 'data:image/gif;base64,${GIF}'
const msg = (n, body, embeds) => ({ id: 'g' + n, bufferId: id, from: 'bob', senderId: 'bob', body, ts: now - (10 - n), isAction: false, isHighlight: false, kind: 'chat', isOwn: false, ...(embeds ? { embeds } : {}) })
shots.patch({
accounts: [...s.accounts, { id: 'discord:gifcheck', service: 'discord', displayName: 'x', state: 'connected', autojoin: '', currentNick: 'x', status: 'online', useTor: false, ssl: true }],
messagesByBuffer: { ...s.messagesByBuffer, [id]: [...(s.messagesByBuffer[id] || []),
msg(1, 'https://tenor.com/view/cat-gif-123', [{ kind: 'gif', url: 'https://tenor.com/view/cat-gif-123', mediaUrl: MP4_URL, mediaKind: 'video', width: 480, height: 270, provider: 'Tenor' }]),
msg(2, 'look at this https://giphy.com/gifs/dog-Z7wFi5b2VEAr6 so good', [{ kind: 'gif', url: 'https://giphy.com/gifs/dog-Z7wFi5b2VEAr6', mediaUrl: GIF_URL, mediaKind: 'image', provider: 'Giphy' }]),
msg(3, 'https://klipy.com/gifs/friends-joey-53')
] }
})
window.__gifFixture = {
categories: [{ name: 'reaction', src: '' }, { name: 'happy', src: '' }],
gifs: [
{ id: 'a', title: 'cat dancing', url: 'https://tenor.com/view/cat-dancing-gif-1', src: 'http://127.0.0.1:9/a.mp4', gifSrc: 'http://127.0.0.1:9/a.gif', preview: '', width: 480, height: 270 },
{ id: 'b', title: 'dog running', url: 'https://tenor.com/view/dog-running-gif-2', src: 'http://127.0.0.1:9/b.mp4', gifSrc: 'http://127.0.0.1:9/b.gif', preview: '', width: 270, height: 480 },
{ id: 'c', title: 'cat sleeping', url: 'https://tenor.com/view/cat-sleeping-gif-3', src: 'http://127.0.0.1:9/c.mp4', gifSrc: 'http://127.0.0.1:9/c.gif', preview: '', width: 480, height: 480 }
]
}
return true
})()`)
await sleep(800)
const row = (n) => `[...document.querySelectorAll('.message-row')].find((r) => r.innerText.includes('${n}') || r.querySelector('[data-id="g${n}"]'))`
// A message that carries a GIF shows the GIF, and not the address it came from.
const rows = await page.evaluate(`[...document.querySelectorAll('.message-row')].map((r) => ({ text: r.innerText, videos: [...r.querySelectorAll('video')].map((v) => v.getAttribute('src')), plays: [...r.querySelectorAll('video')].map((v) => ({ muted: v.muted, loop: v.loop, controls: v.controls, autoplay: v.autoplay })), images: [...r.querySelectorAll('img')].map((i) => i.getAttribute('src')) }))`)
if (process.env.GIF_DEBUG) console.log(JSON.stringify(rows.slice(-4), null, 1))
const tenor = rows.find((r) => r.videos.some((v) => v?.startsWith('blob:')))
check(!!tenor, 'a GIF message is drawn as a video')
check(!!tenor && !/tenor\.com/.test(tenor.text), 'and its page address is not drawn as well', tenor?.text)
const play = tenor?.plays?.[0]
check(!!play && play.muted && play.loop && !play.controls && play.autoplay, 'played as a GIF: silent, going round, with no player to work', JSON.stringify(play))
const giphy = rows.find((r) => r.text.includes('look at this'))
check(!!giphy && /look at this/.test(giphy.text) && /so good/.test(giphy.text) && !/giphy\.com/.test(giphy.text), 'words around a GIF link stay, and the link goes', giphy?.text)
check(!!giphy && (giphy.images.some((i) => i?.startsWith('data:image/gif'))), 'and a GIF file is drawn as a picture')
const klipy = rows.find((r) => r.text.includes('klipy.com'))
check(!!klipy && klipy.videos.length === 0, 'a Klipy page nothing could read stays a link', JSON.stringify(klipy))
// The picker.
check(await page.evaluate('!!document.querySelector(\'button[title="GIFs"]\')'), 'a GIF button is offered where there is a Discord account')
await page.evaluate(click('button[title="GIFs"]'))
await until(page, 'document.querySelector(".gif-picker")', 'the picker')
check((await page.evaluate('document.querySelectorAll(".gif-cell").length')) === 3, 'it lists what it was given')
check((await page.evaluate('document.querySelectorAll(".gif-category").length')) === 2, 'with categories to start from')
const inView = await page.evaluate(`(() => { const r = document.querySelector('.gif-picker').getBoundingClientRect(); return r.left >= 0 && r.top >= 0 && r.right <= window.innerWidth && r.bottom <= window.innerHeight })()`)
check(inView, 'inside the window')
await page.evaluate(`(() => { const i = document.querySelector('.gif-search input'); const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; set.call(i, 'cat'); i.dispatchEvent(new Event('input', { bubbles: true })); return true })()`)
await sleep(700)
check((await page.evaluate('document.querySelectorAll(".gif-cell").length')) === 2, 'a search narrows it')
// Choosing one sends it - to IRC, the file, which anything can show as a link.
await page.evaluate(`document.querySelector('.gif-cell[title="cat dancing"]').click()`)
await sleep(1200)
check(sent.some((l) => /PRIVMSG #lobby :?http:\/\/127\.0\.0\.1:9\/a\.gif/.test(l)), 'choosing one sends the file to a service that is not Discord', JSON.stringify(sent))
check(!(await page.evaluate('!!document.querySelector(".gif-picker")')), 'and the picker closes')
} catch (e) {
console.log('FAIL', e.message)
failures++
} finally {
await stop().catch(() => {})
irc.close()
fs.rmSync(media, { recursive: true, force: true })
}
console.log(failures ? `${failures} failed` : 'all passed')
process.exit(failures ? 1 : 0)
+17 -1
View File
@@ -18,8 +18,16 @@ set -euo pipefail
TAG="${1:?usage: scripts/mirror-release.sh vX.Y.Z}"
VERSION="${TAG#v}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
GITEA="https://git.salastil.com/api/v1"
# Through Cloudflare by default, which refuses a request body over 100 MB. The
# files that are bigger go to Gitea directly across the network, which is what
# MIRROR_GITEA is for - the same instance, reached by its own address:
#
# MIRROR_GITEA=http://192.168.1.212:8991/api/v1 MIRROR_MAX_BYTES=1000000000 \
# scripts/mirror-release.sh v1.0.0
GITEA="${MIRROR_GITEA:-https://git.salastil.com/api/v1}"
TOKEN="$(cat "$HOME/.config/gitea/token")"
# What Cloudflare in front of Gitea will take in one request.
MAX_UPLOAD="${MIRROR_MAX_BYTES:-100000000}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
@@ -42,6 +50,14 @@ gitea_release() { # repo title notes-file prerelease file...
id="$(gitea POST "/repos/$repo/releases" -H 'Content-Type: application/json' -d "$body" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
fi
for f in "$@"; do
# Gitea is behind Cloudflare, which refuses a request body over 100 MB (a
# 413, or a 502 when the connection is cut mid-upload). A file over that
# stays on GitHub only, said plainly rather than failing the whole mirror
# and leaving everything after it - nobilis's releases - undone.
if [ "$(stat -c %s "$f")" -gt "$MAX_UPLOAD" ]; then
echo " SKIPPED $(basename "$f") ($(( $(stat -c %s "$f") / 1000000 )) MB) - over Cloudflare's ${MAX_UPLOAD} byte limit; it is on GitHub only" >&2
continue
fi
gitea POST "/repos/$repo/releases/$id/assets?name=$(basename "$f")" -F "attachment=@$f" >/dev/null
echo " uploaded $(basename "$f") -> Gitea $repo"
done
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env node
/**
* Checks that a reader scrolling through a long unread run is not put back.
*
* The "N new messages since" bar sits over the top of the log. What the list
* remembers about where the reader is came from asking the page what is at its
* top edge - which, while the bar is up, is the bar. So the place stopped being
* kept up to date the moment the bar appeared, and the next thing to change
* size (a card arriving, a picture loading) put the view back where the
* reader had been when it appeared.
*
* npm run test:scroll (builds first; needs the daemon built too)
*/
import { fakeIrc, 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++
}
setTimeout(() => {
console.log('FAIL timed out')
process.exit(1)
}, 90000).unref()
const irc = await fakeIrc({
people: ['bob'],
onJoin: (channel, _nick, send) => send(`:bob!b@h PRIVMSG ${channel} :hello there`)
})
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
try {
await until(page, 'window.__mohoShots', 'the harness hook')
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#lobby'], waitFor: 'hello there' })
// Three hundred messages, the last two hundred of them unread.
await page.evaluate(`(() => {
const shots = window.__mohoShots
const s = shots.state()
const id = s.activeBufferId
const now = Math.floor(Date.now() / 1000)
const list = Array.from({ length: 300 }, (_, n) => ({
id: 'm' + n, bufferId: id, from: n % 3 ? 'bob' : 'carol', senderId: n % 3 ? 'bob' : 'carol',
body: 'message number ' + n + ' with a little text in it', ts: now - 40000 + n * 60,
isAction: false, isHighlight: false, kind: 'chat', isOwn: false
}))
shots.patch({
messagesByBuffer: { ...s.messagesByBuffer, [id]: list },
dividerTsByBuffer: { ...s.dividerTsByBuffer, [id]: list[99].ts }
})
})()`)
await until(page, `!!document.querySelector('.new-divider')`, 'the New line')
await sleep(600)
// Reading down past the line, the way a wheel does: the bar comes up.
await page.evaluate(`(async () => {
const el = document.querySelector('.messagelist-scroll')
el.dispatchEvent(new WheelEvent('wheel', { deltaY: 100, bubbles: true }))
for (let i = 0; i < 8; i++) {
el.scrollTop += 120
el.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true }))
await new Promise((r) => setTimeout(r, 60))
}
})()`)
await until(page, `!!document.querySelector('.unread-bar')`, 'the unread bar')
// Further down, with the bar over the top of the log the whole way.
await page.evaluate(`(async () => {
const el = document.querySelector('.messagelist-scroll')
for (let i = 0; i < 12; i++) {
el.scrollTop += 150
el.dispatchEvent(new WheelEvent('wheel', { deltaY: 150, bubbles: true }))
await new Promise((r) => setTimeout(r, 60))
}
})()`)
await sleep(300)
const before = await page.evaluate(`document.querySelector('.messagelist-scroll').scrollTop`)
// Something above the reader settles taller - a card arriving, a picture
// loading. The row under the reader's eye is what must stay put.
const moved = await page.evaluate(`(async () => {
const el = document.querySelector('.messagelist-scroll')
const box = el.getBoundingClientRect()
const rows = [...el.querySelectorAll('[data-msg-id]')]
const watched = rows.find((r) => r.getBoundingClientRect().top > box.top + 60)
const was = watched.getBoundingClientRect().top - box.top
const above = rows.find((r) => r.getBoundingClientRect().bottom < box.top - 50)
above.style.minHeight = (above.getBoundingClientRect().height + 240) + 'px'
await new Promise((r) => setTimeout(r, 400))
return { id: watched.dataset.msgId, drift: watched.getBoundingClientRect().top - box.top - was }
})()`)
check(Math.abs(moved.drift) < 2, 'a row above growing leaves the row being read where it was', `moved ${moved.drift}px`)
// And the position itself did not fall back towards where the bar appeared.
const after = await page.evaluate(`document.querySelector('.messagelist-scroll').scrollTop`)
check(after >= before - 4, 'the view did not go back up the log', `${before} -> ${after}`)
} finally {
await stop()
irc.close()
}
console.log(failures ? `${failures} FAILED` : 'all passed')
process.exit(failures ? 1 : 0)
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env sh
# Keeps a development checkout from filling the disk.
#
# Everything this removes is build output that the next build makes again, and
# none of it is the project: it is what grew to a hundred gigabytes without
# anybody choosing it.
#
# - dist/ got one AppImage per deploy and never lost one. Only the newest few
# are kept; the installed copy lives in ~/.local/bin.
# - Cargo never removes what a build no longer uses. Every change to a
# dependency, a feature or the compiler leaves the old build beside the new
# one, so nobilis/target held fifty copies of each library. Past a size it
# is emptied, and the build that follows starts it again with only what is
# current. Run before a build, never after one: after, it would throw away
# the thing just made.
# - Debug builds and the Windows cross-build are not what a deploy uses and
# are made again when somebody asks for them.
#
# scripts/tidy.sh (also run first by `npm run deploy`)
set -eu
SRC_DIR="$(cd "$(dirname "$0")/.." && pwd)"
KEEP_APPIMAGES="${MOHO_KEEP_APPIMAGES:-2}"
# In megabytes: a clean release build of the daemon is about two gigabytes.
TARGET_LIMIT_MB="${MOHO_TARGET_LIMIT_MB:-8000}"
size_mb() { du -sm "$1" 2>/dev/null | cut -f1; }
# What is in dist/ besides the newest AppImages: older ones, Windows and
# Debian packages, the unpacked trees and their block maps.
if [ -d "$SRC_DIR/dist" ]; then
keep="$(ls -t "$SRC_DIR"/dist/*.AppImage 2>/dev/null | head -n "$KEEP_APPIMAGES")"
for f in "$SRC_DIR"/dist/* "$SRC_DIR"/dist/.[!.]*; do
[ -e "$f" ] || continue
case "$(basename "$f")" in builder-debug.yml|builder-effective-config.yaml|latest*.yml) continue ;; esac
printf '%s\n' "$keep" | grep -qxF "$f" && continue
rm -rf "$f"
done
fi
# A build directory left over from before the daemon lived in nobilis/.
rm -rf "$SRC_DIR/target"
TARGET="$SRC_DIR/nobilis/target"
if [ -d "$TARGET" ]; then
rm -rf "$TARGET/debug" "$TARGET"/x86_64-pc-windows-* "$TARGET"/aarch64-*
used="$(size_mb "$TARGET")"
if [ "${used:-0}" -gt "$TARGET_LIMIT_MB" ]; then
echo "tidy: nobilis/target is ${used} MB, over ${TARGET_LIMIT_MB} MB - emptying it; the next build starts it fresh"
rm -rf "$TARGET"
fi
fi
+78 -1
View File
@@ -101,7 +101,14 @@ try {
// 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`) })
const aways = []
const irc = await fakeIrc({
people: ['bob'],
onJoin: (channel, _nick, send) => send(`:bob!b@h PRIVMSG ${channel} :hello there`),
onLine: (line) => {
if (/^AWAY/i.test(line)) aways.push(line)
}
})
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')
@@ -114,6 +121,68 @@ try {
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')
// Going idle when the person steps away, and only when they are online.
const shown = () => page.evaluate('window.moho.rpc("listAccounts", {}).then((a) => { const x = a.find((y) => y.id.startsWith("checker")); return { status: x.status, auto: !!x.autoIdle } })')
const waitFor = (expr, what) => until(page, `window.moho.rpc("listAccounts", {}).then((a) => { const x = a.find((y) => y.id.startsWith("checker")); return ${expr} })`, what)
await tray('t.click(["Status", "Online"])')
await waitFor('x.status === "online"', 'online again')
await tray('t.idle(true)')
await waitFor('x.status === "idle" && x.autoIdle === true', 'an online account to go idle when its person steps away')
check(true, 'stepping away turns an online account idle, as automatic')
await sleep(500)
check(aways.some((l) => /AWAY :?Idle/i.test(l)), 'and IRC is told it is away', JSON.stringify(aways))
await tray('t.idle(false)')
await waitFor('x.status === "online" && !x.autoIdle', 'it to come back')
check(aways.some((l) => /^AWAY\s*$/i.test(l)), 'coming back puts it right without anybody touching it', JSON.stringify(aways))
// What was chosen is never moved.
await tray('t.click(["Status", "Do not disturb"])')
await waitFor('x.status === "dnd"', 'Do not disturb')
await tray('t.idle(true)')
await sleep(800)
let now = await shown()
check(now.status === 'dnd' && !now.auto, 'Do not disturb is kept when they step away', JSON.stringify(now))
await tray('t.idle(false)')
await sleep(500)
now = await shown()
check(now.status === 'dnd', 'and when they come back', JSON.stringify(now))
await tray('t.click(["Status", "Idle"])')
await waitFor('x.status === "idle" && !x.autoIdle', 'a chosen Idle')
await tray('t.idle(true)')
await tray('t.idle(false)')
await sleep(500)
now = await shown()
check(now.status === 'idle' && !now.auto, 'a chosen Idle stays until Online is chosen', JSON.stringify(now))
await tray('t.click(["Status", "Online"])')
await waitFor('x.status === "online"', 'online again')
// The tray's number is the window's: it follows what is waiting, by the rail's rule.
const stage = (patch) => page.evaluate(`(() => { const s = window.__mohoShots.state(); const id = s.buffers.find((b) => b.kind === 'channel' && b.id.startsWith('checker')).id; window.__chan = id; window.__mohoShots.patch({ buffers: s.buffers.map((b) => (b.id === id ? { ...b, ...(${JSON.stringify(patch)}) } : b)) }); return id })()`)
const counted = async (want, what) => {
await until(main, `(() => { const s = globalThis.__mohoTray.state(); return s.mentions === ${want.mentions} && s.dms === ${want.dms} })()`, what)
check(true, what)
}
await tray('t.update({ unread: 0, dms: 0, mentions: 0 })')
const chan = await stage({ unread: 33, mentions: 2, highlight: true })
await counted({ mentions: 2, dms: 0 }, 'two mentions in a busy channel make the tray say two, not thirty-three')
check((await tray('t.state().latest')) === chan, 'and the menu opens that channel')
// Said the way another window would say it, through main.
const chanId = await page.evaluate('window.__chan')
await tray(`t.pref('mutedBuffers', [${JSON.stringify(chanId)}])`)
await counted({ mentions: 2, dms: 0 }, 'muting it leaves its mentions on the tray - a mute stops the popups, not the count')
await stage({ unread: 40, mentions: 0, highlight: false })
await counted({ mentions: 0, dms: 0 }, 'and what else is said in it counts for nothing')
await tray("t.pref('mutedBuffers', [])")
await stage({ unread: 33, mentions: 2, highlight: true })
await counted({ mentions: 2, dms: 0 }, 'unmuting changes nothing about the number')
await stage({ unread: 0, mentions: 0, highlight: false })
await counted({ mentions: 0, dms: 0 }, 'reading it somewhere else clears the tray as it clears the window')
await page.evaluate(`(() => { const s = window.__mohoShots.state(); window.__mohoShots.patch({ buffers: [...s.buffers, { id: 'checker@localhost|bobdm', accountId: s.accounts[0].id, kind: 'dm', name: 'bob', lastActivityTs: 1, unread: 3, highlight: false, mentions: 0 }] }); return true })()`)
await counted({ mentions: 0, dms: 3 }, 'a direct message counts for each unread message in it')
check(/3 direct messages/.test(await tray('t.tooltip()')), 'and the tooltip says so', await tray('t.tooltip()'))
await page.evaluate(`(() => { const s = window.__mohoShots.state(); window.__mohoShots.patch({ buffers: s.buffers.filter((b) => b.id !== 'checker@localhost|bobdm') }); return true })()`)
await counted({ mentions: 0, dms: 0 }, 'and the tray is empty again')
} finally {
irc.close()
}
@@ -133,6 +202,14 @@ try {
check(stayed === '~mentions', 'a redrawn tray leaves the chosen server chosen', `${away} -> ${stayed}`)
await tray('t.update({ unread: 0, dms: 0, mentions: 0 })')
// Using a window is heard by main - and only that it happened.
const heardBefore = await tray('t.lastActivity()')
await sleep(6000)
await page.call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 300, y: 300, button: 'none', clickCount: 0 })
await sleep(500)
const heardAfter = await tray('t.lastActivity()')
check(heardAfter > heardBefore, 'moving the pointer in a window is heard as being here', `${heardBefore} -> ${heardAfter}`)
// Restart brings back moho and a daemon of its own.
const before = daemons(scratchDir)
const pidBefore = await main.evaluate('process.pid')
+95
View File
@@ -171,6 +171,76 @@ const discord = (inCall) => async (page) => {
await sleep(300)
}
// --- GIFs (#331) ---------------------------------------------------------------
/**
* Three messages that link to GIFs, with or without what the daemon or Discord
* says about them: a Tenor page, a Giphy page and a Klipy page. Without, they
* are the addresses they were before; with, the first two are the GIFs - the
* Klipy page is one nothing can read, and stays a link either way.
*
* The pictures are made with ffmpeg and handed to the window as blob and data
* URLs, since it plays only https media and nothing is fetched from anywhere.
*/
const stageGifs = (withEmbeds) => async (page) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'moho-gifs-'))
const clip = (name, source) => {
const file = path.join(dir, name)
execFileSync('ffmpeg', ['-v', 'error', '-f', 'lavfi', '-i', source, '-pix_fmt', 'yuv420p', '-y', file])
return fs.readFileSync(file).toString('base64')
}
const mp4 = clip('cat.mp4', 'gradients=s=300x200:d=3:r=15:speed=0.02:c0=0xe0a43a:c1=0xb0457a:c2=0x3a8fe0:nb_colors=3')
const gif = (() => {
const file = path.join(dir, 'wave.gif')
execFileSync('ffmpeg', ['-v', 'error', '-f', 'lavfi', '-i', 'gradients=s=260x180:d=2:r=12:speed=0.03:c0=0x3ae0a4:c1=0x4a2f9a:nb_colors=2', '-y', file])
return fs.readFileSync(file).toString('base64')
})()
fs.rmSync(dir, { recursive: true, force: true })
await page.evaluate(`(() => {
const shots = window.__mohoShots
const s = shots.state()
const id = s.activeBufferId
const now = Math.floor(Date.now() / 1000)
const bytes = Uint8Array.from(atob('${mp4}'), (c) => c.charCodeAt(0))
const MP4 = URL.createObjectURL(new Blob([bytes], { type: 'video/mp4' }))
const GIF = 'data:image/gif;base64,${gif}'
const msg = (n, from, body, embeds) => ({ id: 'gif' + n, bufferId: id, from, senderId: from, body, ts: now - (60 - n) * 20, isAction: false, isHighlight: false, kind: 'chat', isOwn: false, ...(embeds ? { embeds } : {}) })
const embeds = ${withEmbeds ? 'true' : 'false'}
shots.patch({ messagesByBuffer: { ...s.messagesByBuffer, [id]: [...(s.messagesByBuffer[id] || []),
msg(1, 'alice', 'this is how it feels on a monday https://tenor.com/view/monday-cat-gif-22954713', embeds ? [{ kind: 'gif', url: 'https://tenor.com/view/monday-cat-gif-22954713', mediaUrl: MP4, mediaKind: 'video', width: 300, height: 200, provider: 'Tenor' }] : undefined),
msg(2, 'bob', 'https://giphy.com/gifs/wave-hello-Z7wFi5b2VEAr6', embeds ? [{ kind: 'gif', url: 'https://giphy.com/gifs/wave-hello-Z7wFi5b2VEAr6', mediaUrl: GIF, mediaKind: 'image', width: 260, height: 180, provider: 'Giphy' }] : undefined),
msg(3, 'carol', 'and from klipy: https://klipy.com/gifs/friends-joey-53')
] } })
return true
})()`)
await sleep(900)
}
/** The GIF picker, open, with a handful of invented GIFs to look through. */
const openGifPicker = async (page) => {
await page.evaluate(`(() => {
const shots = window.__mohoShots
const s = shots.state()
const art = (a, b, label, w, h) => 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + ' ' + h + '"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="' + a + '"/><stop offset="1" stop-color="' + b + '"/></linearGradient></defs><rect width="' + w + '" height="' + h + '" fill="url(#g)"/><text x="' + (w / 2) + '" y="' + (h / 2 + 14) + '" font-size="44" font-family="sans-serif" fill="#fff" text-anchor="middle">' + label + '</text></svg>')
const gif = (id, title, a, b, w, h) => ({ id, title, url: 'https://tenor.com/view/' + id, src: '', gifSrc: '', preview: art(a, b, title, w, h), width: w, height: h })
window.__gifFixture = {
categories: [{ name: 'reaction', src: '' }, { name: 'happy', src: '' }, { name: 'dancing', src: '' }, { name: 'cats', src: '' }, { name: 'applause', src: '' }, { name: 'facepalm', src: '' }],
gifs: [
gif('1', 'cat dance', '#e0a43a', '#b0457a', 480, 300), gif('2', 'thumbs up', '#3a8fe0', '#4a2f9a', 300, 300),
gif('3', 'happy dog', '#3ae0a4', '#1f6f5a', 480, 270), gif('4', 'mind blown', '#e05a3a', '#7a2f2f', 300, 360),
gif('5', 'clapping', '#8a5ae0', '#3a3fa0', 480, 270), gif('6', 'wow', '#e0d03a', '#a07a1f', 300, 300)
]
}
shots.patch({ accounts: [...s.accounts, { id: 'discord:gifs', service: 'discord', displayName: 'x', state: 'connected', autojoin: '', currentNick: 'x', status: 'online', useTor: false, ssl: true }] })
return true
})()`)
await sleep(500)
await page.evaluate(click('button[title="GIFs"]'))
await until(page, 'document.querySelector(".gif-cell")', 'the GIFs')
await sleep(500)
}
// --- scenes ------------------------------------------------------------------
/** Messages with several pictures, one of them a spoiler (#304): two, three and four, then a lone spoiler. */
@@ -270,6 +340,18 @@ const setMode = async (page, mode) => {
* `video` scenes are recorded instead, while `act` runs.
*/
const SCENES = [
{
name: 'gif-links-before',
setup: async (p) => { await setMode(p, 'comfy'); await openChannel(p, '#general'); await stageGifs(false)(p) }
},
{
name: 'gif-links-after',
setup: async (p) => { await setMode(p, 'comfy'); await openChannel(p, '#general'); await stageGifs(true)(p) }
},
{
name: 'gif-picker',
setup: async (p) => { await setMode(p, 'comfy'); await openChannel(p, '#general'); await openGifPicker(p) }
},
{ name: 'irc-channel-comfy', setup: async (p) => { await setMode(p, 'comfy'); await openChannel(p, '#general') } },
{
// History from several days, scrolled to its start: where date
@@ -511,6 +593,19 @@ const SCENES = [
await sleep(300)
}
},
{
name: 'settings-presence',
setup: async (p) => {
await p.evaluate(`window.__mohoShots.store.setActivePanel('settings')`)
await sleep(400)
await p.evaluate(`(() => {
const el = [...document.querySelectorAll('h1, h2, h3, h4, .settings-section-title')].find((e) => e.textContent.trim() === 'Presence')
el?.scrollIntoView({ block: 'start' })
return !!el
})()`)
await sleep(400)
}
},
{ name: 'settings-general', setup: async (p) => { await p.evaluate(`window.__mohoShots.store.setActivePanel('settings')`); await sleep(400) } },
{
name: 'settings-tor',
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest'
import { IdleWatcher } from './idle'
const MIN = 60_000
/** A watcher on a clock the test holds, with the system's idle time a figure it sets. */
function watcher(over: { enabled?: boolean; minutes?: number } = {}): {
w: IdleWatcher
clock: { now: number; system: number; enabled: boolean }
said: boolean[]
} {
const clock = { now: 1_000_000, system: 0, enabled: over.enabled ?? true }
const said: boolean[] = []
const w = new IdleWatcher({
now: () => clock.now,
systemIdleSeconds: () => clock.system,
enabled: () => clock.enabled,
thresholdMs: () => (over.minutes ?? 10) * MIN,
onChange: (idle) => said.push(idle)
})
return { w, clock, said }
}
describe('being away, by moho\'s own windows', () => {
it('is away after the threshold with nothing touched, and says so once', () => {
const { w, clock, said } = watcher()
clock.now += 9 * MIN
w.evaluate()
expect(w.isIdle()).toBe(false)
clock.now += 2 * MIN
w.evaluate()
w.evaluate()
expect(w.isIdle()).toBe(true)
expect(said).toEqual([true])
})
it('is back the moment anything is used', () => {
const { w, clock, said } = watcher()
clock.now += 11 * MIN
w.evaluate()
w.activity()
expect(w.isIdle()).toBe(false)
expect(said).toEqual([true, false])
})
it('counts use as starting the clock again', () => {
const { w, clock } = watcher()
clock.now += 9 * MIN
w.activity()
clock.now += 9 * MIN
w.evaluate()
expect(w.isIdle()).toBe(false)
})
it('does nothing at all when it is switched off', () => {
const { w, clock, said } = watcher({ enabled: false })
clock.now += 60 * MIN
w.evaluate()
expect(said).toEqual([])
})
it('comes back when it is switched off while away', () => {
const { w, clock, said } = watcher()
clock.now += 11 * MIN
w.evaluate()
clock.enabled = false
w.evaluate()
expect(said).toEqual([true, false])
})
})
describe('being away, by the system\'s idle time', () => {
it('does not take a zero that never changes for somebody who never stops', () => {
// A platform that cannot say reports zero for ever; moho's own windows decide.
const { w, clock } = watcher()
clock.system = 0
clock.now += 11 * MIN
w.evaluate()
expect(w.isIdle()).toBe(true)
})
it('believes the system once it has been seen to be anything but zero, and counts use elsewhere as being here', () => {
const { w, clock } = watcher()
clock.system = 3
w.evaluate()
// Eleven minutes of not touching moho, but the keyboard was used a minute ago.
clock.now += 11 * MIN
clock.system = 60
w.evaluate()
expect(w.isIdle()).toBe(false)
// And then it really is eleven minutes everywhere.
clock.system = 11 * 60
w.evaluate()
expect(w.isIdle()).toBe(true)
})
})
describe('locking', () => {
it('is away at once when the screen locks and back when it unlocks', () => {
const { w, said } = watcher()
w.lock()
expect(w.isIdle()).toBe(true)
w.unlock()
expect(w.isIdle()).toBe(false)
expect(said).toEqual([true, false])
})
})
+97
View File
@@ -0,0 +1,97 @@
/**
* Whether the person has stepped away, which is all this knows.
*
* Discord's own client and every IRC client that sets itself away work from the
* same one fact - how long since anything was touched - and nothing else: not
* what is on screen, not what was typed, not which program is in front. This is
* kept to that, and is deliberately small: a clock, two sources of "how long",
* and a threshold.
*
* The two sources are the system's own idle time, where the platform will say
* (Windows, macOS, X11), and moho's own windows being used, which every
* platform can say. Some Wayland desktops give Chromium no idle time at all and
* report zero for ever, which looks exactly like somebody who never stops
* typing; so the system's figure is only believed once it has been seen to be
* anything but zero, and until then - and where it never is - moho goes by its
* own windows.
*/
export interface IdleOptions {
now: () => number
/** Seconds since the last keyboard or mouse input anywhere; 0 where the platform cannot say. */
systemIdleSeconds: () => number
enabled: () => boolean
thresholdMs: () => number
/** Said when the answer changes, and only then. */
onChange: (idle: boolean) => void
}
export class IdleWatcher {
private lastActivity: number
private systemKnown = false
private away = false
/** The screen is locked or the machine asleep: away at once, whatever the clock says. */
private locked = false
private forced: boolean | null = null
constructor(private o: IdleOptions) {
this.lastActivity = o.now()
}
isIdle(): boolean {
return this.away
}
/** When something was last used in a moho window, for the harness to see that it was heard. */
lastActivityAt(): number {
return this.lastActivity
}
/** Something was used in a moho window - including coming back to one. */
activity(): void {
this.lastActivity = this.o.now()
this.locked = false
this.evaluate()
}
lock(): void {
this.locked = true
this.evaluate()
}
unlock(): void {
this.locked = false
this.lastActivity = this.o.now()
this.evaluate()
}
/** For the harness: an answer that the clock does not get to change. */
forceForTesting(idle: boolean | null): void {
this.forced = idle
this.evaluate()
}
/** Asks the clocks, and says so if the answer is different. Run on a timer, and when a setting moves. */
evaluate(): void {
this.set(this.decide())
}
private decide(): boolean {
if (this.forced !== null) return this.forced
if (!this.o.enabled()) return false
if (this.locked) return true
const system = this.o.systemIdleSeconds()
if (system > 0) this.systemKnown = true
const own = this.o.now() - this.lastActivity
// Whichever is shorter: a keystroke in another program counts as being
// here once the system can say so, and one in moho counts either way.
const idleFor = this.systemKnown ? Math.min(own, system * 1000) : own
return idleFor >= this.o.thresholdMs()
}
private set(idle: boolean): void {
if (idle === this.away) return
this.away = idle
this.o.onChange(idle)
}
}
+40 -2
View File
@@ -13,6 +13,7 @@ import {
nativeImage,
nativeTheme,
net,
powerMonitor,
session,
Menu,
screen,
@@ -25,6 +26,7 @@ import { defaultSocketPath, NobilisClient } from './nobilis-client'
import { NobilisProcess } from './nobilis-process'
import { Prefs } from './prefs'
import { Notifier } from './notifications'
import { IdleWatcher } from './idle'
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'
@@ -134,6 +136,7 @@ let prefs: Prefs
let nobilis: NobilisProcess
let client: NobilisClient
let notifier: Notifier
let idleWatcher: IdleWatcher
let registeredHotkey: string | null = null
/**
@@ -214,6 +217,7 @@ function applyPref(key: string, value: unknown, except?: Electron.WebContents):
// 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))
if (key === 'presence.autoIdle' || key === 'presence.autoIdleMinutes') idleWatcher?.evaluate()
}
/**
@@ -675,7 +679,7 @@ function refreshTrayMenu(): void {
{
toggleWindow,
openLatest: () => {
const id = notifier.latestUnread()
const id = lastTray.latest ?? notifier.latestUnread()
if (id) openConversation(id)
},
openSettings: () => tellWindow('settings'),
@@ -782,6 +786,16 @@ function wireIpc(): void {
updateTray(lastTray)
})
ipcMain.on(IPC.activity, () => idleWatcher.activity())
// What is waiting, as the window counts it. The tray's number is the window's
// own: counted here from notifications it drifted from what the window showed.
ipcMain.on(IPC.trayUnread, (_e, count: { dms: number; mentions: number; latest: string | null }) => {
const dms = Math.max(0, Number(count?.dms) || 0)
const mentions = Math.max(0, Number(count?.mentions) || 0)
updateTray({ unread: dms + mentions, dms, mentions, latest: typeof count?.latest === 'string' ? count.latest : null })
})
ipcMain.handle(IPC.markBufferRead, (_e, bufferId: string) => notifier.clear(bufferId))
// Every window draws its own title bar, so these act on whichever window
@@ -1235,9 +1249,27 @@ app.whenReady().then(() => {
nobilis = new NobilisProcess()
client = new NobilisClient()
// Going idle when the person steps away. What it says, and to whom, is
// decided in the daemon - which accounts are online, which are in a call -
// so all this says is whether they are here.
idleWatcher = new IdleWatcher({
now: () => Date.now(),
systemIdleSeconds: () => powerMonitor.getSystemIdleTime(),
enabled: () => prefs.get<boolean>('presence.autoIdle', true),
thresholdMs: () => (Number(prefs.get<unknown>('presence.autoIdleMinutes', '10')) || 10) * 60_000,
onChange: (idle) => void client.request('setUserIdle', { idle }).catch(() => {})
})
setInterval(() => idleWatcher.evaluate(), 10_000).unref()
app.on('browser-window-focus', () => idleWatcher.activity())
powerMonitor.on('lock-screen', () => idleWatcher.lock())
powerMonitor.on('suspend', () => idleWatcher.lock())
powerMonitor.on('unlock-screen', () => idleWatcher.unlock())
powerMonitor.on('resume', () => idleWatcher.unlock())
notifier = new Notifier(
prefs,
updateTray,
// The tray's number is the window's, not this tally's - see IPC.trayUnread.
() => {},
openConversation,
() => mainWindow?.webContents ?? null,
(bufferId) => popoutState().watched.includes(bufferId),
@@ -1247,6 +1279,8 @@ app.whenReady().then(() => {
client.on('link', (up) => {
send(IPC.link, up)
if (!up) return
// A daemon that has just started knows nothing of this.
if (idleWatcher.isIdle()) void client.request('setUserIdle', { idle: true }).catch(() => {})
// Before anything is fetched for the window: whether its own traffic goes
// through Tor or the proxy is the daemon's setting, asked of it here.
void applyTunnel((method, params) => client.request(method, params))
@@ -1293,6 +1327,10 @@ app.whenReady().then(() => {
if (process.env.MOHO_UI_SHOTS === '1') {
;(globalThis as Record<string, unknown>).__mohoTray = {
update: updateTray,
idle: (v: boolean | null) => idleWatcher.forceForTesting(v),
lastActivity: () => idleWatcher.lastActivityAt(),
state: () => lastTray,
pref: (key: string, value: unknown) => applyPref(key, value),
icon: () => lastTrayIcon,
tooltip: () => lastTrayTooltip,
menu: () => lastTrayMenu,
+2
View File
@@ -15,6 +15,8 @@ export interface TrayState {
dms: number
/** Of those, everything else: somebody mentioning this account in a channel. */
mentions: number
/** The conversation the menu's first line opens, where the window has said. */
latest?: string | null
}
export const NOTHING_WAITING: TrayState = { unread: 0, dms: 0, mentions: 0 }
+24
View File
@@ -99,6 +99,11 @@ const api = {
ipcRenderer.send(IPC.trayStatus, statuses, offered, shown, invisibleName)
},
/** Says what is waiting, counted the way the rail counts it, for the tray's number. */
setTrayUnread(count: { dms: number; mentions: number; latest: string | null }): void {
ipcRenderer.send(IPC.trayUnread, count)
},
onMaximizeChange(cb: (maximized: boolean) => void): () => void {
const handler = (_e: unknown, v: boolean): void => cb(v)
ipcRenderer.on(IPC.maximizeChanged, handler)
@@ -228,6 +233,25 @@ const api = {
}
}
/**
* Tells main that somebody is using this window.
*
* Only that it happened - the event's kind and what it touched are not looked
* at, and nothing about them is sent. It is what lets moho know the person has
* stepped away, which is the one thing it wants to know, and it is sent at most
* every few seconds however much is going on.
*/
let lastActivityPing = 0
const noteActivity = (): void => {
const now = Date.now()
if (now - lastActivityPing < 5000) return
lastActivityPing = now
ipcRenderer.send(IPC.activity)
}
for (const kind of ['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart']) {
;(globalThis as unknown as EventTarget).addEventListener(kind, noteActivity, { capture: true, passive: true })
}
contextBridge.exposeInMainWorld('moho', api)
export type MohoApi = typeof api
+17
View File
@@ -8,6 +8,7 @@ import { MessageList } from './components/MessageList'
import { MentionsInbox } from './components/MentionsInbox'
import { MentionsPage } from './components/MentionsPage'
import { INVITE_PREFIX, MENTIONS_GROUP_ID } from './lib/groups'
import { trayCount } from './lib/traycount'
import { Composer } from './components/Composer'
import { MembershipGate } from './components/MembershipGate'
import { NickList } from './components/NickList'
@@ -89,6 +90,22 @@ export default function App(): JSX.Element {
else delete document.documentElement.dataset.reduceMotion
}, [density, reduceMotion])
const [userListFolded, setUserListFolded] = usePref<boolean>('ui.userListFolded', false)
// What the tray says is waiting, which is whatever the rail would add up to:
// the same rule, from the same lists, said to the main process whenever the
// answer changes. Told only when it has, since most changes to the buffers
// are not to what is waiting.
const [trayMuted] = usePref<string[]>('mutedBuffers', [])
const [trayHidden] = usePref<string[]>('hiddenBuffers', [])
const [trayMutedGroups] = usePref<string[]>('mutedGroups', [])
const lastTray = useRef('')
useEffect(() => {
const count = trayCount(allBuffers, trayMuted, trayHidden, trayMutedGroups)
const said = JSON.stringify(count)
if (said === lastTray.current) return
lastTray.current = said
window.moho.setTrayUnread(count)
}, [allBuffers, trayMuted, trayHidden, trayMutedGroups])
const [savedBufferId] = usePref<string>('ui.activeBufferId', '')
const [savedGroupId] = usePref<string>('ui.activeGroupId', '')
+100
View File
@@ -10405,3 +10405,103 @@ html[data-reduce-motion] *::after {
flex-wrap: wrap;
gap: var(--spacing-xs);
}
/* GIFs to browse and send. */
.gif-picker {
position: fixed;
z-index: var(--z-picker);
width: min(420px, calc(100vw - 8px));
height: min(480px, calc(100vh - 8px));
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
padding: var(--spacing-s);
overflow: hidden;
border-radius: var(--corner-radius);
background: var(--surface-container-high);
border: 1px solid var(--outline-medium);
box-shadow: var(--shadow-2);
animation: overlay-in var(--motion) var(--ease);
}
.gif-search {
display: flex;
align-items: center;
gap: var(--spacing-xs);
padding: 0 var(--spacing-xs);
color: var(--surface-variant-text);
}
.gif-search .text-field {
flex: 1;
min-width: 0;
}
.gif-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.gif-categories {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
padding-bottom: var(--spacing-s);
}
.gif-category {
padding: var(--spacing-xxs) var(--spacing-s);
border-radius: var(--radius-pill);
background: var(--surface-hover);
color: var(--surface-text);
font-size: var(--font-size-small);
text-transform: capitalize;
}
.gif-category:hover {
background: var(--surface-selected);
}
.gif-empty {
padding: var(--spacing-l);
text-align: center;
}
.gif-grid {
display: flex;
gap: var(--spacing-xs);
align-items: flex-start;
}
.gif-column {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.gif-cell {
display: block;
width: 100%;
padding: 0;
overflow: hidden;
border-radius: var(--radius-s, 6px);
background: var(--surface-container);
cursor: pointer;
}
.gif-cell:hover,
.gif-cell:focus-visible {
outline: 2px solid var(--primary);
}
.gif-cell video,
.gif-cell img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
+20 -14
View File
@@ -19,6 +19,7 @@ import {
MENTIONS_GROUP_ID,
PINNED_GROUP_ID,
pinnedGroup,
waitingCount,
visibleGroups,
type RailGroup
} from '../lib/groups'
@@ -248,6 +249,11 @@ export function BufferList(): JSX.Element {
// The menu still toggles a buffer's *own* flag independently of a muted
// rail entry above it, the same way muting a channel inside an
// already-muted Slack workspace works.
/** The call that sets a mute on the account itself, for a service that keeps one. */
const accountMute = (accountId: string): string | null => {
const service = accounts.find((a) => a.id === accountId)?.service
return service === 'matrix' ? 'setMatrixRoomMuted' : service === 'discord' ? 'setDiscordChannelMuted' : null
}
const isEffectivelyMuted = (buffer: BufferEntry): boolean =>
isMutedBuffer(buffer, muted, mutedGroups)
@@ -304,20 +310,20 @@ export function BufferList(): JSX.Element {
onTogglePin={() => togglePin(b.id)}
onToggleMute={() => {
toggleMute(b.id)
// On Matrix the account itself can hold the answer, so muting here
// mutes on a phone too. Everywhere else this stays what it always
// was: a preference belonging to this window.
if (accounts.find((a) => a.id === b.accountId)?.service === 'matrix') {
// On Matrix and Discord the account itself can hold the answer, so
// muting here mutes on a phone too. Everywhere else this stays what
// it always was: a preference belonging to this window.
const writeBack = accountMute(b.accountId)
if (writeBack)
void window.moho
.rpc('setMatrixRoomMuted', { bufferId: b.id, muted: !isEffectivelyMuted(b) })
.rpc(writeBack, { bufferId: b.id, muted: !isEffectivelyMuted(b) })
.catch((e: Error) => store.toast('error', e.message))
}
}}
onUnmuteAccount={
accounts.find((a) => a.id === b.accountId)?.service === 'matrix'
accountMute(b.accountId)
? () =>
void window.moho
.rpc('setMatrixRoomMuted', { bufferId: b.id, muted: false })
.rpc(accountMute(b.accountId)!, { bufferId: b.id, muted: false })
.catch((e: Error) => store.toast('error', e.message))
: undefined
}
@@ -593,12 +599,12 @@ export function BufferList(): JSX.Element {
// What is waiting in what is folded away, which is
// the thing worth knowing about a closed heading;
// how many channels it has is the fallback.
const waiting = section.buffers.filter((b) => b.unread > 0 && (!isEffectivelyMuted(b) || b.highlight))
const unread = waiting.reduce((n, b) => n + b.unread, 0)
const waiting = section.buffers.filter((b) => waitingCount(b, isEffectivelyMuted(b)) > 0)
const unread = waiting.reduce((n, b) => n + waitingCount(b, isEffectivelyMuted(b)), 0)
return unread > 0 ? (
<span
className={classes('unread-badge category-count', waiting.some((b) => b.highlight) && 'highlight')}
title={`${unread} unread in ${waiting.length} ${waiting.length === 1 ? 'channel' : 'channels'}`}
title={`${unread} ${unread === 1 ? 'mention' : 'mentions'} in ${waiting.length} ${waiting.length === 1 ? 'channel' : 'channels'}`}
>
{unread > 99 ? '99+' : unread}
</span>
@@ -1062,9 +1068,9 @@ function BufferRow({
arrive is worse than either on its own. */}
{live ? (
<span className="live-badge">LIVE</span>
) : buffer.unread > 0 && (!muted || buffer.highlight) ? (
<span className={classes('unread-badge', buffer.highlight && 'highlight')}>
{buffer.unread > 99 ? '99+' : buffer.unread}
) : waitingCount(buffer, muted) > 0 ? (
<span className={classes('unread-badge', (buffer.highlight || muted) && 'highlight')}>
{waitingCount(buffer, muted) > 99 ? '99+' : waitingCount(buffer, muted)}
</span>
) : (
// Left unread on purpose, with nothing new in it. A dot rather than
+34
View File
@@ -9,6 +9,7 @@ import torMark from '../assets/tor.svg'
import { PollComposer } from './PollComposer'
import { Avatar } from './Avatar'
import { COMMON_EMOJI, emojiToken, EmojiPicker, type StickerEntry } from './EmojiPicker'
import { GifPicker } from './GifPicker'
import { PlaceField } from './PlaceField'
import { VoiceRecorder } from './VoiceRecorder'
import { useActiveBuffer, useChat, useStore } from '../state/hooks'
@@ -133,6 +134,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
/** The account's sticker packs, fetched when the picker is first opened. */
const [stickers, setStickers] = useState<StickerEntry[]>([])
const [stickerPicker, setStickerPicker] = useState(false)
const [gifPicker, setGifPicker] = useState(false)
/**
* What to do with files dropped on the window: they land in the tray, to be
* looked over and sent with the next message rather than going out on the
@@ -218,6 +220,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
const [roles, setRoles] = useState<{ id: string; name: string; colour?: string }[]>([])
const emojiButtonRef = useRef<HTMLButtonElement>(null)
const stickerButtonRef = useRef<HTMLButtonElement>(null)
const gifButtonRef = useRef<HTMLButtonElement>(null)
const plusButtonRef = useRef<HTMLButtonElement>(null)
/** The "+" menu, where it opened. */
const [plusMenu, setPlusMenu] = useState<{ x: number; y: number } | null>(null)
@@ -1259,6 +1262,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
title="Emoji"
onClick={() => {
setStickerPicker(false)
setGifPicker(false)
if (!pickerOpen) loadStickers()
setPickerOpen(!pickerOpen)
}}
@@ -1266,6 +1270,25 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
<Icon name="mood" size={18} />
</button>
{/* GIFs, wherever there is a Discord account to ask for them: the
picker borrows that account's way of looking, and what it finds can
be sent in any conversation. */}
{!thread && accounts.some((a) => a.service === 'discord') && (
<button
ref={gifButtonRef}
type="button"
className="icon-button"
title="GIFs"
onClick={() => {
setPickerOpen(false)
setStickerPicker(false)
setGifPicker(!gifPicker)
}}
>
<Icon name="gif_box" size={18} />
</button>
)}
{/* Its own button beside the emoji one, because it is its own
gesture: an emoji goes into the line being written and a sticker
is the message. Only where the service has them: Matrix, where a
@@ -1279,6 +1302,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
onClick={() => {
const opening = !stickerPicker
setPickerOpen(false)
setGifPicker(false)
setStickerPicker(opening)
if (opening) loadStickers()
}}
@@ -1323,6 +1347,16 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
{polling && <PollComposer bufferId={buffer.id} onClose={() => setPolling(false)} />}
{gifPicker && (
<GifPicker
anchor={gifButtonRef.current}
bufferId={buffer.id}
accountId={buffer.accountId}
discord={service === 'discord'}
onClose={() => setGifPicker(false)}
/>
)}
{(pickerOpen || stickerPicker) && (
<EmojiPicker
// Remounted when the other button is pressed, so it opens on that
+238
View File
@@ -0,0 +1,238 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Icon } from './Icon'
import { useEscapeLayer } from '../lib/layers'
import { useMediaUrl } from '../lib/route'
import { useStore } from '../state/hooks'
/** One of the GIFs the daemon lists: what to play, and what to send. */
export interface GifEntry {
id: string
title: string
/** The page on Tenor or Klipy, which is what Discord is sent. */
url: string
/** The video, to play in the grid. */
src: string
/** The file, which is what everything that is not Discord is sent. */
gifSrc: string
preview: string
width: number
height: number
}
interface GifCategory {
name: string
src: string
}
/** How long after the last keystroke before a search is made. */
const SEARCH_DELAY = 450
/**
* GIFs to browse and send, as Discord's own picker has them: what is trending,
* a search, and a row of categories to start from.
*
* They come from Discord - any account will do, whichever conversation this is
* open in - and go anywhere: to a Discord conversation as the link Discord's
* own client sends, so that Discord unfurls it; to every other service as the
* file itself, which a window of this kind draws as the GIF and any other
* client shows as a link.
*/
export function GifPicker({
anchor,
bufferId,
accountId,
discord,
onClose
}: {
anchor: HTMLElement | null
bufferId: string
accountId: string
/** Whether the conversation is on Discord, which decides what is sent. */
discord: boolean
onClose: () => void
}): JSX.Element {
const store = useStore()
const media = useMediaUrl(accountId)
const ref = useRef<HTMLDivElement>(null)
const scroll = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState({ left: 0, top: 0 })
const [query, setQuery] = useState('')
const [gifs, setGifs] = useState<GifEntry[]>([])
const [categories, setCategories] = useState<GifCategory[]>([])
const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading')
const [error, setError] = useState('')
useEscapeLayer(onClose)
// Above and right-aligned to its button, and kept in view, as the emoji
// picker is.
useLayoutEffect(() => {
const el = ref.current
if (!el || !anchor) return
const place = (): void => {
const a = anchor.getBoundingClientRect()
const r = el.getBoundingClientRect()
const left = Math.max(4, Math.min(a.right - r.width, window.innerWidth - r.width - 4))
const top = Math.max(4, Math.min(a.top - r.height - 6, window.innerHeight - r.height - 4))
setPos((was) => (was.left === left && was.top === top ? was : { left, top }))
}
place()
const ro = new ResizeObserver(place)
ro.observe(el)
window.addEventListener('resize', place)
return () => {
ro.disconnect()
window.removeEventListener('resize', place)
}
}, [anchor])
// Pressing anywhere else closes it; pressing inside, or on its button, does not.
useEffect(() => {
const onDown = (e: MouseEvent): void => {
const target = e.target as Node
if (ref.current?.contains(target) || anchor?.contains(target)) return
onClose()
}
window.addEventListener('mousedown', onDown, true)
return () => window.removeEventListener('mousedown', onDown, true)
}, [anchor, onClose])
// What is trending when it opens, and a search once typing has stopped. An
// answer that arrives after a newer question was asked is not shown.
useEffect(() => {
// The harness has no Discord to ask, so it can leave the answer here. Only
// a window it started has the hook it is read from.
const fixture = (window as unknown as { __mohoShots?: unknown; __gifFixture?: { gifs: GifEntry[]; categories: GifCategory[] } })
if (fixture.__mohoShots && fixture.__gifFixture) {
const wanted = query.trim().toLowerCase()
setGifs(fixture.__gifFixture.gifs.filter((g) => !wanted || g.title.toLowerCase().includes(wanted)))
setCategories(fixture.__gifFixture.categories)
setState('ready')
return
}
let current = true
setState('loading')
const wait = setTimeout(
() => {
window.moho
.rpc<{ gifs: GifEntry[]; categories: GifCategory[] }>('listGifs', { query: query.trim() })
.then((answer) => {
if (!current) return
setGifs(answer.gifs)
setCategories(answer.categories)
setState('ready')
})
.catch((e: Error) => {
if (!current) return
setError(e.message)
setState('failed')
})
},
query.trim() ? SEARCH_DELAY : 0
)
return () => {
current = false
clearTimeout(wait)
}
}, [query])
const pick = (gif: GifEntry): void => {
onClose()
// Discord's own client sends the link, which Discord turns into the GIF.
// Everything else is sent the file, since a page address would be only
// that.
void store.sendMessage(bufferId, discord ? gif.url : gif.gifSrc)
}
// Two columns, each GIF into whichever is shorter, so differently shaped ones
// fit together the way Discord's do.
const columns: GifEntry[][] = [[], []]
const heights = [0, 0]
for (const gif of gifs) {
const into = heights[0] <= heights[1] ? 0 : 1
columns[into].push(gif)
heights[into] += gif.width && gif.height ? gif.height / gif.width : 0.75
}
return createPortal(
<div ref={ref} className="gif-picker" style={{ left: pos.left, top: pos.top }} role="dialog" aria-label="GIFs">
<div className="gif-search">
<Icon name="search" size={18} />
<input
autoFocus
className="text-field"
value={query}
placeholder="Search for a GIF"
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<div className="gif-scroll" ref={scroll}>
{!query.trim() && categories.length > 0 && (
<div className="gif-categories">
{categories.map((c) => (
<button key={c.name} type="button" className="gif-category" onClick={() => setQuery(c.name)}>
{c.name}
</button>
))}
</div>
)}
{state === 'failed' && <div className="gif-empty muted">{error || 'GIFs could not be loaded'}</div>}
{state === 'loading' && gifs.length === 0 && <div className="gif-empty muted">Loading…</div>}
{state === 'ready' && gifs.length === 0 && <div className="gif-empty muted">No GIFs found</div>}
{gifs.length > 0 && (
<div className="gif-grid">
{columns.map((column, i) => (
<div key={i} className="gif-column">
{column.map((gif) => (
<GifCell key={gif.id} gif={gif} media={media} root={scroll} onPick={pick} />
))}
</div>
))}
</div>
)}
</div>
</div>,
document.body
)
}
/**
* One GIF. Played only while it is on screen: a grid of forty videos all
* running is a great deal of work for a list nobody is looking at the bottom
* of, so out of view it is a still picture and nothing more.
*/
function GifCell({
gif,
media,
root,
onPick
}: {
gif: GifEntry
media: (url: string) => string
root: React.RefObject<HTMLDivElement | null>
onPick: (gif: GifEntry) => void
}): JSX.Element {
const cell = useRef<HTMLButtonElement>(null)
const [visible, setVisible] = useState(false)
useEffect(() => {
const el = cell.current
if (!el) return
const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting), { root: root.current, rootMargin: '80px' })
observer.observe(el)
return () => observer.disconnect()
}, [root])
const ratio = gif.width && gif.height ? `${gif.width} / ${gif.height}` : '4 / 3'
return (
<button ref={cell} type="button" className="gif-cell" title={gif.title} style={{ aspectRatio: ratio }} onClick={() => onPick(gif)}>
{visible ? (
<video src={media(gif.src)} poster={gif.preview ? media(gif.preview) : undefined} autoPlay loop muted playsInline />
) : gif.preview ? (
<img src={media(gif.preview)} alt="" draggable={false} />
) : null}
</button>
)
}
+13 -2
View File
@@ -433,11 +433,22 @@ export function MediaEmbed({
className="media-embed"
style={ratio}
src={media(fullSrc)}
controls
// A GIF is silent, runs on its own and goes round, and has no
// player to operate: it plays when it is shown if autoplay is on,
// and otherwise while the pointer is over it. Any other video is a
// video, with its controls.
{...(attachment?.gif
? {
muted: true,
autoPlay: autoplay,
loop: true,
onMouseEnter: (e: React.MouseEvent<HTMLVideoElement>) => !autoplay && void e.currentTarget.play().catch(() => {}),
onMouseLeave: (e: React.MouseEvent<HTMLVideoElement>) => !autoplay && e.currentTarget.pause()
}
: { controls: true, loop })}
// Enough to draw the first frame and know how long it is, without
// pulling megabytes for every video in a scrolled-past backlog.
preload="metadata"
loop={loop}
playsInline
onError={() => setFailed(true)}
/>
+21 -6
View File
@@ -52,6 +52,9 @@ const REANCHOR_THRESHOLD = 100
*/
const SCROLL_JITTER = 2
/** How far down from the top edge to look for the row being read, nearest first. */
const PLACE_PROBES = [1, 24, 48, 80, 120, 180]
/**
* How long after the reader last touched the list a scroll still counts as
* theirs.
@@ -363,17 +366,29 @@ export function MessageList(): JSX.Element {
*
* By hit test rather than by walking the rows: this runs on every scroll,
* and a list of two thousand is not a thing to scan that often.
*
* Every element under the point, not the topmost one, and a few points down
* the page rather than one. The "N new messages since" bar is laid over the
* top of the log, so asking what was at the edge was answered with the bar
* for as long as it was up - and the place stopped being kept, which the
* next thing to change size then put the view back to. A date line or the
* gap between two groups at the edge gave the same non-answer for a moment.
*/
const notePlace = useCallback(() => {
const el = scrollRef.current
if (!el) return
const box = el.getBoundingClientRect()
const hit = document.elementFromPoint(box.left + 8, box.top + 1)
const row = hit?.closest?.('[data-msg-id]') as HTMLElement | null
if (!row) return
placeRef.current = {
id: row.dataset.msgId ?? '',
offset: row.getBoundingClientRect().top - box.top
for (const down of PLACE_PROBES) {
for (const hit of document.elementsFromPoint(box.left + 8, box.top + down)) {
if (!el.contains(hit)) continue
const row = hit.closest?.('[data-msg-id]') as HTMLElement | null
if (!row) continue
placeRef.current = {
id: row.dataset.msgId ?? '',
offset: row.getBoundingClientRect().top - box.top
}
return
}
}
}, [])
+29 -4
View File
@@ -17,6 +17,7 @@ import { useSniffedTypes, sniffUrl } from '../lib/sniff'
import { useMediaUrl, useStrictRoute } from '../lib/route'
import type { ChatMessage } from '../state/store'
import type {
Attachment,
CustomEmoji,
MessageBadge,
MessageComponent,
@@ -418,7 +419,24 @@ function MessageRowBody({
*/
const own = bubbles && !isSystem && !!message.isOwn
const attachments = message.attachments ?? []
// A GIF that was linked is a picture to play, and goes where pictures go:
// the same sizing, the same autoplay and loop settings, the same viewer.
const gifs = useMemo(
() =>
(message.embeds ?? [])
.filter((e) => e.kind === 'gif' && e.mediaUrl)
.map((e): Attachment => ({
// As the daemon said; by the ending only for one that did not.
kind: e.mediaKind ?? (/\.(mp4|webm|mov|mkv|ogv)(\?|$)/i.test(e.mediaUrl!) ? 'video' : 'image'),
gif: true,
url: e.mediaUrl,
width: e.width,
height: e.height,
filename: 'gif'
})),
[message.embeds]
)
const attachments = useMemo(() => [...(message.attachments ?? []), ...gifs], [message.attachments, gifs])
// The ones that go in a grid, and the ones that stay in the row, each with
// its place in the message so its key stays the same whichever it is.
const indexed = attachments.map((att, i) => ({ att, i }))
@@ -448,7 +466,12 @@ function MessageRowBody({
const codeBlocks = extractCodeBlocks(normalized)
const quoteBlocks = extractQuoteBlocks(stripCodeBlocks(normalized))
let body = stripEmbeddedUrls(normalized, media)
// The page a GIF was linked from is not shown as well as the GIF: it is the
// same thing said twice, and is how a posted GIF read as an address.
const gifPages = (message.embeds ?? [])
.filter((e) => e.kind === 'gif' && e.url)
.map((e) => ({ url: e.url!, kind: 'image' as const }))
let body = stripEmbeddedUrls(normalized, [...media, ...gifPages])
body = stripCodeBlocks(body)
body = stripQuoteBlocks(body)
@@ -1077,7 +1100,8 @@ function MessageRowBody({
</pre>
))}
{(message.embeds || []).map((embed, i) => (
{(message.embeds || []).map((embed, i) =>
embed.kind === 'gif' ? null : (
<div
key={`e${i}`}
className={classes('rich-embed selectable', embed.kind === 'notice' && 'notice')}
@@ -1170,7 +1194,8 @@ function MessageRowBody({
</div>
)}
</div>
))}
)
)}
{/* Pictures and videos that came together are laid out together:
two side by side, three with the first tall, four in a square,
@@ -2,7 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Avatar } from './Avatar'
import { Icon } from './Icon'
import { useChat, useStore } from '../state/hooks'
import { useChat, useIdSetPref, usePref, useStore } from '../state/hooks'
import { isMutedBuffer, waitingCount } from '../lib/groups'
import { rankBuffers, whereText } from '../lib/switcher'
import { bufferKindGlyph, classes } from '../lib/util'
@@ -18,6 +19,8 @@ export function QuickSwitcher(): JSX.Element | null {
const store = useStore()
const buffers = useChat((s) => s.buffers)
const accounts = useChat((s) => s.accounts)
const [muted] = useIdSetPref('mutedBuffers')
const [mutedGroups] = usePref<string[]>('mutedGroups', [])
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [index, setIndex] = useState(0)
@@ -103,6 +106,7 @@ export function QuickSwitcher(): JSX.Element | null {
{hits.length === 0 && <div className="switcher-empty muted">Nothing by that name.</div>}
{hits.map((hit, i) => {
const b = hit.buffer
const waiting = waitingCount(b, isMutedBuffer(b, muted, mutedGroups))
return (
<button
key={b.id}
@@ -122,9 +126,9 @@ export function QuickSwitcher(): JSX.Element | null {
</span>
<span className="switcher-title ellipsis">{hit.title}</span>
<span className="switcher-where muted small ellipsis">{hit.where}</span>
{b.unread > 0 && (
{waiting > 0 && (
<span className={classes('unread-badge', b.highlight && 'highlight')}>
{b.unread > 99 ? '99+' : b.unread}
{waiting > 99 ? '99+' : waiting}
</span>
)}
</button>
+16 -12
View File
@@ -28,7 +28,8 @@ import {
reorderFolder,
FOLDER_DRAG_PREFIX,
visibleGroups,
countsTowardRail,
countedFor,
isMutedBuffer,
dmGroup,
DM_GROUP_ID,
isDirectMessage,
@@ -424,24 +425,27 @@ export function ServerRail(): JSX.Element | null {
// Unread rolls up from the buffers under each entry, so a guild whose
// channels are all collapsed away still shows it has something waiting.
const totals = new Map<string, { unread: number; highlight: boolean; mentions: number }>()
// `direct` is a page of conversations addressed to you, where every unread
// message is one that is for you and so counts as a mention.
const bump = (key: string, b: BufferEntry, direct = false): void => {
// A direct message counts for every unread message in it - all of them are
// for you - and anywhere else for its mentions (see countedFor).
const bump = (key: string, b: BufferEntry): void => {
const waiting = countedFor(b, muted, hidden, mutedGroups)
// What a muted conversation says is only that your name was used in it:
// its unread messages are not asking for anything.
const quiet = isMutedBuffer(b, muted, mutedGroups)
const t = totals.get(key) || { unread: 0, highlight: false, mentions: 0 }
t.unread += b.unread
t.highlight = t.highlight || b.highlight
t.mentions += direct ? b.unread : b.mentions
t.unread += quiet ? 0 : b.unread
t.highlight = t.highlight || (quiet ? waiting > 0 : b.highlight)
t.mentions += waiting
totals.set(key, t)
}
const all = buffers as BufferEntry[]
for (const b of all) {
// Only what the pane below would show: a hidden buffer has no row to
// reach, and a muted one is not asking for attention.
if (!countsTowardRail(b, muted, hidden, mutedGroups)) continue
// A hidden buffer has no row to reach.
if (hidden.includes(b.id)) continue
// A buffer can count on more than one tile: where it lives, and on any
// aggregate page that also lists it. Both are places the user would look.
if (b.groupId) bump(b.groupId, b)
if (isDirectMessage(b)) bump(DM_GROUP_ID, b, true)
if (isDirectMessage(b)) bump(DM_GROUP_ID, b)
if (pinned.includes(b.id)) bump(PINNED_GROUP_ID, b)
}
@@ -527,7 +531,7 @@ export function ServerRail(): JSX.Element | null {
*/
const unreadDms = all
.filter(
(b) => isDirectMessage(b) && b.unread > 0 && countsTowardRail(b, muted, hidden, mutedGroups)
(b) => isDirectMessage(b) && countedFor(b, muted, hidden, mutedGroups) > 0
)
.sort((a, b) => (b.lastActivityTs || 0) - (a.lastActivityTs || 0))
+5 -4
View File
@@ -35,8 +35,9 @@ export function effectiveStatus(account: Account): Status | 'connecting' {
return (account.status as Status) || 'online'
}
function label(status: Status | 'connecting', service: string): string {
function label(status: Status | 'connecting', service: string, auto = false): string {
if (status === 'connecting') return 'Connecting…'
if (status === 'idle' && auto) return 'Idle (away)'
return status === 'invisible' ? statusName(service, 'invisible') : presenceLabel(status)
}
@@ -73,12 +74,12 @@ export function UserFooter({ account }: { account?: Account }): JSX.Element {
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen(!open)}
title={detail ? `${name} — ${label(status, account.service)}\n${detail}` : `${name} — ${label(status, account.service)}`}
title={detail ? `${name} — ${label(status, account.service, account.autoIdle)}\n${detail}` : `${name} — ${label(status, account.service, account.autoIdle)}`}
>
<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, account.service)}</span>
<span className="ellipsis small muted">{watching ? 'Watching, signed out' : label(status, account.service, account.autoIdle)}</span>
</span>
</button>
@@ -119,7 +120,7 @@ export function UserFooter({ account }: { account?: Account }): JSX.Element {
</div>
{open && (
<StatusPopout account={account} status={status} anchor={identity.current} onClose={() => setOpen(false)} />
<StatusPopout account={account} status={account.autoIdle ? 'online' : status} anchor={identity.current} onClose={() => setOpen(false)} />
)}
</>
)
@@ -855,6 +855,27 @@ function GeneralSettings(): JSX.Element {
<GlobalHighlightKeywords />
</SettingsSection>
<SettingsSection title="Presence">
<ToggleSetting
settingKey="presence.autoIdle"
label="Go idle when I step away"
description="Shows you as Idle on Discord and IRC after a while with nothing touched, and back to Online when you return. Only while your status is Online: Idle, Do not disturb and Invisible stay where you put them until you set Online again. Not while you are in a call. moho looks only at how long it has been since the keyboard or mouse was used - never at what you do. Where the system will not say (some Wayland desktops), it goes by your use of moho's own windows."
defaultValue={true}
/>
<SelectionSetting
settingKey="presence.autoIdleMinutes"
label="Idle after"
defaultValue="10"
options={[
{ label: '5 min', value: '5' },
{ label: '10 min', value: '10' },
{ label: '15 min', value: '15' },
{ label: '30 min', value: '30' },
{ label: '1 hour', value: '60' }
]}
/>
</SettingsSection>
<SettingsSection title="Updates">
<ToggleSetting
settingKey="updates.notify"
+54
View File
@@ -9,6 +9,8 @@ import {
extractMedia,
extractQuoteBlocks,
formatMessage,
isForumUpload,
isGifPage,
hostnameOf,
ircFormat,
normalizeBBCode,
@@ -253,3 +255,55 @@ describe('blocks', () => {
expect(embedColor(undefined)).toBeUndefined()
})
})
describe('links to the pages GIFs are shared from', () => {
it('knows Tenor, Giphy and Klipy pages when it sees one', () => {
for (const url of [
'https://tenor.com/view/cat-gif-22954713',
'https://tenor.com/de/view/cat-gif-22954713',
'https://giphy.com/gifs/cat-Z7wFi5b2VEAr6',
'https://giphy.com/embed/Z7wFi5b2VEAr6',
'https://klipy.com/gifs/friends-joey-53',
'https://klipy.com/clips/angry-8'
]) {
expect(isGifPage(url), url).toBe(true)
}
expect(isGifPage('https://tenor.com/')).toBe(false)
expect(isGifPage('https://example.com/view/cat')).toBe(false)
})
it('does not ask a page what it is, which is a request to a third party for nothing', () => {
const asked: string[] = []
const media = extractMedia('look https://tenor.com/view/cat-gif-1 and https://klipy.com/gifs/x-1', {
contentSniffing: true,
onNeedSniff: (url) => asked.push(url)
})
expect(media).toEqual([])
expect(asked).toEqual([])
})
it('still draws a direct file from those hosts, like any other', () => {
const media = extractMedia('https://media.tenor.com/abc/cat.mp4 https://media.giphy.com/media/Z7wFi5b2VEAr6/giphy.gif', {})
expect(media.map((m) => m.kind)).toEqual(['video', 'image'])
})
})
describe('the forum\'s own files', () => {
const picture = 'https://uploads.kiwifarms.st/data/attachments/9671/9671535-a2e5ae0d55aadd338162770b3fa79f97-alt.avif?hash=-d_TarFo41'
it('are known by where they are', () => {
expect(isForumUpload(picture)).toBe(true)
expect(isForumUpload('https://kiwifarms.st/data/video/9667/9667943-abc.mp4?hash=x')).toBe(true)
expect(isForumUpload('https://kiwifarms.st/threads/a-thread.123/')).toBe(false)
expect(isForumUpload('https://example.com/data/attachments/1/2-a.avif')).toBe(false)
})
it('are not loaded by the window, which would ask for them from the reader\'s own address', () => {
expect(extractMedia(`[img]${picture}[/img]`, {})).toEqual([])
expect(extractMedia(`look ${picture}`, { contentSniffing: true, onNeedSniff: () => { throw new Error('asked') } })).toEqual([])
})
it('leave other pictures and an AVIF anywhere else as they were', () => {
expect(extractMedia('https://i.ibb.co/JRkN8BFv/photo.avif', {}).map((m) => m.kind)).toEqual(['image'])
})
})
+41 -1
View File
@@ -583,6 +583,43 @@ export const OPAQUE_IMAGE_HOSTS = ['i.ddos.lgbt']
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|avif)(\?\S*)?$/i
const VIDEO_EXT_RE = /\.(mp4|webm|mov|mkv|ogv)(\?\S*)?$/i
/**
* A page of one of the sites GIFs are shared from - Tenor, Giphy, Klipy.
*
* Not a picture, whatever it shows: a page. What it shows reaches the message
* as an embed, from Discord or from the daemon having read the page, so this
* need not be asked what it is - which, for a probe, is a request to a third
* party the moment a message arrives, and for Klipy one it answers with a
* challenge.
*/
const GIF_PAGE_RE = /^https?:\/\/(?:www\.)?(?:tenor\.com\/(?:[a-z]{2}(?:-[A-Za-z]{2})?\/)?view\/|giphy\.com\/(?:gifs|embed)\/|klipy\.com\/(?:gifs?|clips?|stickers?|memes?)\/)/i
/**
* One of the forum's own files: what its uploads host serves, a picture or a
* video.
*
* Not loaded by the window. The daemon fetches these through the account's own
* route and puts the file on the message, which is what draws; left to the
* window they would be asked for from the reader's own address - which the host
* turns away, and which for somebody on Tor is the leak routing is for. Between
* the message arriving and the file being kept the link is simply a link, and if
* it cannot be kept it stays one.
*
* The onion address is here as the clearnet one because the text has been
* through `clearnetLinks` by the time it reaches this.
*/
const FORUM_UPLOAD_RE = /^https?:\/\/(?:uploads\.|www\.)?kiwifarms\.st\/data\/(?:attachments|video)\//i
/** Whether this is a link to one of the forum's own files. */
export function isForumUpload(url: string): boolean {
return FORUM_UPLOAD_RE.test(url)
}
/** Whether this is a link to a GIF's page on one of those sites. */
export function isGifPage(url: string): boolean {
return GIF_PAGE_RE.test(url)
}
/**
* What a thumbnail was linked to, told apart by what it is.
*
@@ -652,7 +689,7 @@ export function extractMedia(
const url = raw.replace(/[),.;!?]+$/, '')
const yt = youtubeId(url)
const identity = yt ? `yt:${yt}` : url
if (seen.has(identity) || targets.has(url)) continue
if (seen.has(identity) || targets.has(url) || isForumUpload(url)) continue
if (yt) {
seen.add(identity)
@@ -666,6 +703,9 @@ export function extractMedia(
} else if (OPAQUE_IMAGE_HOSTS.includes(hostnameOf(url))) {
seen.add(identity)
result.push({ url, kind: 'image', ...biggerCopy(url, opts.thumbnails) })
} else if (isGifPage(url)) {
// A page, not a file: see GIF_PAGE_RE. Left as the link it is.
continue
} else if (opts.contentSniffing && /^https?:\/\//i.test(url)) {
// Nothing else could classify this. Not for file:// URLs: those are
// always nobilis's own already-classified attachment paths.
+24 -14
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { BufferEntry } from '../state/store'
import {
compareInBand,
countsTowardRail,
countedFor,
fileInFolder,
foldTogether,
orderedGroups,
@@ -10,6 +10,7 @@ import {
removeFromFolders,
reorder,
reorderFolder,
waitingCount,
type RailFolder,
type RailGroup
} from './groups'
@@ -83,24 +84,21 @@ describe('what a muted place still says', () => {
const buf = (extra: Partial<BufferEntry>): BufferEntry =>
({ id: 'a|#x', accountId: 'a', kind: 'channel', name: '#x', lastActivityTs: 0, groupId: 'g', unread: 3, highlight: false, mentions: 0, ...extra }) as BufferEntry
it('counts a muted channel for nothing', () => {
expect(countsTowardRail(buf({}), ['a|#x'], [])).toBe(false)
expect(countsTowardRail(buf({}), [], [], ['g'])).toBe(false)
it('counts a muted channel by its mentions, since muting is not hiding that you were asked for', () => {
expect(countedFor(buf({ highlight: true, mentions: 2 }), ['a|#x'], [])).toBe(2)
expect(countedFor(buf({ highlight: true, mentions: 2 }), [], [], ['g'])).toBe(2)
expect(countedFor(buf({ highlight: true, mentions: 2, serverMuted: true }), [], [])).toBe(2)
// Everything else said in it is not counted whether muted or not.
expect(countedFor(buf({ unread: 97 }), ['a|#x'], [])).toBe(0)
})
it('counts it for nothing even once somebody has used your name', () => {
expect(countsTowardRail(buf({ highlight: true, mentions: 2 }), ['a|#x'], [])).toBe(false)
expect(countsTowardRail(buf({ highlight: true, mentions: 2 }), [], [], ['g'])).toBe(false)
// A mute made on the account itself is the same.
expect(countsTowardRail(buf({ highlight: true, mentions: 2, serverMuted: true }), [], [])).toBe(false)
})
it('counts what is not muted', () => {
expect(countsTowardRail(buf({ highlight: true, mentions: 2 }), [], [])).toBe(true)
it('counts a muted direct message for nothing - it has no mentions, only messages', () => {
expect(countedFor(buf({ kind: 'dm', unread: 5 }), [], [])).toBe(5)
expect(countedFor(buf({ kind: 'dm', unread: 5 }), ['a|#x'], [])).toBe(0)
})
it('never counts what has no row to click through to', () => {
expect(countsTowardRail(buf({ highlight: true }), [], ['a|#x'])).toBe(false)
expect(countedFor(buf({ highlight: true, mentions: 3 }), [], ['a|#x'])).toBe(0)
})
})
@@ -124,3 +122,15 @@ describe('compareInBand', () => {
expect([dm('old', 1), dm('new', 9)].sort(compareInBand).map((c) => c.name)).toEqual(['new', 'old'])
})
})
describe('waitingCount', () => {
const buffer = (over: Partial<BufferEntry>): BufferEntry =>
({ id: 'a|#x', kind: 'channel', unread: 97, mentions: 0, highlight: false, ...over }) as BufferEntry
it('is the mentions in a channel, not everything said in it', () => {
expect(waitingCount(buffer({}))).toBe(0)
expect(waitingCount(buffer({ mentions: 2, highlight: true }))).toBe(2)
})
it('is each unread message in a direct message, which are all for you', () => {
expect(waitingCount(buffer({ kind: 'dm', unread: 4 }))).toBe(4)
})
})
+31 -8
View File
@@ -95,6 +95,24 @@ export function isDirectMessage(buffer: BufferEntry): boolean {
return buffer.kind === 'dm'
}
/**
* The number to show beside a conversation: what is addressed to you.
*
* Mentions, not everything said - a bold name already says there is activity,
* and a count of it is the noise the rail and tray were rid of. A direct
* message is the exception that is the same rule: every line in one is
* addressed to you, so it is counted by each unread message - unless it is
* muted, which is asking not to hear about it at all.
*
* A muted channel still counts the times your name was used in it. Muting is
* about being interrupted - a popup, a sound, a phone buzzing - not about
* hiding that you were asked for; Discord and Element both keep that badge.
*/
export function waitingCount(buffer: BufferEntry, muted = false): number {
if (isDirectMessage(buffer)) return muted ? 0 : buffer.unread
return buffer.mentions
}
export function pinnedGroup(): RailGroup {
return {
id: PINNED_GROUP_ID,
@@ -261,19 +279,24 @@ export function isMutedBuffer(
}
/**
* The buffers whose unread should reach a rail tile: what the pane below it
* would actually list. A hidden buffer has no row to click through to, and a
* muted one is muted - it does not count, mentions included. Muting a place is
* the one way to say "not now", and a tile that goes on counting what was
* muted says the setting does nothing.
* What a conversation adds to a rail tile or the tray: its waiting count, or
* nothing at all if it is hidden, which has no row to click through to.
*
* Muting does not remove it. A muted place is not interrupting anybody - no
* popup, no sound, no buzz on a phone - but the times it asked for you are
* still true, and a tile that hid them would send you looking in the wrong
* place. What a mute does take away is a direct message's count, since that
* is everything said in it rather than anything that was meant for you in
* particular.
*/
export function countsTowardRail(
export function countedFor(
buffer: BufferEntry,
muted: string[],
hidden: string[],
mutedGroups: string[] = []
): boolean {
return !hidden.includes(buffer.id) && !isMutedBuffer(buffer, muted, mutedGroups)
): number {
if (hidden.includes(buffer.id)) return 0
return waitingCount(buffer, isMutedBuffer(buffer, muted, mutedGroups))
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { trayCount } from './traycount'
import type { BufferEntry } from '../state/store'
const buf = (over: Partial<BufferEntry>): BufferEntry =>
({ id: 'a|#x', accountId: 'a', kind: 'channel', name: '#x', lastActivityTs: 0, groupId: 'g', unread: 0, highlight: false, mentions: 0, ...over }) as BufferEntry
describe('the number on the tray', () => {
it('is nothing when nothing is waiting', () => {
expect(trayCount([buf({}), buf({ id: 'a|#y', unread: 40 })], [], [], [])).toEqual({ dms: 0, mentions: 0, latest: null })
})
it('counts a channel by its mentions and not by its messages', () => {
expect(trayCount([buf({ unread: 33, mentions: 2, highlight: true })], [], [], [])).toEqual({ dms: 0, mentions: 2, latest: 'a|#x' })
})
it('counts a direct message by each unread message in it', () => {
expect(trayCount([buf({ id: 'a|bob', kind: 'dm', unread: 3 })], [], [], [])).toEqual({ dms: 3, mentions: 0, latest: 'a|bob' })
})
it('does not count what is hidden', () => {
expect(trayCount([buf({ id: 'a|#h', mentions: 5 })], [], ['a|#h'], [])).toEqual({ dms: 0, mentions: 0, latest: null })
})
it('counts the mentions in what is muted, and nothing else there', () => {
const rows = [buf({ id: 'a|#m', mentions: 4, unread: 90 }), buf({ id: 'a|gm', mentions: 6, groupId: 'muted-group' })]
expect(trayCount(rows, ['a|#m'], [], ['muted-group'])).toEqual({ dms: 0, mentions: 10, latest: 'a|#m' })
expect(trayCount([buf({ mentions: 2, serverMuted: true })], [], [], [])).toMatchObject({ mentions: 2 })
expect(trayCount([buf({ unread: 50, serverMuted: true })], [], [], [])).toMatchObject({ mentions: 0 })
})
it('counts a muted direct message for nothing', () => {
expect(trayCount([buf({ id: 'a|bob', kind: 'dm', unread: 3 })], ['a|bob'], [], [])).toEqual({ dms: 0, mentions: 0, latest: null })
})
it('names the most recently active conversation that has something, for the tray to open', () => {
const rows = [buf({ id: 'a|old', mentions: 1, lastActivityTs: 10 }), buf({ id: 'a|new', mentions: 1, lastActivityTs: 20 }), buf({ id: 'a|quiet', lastActivityTs: 99 })]
expect(trayCount(rows, [], [], []).latest).toBe('a|new')
})
it('adds the two kinds together and keeps them apart', () => {
const rows = [buf({ id: 'a|bob', kind: 'dm', unread: 2 }), buf({ mentions: 3 })]
expect(trayCount(rows, [], [], [])).toMatchObject({ dms: 2, mentions: 3 })
})
})
+41
View File
@@ -0,0 +1,41 @@
import { countedFor, isDirectMessage } from './groups'
import type { BufferEntry } from '../state/store'
/** What the tray is told is waiting. */
export interface TrayCount {
/** Unread direct messages, counted by the message. */
dms: number
/** Mentions in everywhere that is not a direct message, counted by the mention. */
mentions: number
/** The conversation to open from the tray: the most recently active one with something counted. */
latest: string | null
}
/**
* The number on the tray, worked out the way the rail's is.
*
* The tray used to keep a count of its own in the main process, from the
* notifications it was sent and a handful of "this was read" messages, and the
* two drifted: a read on another device cleared the window and not the tray, a
* mention in the conversation already open raised the tray and was never
* counted by the window, and a room muted afterwards stayed in the tray's
* number. It also counted conversations where the rail counts messages.
*
* So the window - which already knows every one of those things, because it is
* what draws them - says what is waiting, by the same rule as the rail: a
* muted or hidden conversation does not count, a direct message counts for each
* unread message in it, and anywhere else counts for its mentions.
*/
export function trayCount(buffers: BufferEntry[], muted: string[], hidden: string[], mutedGroups: string[]): TrayCount {
let dms = 0
let mentions = 0
let latest: BufferEntry | null = null
for (const b of buffers) {
const waiting = countedFor(b, muted, hidden, mutedGroups)
if (waiting <= 0) continue
if (isDirectMessage(b)) dms += waiting
else mentions += waiting
if (!latest || b.lastActivityTs > latest.lastActivityTs) latest = b
}
return { dms, mentions, latest: latest?.id ?? null }
}
+12 -3
View File
@@ -1202,15 +1202,17 @@ export class ChatStore {
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
// What was chosen, not what is shown: an account idle because its person is
// away is still online as far as the menu and the notifier are concerned.
for (const a of connected) if (a.status) statuses[a.id] = a.autoIdle ? 'online' : 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])
const report = JSON.stringify([statuses, offered, (widest?.autoIdle ? 'online' : widest?.status) ?? null, invisibleName])
if (report === this.lastTrayReport) return
this.lastTrayReport = report
window.moho.setAccountStatuses(statuses, offered, widest?.status ?? null, invisibleName)
window.moho.setAccountStatuses(statuses, offered, (widest?.autoIdle ? 'online' : widest?.status) ?? null, invisibleName)
}
// --- lifecycle ------------------------------------------------------
@@ -3511,6 +3513,13 @@ export class ChatStore {
break
}
// Accounts shown as idle because their person stepped away, or back as
// online because they returned: nothing they chose changed, so the list
// is read again rather than patched.
case 'accountStatus':
void this.refreshAccounts()
break
case 'presenceChange':
this.set({
presenceByBuffer: { ...this.state.presenceByBuffer, [data.bufferId]: data.members }
+5 -1
View File
@@ -56,7 +56,11 @@ export const IPC = {
/** 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'
trayStatus: 'moho:tray:status',
/** window -> main: what is waiting, for the number on the tray. */
trayUnread: 'moho:tray:unread',
/** window -> main: something was used in this window, so the person is here. */
activity: 'moho:activity'
} as const
/**
+21 -1
View File
@@ -12,6 +12,11 @@ export interface Account {
status?: string
/** What it says beside the status, where the service has a place for that. */
statusText?: string
/**
* Shown as idle because the person stepped away, not because they chose it:
* `status` then reads idle while what they chose is online.
*/
autoIdle?: boolean
state: string
autojoin: string
hasNickservPassword: boolean
@@ -472,8 +477,18 @@ export interface Embed {
footer?: string
/** A button along the bottom of the card. With no address it is drawn and goes nowhere. */
cta?: { label: string; url?: string }
/** "notice" for the platform's own notices to an account. */
/**
* "notice" for the platform's own notices to an account; "gif" for a moving
* picture that was linked, drawn as the picture and not as a card.
*/
kind?: string
/** For a "gif": the video or image to play as it stands, which is not the page that was linked. */
mediaUrl?: string
/** Whether `mediaUrl` is a video or a picture, as the daemon knows it. */
mediaKind?: 'video' | 'image'
/** Its size, so the row is laid out before it has loaded. */
width?: number
height?: number
/** A glyph for the heading. */
icon?: string
}
@@ -511,6 +526,11 @@ export interface Attachment {
* flag, not a guess from the mimetype.
*/
voice?: boolean
/**
* A GIF that was linked, to be played like one - silent, looping, without a
* player's controls. Only ever set by the window itself, from an embed.
*/
gif?: boolean
}
export interface Message {