diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9364fcc..eb3a5ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index e46bb8c..d2c23f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,26 @@ Each section has two parts: that made it, including dependency updates (`node scripts/changelog-deps.mjs ` 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 diff --git a/nobilis b/nobilis index 58dfa58..d232be9 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit 58dfa5894d861976d075cbae39c261b7031cb342 +Subproject commit d232be9c5e0c2c4c0e74d285ce54dc4955a70a38 diff --git a/package-lock.json b/package-lock.json index 5a234e9..e03ad22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7b798e3..c21e353 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/gif-check.mjs b/scripts/gif-check.mjs new file mode 100644 index 0000000..9d031af --- /dev/null +++ b/scripts/gif-check.mjs @@ -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(`${label}`) + 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) diff --git a/scripts/mirror-release.sh b/scripts/mirror-release.sh index 9147bee..820a785 100755 --- a/scripts/mirror-release.sh +++ b/scripts/mirror-release.sh @@ -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 diff --git a/scripts/scroll-check.mjs b/scripts/scroll-check.mjs new file mode 100644 index 0000000..6c63119 --- /dev/null +++ b/scripts/scroll-check.mjs @@ -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) diff --git a/scripts/tidy.sh b/scripts/tidy.sh new file mode 100755 index 0000000..63aab7b --- /dev/null +++ b/scripts/tidy.sh @@ -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 diff --git a/scripts/tray-check.mjs b/scripts/tray-check.mjs index 40d1c81..29d7c04 100644 --- a/scripts/tray-check.mjs +++ b/scripts/tray-check.mjs @@ -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') diff --git a/scripts/ui-shots.mjs b/scripts/ui-shots.mjs index 6ac005b..4d64a57 100644 --- a/scripts/ui-shots.mjs +++ b/scripts/ui-shots.mjs @@ -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('' + label + '') + 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', diff --git a/src/main/idle.test.ts b/src/main/idle.test.ts new file mode 100644 index 0000000..3d41068 --- /dev/null +++ b/src/main/idle.test.ts @@ -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]) + }) +}) diff --git a/src/main/idle.ts b/src/main/idle.ts new file mode 100644 index 0000000..6d45be5 --- /dev/null +++ b/src/main/idle.ts @@ -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) + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 115df9d..c4983b6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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('presence.autoIdle', true), + thresholdMs: () => (Number(prefs.get('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).__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, diff --git a/src/main/tray.ts b/src/main/tray.ts index 67ca8e5..c038c55 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -15,6 +15,8 @@ export interface TrayState { dms: number /** Of those, everything else: somebody mentioning this account in a channel. */ mentions: number + /** The conversation the menu's first line opens, where the window has said. */ + latest?: string | null } export const NOTHING_WAITING: TrayState = { unread: 0, dms: 0, mentions: 0 } diff --git a/src/preload/index.ts b/src/preload/index.ts index 8fe447a..67a2b15 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -99,6 +99,11 @@ const api = { ipcRenderer.send(IPC.trayStatus, statuses, offered, shown, invisibleName) }, + /** Says what is waiting, counted the way the rail counts it, for the tray's number. */ + setTrayUnread(count: { dms: number; mentions: number; latest: string | null }): void { + ipcRenderer.send(IPC.trayUnread, count) + }, + onMaximizeChange(cb: (maximized: boolean) => void): () => void { const handler = (_e: unknown, v: boolean): void => cb(v) ipcRenderer.on(IPC.maximizeChanged, handler) @@ -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 diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7d3681f..dcdd8ff 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -8,6 +8,7 @@ import { MessageList } from './components/MessageList' import { MentionsInbox } from './components/MentionsInbox' import { MentionsPage } from './components/MentionsPage' import { INVITE_PREFIX, MENTIONS_GROUP_ID } from './lib/groups' +import { trayCount } from './lib/traycount' import { Composer } from './components/Composer' import { MembershipGate } from './components/MembershipGate' import { NickList } from './components/NickList' @@ -89,6 +90,22 @@ export default function App(): JSX.Element { else delete document.documentElement.dataset.reduceMotion }, [density, reduceMotion]) const [userListFolded, setUserListFolded] = usePref('ui.userListFolded', false) + + // What the tray says is waiting, which is whatever the rail would add up to: + // the same rule, from the same lists, said to the main process whenever the + // answer changes. Told only when it has, since most changes to the buffers + // are not to what is waiting. + const [trayMuted] = usePref('mutedBuffers', []) + const [trayHidden] = usePref('hiddenBuffers', []) + const [trayMutedGroups] = usePref('mutedGroups', []) + const lastTray = useRef('') + useEffect(() => { + const count = trayCount(allBuffers, trayMuted, trayHidden, trayMutedGroups) + const said = JSON.stringify(count) + if (said === lastTray.current) return + lastTray.current = said + window.moho.setTrayUnread(count) + }, [allBuffers, trayMuted, trayHidden, trayMutedGroups]) const [savedBufferId] = usePref('ui.activeBufferId', '') const [savedGroupId] = usePref('ui.activeGroupId', '') diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index aa7a6e9..92ddab7 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -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; +} diff --git a/src/renderer/src/components/BufferList.tsx b/src/renderer/src/components/BufferList.tsx index b462e3d..cdaef2f 100644 --- a/src/renderer/src/components/BufferList.tsx +++ b/src/renderer/src/components/BufferList.tsx @@ -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 ? ( 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} @@ -1062,9 +1068,9 @@ function BufferRow({ arrive is worse than either on its own. */} {live ? ( LIVE - ) : buffer.unread > 0 && (!muted || buffer.highlight) ? ( - - {buffer.unread > 99 ? '99+' : buffer.unread} + ) : waitingCount(buffer, muted) > 0 ? ( + + {waitingCount(buffer, muted) > 99 ? '99+' : waitingCount(buffer, muted)} ) : ( // Left unread on purpose, with nothing new in it. A dot rather than diff --git a/src/renderer/src/components/Composer.tsx b/src/renderer/src/components/Composer.tsx index cb7572e..801ec42 100644 --- a/src/renderer/src/components/Composer.tsx +++ b/src/renderer/src/components/Composer.tsx @@ -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([]) 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(null) const stickerButtonRef = useRef(null) + const gifButtonRef = useRef(null) const plusButtonRef = useRef(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 + {/* 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') && ( + + )} + {/* 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 && setPolling(false)} />} + {gifPicker && ( + setGifPicker(false)} + /> + )} + {(pickerOpen || stickerPicker) && ( void +}): JSX.Element { + const store = useStore() + const media = useMediaUrl(accountId) + const ref = useRef(null) + const scroll = useRef(null) + const [pos, setPos] = useState({ left: 0, top: 0 }) + const [query, setQuery] = useState('') + const [gifs, setGifs] = useState([]) + const [categories, setCategories] = useState([]) + 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( +
+
+ + setQuery(e.target.value)} + /> +
+ +
+ {!query.trim() && categories.length > 0 && ( +
+ {categories.map((c) => ( + + ))} +
+ )} + + {state === 'failed' &&
{error || 'GIFs could not be loaded'}
} + {state === 'loading' && gifs.length === 0 &&
Loading…
} + {state === 'ready' && gifs.length === 0 &&
No GIFs found
} + + {gifs.length > 0 && ( +
+ {columns.map((column, i) => ( +
+ {column.map((gif) => ( + + ))} +
+ ))} +
+ )} +
+
, + 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 + onPick: (gif: GifEntry) => void +}): JSX.Element { + const cell = useRef(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 ( + + ) +} diff --git a/src/renderer/src/components/MediaEmbed.tsx b/src/renderer/src/components/MediaEmbed.tsx index 342859e..aca2d25 100644 --- a/src/renderer/src/components/MediaEmbed.tsx +++ b/src/renderer/src/components/MediaEmbed.tsx @@ -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) => !autoplay && void e.currentTarget.play().catch(() => {}), + onMouseLeave: (e: React.MouseEvent) => !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)} /> diff --git a/src/renderer/src/components/MessageList.tsx b/src/renderer/src/components/MessageList.tsx index 8c585f1..1c95499 100644 --- a/src/renderer/src/components/MessageList.tsx +++ b/src/renderer/src/components/MessageList.tsx @@ -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 + } } }, []) diff --git a/src/renderer/src/components/MessageRow.tsx b/src/renderer/src/components/MessageRow.tsx index 623d3ee..daad502 100644 --- a/src/renderer/src/components/MessageRow.tsx +++ b/src/renderer/src/components/MessageRow.tsx @@ -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({ ))} - {(message.embeds || []).map((embed, i) => ( + {(message.embeds || []).map((embed, i) => + embed.kind === 'gif' ? null : (
)}
- ))} + ) + )} {/* Pictures and videos that came together are laid out together: two side by side, three with the first tall, four in a square, diff --git a/src/renderer/src/components/QuickSwitcher.tsx b/src/renderer/src/components/QuickSwitcher.tsx index 8cadf39..8638444 100644 --- a/src/renderer/src/components/QuickSwitcher.tsx +++ b/src/renderer/src/components/QuickSwitcher.tsx @@ -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('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 &&
Nothing by that name.
} {hits.map((hit, i) => { const b = hit.buffer + const waiting = waitingCount(b, isMutedBuffer(b, muted, mutedGroups)) return ( diff --git a/src/renderer/src/components/ServerRail.tsx b/src/renderer/src/components/ServerRail.tsx index 4f1e405..a37fc01 100644 --- a/src/renderer/src/components/ServerRail.tsx +++ b/src/renderer/src/components/ServerRail.tsx @@ -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() - // `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)) diff --git a/src/renderer/src/components/UserFooter.tsx b/src/renderer/src/components/UserFooter.tsx index 07d5d06..f86eb3b 100644 --- a/src/renderer/src/components/UserFooter.tsx +++ b/src/renderer/src/components/UserFooter.tsx @@ -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)}`} > {name} - {watching ? 'Watching, signed out' : label(status, account.service)} + {watching ? 'Watching, signed out' : label(status, account.service, account.autoIdle)} @@ -119,7 +120,7 @@ export function UserFooter({ account }: { account?: Account }): JSX.Element { {open && ( - setOpen(false)} /> + setOpen(false)} /> )} ) diff --git a/src/renderer/src/components/settings/SettingsPanel.tsx b/src/renderer/src/components/settings/SettingsPanel.tsx index d5f08a7..5ed4e34 100644 --- a/src/renderer/src/components/settings/SettingsPanel.tsx +++ b/src/renderer/src/components/settings/SettingsPanel.tsx @@ -855,6 +855,27 @@ function GeneralSettings(): JSX.Element { + + + + + { 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']) + }) +}) diff --git a/src/renderer/src/lib/format.ts b/src/renderer/src/lib/format.ts index 61f3d49..0c49354 100644 --- a/src/renderer/src/lib/format.ts +++ b/src/renderer/src/lib/format.ts @@ -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. diff --git a/src/renderer/src/lib/groups.test.ts b/src/renderer/src/lib/groups.test.ts index c8e5160..026ad91 100644 --- a/src/renderer/src/lib/groups.test.ts +++ b/src/renderer/src/lib/groups.test.ts @@ -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 => ({ 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 => + ({ 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) + }) +}) diff --git a/src/renderer/src/lib/groups.ts b/src/renderer/src/lib/groups.ts index afe007a..bfd3c3b 100644 --- a/src/renderer/src/lib/groups.ts +++ b/src/renderer/src/lib/groups.ts @@ -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)) } diff --git a/src/renderer/src/lib/traycount.test.ts b/src/renderer/src/lib/traycount.test.ts new file mode 100644 index 0000000..ee8b1b5 --- /dev/null +++ b/src/renderer/src/lib/traycount.test.ts @@ -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 => + ({ 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 }) + }) +}) diff --git a/src/renderer/src/lib/traycount.ts b/src/renderer/src/lib/traycount.ts new file mode 100644 index 0000000..fec2269 --- /dev/null +++ b/src/renderer/src/lib/traycount.ts @@ -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 } +} diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/state/store.ts index f20509c..d0b74de 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/state/store.ts @@ -1202,15 +1202,17 @@ export class ChatStore { private reportTrayStatus(): void { const statuses: Record = {} const connected = this.state.accounts.filter((a) => a.state === 'connected') - for (const a of connected) if (a.status) statuses[a.id] = a.status + // 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 } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index df05cd3..e5b1790 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -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 /** diff --git a/src/shared/wire.ts b/src/shared/wire.ts index f351bff..2cbaaf4 100644 --- a/src/shared/wire.ts +++ b/src/shared/wire.ts @@ -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 {