Author SHA1 Message Date
SalastilandClaude Sonnet 5.5 3c6f6e90c3 Development takes only the README from GitHub: master's .gitattributes and PKGBUILD stay on master
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 20:31:53 -04:00
SalastilandClaude Sonnet 5.5 74a6a43a61 nobilis: the README as it stands on GitHub
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 20:30:36 -04:00
Salastil f046e28a37 Revise README for protocol updates and clarity
Updated README to reflect changes in protocol support and features.
2026-10-11 20:19:55 -04:00
Salastil 6d4545f5ec Add showcase screenshot to README
Added a showcase screenshot to the README.
2026-10-11 20:11:14 -04:00
SalastilandClaude Sonnet 5.5 7e94675b2f Merge branch 'development'
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 17:26:25 -04:00
SalastilandClaude Sonnet 5.5 02e5aa364f moho 1.0.0
Moho 1.0. Five protocols in one window - IRC, Discord, Matrix, Kick and Sneedchat - over a daemon that keeps them connected; this is everything since the first release candidate: the rebuilt interface and settings, GIFs and link cards on every service, idle detection, the tray, Kick events as cards, conversations that open where you stopped reading, and every Rust test read.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 17:26:09 -04:00
SalastilandClaude Sonnet 5.5 09c060df51 Which end of a Matrix call offers now has a test where the rule lives, and nobilis carries the test review: every test read, the ones that could not fail made to, and the faults they were hiding fixed
The offering rule was only ever tested through a copy of it compiled for the daemon's tests; the window's own is checked here, including that two devices of one person tell each other apart and that nobody offers to themselves.

nobilis: ddd4503

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 16:14:00 -04:00
SalastilandClaude Sonnet 5.5 47653c5f7f nobilis: tests for the Sneedchat caches
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 14:54:18 -04:00
SalastilandClaude Sonnet 5.5 f1183958ab Kick subscriptions, moderation and the stream's going live or ending are cards in the same theme as the gifts, raids and redeems
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 13:44:49 -04:00
SalastilandClaude Sonnet 5.5 b0e67750ff nobilis: tests found by mutation testing, and an unpin that never reached the screen
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 01:15:58 -04:00
SalastilandClaude Sonnet 5.5 8a7e3e7337 nobilis: tests that could not fail now can
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 00:37:28 -04:00
SalastilandClaude Sonnet 5.5 d99ad2ba68 Documentation brought up to date: the settings pages, how a conversation opens, presence and the tray, link cards and Kick events, Discord mutes set on the account, the tests and the tidy step, and the daemon described as it is rather than as it was meant to be
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 00:30:08 -04:00
SalastilandClaude Sonnet 5.5 97611c8742 No reference to the maintainer's own server: .gitmodules and PKGBUILD are the public ones on every branch, the nobilis release is its own script and the mirroring is kept outside the repository, and the maintainer is [email protected] everywhere it is named
Closes #335

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 00:28:47 -04:00
SalastilandClaude Sonnet 5.5 64ade92ee1 Kick cards sit where a person does: no join mark beside them, starting at the picture's edge with the name where a name is in the log - in comfy and in bubbles, where they had been centred - and with the air above them a message has
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 00:07:59 -04:00
SalastilandClaude Sonnet 5.5 39dcba548a A conversation opens as Discord's does: at its first unread only when the run is short (fifty or fewer), otherwise at its end with the bar to go back by - and the line from a visit is dropped when the conversation is left, so a return no longer goes up the history to a place nothing is unread
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 23:58:14 -04:00
SalastilandClaude Sonnet 5.5 7bae0eaaa8 Kick events are cards in Kick's shape on this client's surfaces: KICKs sent with the gift and its tier, gifted subs with their recipients on a purple bar, a raid, and a redeemed reward in the streamer's colour
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 20:49:58 -04:00
SalastilandClaude Sonnet 5.5 db495d2e98 Merge branch 'development'
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 16:37:52 -04:00
SalastilandClaude Sonnet 5.5 9b14536112 moho 1.0.0-rc.5
Fifth release candidate: GIFs on every service with a picker, idle detection, YouTube and GIF cards on every service including over Tor, the tray's number counted by the window, mentions-only counts that survive a mute, and Discord mutes set on the account.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 16:37:23 -04:00
SalastilandClaude Sonnet 5.5 5d826a785d Mirror script: MIRROR_GITEA reaches Gitea by its own address for the files Cloudflare will not take
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 16:33:00 -04:00
SalastilandClaude Sonnet 5.5 99e03116e9 Mirror script: a file over Cloudflare's 100 MB limit stays on GitHub only, said plainly, instead of stopping the mirror before nobilis
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 16:28:04 -04:00
SalastilandClaude Sonnet 5.5 ee0120ff5e A deploy tidies first: old AppImages, debug and cross builds, and a daemon build directory past 8 GB are removed, so a checkout stops growing to a hundred gigabytes
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 16:12:52 -04:00
SalastilandClaude Sonnet 5.5 7803cc38a6 A mute stops the interruptions, not the count: a muted channel still shows its mentions in the list, the rail, the switcher and the tray; a muted direct message shows none; and muting on Discord is written to the account as it is on Matrix
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 13:33:05 -04:00
SalastilandClaude Sonnet 5.5 987f31cf77 The number beside a conversation is what is addressed to you: mentions in a channel, each unread message in a direct message - the bold name says there is activity, a count of it does not
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 13:21:56 -04:00
SalastilandClaude Sonnet 5.5 336335a8a3 Scrolling a long unread run no longer snaps back: the place being read is found through the bar laid over the top of the log, not read off the bar, so a card or picture settling above does not put the view back where the bar appeared
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 13:17:22 -04:00
SalastilandClaude Sonnet 5.5 638b02e07f nobilis: link cards survive edits and reach backlog on every service
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 23:07:05 -04:00
SalastilandClaude Sonnet 5.5 8064e79bc3 nobilis: YouTube cards for accounts on Tor, from the video's page when oEmbed is refused
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 22:57:05 -04:00
SalastilandClaude Sonnet 5.5 1e314f85b6 The tray's number is the window's: counted by the rail's rule - muted and hidden conversations out, a direct message by each unread message, anywhere else by its mentions - and told to the tray whenever it changes, instead of a tally of its own that drifted
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 20:08:34 -04:00
SalastilandClaude Sonnet 5.5 07f4fc2ef6 The window does not load the forum's own files itself: the daemon fetches them through the account's route, and asked for from the reader's address the host refuses them and Tor is bypassed
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 19:06:23 -04:00
SalastilandClaude Sonnet 5.5 55248eb583 GIFs: a linked GIF is drawn as the picture - silent, looping, without the player - on every service, a GIF picker with trending, categories and search, and Klipy/Tenor/Giphy pages are never probed (#331)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 17:55:10 -04:00
SalastilandClaude Sonnet 5.5 23c87dd353 Idle detection: how long since anything was used, from the system where it will say and moho's own windows where it will not; a setting for it and for how long; the plaque says Idle (away) and keeps what was chosen checked (#332)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 17:35:58 -04:00
Salastil 74af46a6f2 Merge branch 'development' 2026-10-09 15:40:06 -04:00
SalastilandClaude Sonnet 5.5 069371959a moho 1.0.0-rc.4: the rail's mention counts and Matrix unmute fixes join the release
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 15:40:06 -04:00
SalastilandClaude Sonnet 5.5 95f1784dbc A room unmuted on the account stops being drawn as muted: the buffer merge takes the flags the daemon leaves out as cleared; an unmute the server did not take is reported
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 15:37:39 -04:00
SalastilandClaude Sonnet 5.5 0dc048cdc0 Server rail: a tile counts the mentions waiting in it and not the messages, and a muted room counts for nothing, mentions included
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 15:28:01 -04:00
Salastil 5929fff8bb Merge branch 'development' 2026-10-09 15:21:54 -04:00
SalastilandClaude Sonnet 5.5 dc94dc3aca moho 1.0.0-rc.4
Fourth release candidate: a rebuilt tray, statuses by service, Do not disturb that silences notifications, and fixes to switching servers, Matrix rate limits and mutes, and Discord's connection.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 15:21:54 -04:00
SalastilandClaude Sonnet 5.5 745641a1ad The window stays where it is put: the link coming up is acted on once, a refresh does not follow the open conversation to its server, and the tray is told only what changed; Matrix's invisible is called offline; a mute held on a Matrix account can be taken off from the menu
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 11:45:38 -04:00
SalastilandClaude Sonnet 5.5 c1bcb72a38 Status: the plaque and the tray offer what each service has; idle from the tray reaches Discord and IRC and leaves the rest online; do-not-disturb silences desktop notifications everywhere
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 11:26:52 -04:00
SalastilandClaude Sonnet 5.5 0dfff0d74b nobilis: Matrix rate limits are waited out (status from the tray)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 11:15:38 -04:00
SalastilandClaude Sonnet 5.5 53fb0f34ce Tray: no notifications switch of its own - Do not disturb is that, and now silences an account's desktop notifications and the taskbar flash (#330)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 11:07:50 -04:00
SalastilandClaude Sonnet 5.5 24106e51f4 Tray: a bubble drawn for the system's colour scheme with the number waiting on it; a menu of what is new, Status, notifications, Settings, Restart moho and Quit; no daemon plumbing (#330)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 11:00:10 -04:00
SalastilandClaude Sonnet 5.5 b151ef8c07 nobilis: Discord request timeouts, history fetch guard, gateway tally
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 10:37:44 -04:00
82 changed files with 3555 additions and 403 deletions
-23
View File
@@ -1,23 +0,0 @@
# On a merge, .gitmodules keeps the answer this branch already has.
#
# master names the daemon at github.com because master is what a stranger
# clones, and development names it at the Gitea instance where the work
# happens. Without this, a merge that touched .gitmodules on both sides would
# stop and ask which host is right - and the answer is always "the one the
# branch being merged into already said".
#
# `merge=ours` needs a driver by that name, which is one line per clone and
# not something a repository can carry for you:
#
# git config merge.ours.driver true
#
# Without it git falls back to an ordinary merge, which conflicts rather than
# doing the wrong thing quietly - so a clone that has not run it is no worse
# off than before this file existed.
#
# The edge, because it is a real one: this keeps the *whole file*, not just the
# URL. If development ever adds a second submodule, master takes none of that
# entry while still taking the gitlink the merge brings with it - a submodule
# with nothing in .gitmodules to say where it comes from. Adding a submodule is
# therefore a thing to re-do by hand on master, and the only such thing.
/.gitmodules merge=ours
+29 -4
View File
@@ -99,9 +99,8 @@ jobs:
build: ${{ steps.stamp.outputs.build }}
steps:
# nobilis is a submodule, and on master its URL is the public GitHub
# one - on development it points at Gitea. That is the other reason this
# workflow only ever runs on master.
# nobilis is a submodule at a commit its own master holds, which is why
# this workflow only ever runs on master.
#
# Depth 1 is enough: nobilis/build.rs only asks git for a short sha and
# a dirty flag, and falls back to "unknown" rather than failing.
@@ -270,6 +269,32 @@ jobs:
- name: layout check
run: xvfb-run -a npm run test:ui
# The tray cannot be looked at, so this reads what main gave it - icon,
# tooltip, menu - and presses its entries, including Restart moho, which
# has to bring back both the window and a daemon of its own.
- name: tray check
run: xvfb-run -a npm run test:tray
# 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
# Kick event cards: where they sit in each layout, measured against a
# chat line beside them - the picture's column, the name's place, and the
# air above.
- name: kick card check
run: xvfb-run -a npm run test:kick
# 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
@@ -314,7 +339,7 @@ jobs:
retention-days: 7
# The daemon on its own, for nobilis's release - attached to that by
# scripts/mirror-release.sh, since this job's token can only release
# scripts/release-nobilis.sh, since this job's token can only release
# here. Not an asset of moho's release: the AppImage already carries it.
- uses: actions/upload-artifact@v4
if: github.ref_type == 'tag'
+32 -12
View File
@@ -6,9 +6,10 @@ How the two halves fit together, what the daemon speaks, and where it puts thing
## Why two parts
- **`nobilis`** (`nobilis/`, Rust) speaks each protocol directly and translates it into a small
unified JSON model (accounts, buffers, messages) over a Unix socket. It never exposes
protocol-specific concepts to the client; it speaks the unified model, and each backend module
happens to be how that model gets populated for a given service.
unified JSON model (accounts, buffers, messages) over a Unix socket. What every service has in
common - sending, editing, reacting, backlog, search - is one set of calls and events. What
only one service has - a Matrix room's power levels, a Discord poll, a Kick prediction - is a
call of its own, so a client never has to pretend the services are the same where they are not.
- **The Electron client** (`src/`) spawns and supervises `nobilis` and renders whatever it reports.
Keeping the protocol work in a separate long-lived daemon means connections, Tor circuits, and
@@ -42,14 +43,29 @@ Three panes: an account-grouped buffer sidebar, the message log, and a member li
sidebar and member list independently foldable. Closing the window hides it to the tray; nobilis
keeps running, so connections and unread tracking survive.
- **Message rendering** - an IRC-style log by default, with nick colouring, BBCode and
markdown, spoilers, code and quote blocks, inline image/video/YouTube embeds, custom emoji,
emotes and stickers (Discord's animated Lottie ones included), reactions, replies, inline
edit, and Sneedchat's bundled smilies. "Comfy" groups consecutive messages under one avatar,
and "Bubbles" draws them as chat bubbles.
- **Settings** - a category rail covering General, IRC, Sneedchat, Tor, Discord, Matrix, Kick
- **Message rendering** - an IRC-style log by default ("Classic"), with nick colouring, BBCode and
markdown, spoilers, code and quote blocks, inline image/video embeds, custom emoji, emotes and
stickers (Discord's animated Lottie ones included), reactions, replies, inline edit, and
Sneedchat's bundled smilies. "Comfy" groups consecutive messages under one avatar, and "Bubbles"
draws them as chat bubbles. A link is described by the daemon for every service, not only the
ones that do it themselves: a YouTube link becomes a titled card, a Tenor or Giphy link is drawn
as the GIF, and a GIF picker sends one from the composer. What somebody did on a Kick stream -
KICKs sent, subscriptions gifted and to whom, a raid, a redeemed reward - is drawn as a card.
- **Settings** - a searchable rail of categories: Accounts and Downloads; General, Appearance,
Notifications and Keybinds; a page for each network (IRC, Sneedchat, Tor, Discord, Matrix, Kick);
and About. Message-kind filters (joins, parts, mode changes) are applied client-side, so toggling
one takes effect immediately without a reconnect.
- **Moving around** - Ctrl+K opens a quick switcher over every conversation on every account.
A conversation opens at its first unread message when the unread run is short, and at its end,
with a bar to go back by, when it is long - as Discord does it. Escape marks the conversation
read and Shift+Escape the whole server; a muted conversation stays quiet but still counts the
times your name was used in it.
- **Presence and the tray** - the status menu offers only the statuses each service has (Discord
all four, IRC away and back, Matrix online and offline) and sends nothing to a service that
has no such thing. Do not disturb silences desktop notifications everywhere and is Discord's
real do-not-disturb on Discord. When nothing has been used for a while an account is put on
Idle and back, from the system's idle time where it will say and moho's own windows where it
will not. The tray draws a number counted by the window by the rule the server rail uses.
- **Per-protocol join pages** - each service's mechanism is genuinely different (IRC joins by
channel name, Discord offers the friends list and sends joining, adding a friend and making
a server to discord.com - see below - Matrix takes a room address
@@ -165,7 +181,8 @@ called `subscribe` for that buffer.
gateway. Messages with edit/delete/reactions/replies/forwarding, threads and forum posts, slash
commands with buttons, menus and modal forms, polls that can be voted in, stickers sent and
drawn, pinned messages, Discord's own search, invites, scheduled events, AutoMod's verdicts,
and server and channel mutes read from the account itself.
and server and channel mutes - read from the account itself, and set on it when a channel is muted
here, so a phone is quiet too.
Voice and video are moho's own connections rather than a library's: voice, the camera and Go
Live streams - hosted and watched, with sound - over Discord's RTP with DAVE end-to-end
@@ -197,8 +214,9 @@ called `subscribe` for that buffer.
is a renegotiation rather than a new call.
- **Kick** - the streaming site's chat, over its Pusher socket. Joins by streamer handle, imports
the account's follows, and carries the three emote tiers with subscriber gating (and 7TV and
BTTV beside them), redemptions, subscriptions, gifted subs and raids, moderation, chat modes,
and polls and predictions that can be answered rather than only watched. The stream itself
BTTV beside them), KICKs sent to a streamer, redemptions, subscriptions, gifted subs with who
received them, raids, moderation, chat modes, and polls and predictions that can be answered
rather than only watched. The stream itself
plays in the window over HLS, with its VODs and clips.
## On-disk state
@@ -209,6 +227,8 @@ called `subscribe` for that buffer.
| `~/.config/nobilis/scrollback.db` | SQLite scrollback |
| `~/.config/nobilis/matrix-crypto/` | Matrix E2EE device keys and Olm sessions |
| `~/.config/nobilis/voice.toml` | microphone, speakers, volumes and voice processing |
| `~/.config/nobilis/highlights.toml`, `ignores.toml` | the words that count as a mention, and who is ignored |
| `~/.config/nobilis/dcc.toml`, `irc-sts.toml` | DCC settings, and the IRC networks that have pinned TLS (STS) |
| `~/.config/nobilis/tor-state/`, `tor-cache/` | the embedded Tor client's state |
| `~/.cache/nobilis/` | re-derivable media caches (Matrix media, Discord thumbnails, stickers and sounds, Kick emotes, Sneedchat avatars and attachments), each held to a size and to thirty days, and fetched again when a missing file is looked at - see `nobilis/src/media_cache.rs` |
| `~/.cache/moho/moho.log` | the app's log, the daemon's included |
+154 -15
View File
@@ -17,18 +17,63 @@ Each section has two parts:
that made it, including dependency updates
(`node scripts/changelog-deps.mjs <previous tag>` lists those).
## [1.0.0-rc.3]
## [1.0.0]
### Major features
- **Five protocols in one window:** IRC, Discord, Matrix with end-to-end encryption, Sneedchat, and Kick.
- **Discord voice and video:** calls, cameras, screen sharing and Go Live, stage channels, the soundboard and per-person volume, all with Discord's DAVE end-to-end encryption.
- **Matrix:** encrypted rooms with device verification and key backup, Element Call, sliding sync, spaces, and signing in by QR code from a phone.
- **Per-account Tor and SOCKS5 routing:** Clearnet, Service tunnel or Strict - Strict sends every picture, link check and embed through the account's route too. Or send everything through Tor at once.
- **One mentions inbox across every service,** link previews, YouTube cards that play in place, voice messages, polls, and exporting a conversation to disk.
- **A background daemon** (nobilis) that keeps every connection alive with the window closed.
- **Discord forums:** forum and media channels appear in the channel list and open as a pane of posts - title, tags, the first message's words, replies, age, picture and top reaction - with search, sorting, older posts, and a New Post form
- **A redesigned interface:** Discord-shaped messages with date separators and an unread bar, red mention badges, a Ctrl+K quick switcher, keyboard navigation, a rail with mention counts and folders, a member list with faces, and motion throughout
- **Settings rebuilt:** one click from the gear, a grouped rail with a search over every setting, and new Appearance (layout, spacing, zoom, clock, reduce motion), Notifications and Keybinds pages with a hotkey recorder
- **A one-box message composer:** a + menu, Send only when there is something to send, Shift+Enter for a new line on every service, an attachment tray with spoilers, and dropped files going into the tray
- **Discord notices and permissions:** Discord's own safety notices drawn as the cards they are, and a banner in place of the message box wherever nothing can be written
- **A rebuilt tray:** an icon drawn for the system's colour scheme with the number of conversations waiting on it, a tooltip that says what is waiting, and a menu of what is new, Show/Hide, Status, Settings, Restart moho and Quit - Restart moho brings back both the window and the daemon
- **Status by service:** the plaque and the tray offer only the statuses each service has - Discord all four, IRC away and back, Matrix online and offline, Kick and Sneedchat none - and Do not disturb silences desktop notifications on every service, and is Discord's real mode there
- **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.
- **Kick events as cards:** KICKs sent to a streamer, gifted subscriptions with who received them, subscriptions, raids, redeemed rewards, bans and timeouts, and the stream going live or ending are each a card in moho's own theme, sitting where a person's message does in every layout.
- **Conversations open where you stopped reading,** as Discord's do: at the first unread when the run is short, otherwise at the end with a bar to go back by, and a return to a conversation no longer lands hundreds of messages up.
### Fixes, patches and changes
- Fixed Kick's KICKs never being heard - they arrive on a subscription that was never opened - and drew KICKs, gifted subscriptions with their recipients, raids and redeemed rewards as cards ([moho `7bae0ea`](https://github.com/Moho-Chat/moho/commit/7bae0ea), [nobilis `95ee3b1`](https://github.com/Moho-Chat/nobilis/commit/95ee3b1))
- Every other Kick event is a card too: subscriptions, bans, timeouts, unbans, a cleared chat, and the stream going live or ending ([moho `f118395`](https://github.com/Moho-Chat/moho/commit/f118395), [nobilis `23489f8`](https://github.com/Moho-Chat/nobilis/commit/23489f8))
- Kick cards start at the picture's edge with the name where a name is in the log, in comfy and bubbles, with the same air above as a message ([moho `64ade92`](https://github.com/Moho-Chat/moho/commit/64ade92))
- A conversation opens as Discord's does, and the line from a visit is dropped when the conversation is left ([moho `39dcba5`](https://github.com/Moho-Chat/moho/commit/39dcba5))
- Fixed an unpinned Kick message never leaving the screen ([nobilis `81a33a1`](https://github.com/Moho-Chat/nobilis/commit/81a33a1))
- Fixed cancelling an outgoing IRC file offer that nobody had accepted doing nothing for the three minutes the offer lasts ([nobilis `b3e92b5`](https://github.com/Moho-Chat/nobilis/commit/b3e92b5))
- Fixed the IRC capability list being cut short when a server sends it over several lines with the continuation mark in the other field ([nobilis `b3e92b5`](https://github.com/Moho-Chat/nobilis/commit/b3e92b5))
- Fixed a removed account leaving six lists behind in memory, among them its silenced channels and what its network said about MONITOR ([nobilis `b3e92b5`](https://github.com/Moho-Chat/nobilis/commit/b3e92b5))
- Fixed a raid of one viewer reading "1 viewers" ([nobilis `b3e92b5`](https://github.com/Moho-Chat/nobilis/commit/b3e92b5))
- Every test was read; those that could not fail now can, tests that checked a copy of the code now check the code, and the untested cases mutation testing found are covered ([moho `8a7e3e7`](https://github.com/Moho-Chat/moho/commit/8a7e3e7), [moho `b0e6775`](https://github.com/Moho-Chat/moho/commit/b0e6775), [moho `47653c5`](https://github.com/Moho-Chat/moho/commit/47653c5), [moho `09c060d`](https://github.com/Moho-Chat/moho/commit/09c060d), [nobilis `2477514`](https://github.com/Moho-Chat/nobilis/commit/2477514), [nobilis `b522fe6`](https://github.com/Moho-Chat/nobilis/commit/b522fe6), [nobilis `b3e92b5`](https://github.com/Moho-Chat/nobilis/commit/b3e92b5))
- Nothing in the code or documentation refers to the maintainer's own server any more ([moho `97611c8`](https://github.com/Moho-Chat/moho/commit/97611c8), [nobilis `1c70d32`](https://github.com/Moho-Chat/nobilis/commit/1c70d32))
- Documentation brought up to date with the settings pages, how a conversation opens, presence, link cards, Kick events and the daemon as it is ([moho `d99ad2b`](https://github.com/Moho-Chat/moho/commit/d99ad2b), [nobilis `6a1bb8e`](https://github.com/Moho-Chat/nobilis/commit/6a1bb8e))
- 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))
- Reworked the tray icon and menu, and dropped Restart daemon and Stop daemon from it ([moho `24106e5`](https://github.com/Moho-Chat/moho/commit/24106e5))
- Do not disturb now silences an account's desktop notifications and the taskbar flash; the tray no longer has a switch of its own for it ([moho `53fb0f3`](https://github.com/Moho-Chat/moho/commit/53fb0f3))
- Idle from the tray reaches Discord and IRC and leaves Matrix, Kick and Sneedchat as they were; a service is no longer asked for a status it does not have ([moho `c1bcb72`](https://github.com/Moho-Chat/moho/commit/c1bcb72), [nobilis `a8441ab`](https://github.com/Moho-Chat/nobilis/commit/a8441ab))
- Fixed Matrix's rate limit on changing status showing as M_LIMIT_EXCEEDED: a limited request now waits the time the server names, and a longer wait is applied when it is over ([moho `0dfff0d`](https://github.com/Moho-Chat/moho/commit/0dfff0d), [nobilis `43135c1`](https://github.com/Moho-Chat/nobilis/commit/43135c1))
- Fixed the window being pulled back to the open conversation's server whenever you chose another: the daemon link coming up is acted on once, a refresh no longer follows the open conversation, and the tray is told only what changed ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a))
- Matrix's invisible is called Offline in the plaque, its popout and the tray, which is what it sets ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a))
- A room muted on a Matrix account can now be unmuted from the menu, including a mute set from Element ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a), [nobilis `59db82c`](https://github.com/Moho-Chat/nobilis/commit/59db82c))
- Server rail tiles now count the mentions waiting in them rather than the messages, and a muted room counts for nothing, mentions included ([moho `0dc048c`](https://github.com/Moho-Chat/moho/commit/0dc048c))
- Fixed a room unmuted on a Matrix account still being drawn as muted; a mute or unmute the server did not take is now reported with its reason, and Matrix requests to a server that does not answer give up ([moho `95f1784`](https://github.com/Moho-Chat/moho/commit/95f1784), [nobilis `d1bbcaf`](https://github.com/Moho-Chat/nobilis/commit/d1bbcaf))
- Discord requests now give up on a connection that has gone quiet, a history catch-up always releases its place, and the gateway logs what it receives and why a message is dropped ([moho `b151ef8`](https://github.com/Moho-Chat/moho/commit/b151ef8), [nobilis `a34821e`](https://github.com/Moho-Chat/nobilis/commit/a34821e))
- Fixed Discord reconnecting every few seconds after being told it is rate limited: it now waits out the minute Discord asks for ([nobilis `59db82c`](https://github.com/Moho-Chat/nobilis/commit/59db82c))
- Fixed Sneedchat pictures posted back to back by a script merging into one unusable link ([moho `6edae7a`](https://github.com/Moho-Chat/moho/commit/6edae7a))
- Discord files staged together now go as one message, with an upload ring that fills as it uploads ([moho `9888c80`](https://github.com/Moho-Chat/moho/commit/9888c80), [nobilis `9690905`](https://github.com/Moho-Chat/nobilis/commit/9690905))
- Fixed the release mirror script printing a traceback while the release did not exist yet ([moho `a8d9f41`](https://github.com/Moho-Chat/moho/commit/a8d9f41))
@@ -83,20 +128,6 @@ Each section has two parts:
- A window whose page dies is reloaded and the reason logged, instead of being left empty ([moho `6899694`](https://github.com/Moho-Chat/moho/commit/6899694), [moho `06bb812`](https://github.com/Moho-Chat/moho/commit/06bb812))
- Discord forums, with live and archived posts read together ([moho `dd0c25e`](https://github.com/Moho-Chat/moho/commit/dd0c25e), [nobilis `d02bba0`](https://github.com/Moho-Chat/nobilis/commit/d02bba0), [nobilis `8363003`](https://github.com/Moho-Chat/nobilis/commit/8363003), [nobilis `3cf1a5b`](https://github.com/Moho-Chat/nobilis/commit/3cf1a5b), [nobilis `6abcb8e`](https://github.com/Moho-Chat/nobilis/commit/6abcb8e))
- Added Rust crate tokio-util 0.7.19 ([nobilis `9690905`](https://github.com/Moho-Chat/nobilis/commit/9690905))
## [1.0.0]
### Major features
- **Five protocols in one window:** IRC, Discord, Matrix with end-to-end encryption, Sneedchat, and Kick.
- **Discord voice and video:** calls, cameras, screen sharing and Go Live, stage channels, the soundboard and per-person volume, all with Discord's DAVE end-to-end encryption.
- **Matrix:** encrypted rooms with device verification and key backup, Element Call, sliding sync, spaces, and signing in by QR code from a phone.
- **Per-account Tor and SOCKS5 routing:** Clearnet, Service tunnel or Strict - Strict sends every picture, link check and embed through the account's route too. Or send everything through Tor at once.
- **One mentions inbox across every service,** link previews, YouTube cards that play in place, voice messages, polls, and exporting a conversation to disk.
- **A background daemon** (nobilis) that keeps every connection alive with the window closed.
### Fixes, patches and changes
- Channel rows in the sidebar now show a single `#` instead of two ([moho `ccdf090`](https://github.com/Moho-Chat/moho/commit/ccdf090))
- Typing anywhere in the window now goes into the message box, without clicking it first ([moho `b5f920f`](https://github.com/Moho-Chat/moho/commit/b5f920f))
- Added a formatting strip over selected text in the message box, offering the formats each service can carry ([moho `9ca477c`](https://github.com/Moho-Chat/moho/commit/9ca477c))
@@ -175,3 +206,111 @@ Each section has two parts:
- Added npm package lottie-web 5.13.0 ([moho `f67c753`](https://github.com/Moho-Chat/moho/commit/f67c753))
- Added npm package happy-dom 15.11.7 ([moho `53a6f92`](https://github.com/Moho-Chat/moho/commit/53a6f92))
- Added npm package vitest 2.1.9 ([moho `53a6f92`](https://github.com/Moho-Chat/moho/commit/53a6f92))
## [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
- **A rebuilt tray:** an icon drawn for the system's colour scheme with the number of conversations waiting on it, a tooltip that says what is waiting, and a menu of what is new, Show/Hide, Status, Settings, Restart moho and Quit - Restart moho brings back both the window and the daemon
- **Status by service:** the plaque and the tray offer only the statuses each service has - Discord all four, IRC away and back, Matrix online and offline, Kick and Sneedchat none - and Do not disturb silences desktop notifications on every service, and is Discord's real mode there
### Fixes, patches and changes
- Reworked the tray icon and menu, and dropped Restart daemon and Stop daemon from it ([moho `24106e5`](https://github.com/Moho-Chat/moho/commit/24106e5))
- Do not disturb now silences an account's desktop notifications and the taskbar flash; the tray no longer has a switch of its own for it ([moho `53fb0f3`](https://github.com/Moho-Chat/moho/commit/53fb0f3))
- Idle from the tray reaches Discord and IRC and leaves Matrix, Kick and Sneedchat as they were; a service is no longer asked for a status it does not have ([moho `c1bcb72`](https://github.com/Moho-Chat/moho/commit/c1bcb72), [nobilis `a8441ab`](https://github.com/Moho-Chat/nobilis/commit/a8441ab))
- Fixed Matrix's rate limit on changing status showing as M_LIMIT_EXCEEDED: a limited request now waits the time the server names, and a longer wait is applied when it is over ([moho `0dfff0d`](https://github.com/Moho-Chat/moho/commit/0dfff0d), [nobilis `43135c1`](https://github.com/Moho-Chat/nobilis/commit/43135c1))
- Fixed the window being pulled back to the open conversation's server whenever you chose another: the daemon link coming up is acted on once, a refresh no longer follows the open conversation, and the tray is told only what changed ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a))
- Matrix's invisible is called Offline in the plaque, its popout and the tray, which is what it sets ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a))
- A room muted on a Matrix account can now be unmuted from the menu, including a mute set from Element ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a), [nobilis `59db82c`](https://github.com/Moho-Chat/nobilis/commit/59db82c))
- Server rail tiles now count the mentions waiting in them rather than the messages, and a muted room counts for nothing, mentions included ([moho `0dc048c`](https://github.com/Moho-Chat/moho/commit/0dc048c))
- Fixed a room unmuted on a Matrix account still being drawn as muted; a mute or unmute the server did not take is now reported with its reason, and Matrix requests to a server that does not answer give up ([moho `95f1784`](https://github.com/Moho-Chat/moho/commit/95f1784), [nobilis `d1bbcaf`](https://github.com/Moho-Chat/nobilis/commit/d1bbcaf))
- Discord requests now give up on a connection that has gone quiet, a history catch-up always releases its place, and the gateway logs what it receives and why a message is dropped ([moho `b151ef8`](https://github.com/Moho-Chat/moho/commit/b151ef8), [nobilis `a34821e`](https://github.com/Moho-Chat/nobilis/commit/a34821e))
- Fixed Discord reconnecting every few seconds after being told it is rate limited: it now waits out the minute Discord asks for ([nobilis `59db82c`](https://github.com/Moho-Chat/nobilis/commit/59db82c))
## [1.0.0-rc.3]
### Major features
- **Discord forums:** forum and media channels appear in the channel list and open as a pane of posts - title, tags, the first message's words, replies, age, picture and top reaction - with search, sorting, older posts, and a New Post form
- **A redesigned interface:** Discord-shaped messages with date separators and an unread bar, red mention badges, a Ctrl+K quick switcher, keyboard navigation, a rail with mention counts and folders, a member list with faces, and motion throughout
- **Settings rebuilt:** one click from the gear, a grouped rail with a search over every setting, and new Appearance (layout, spacing, zoom, clock, reduce motion), Notifications and Keybinds pages with a hotkey recorder
- **A one-box message composer:** a + menu, Send only when there is something to send, Shift+Enter for a new line on every service, an attachment tray with spoilers, and dropped files going into the tray
- **Discord notices and permissions:** Discord's own safety notices drawn as the cards they are, and a banner in place of the message box wherever nothing can be written
### Fixes, patches and changes
- Fixed Sneedchat pictures posted back to back by a script merging into one unusable link ([moho `6edae7a`](https://github.com/Moho-Chat/moho/commit/6edae7a))
- Discord files staged together now go as one message, with an upload ring that fills as it uploads ([moho `9888c80`](https://github.com/Moho-Chat/moho/commit/9888c80), [nobilis `9690905`](https://github.com/Moho-Chat/nobilis/commit/9690905))
- Fixed the release mirror script printing a traceback while the release did not exist yet ([moho `a8d9f41`](https://github.com/Moho-Chat/moho/commit/a8d9f41))
- Defined the design tokens the stylesheet used but never had, and applied one set across it ([moho `9db25c8`](https://github.com/Moho-Chat/moho/commit/9db25c8), [moho `3cdfe37`](https://github.com/Moho-Chat/moho/commit/3cdfe37))
- Added a screenshot harness for before-and-after pictures of the interface ([moho `88375ba`](https://github.com/Moho-Chat/moho/commit/88375ba))
- Unread channels now read as unread, mentions are red, and every badge is one style ([moho `4ca37dd`](https://github.com/Moho-Chat/moho/commit/4ca37dd))
- Added date separators to the message list ([moho `b2464e4`](https://github.com/Moho-Chat/moho/commit/b2464e4))
- Added Mark as read on channels, categories and servers, and Escape to read what you are in ([moho `2745a2f`](https://github.com/Moho-Chat/moho/commit/2745a2f))
- Added a Ctrl+K quick switcher and keyboard navigation between channels ([moho `1eef37e`](https://github.com/Moho-Chat/moho/commit/1eef37e))
- Voice panel: a horizontal microphone bar, the connection's real latency, and a share button ([moho `ad0f0c0`](https://github.com/Moho-Chat/moho/commit/ad0f0c0), [nobilis `e0f6169`](https://github.com/Moho-Chat/nobilis/commit/e0f6169))
- Replies jump to the message they answer, and names and pictures open the profile ([moho `bf45147`](https://github.com/Moho-Chat/moho/commit/bf45147))
- Added Copy and Copy text on every message, and Copy message link on Discord ([moho `0033955`](https://github.com/Moho-Chat/moho/commit/0033955))
- The hover toolbar fades in, can be reached by keyboard, and offers Edit, Copy and your recent reactions ([moho `57e1856`](https://github.com/Moho-Chat/moho/commit/57e1856))
- Fixed six interface bugs: a nested voice button, folded categories hiding unread, muted channels hiding mentions, overlapping corners, the typing strip moving the log, and pinned rows missing their service mark ([moho `6598043`](https://github.com/Moho-Chat/moho/commit/6598043))
- Pending sends dim only their text, failed ones can be deleted, and Up in an empty box edits your last message in a multi-line editor ([moho `6a82c32`](https://github.com/Moho-Chat/moho/commit/6a82c32))
- Server rail: mention counts, folders that say what is waiting and stay open, an instant name flyout and a red close button ([moho `8072b24`](https://github.com/Moho-Chat/moho/commit/8072b24))
- Member list: faces, presence dots, and a click for the profile; Discord's member pictures now come with the list ([moho `c912ed3`](https://github.com/Moho-Chat/moho/commit/c912ed3), [nobilis `4099305`](https://github.com/Moho-Chat/nobilis/commit/4099305))
- Voice channel list: faces, muted and deafened marks, a speaking ring and a collapsible heading ([moho `843288d`](https://github.com/Moho-Chat/moho/commit/843288d))
- Comfy messages in Discord's shape, amber mention rows, one code and quote style, and marked system lines ([moho `3a7677e`](https://github.com/Moho-Chat/moho/commit/3a7677e))
- Buttons are primary, subtle or the one danger style, and the restarts ask first ([moho `2c8609c`](https://github.com/Moho-Chat/moho/commit/2c8609c))
- Escape closes only the top layer, and full-page panels close on Escape ([moho `c5e7619`](https://github.com/Moho-Chat/moho/commit/c5e7619))
- Thread panel reads the display settings, groups replies and follows the newest ([moho `33129f9`](https://github.com/Moho-Chat/moho/commit/33129f9))
- Toasts slide in and out, come in three kinds, pause on hover and fold repeats ([moho `2d1b8be`](https://github.com/Moho-Chat/moho/commit/2d1b8be))
- Context menu: keyboard navigation, checked items, shortcut hints, and placement that holds while it animates ([moho `4065963`](https://github.com/Moho-Chat/moho/commit/4065963), [moho `fcb7df5`](https://github.com/Moho-Chat/moho/commit/fcb7df5))
- A conversation opens at its first unread, with a bar saying how many are above ([moho `55fd192`](https://github.com/Moho-Chat/moho/commit/55fd192))
- Added hover fades, turning fold arrows, arriving messages, a reaction pop and growing overlays ([moho `6d8a050`](https://github.com/Moho-Chat/moho/commit/6d8a050))
- Every dialog is built from one shared frame with a close button, kept focus and a scrim below the title bar ([moho `5cb30ce`](https://github.com/Moho-Chat/moho/commit/5cb30ce), [moho `420a2f7`](https://github.com/Moho-Chat/moho/commit/420a2f7), [moho `6a329c6`](https://github.com/Moho-Chat/moho/commit/6a329c6))
- Shift+Enter makes a new line on every service and pasted line breaks stay; IRC sends them as a batch or separate lines, Sneedchat as [br], Kick as one line ([moho `9ba630c`](https://github.com/Moho-Chat/moho/commit/9ba630c), [nobilis `ccc8de6`](https://github.com/Moho-Chat/nobilis/commit/ccc8de6))
- Reply bar: its own surface, the author's face, Esc to cancel, and an @ switch to answer without pinging on Discord ([moho `f48185e`](https://github.com/Moho-Chat/moho/commit/f48185e), [nobilis `918fcfa`](https://github.com/Moho-Chat/nobilis/commit/918fcfa))
- One style for the name, command and emoji lists, with member pictures and :shortcode emoji completion ([moho `28d8b3c`](https://github.com/Moho-Chat/moho/commit/28d8b3c))
- Lightbox: step through a conversation's pictures, wheel and pinch zoom with panning, and Copy picture ([moho `817cb1e`](https://github.com/Moho-Chat/moho/commit/817cb1e))
- Emoji picker: walk it with the arrow keys, Enter picks the top result, and skin tones are remembered ([moho `99fd0d3`](https://github.com/Moho-Chat/moho/commit/99fd0d3))
- Attachment tray cards with name, size and a Discord spoiler toggle; dropped files go into the tray instead of being sent at once ([moho `15e7061`](https://github.com/Moho-Chat/moho/commit/15e7061), [nobilis `0f5669b`](https://github.com/Moho-Chat/nobilis/commit/0f5669b))
- Composer: one box with the + on the left and emoji, stickers, voice and Send on the right ([moho `a59c1ab`](https://github.com/Moho-Chat/moho/commit/a59c1ab))
- Several pictures in one message lay out as a grid, and SPOILER_ files are blurred until clicked ([moho `87ea3b8`](https://github.com/Moho-Chat/moho/commit/87ea3b8))
- Pinned messages show as a banner over the log for Discord and Matrix, with a count on the header ([moho `3a74cf0`](https://github.com/Moho-Chat/moho/commit/3a74cf0))
- Reactions say what they are and who gave them on hover ([moho `506140d`](https://github.com/Moho-Chat/moho/commit/506140d), [nobilis `1da3ad8`](https://github.com/Moho-Chat/nobilis/commit/1da3ad8))
- User footer: muted and deafened are red, voice buttons stay on every service, and the status is a popout with a custom status for Discord and Matrix and Apply to all accounts ([moho `c026609`](https://github.com/Moho-Chat/moho/commit/c026609), [nobilis `f280563`](https://github.com/Moho-Chat/nobilis/commit/f280563))
- The footer's join button is gone; the rail's + is the one way in, pinned under a rule above the settings cog while the server list scrolls ([moho `b4c1ced`](https://github.com/Moho-Chat/moho/commit/b4c1ced), [moho `dad5543`](https://github.com/Moho-Chat/moho/commit/dad5543))
- Header: one 48px row across every column, filled toggles, and the channel topic from IRC, Matrix and Discord ([moho `2ef9e35`](https://github.com/Moho-Chat/moho/commit/2ef9e35), [nobilis `44d1611`](https://github.com/Moho-Chat/nobilis/commit/44d1611))
- "(edited)" says when on hover, for edits seen live ([moho `7f4c356`](https://github.com/Moho-Chat/moho/commit/7f4c356), [nobilis `e51f139`](https://github.com/Moho-Chat/nobilis/commit/e51f139))
- Incoming Discord and Matrix calls share one card style and stack ([moho `59d4893`](https://github.com/Moho-Chat/moho/commit/59d4893))
- Settings: the gear opens it directly, with Accounts and Downloads as pages, a search, one switch for every boolean and a saved tick ([moho `66a0c0f`](https://github.com/Moho-Chat/moho/commit/66a0c0f), [moho `cc4c9f6`](https://github.com/Moho-Chat/moho/commit/cc4c9f6))
- Settings: added Appearance, Notifications and Keybinds pages, the last with a recorder for the global hotkey ([moho `fe6ad97`](https://github.com/Moho-Chat/moho/commit/fe6ad97), [moho `adca84d`](https://github.com/Moho-Chat/moho/commit/adca84d), [moho `82a0bdf`](https://github.com/Moho-Chat/moho/commit/82a0bdf))
- Accounts open by default with few accounts, Remove sits in a danger zone, and Join picks its account and says Join ([moho `f8dcccd`](https://github.com/Moho-Chat/moho/commit/f8dcccd))
- Mentions page: service and unread filters, day groups, two-line bodies and put-away ([moho `078fe41`](https://github.com/Moho-Chat/moho/commit/078fe41))
- Room settings is a tabbed dialog instead of a popover ([moho `f8936c6`](https://github.com/Moho-Chat/moho/commit/f8936c6))
- Quiet buttons such as Cancel have their own fill, so they no longer vanish into a dialog ([moho `50aaa97`](https://github.com/Moho-Chat/moho/commit/50aaa97))
- Fixed browsing an IRC network's channels freezing or blanking the window: the daemon holds the list and sends a page, it times out after a minute, and a refusal is reported ([moho `2b41c5e`](https://github.com/Moho-Chat/moho/commit/2b41c5e), [nobilis `152fdf1`](https://github.com/Moho-Chat/nobilis/commit/152fdf1))
- Channels in the list no longer jump to the top as messages arrive ([moho `9f03d6b`](https://github.com/Moho-Chat/moho/commit/9f03d6b))
- Fixed Discord's notices showing only a stand-in title: they are read as cards, and a banner replaces the message box in them and in channels this account cannot write in ([moho `ff7d313`](https://github.com/Moho-Chat/moho/commit/ff7d313), [moho `d7c4215`](https://github.com/Moho-Chat/moho/commit/d7c4215), [moho `2a4f797`](https://github.com/Moho-Chat/moho/commit/2a4f797), [nobilis `1b270be`](https://github.com/Moho-Chat/nobilis/commit/1b270be), [nobilis `97ac6bc`](https://github.com/Moho-Chat/nobilis/commit/97ac6bc), [nobilis `7561b88`](https://github.com/Moho-Chat/nobilis/commit/7561b88))
- A window whose page dies is reloaded and the reason logged, instead of being left empty ([moho `6899694`](https://github.com/Moho-Chat/moho/commit/6899694), [moho `06bb812`](https://github.com/Moho-Chat/moho/commit/06bb812))
- Discord forums, with live and archived posts read together ([moho `dd0c25e`](https://github.com/Moho-Chat/moho/commit/dd0c25e), [nobilis `d02bba0`](https://github.com/Moho-Chat/nobilis/commit/d02bba0), [nobilis `8363003`](https://github.com/Moho-Chat/nobilis/commit/8363003), [nobilis `3cf1a5b`](https://github.com/Moho-Chat/nobilis/commit/3cf1a5b), [nobilis `6abcb8e`](https://github.com/Moho-Chat/nobilis/commit/6abcb8e))
- Added Rust crate tokio-util 0.7.19 ([nobilis `9690905`](https://github.com/Moho-Chat/nobilis/commit/9690905))
+1
View File
@@ -1,3 +1,4 @@
# Maintainer: Salastil <[email protected]>
pkgname=moho-git
pkgver=r1.268751e
pkgrel=1
+24 -35
View File
@@ -1,42 +1,25 @@
# moho
**Every conversation you're in, in one window.**
<img width="1545" height="912" alt="showcase-screenshot" src="https://github.com/user-attachments/assets/b9dabddb-bb3c-4a71-b872-9ad167ecd400" />
moho is a desktop chat client that speaks IRC, Discord, Matrix, Kick and Sneedchat at the
same time. One buffer list down the side, one log in the middle, one set of habits — whether
the room is a twenty-year-old IRC channel, a Discord server, an encrypted Matrix room, or a
stream's chat going past at forty lines a minute.
It isn't five web apps in a trenchcoat. Underneath sits a small Rust daemon that holds the
connections; the window is just what draws them. Close it and you stay connected. Open it
again and everything is where you left it.
moho is a desktop chat client that speaks IRC, Discord, Matrix, Kick and many more protocols at the
same time. Whether the room is a twenty-year-old IRC channel, a Discord server, an encrypted Matrix room, or a
stream's chat going past at forty lines a minute you're able to be part of the conversation.
## What it does
- **Five networks, one place.** IRC, Discord, Matrix, Kick and Sneedchat, side by side, with
- **Many networks, one place.** IRC, Discord, Matrix, Kick and many more protocols soon, side by side, with
the same reply, edit, react, search and tag-someone gestures on each — as far as each
service actually supports them.
- **A log, not a feed.** Compact IRC-style scrollback with nick colours, markdown and BBCode,
spoilers, code blocks, inline images and video, and custom emoji. There's a roomier mode
with avatars if you prefer one.
- **It stays connected.** The daemon keeps IRC registered, Matrix syncing and Tor circuits
open while the window is shut. Quitting says a proper goodbye rather than dropping the
socket, so you don't come back to a ghosted nick.
- **Calls.** Voice, camera and screen sharing on Discord and on Matrix, including the group
calls Element holds on a media server. On Discord, Go Live streams both ways with sound,
calls Element holds on a media server. On Discord, Go Live, streams both ways with sound,
echo cancellation, volume per person, the soundboard and stage channels. A call can sit in
the corner or pop out into a window of its own.
- **Matrix, properly encrypted.** End-to-end encryption with device verification by emoji or
QR, cross-signing, key backup, and encrypted attachments.
- **Sneedchat, and Tor where you want it.** The site's proof-of-work gate and its login
captcha both handled, and every room you've joined connected at once. Any account on any of
the five can go through an embedded Tor client or a SOCKS5 proxy instead of the open internet.
- **Kick streams in the window.** Watch the stream beside its chat, in the corner or in a
window of its own, with VODs, clips, 7TV and BTTV emotes, polls and predictions.
- **No captchas on Discord, on purpose.** Discord puts joining a server, adding a friend and
making a server behind a captcha when they come from anything but its own client, and
answering one gets an account flagged for spam. moho opens discord.com for those three
instead of putting your account at risk.
window of its own, polls, predictions and other interactive methods are functional and at your fingertips.
- **Yours.** GPL-3.0, no telemetry, no account with us — your history is a SQLite file on
your own disk.
@@ -47,10 +30,6 @@ Prebuilt Linux and Windows downloads are on the
changed is in [CHANGELOG.md](CHANGELOG.md). moho tells you in its mentions inbox when a newer
version is out.
Versions are MAJOR.FEATURE.FIX: a major release, then protocols added and major features,
then bug and security fixes - 1.1.0 would add a protocol, 1.0.5 is the fifth fix release
of 1.0.
The AppImage needs `libfuse2` on recent distributions; if it refuses to start, either install
that or run it with `APPIMAGE_EXTRACT_AND_RUN=1`.
@@ -85,13 +64,18 @@ npm install-scripts approve electron esbuild
## Test it
```bash
npm test # the window's unit tests (Vitest)
npm run test:ui # layout checks against the real app - popups must stay inside the window
npm test # the window's unit tests (Vitest)
npm run test:ui # layout checks against the real app - popups must stay inside the window
npm run test:tray # the tray: its icon, tooltip, menu, statuses and restart
npm run test:gif # GIFs drawn as pictures, and the GIF picker
npm run test:scroll # scrolling and where a conversation opens
npm run test:kick # where Kick event cards sit in each layout
cd nobilis && cargo test --release
```
`test:ui` builds the window, runs it against a scratch home and a fake IRC server, and needs the
daemon built first. On a machine with no display, run it under `xvfb-run`.
The `test:` scripts other than `test` build the window, run it against a scratch home and a
fake IRC server, and need the daemon built first. On a machine with no display, run them under
`xvfb-run`. `npm run shots` photographs the interface scene by scene, for looking at changes.
## Run it
@@ -107,9 +91,14 @@ npm run deploy
```
Other ways out of the box: `npm run pack:dir` for a self-contained `dist/linux-unpacked/`
directory to run in place, `npm run pack:win` for the Windows installer (cross-built with
mingw-w64; packaging it also needs wine with 32-bit support), and `makepkg -si` against the
bundled `PKGBUILD` to install it as an Arch package under `/opt/moho`.
directory to run in place, `npm run pack:deb` for a Debian package, `npm run pack:win` for the
Windows installer (cross-built with mingw-w64; packaging it also needs wine with 32-bit
support), and `makepkg -si` against the bundled `PKGBUILD` to install it as an Arch package
under `/opt/moho`.
Packaging leaves a lot behind - an AppImage per build, and Rust build output that Cargo never
cleans up. `npm run deploy` first runs `npm run tidy`, which keeps the newest two AppImages and
empties `nobilis/target/` once it passes 8 GB.
---
+1 -1
Submodule nobilis updated: baa37abde4...4dbcade8fd
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "moho",
"version": "1.0.0-rc.3",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "moho",
"version": "1.0.0-rc.3",
"version": "1.0.0",
"license": "GPL-3.0-or-later",
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
+9 -4
View File
@@ -1,8 +1,8 @@
{
"name": "moho",
"version": "1.0.0-rc.3",
"version": "1.0.0",
"description": "Unified desktop chat client for IRC, Discord, Sneedchat and Matrix",
"author": "Salastil",
"author": "Salastil <[email protected]>",
"license": "GPL-3.0-or-later",
"homepage": "https://github.com/Moho-Chat/moho",
"repository": {
@@ -21,13 +21,18 @@
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
"test": "vitest run",
"test:ui": "electron-vite build && node scripts/ui-check.mjs",
"test:tray": "electron-vite build && node scripts/tray-check.mjs",
"test:gif": "electron-vite build && node scripts/gif-check.mjs",
"test:scroll": "electron-vite build && node scripts/scroll-check.mjs",
"test:kick": "electron-vite build && node scripts/kick-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"
},
@@ -74,7 +79,7 @@
"AppImage",
"deb"
],
"maintainer": "Salastil <salastil@users.noreply.github.com>",
"maintainer": "Salastil <salastil@cock.li>",
"category": "Network",
"synopsis": "Unified desktop chat client",
"icon": "resources/icons/moho.png",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 797 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 771 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env node
/**
* Checks GIFs against the running app: that one a message carries is drawn as
* the picture and not as its address, that a link to a page nothing could read
* is still a link, and that the picker finds, filters and sends.
*
* What it stands in for, and why: Discord is not here, so the picker's list is
* left for it (a fixture only a harness window reads) and the embeds are put on
* messages directly. What is real is everything the window does with them, and
* the send, which goes through the daemon to a fake IRC server and is read back
* off the wire.
*
* npm run test:gif (builds first; needs the daemon built too)
*/
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fakeIrc, launchApp, openIrcChannel, sleep, until, click } from './lib/harness.mjs'
let failures = 0
const check = (ok, what, detail = '') => {
console.log(`${ok ? 'ok ' : 'FAIL'} ${what}${ok ? '' : detail ? ` - ${detail}` : ''}`)
if (!ok) failures++
}
setTimeout(() => {
console.log('FAIL timed out')
process.exit(1)
}, 120000).unref()
// A GIF and a video to be served, so what is drawn has something to load: made
// with ffmpeg, a second of a flat colour. The window only plays https, blob and
// data media, so they are handed to it as the last two.
const media = fs.mkdtempSync(path.join(os.tmpdir(), 'moho-gif-'))
execFileSync('ffmpeg', ['-v', 'error', '-f', 'lavfi', '-i', 'color=c=0x3a8fe0:s=96x54:d=1:r=10', '-pix_fmt', 'yuv420p', '-y', path.join(media, 'cat.mp4')])
execFileSync('ffmpeg', ['-v', 'error', '-f', 'lavfi', '-i', 'color=c=0xe0a43a:s=96x54:d=1:r=10', '-y', path.join(media, 'giphy.gif')])
const MP4 = fs.readFileSync(path.join(media, 'cat.mp4')).toString('base64')
const GIF = fs.readFileSync(path.join(media, 'giphy.gif')).toString('base64')
const sent = []
const irc = await fakeIrc({
people: ['bob'],
onJoin: (channel, _nick, send) => send(`:bob!b@h PRIVMSG ${channel} :hello there`),
onLine: (line) => {
if (/^PRIVMSG/i.test(line)) sent.push(line)
}
})
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
let home = ''
try {
home = await page.evaluate('window.moho.diagnostics?.home ?? ""').catch(() => '')
await until(page, 'window.__mohoShots', 'the harness hook')
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#lobby'], waitFor: 'hello there' })
// The same GIF, as a message that carries it.
const SIZE = (a, b, label) =>
'data:image/svg+xml;utf8,' +
encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="480" height="270"><rect width="480" height="270" fill="${a}"/><text x="240" y="150" font-size="60" fill="${b}" text-anchor="middle">${label}</text></svg>`)
await page.evaluate(`(() => {
const shots = window.__mohoShots
const s = shots.state()
const id = s.activeBufferId
const now = Math.floor(Date.now() / 1000)
const bytes = Uint8Array.from(atob('${MP4}'), (c) => c.charCodeAt(0))
const MP4_URL = URL.createObjectURL(new Blob([bytes], { type: 'video/mp4' }))
const GIF_URL = 'data:image/gif;base64,${GIF}'
const msg = (n, body, embeds) => ({ id: 'g' + n, bufferId: id, from: 'bob', senderId: 'bob', body, ts: now - (10 - n), isAction: false, isHighlight: false, kind: 'chat', isOwn: false, ...(embeds ? { embeds } : {}) })
shots.patch({
accounts: [...s.accounts, { id: 'discord:gifcheck', service: 'discord', displayName: 'x', state: 'connected', autojoin: '', currentNick: 'x', status: 'online', useTor: false, ssl: true }],
messagesByBuffer: { ...s.messagesByBuffer, [id]: [...(s.messagesByBuffer[id] || []),
msg(1, 'https://tenor.com/view/cat-gif-123', [{ kind: 'gif', url: 'https://tenor.com/view/cat-gif-123', mediaUrl: MP4_URL, mediaKind: 'video', width: 480, height: 270, provider: 'Tenor' }]),
msg(2, 'look at this https://giphy.com/gifs/dog-Z7wFi5b2VEAr6 so good', [{ kind: 'gif', url: 'https://giphy.com/gifs/dog-Z7wFi5b2VEAr6', mediaUrl: GIF_URL, mediaKind: 'image', provider: 'Giphy' }]),
msg(3, 'https://klipy.com/gifs/friends-joey-53')
] }
})
window.__gifFixture = {
categories: [{ name: 'reaction', src: '' }, { name: 'happy', src: '' }],
gifs: [
{ id: 'a', title: 'cat dancing', url: 'https://tenor.com/view/cat-dancing-gif-1', src: 'http://127.0.0.1:9/a.mp4', gifSrc: 'http://127.0.0.1:9/a.gif', preview: '', width: 480, height: 270 },
{ id: 'b', title: 'dog running', url: 'https://tenor.com/view/dog-running-gif-2', src: 'http://127.0.0.1:9/b.mp4', gifSrc: 'http://127.0.0.1:9/b.gif', preview: '', width: 270, height: 480 },
{ id: 'c', title: 'cat sleeping', url: 'https://tenor.com/view/cat-sleeping-gif-3', src: 'http://127.0.0.1:9/c.mp4', gifSrc: 'http://127.0.0.1:9/c.gif', preview: '', width: 480, height: 480 }
]
}
return true
})()`)
await sleep(800)
const row = (n) => `[...document.querySelectorAll('.message-row')].find((r) => r.innerText.includes('${n}') || r.querySelector('[data-id="g${n}"]'))`
// A message that carries a GIF shows the GIF, and not the address it came from.
const rows = await page.evaluate(`[...document.querySelectorAll('.message-row')].map((r) => ({ text: r.innerText, videos: [...r.querySelectorAll('video')].map((v) => v.getAttribute('src')), plays: [...r.querySelectorAll('video')].map((v) => ({ muted: v.muted, loop: v.loop, controls: v.controls, autoplay: v.autoplay })), images: [...r.querySelectorAll('img')].map((i) => i.getAttribute('src')) }))`)
if (process.env.GIF_DEBUG) console.log(JSON.stringify(rows.slice(-4), null, 1))
const tenor = rows.find((r) => r.videos.some((v) => v?.startsWith('blob:')))
check(!!tenor, 'a GIF message is drawn as a video')
check(!!tenor && !/tenor\.com/.test(tenor.text), 'and its page address is not drawn as well', tenor?.text)
const play = tenor?.plays?.[0]
check(!!play && play.muted && play.loop && !play.controls && play.autoplay, 'played as a GIF: silent, going round, with no player to work', JSON.stringify(play))
const giphy = rows.find((r) => r.text.includes('look at this'))
check(!!giphy && /look at this/.test(giphy.text) && /so good/.test(giphy.text) && !/giphy\.com/.test(giphy.text), 'words around a GIF link stay, and the link goes', giphy?.text)
check(!!giphy && (giphy.images.some((i) => i?.startsWith('data:image/gif'))), 'and a GIF file is drawn as a picture')
const klipy = rows.find((r) => r.text.includes('klipy.com'))
check(!!klipy && klipy.videos.length === 0, 'a Klipy page nothing could read stays a link', JSON.stringify(klipy))
// The picker.
check(await page.evaluate('!!document.querySelector(\'button[title="GIFs"]\')'), 'a GIF button is offered where there is a Discord account')
await page.evaluate(click('button[title="GIFs"]'))
await until(page, 'document.querySelector(".gif-picker")', 'the picker')
check((await page.evaluate('document.querySelectorAll(".gif-cell").length')) === 3, 'it lists what it was given')
check((await page.evaluate('document.querySelectorAll(".gif-category").length')) === 2, 'with categories to start from')
const inView = await page.evaluate(`(() => { const r = document.querySelector('.gif-picker').getBoundingClientRect(); return r.left >= 0 && r.top >= 0 && r.right <= window.innerWidth && r.bottom <= window.innerHeight })()`)
check(inView, 'inside the window')
await page.evaluate(`(() => { const i = document.querySelector('.gif-search input'); const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; set.call(i, 'cat'); i.dispatchEvent(new Event('input', { bubbles: true })); return true })()`)
await sleep(700)
check((await page.evaluate('document.querySelectorAll(".gif-cell").length')) === 2, 'a search narrows it')
// Choosing one sends it - to IRC, the file, which anything can show as a link.
await page.evaluate(`document.querySelector('.gif-cell[title="cat dancing"]').click()`)
await sleep(1200)
check(sent.some((l) => /PRIVMSG #lobby :?http:\/\/127\.0\.0\.1:9\/a\.gif/.test(l)), 'choosing one sends the file to a service that is not Discord', JSON.stringify(sent))
check(!(await page.evaluate('!!document.querySelector(".gif-picker")')), 'and the picker closes')
} catch (e) {
console.log('FAIL', e.message)
failures++
} finally {
await stop().catch(() => {})
irc.close()
fs.rmSync(media, { recursive: true, force: true })
}
console.log(failures ? `${failures} failed` : 'all passed')
process.exit(failures ? 1 : 0)
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env node
/**
* Checks where a Kick event card sits in the log, in each layout.
*
* A card is what a person did, so it takes the column a person's picture would
* (and not the mark a join gets), what is written in it starts where a name
* does, and it has the air above it that a message and an embed have. These
* are measured against a real chat line beside it rather than against numbers,
* so they hold when the layout's own numbers change.
*
* npm run test:kick (builds first; needs the daemon built too)
*/
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()
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' } })
const stage = `(() => {
const shots = window.__mohoShots
const s = shots.state()
const id = s.activeBufferId
const now = Math.floor(Date.now() / 1000)
const row = (n, kind, body, embed, from) => ({ id: 'k' + n, bufferId: id, from, senderId: from, body, ts: now - 600 + n * 70, isAction: false, isHighlight: false, kind, isOwn: false, embeds: embed ? [embed] : [] })
shots.patch({ messagesByBuffer: { ...s.messagesByBuffer, [id]: [
row(1, 'chat', 'that was a clean run', null, 'bob'),
row(2, 'chat', 'and then it got worse', null, 'carol'),
row(3, 'kicks', 'Currington sent 1 Kick', { kind: 'kicks', author: 'Currington', title: '1', color: 0x53fc18 }, 'kicker'),
row(4, 'sub', 'ItsDez gifted 2 subscriptions', { kind: 'gift-sub', author: 'ItsDez', title: '2', description: 'a\\nb' }, 'kicker'),
row(5, 'reward', 'poo_nana redeemed MUTE', { kind: 'redeem', author: 'poo_nana', title: 'MUTE (user) 30 MINUTES', description: 'O_C_T_O', color: 0xff4b1f }, 'kicker'),
row(6, 'raid', 'x raided with 25 viewers', { kind: 'raid', author: 'kreationX', title: '25' }, 'kicker'),
row(8, 'sub', 'bigkhernandez subscribed - 21 months', { kind: 'subscribe', author: 'bigkhernandez', title: '21' }, 'kicker'),
row(9, 'moderation', 'troll was timed out by mod', { kind: 'moderation', author: 'troll', title: 'was timed out', description: 'mod' }, 'kicker'),
row(10, 'stream', 'went live', { kind: 'stream', author: 'streamer', title: 'went live', icon: 'live' }, 'kicker'),
row(11, 'chat', 'thanks everybody', null, 'dave')
] } })
})()`
const measure = `(() => {
const x = (el) => (el ? Math.round(el.getBoundingClientRect().left) : null)
const rows = [...document.querySelectorAll('.message-row')]
const chat = rows.find((r) => !r.classList.contains('has-kick-event') && r.querySelector('.message-from'))
const cards = rows.filter((r) => r.classList.contains('has-kick-event'))
const margin = (r) => parseFloat(getComputedStyle(r).marginTop)
return {
marks: cards.filter((r) => r.querySelector('.system-mark')).length,
cards: cards.length,
chatEdge: x(chat.querySelector('.message-avatar img, .message-avatar .avatar-fallback') || chat.querySelector('.message-content')),
chatName: x(chat.querySelector('.message-from')),
cardEdge: cards.map((r) => x(r.querySelector('.kick-event'))),
cardName: cards.map((r) => x(r.querySelector('.kick-event-name'))),
gapAbove: cards.map(margin),
chatGap: margin(chat) || 0,
wide: cards.map((r) => Math.round(r.querySelector('.kick-event').getBoundingClientRect().width))
}
})()`
try {
await until(page, 'window.__mohoShots', 'the harness hook')
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#lobby'], waitFor: 'hello there' })
for (const mode of ['comfy', 'bubbles', 'classic']) {
await page.evaluate(`window.moho.prefs.set('display.messageMode', ${JSON.stringify(mode)})`)
await page.call('Page.reload')
await until(page, 'window.__mohoShots && document.querySelector("nav[aria-label=Servers] [aria-label]")', 'the window after a reload')
await sleep(500)
await page.evaluate(click('nav[aria-label=Servers] [aria-label*="localhost"]'))
await sleep(300)
await page.evaluate(click('.buffer-row', '#lobby'))
await sleep(500)
await page.evaluate(stage)
await sleep(700)
const m = await page.evaluate(measure)
console.log(`${mode}: ${JSON.stringify(m)}`)
check(m.cards === 7, `${mode}: every kind of card is drawn`, `${m.cards}`)
check(m.marks === 0, `${mode}: no join mark beside a card`, `${m.marks}`)
if (mode !== 'classic') {
// The card starts at the picture's edge.
check(m.cardEdge.every((e) => Math.abs(e - m.chatEdge) <= 2), `${mode}: a card starts where a person's picture does`, `${m.cardEdge} vs ${m.chatEdge}`)
// And what is written in it starts where a name does.
check(m.cardName.every((n) => Math.abs(n - m.chatName) <= 2), `${mode}: the name in a card is where a name is in the log`, `${m.cardName} vs ${m.chatName}`)
}
check(m.gapAbove.every((g) => g >= (mode === 'comfy' ? m.chatGap : 4) - 0.5), `${mode}: a card has air above it`, `${m.gapAbove} vs ${m.chatGap}`)
check(m.wide.every((w) => w > 200), `${mode}: and is not squeezed or centred away`, `${m.wide}`)
}
} finally {
await stop()
irc.close()
}
console.log(failures ? `${failures} FAILED` : 'all passed')
process.exit(failures ? 1 : 0)
+2 -2
View File
@@ -207,7 +207,7 @@ function moveAside(pid) {
* The built app, against a scratch home, with a debugging port. Returns the
* page and a `stop()` that takes everything down again.
*/
export async function launchApp({ env = {} } = {}) {
export async function launchApp({ env = {}, args = [] } = {}) {
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'moho-ui-'))
const run = path.join(scratch, 'run')
fs.mkdirSync(run, { mode: 0o700 })
@@ -216,7 +216,7 @@ export async function launchApp({ env = {} } = {}) {
// A CI runner's kernel refuses Chromium's sandbox (Ubuntu 24.04 restricts
// the unprivileged namespaces it needs); there it runs without, as an
// ordinary Chromium in a container does.
const flags = [`--remote-debugging-port=${port}`, ...(process.env.CI ? ['--no-sandbox'] : [])]
const flags = [`--remote-debugging-port=${port}`, ...(process.env.CI ? ['--no-sandbox'] : []), ...args]
const app = spawn(electronPath, [root, ...flags], {
cwd: root,
detached: true,
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env node
// Draws the tray icons into resources/icons/tray-*.png.
//
// A tray cannot be recoloured by the desktop the way a themed icon can, so
// there is one set for a dark panel (a light bubble) and one for a light panel
// (a dark bubble), and main picks by the system's colour scheme. Each set is
// the bubble alone, and the bubble with a red badge carrying the number of
// conversations waiting - 1 to 9, then 9+ - because a tray is the one place
// still in view when a notification has faded, and "something" is a poorer
// answer than "three things".
//
// Run when the artwork changes; the PNGs are committed. Needs rsvg-convert.
import { execFileSync } from 'node:child_process'
import { mkdirSync, writeFileSync, rmSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const out = join(dirname(fileURLToPath(import.meta.url)), '..', 'resources', 'icons')
const SIZE = 64
// The app's mention red, so the badge is the same colour as the one in the window.
const BADGE = '#e5484d'
const BUBBLE = 'M14 8h36a8 8 0 0 1 8 8v22a8 8 0 0 1-8 8H34L22 58V46h-8a8 8 0 0 1-8-8V16a8 8 0 0 1 8-8z'
function svg(ink, label) {
const badge = label !== null
// The badge sits in a gap cut from the bubble, so it reads against any panel
// without a ring of a colour that panel may not be.
// Without one, three dots are cut from it, which is what makes the shape a
// conversation and not a label.
const mask = badge
? `<mask id="m"><rect width="64" height="64" fill="#fff"/><circle cx="46" cy="18" r="19" fill="#000"/></mask>`
: `<mask id="m"><rect width="64" height="64" fill="#fff"/><circle cx="21" cy="27" r="4" fill="#000"/><circle cx="32" cy="27" r="4" fill="#000"/><circle cx="43" cy="27" r="4" fill="#000"/></mask>`
const size = label && label.length > 1 ? 24 : 30
const text = badge
? `<circle cx="46" cy="18" r="16" fill="${BADGE}"/><text x="46" y="${18 + size * 0.36}" font-family="DejaVu Sans" font-weight="bold" font-size="${size}" text-anchor="middle" fill="#fff">${label}</text>`
: ''
return `<svg xmlns="http://www.w3.org/2000/svg" width="${SIZE}" height="${SIZE}" viewBox="0 0 64 64">${mask}<path d="${BUBBLE}" fill="${ink}" mask="url(#m)"/>${text}</svg>`
}
mkdirSync(out, { recursive: true })
const labels = [null, '1', '2', '3', '4', '5', '6', '7', '8', '9', '9+']
for (const [name, ink] of [['light', '#f1f3f5'], ['dark', '#25282e']]) {
for (const label of labels) {
const file = join(out, `tray-${name}${label === null ? '' : `-${label === '9+' ? 'more' : label}`}.png`)
const tmp = `${file}.svg`
writeFileSync(tmp, svg(ink, label))
execFileSync('rsvg-convert', ['-w', String(SIZE), '-h', String(SIZE), '-o', file, tmp])
rmSync(tmp)
}
}
console.log('tray icons written to', out)
-101
View File
@@ -1,101 +0,0 @@
#!/usr/bin/env bash
# Mirrors a published GitHub release of moho to Gitea, and makes nobilis's
# release of the same version on both hosts. Run here, by hand, once the
# release workflow has published the tag on GitHub - Gitea releases are never
# made by Actions.
#
# scripts/mirror-release.sh v1.0.0
#
# moho on Gitea: the same title, notes, pre-release flag and files as GitHub.
# nobilis, on GitHub and Gitea: tagged at the commit moho's tag points to,
# named after the same version, its notes the version's major features and
# the nobilis lines of the changelog, with the two daemon binaries the
# release's run built.
#
# Safe to run again: a release that exists is updated rather than repeated.
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"
TOKEN="$(cat "$HOME/.config/gitea/token")"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
gitea() { # method path [curl args...]
local method="$1" path="$2"; shift 2
curl -fsS -X "$method" -H "Authorization: token $TOKEN" "$GITEA$path" "$@"
}
# Creates or updates a Gitea release and makes its files exactly those given.
gitea_release() { # repo title notes-file prerelease file...
local repo="$1" title="$2" notes="$3" pre="$4"; shift 4
local body id
body="$(python3 -c 'import json,sys; print(json.dumps({"tag_name": sys.argv[1], "name": sys.argv[2], "body": open(sys.argv[3]).read(), "prerelease": sys.argv[4] == "true"}))' "$TAG" "$title" "$notes" "$pre")"
if id="$(gitea GET "/repos/$repo/releases/tags/$TAG" 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' 2>/dev/null)"; then
gitea PATCH "/repos/$repo/releases/$id" -H 'Content-Type: application/json' -d "$body" >/dev/null
for asset in $(gitea GET "/repos/$repo/releases/$id/assets" | python3 -c 'import json,sys; print(" ".join(str(a["id"]) for a in json.load(sys.stdin)))'); do
gitea DELETE "/repos/$repo/releases/$id/assets/$asset" >/dev/null
done
else
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 POST "/repos/$repo/releases/$id/assets?name=$(basename "$f")" -F "attachment=@$f" >/dev/null
echo " uploaded $(basename "$f") -> Gitea $repo"
done
}
# --- moho -------------------------------------------------------------------
echo "moho $TAG: GitHub -> Gitea"
read -r PRE TITLE < <(gh release view "$TAG" --repo Moho-Chat/moho --json isPrerelease,name -q '"\(.isPrerelease) \(.name)"')
gh release view "$TAG" --repo Moho-Chat/moho --json body -q .body > "$WORK/moho-notes.md"
mkdir -p "$WORK/moho"
gh release download "$TAG" --repo Moho-Chat/moho --dir "$WORK/moho"
git -C "$ROOT" push -q origin "refs/tags/$TAG" 2>/dev/null || true
gitea_release Salastil/moho "$TITLE" "$WORK/moho-notes.md" "$PRE" "$WORK"/moho/*
# --- nobilis ------------------------------------------------------------------
NOBILIS_SHA="$(git -C "$ROOT" rev-parse "$TAG:nobilis")"
echo "nobilis $TAG at ${NOBILIS_SHA:0:7}"
if ! git -C "$ROOT/nobilis" rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
git -C "$ROOT/nobilis" tag -a "$TAG" "$NOBILIS_SHA" -m "nobilis $VERSION"
fi
git -C "$ROOT/nobilis" push -q github "refs/tags/$TAG"
git -C "$ROOT/nobilis" push -q origin "refs/tags/$TAG"
# Its notes: the version's major features, and the nobilis lines only.
SECTION="$VERSION"
grep -q "^## \[$VERSION\]" "$ROOT/CHANGELOG.md" || SECTION="${VERSION%%-*}"
awk -v v="$SECTION" '
index($0, "## [" v "]") == 1 { on = 1; next }
on && /^## \[/ { exit }
!on { next }
/^### / { fixes = ($0 ~ /^### Fixes/); print; next }
/^- / { if (!fixes || /\[nobilis /) print; next }
{ print }
' "$ROOT/CHANGELOG.md" > "$WORK/nobilis-notes.md"
printf '\nnobilis is the daemon inside moho %s; these are its own builds, for running it on its own.\n' "$VERSION" >> "$WORK/nobilis-notes.md"
# The daemon binaries, from the run that built this release.
RUN="$(gh run list --repo Moho-Chat/moho --workflow release.yml --branch "$TAG" --status success --limit 1 --json databaseId -q '.[0].databaseId')"
[ -n "$RUN" ] || { echo "no successful release run for $TAG" >&2; exit 1; }
mkdir -p "$WORK/nobilis"
gh run download "$RUN" --repo Moho-Chat/moho -n daemon-linux -D "$WORK/d-linux"
gh run download "$RUN" --repo Moho-Chat/moho -n daemon-windows -D "$WORK/d-win"
install -m 755 "$WORK/d-linux/nobilis" "$WORK/nobilis/nobilis-$VERSION-linux-x86_64"
install -m 644 "$WORK/d-win/nobilis.exe" "$WORK/nobilis/nobilis-$VERSION-windows-x86_64.exe"
NOBILIS_FLAGS=(--latest)
[ "$PRE" = "true" ] && NOBILIS_FLAGS=(--prerelease --latest=false)
if gh release view "$TAG" --repo Moho-Chat/nobilis >/dev/null 2>&1; then
gh release edit "$TAG" --repo Moho-Chat/nobilis --notes-file "$WORK/nobilis-notes.md"
gh release upload "$TAG" --repo Moho-Chat/nobilis "$WORK"/nobilis/* --clobber
else
gh release create "$TAG" --repo Moho-Chat/nobilis --verify-tag --title "nobilis $VERSION" \
--notes-file "$WORK/nobilis-notes.md" "${NOBILIS_FLAGS[@]}" "$WORK"/nobilis/*
fi
echo " GitHub Moho-Chat/nobilis $TAG"
gitea_release Salastil/nobilis "nobilis $VERSION" "$WORK/nobilis-notes.md" "$PRE" "$WORK"/nobilis/*
echo "done: moho and nobilis $TAG on GitHub and Gitea"
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Makes nobilis's release of the same version as one of moho's.
#
# Run by the maintainer once the release workflow has published moho's tag:
# the daemon is its own repository with its own releases, and the job that
# builds it holds a token that can only release moho.
#
# scripts/release-nobilis.sh v1.0.0
# scripts/release-nobilis.sh v1.0.0 --keep DIR leave the notes and the two
# daemon binaries in DIR
#
# nobilis is tagged at the commit moho's tag points to, named after the same
# version, its notes the version's major features and the nobilis lines of the
# changelog, with the two daemon binaries the release's run built. Safe to run
# again: a release that exists is updated rather than repeated.
set -euo pipefail
TAG="${1:?usage: scripts/release-nobilis.sh vX.Y.Z [--keep DIR]}"
KEEP=""
[ "${2:-}" = "--keep" ] && KEEP="${3:?--keep needs a directory}"
VERSION="${TAG#v}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
read -r PRE < <(gh release view "$TAG" --repo Moho-Chat/moho --json isPrerelease -q .isPrerelease)
NOBILIS_SHA="$(git -C "$ROOT" rev-parse "$TAG:nobilis")"
echo "nobilis $TAG at ${NOBILIS_SHA:0:7}"
if ! git -C "$ROOT/nobilis" rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
git -C "$ROOT/nobilis" tag -a "$TAG" "$NOBILIS_SHA" -m "nobilis $VERSION"
fi
git -C "$ROOT/nobilis" push -q github "refs/tags/$TAG"
# Its notes: the version's major features, and the nobilis lines only.
SECTION="$VERSION"
grep -q "^## \[$VERSION\]" "$ROOT/CHANGELOG.md" || SECTION="${VERSION%%-*}"
awk -v v="$SECTION" '
index($0, "## [" v "]") == 1 { on = 1; next }
on && /^## \[/ { exit }
!on { next }
/^### / { fixes = ($0 ~ /^### Fixes/); print; next }
/^- / { if (!fixes || /\[nobilis /) print; next }
{ print }
' "$ROOT/CHANGELOG.md" > "$WORK/nobilis-notes.md"
printf '\nnobilis is the daemon inside moho %s; these are its own builds, for running it on its own.\n' "$VERSION" >> "$WORK/nobilis-notes.md"
# The daemon binaries, from the run that built this release.
RUN="$(gh run list --repo Moho-Chat/moho --workflow release.yml --branch "$TAG" --status success --limit 1 --json databaseId -q '.[0].databaseId')"
[ -n "$RUN" ] || { echo "no successful release run for $TAG" >&2; exit 1; }
mkdir -p "$WORK/nobilis"
gh run download "$RUN" --repo Moho-Chat/moho -n daemon-linux -D "$WORK/d-linux"
gh run download "$RUN" --repo Moho-Chat/moho -n daemon-windows -D "$WORK/d-win"
install -m 755 "$WORK/d-linux/nobilis" "$WORK/nobilis/nobilis-$VERSION-linux-x86_64"
install -m 644 "$WORK/d-win/nobilis.exe" "$WORK/nobilis/nobilis-$VERSION-windows-x86_64.exe"
NOBILIS_FLAGS=(--latest)
[ "$PRE" = "true" ] && NOBILIS_FLAGS=(--prerelease --latest=false)
if gh release view "$TAG" --repo Moho-Chat/nobilis >/dev/null 2>&1; then
gh release edit "$TAG" --repo Moho-Chat/nobilis --notes-file "$WORK/nobilis-notes.md"
gh release upload "$TAG" --repo Moho-Chat/nobilis "$WORK"/nobilis/* --clobber
else
gh release create "$TAG" --repo Moho-Chat/nobilis --verify-tag --title "nobilis $VERSION" \
--notes-file "$WORK/nobilis-notes.md" "${NOBILIS_FLAGS[@]}" "$WORK"/nobilis/*
fi
echo " GitHub Moho-Chat/nobilis $TAG"
if [ -n "$KEEP" ]; then
mkdir -p "$KEEP"
cp "$WORK/nobilis-notes.md" "$KEEP/"
cp -r "$WORK/nobilis" "$KEEP/"
fi
+167
View File
@@ -0,0 +1,167 @@
#!/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', '#other'], 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 }
})
})()`)
// Two hundred unread is more than a screenful: opened at its end, as
// Discord does, with the bar saying how much is above and not the log
// dropped hundreds of messages up.
await until(page, `!!document.querySelector('.unread-bar')`, 'the unread bar')
await sleep(600)
const opened = await page.evaluate(`(() => {
const el = document.querySelector('.messagelist-scroll')
return { fromBottom: el.scrollHeight - el.scrollTop - el.clientHeight, line: !!document.querySelector('.new-divider'), bar: document.querySelector('.unread-bar-text')?.textContent }
})()`)
check(opened.fromBottom < 8 && !opened.line, 'a long unread run opens at the end of the conversation', JSON.stringify(opened))
check(/^200 new messages/.test(opened.bar ?? ''), 'and the bar says how much there is', opened.bar)
await page.evaluate(`document.querySelector('.unread-bar-text').click()`)
await until(page, `!!document.querySelector('.new-divider')`, 'the New line after pressing the bar')
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}`)
// Leaving, and coming back after more has been said. The line was kept from
// the first visit, so the return went up the log to a place nothing was
// unread any more.
const ids = await page.evaluate(`(() => {
const s = window.__mohoShots.state()
return { lobby: s.activeBufferId, other: s.buffers.find((b) => b.name.endsWith('#other')).id }
})()`)
const said = (count) => `(() => {
const shots = window.__mohoShots
const s = shots.state()
const id = ${JSON.stringify(ids.lobby)}
const base = Math.max(...s.messagesByBuffer[id].map((m) => m.ts)) + 1
const more = Array.from({ length: ${count} }, (_, n) => ({
id: 'later' + Date.now() + '-' + n, bufferId: id, from: 'bob', senderId: 'bob', body: 'said while away ' + n, ts: base + n,
isAction: false, isHighlight: false, kind: 'chat', isOwn: false
}))
shots.patch({
messagesByBuffer: { ...s.messagesByBuffer, [id]: [...s.messagesByBuffer[id], ...more] },
buffers: s.buffers.map((b) => (b.id === id ? { ...b, unread: ${count} } : b))
})
})()`
const away = async (count) => {
await page.evaluate(`window.__mohoShots.store.selectBuffer(${JSON.stringify(ids.other)}, false)`)
await sleep(500)
await page.evaluate(said(count))
await page.evaluate(`window.__mohoShots.store.selectBuffer(${JSON.stringify(ids.lobby)}, false)`)
await sleep(1200)
return page.evaluate(`(() => {
const el = document.querySelector('.messagelist-scroll')
const line = document.querySelector('.new-divider')
const rows = [...el.querySelectorAll('[data-msg-id]')]
const after = line ? rows.filter((r) => line.compareDocumentPosition(r) & Node.DOCUMENT_POSITION_FOLLOWING).length : -1
return { fromBottom: el.scrollHeight - el.scrollTop - el.clientHeight, line: !!line, after, bar: document.querySelector('.unread-bar-text')?.textContent ?? '' }
})()`)
}
// A few unread: opens at the first of them, and the line is this visit's -
// thirty rows under it, not the two hundred of the visit before.
const few = await away(30)
check(few.line && few.after === 30, 'a short unread run opens at its first message, with the line where this visit began', JSON.stringify(few))
// A great many: opens at the end, with the bar to go back by.
const many = await away(100)
check(many.fromBottom < 8, 'a long one opens at the end and not hundreds of messages up', JSON.stringify(many))
check(/^100 new messages/.test(many.bar), 'with the bar to go back by', many.bar)
// Nothing unread: the end.
await page.evaluate(`window.__mohoShots.store.selectBuffer(${JSON.stringify(ids.other)}, false)`)
await sleep(500)
await page.evaluate(`window.__mohoShots.store.selectBuffer(${JSON.stringify(ids.lobby)}, false)`)
await sleep(1000)
const nothing = await page.evaluate(`(() => { const el = document.querySelector('.messagelist-scroll'); return { fromBottom: el.scrollHeight - el.scrollTop - el.clientHeight, line: !!document.querySelector('.new-divider') } })()`)
check(nothing.fromBottom < 8 && !nothing.line, 'a conversation left read and returned to is at its end', JSON.stringify(nothing))
} finally {
await stop()
irc.close()
}
console.log(failures ? `${failures} FAILED` : 'all passed')
process.exit(failures ? 1 : 0)
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env sh
# Keeps a development checkout from filling the disk.
#
# Everything this removes is build output that the next build makes again, and
# none of it is the project: it is what grew to a hundred gigabytes without
# anybody choosing it.
#
# - dist/ got one AppImage per deploy and never lost one. Only the newest few
# are kept; the installed copy lives in ~/.local/bin.
# - Cargo never removes what a build no longer uses. Every change to a
# dependency, a feature or the compiler leaves the old build beside the new
# one, so nobilis/target held fifty copies of each library. Past a size it
# is emptied, and the build that follows starts it again with only what is
# current. Run before a build, never after one: after, it would throw away
# the thing just made.
# - Debug builds and the Windows cross-build are not what a deploy uses and
# are made again when somebody asks for them.
#
# scripts/tidy.sh (also run first by `npm run deploy`)
set -eu
SRC_DIR="$(cd "$(dirname "$0")/.." && pwd)"
KEEP_APPIMAGES="${MOHO_KEEP_APPIMAGES:-2}"
# In megabytes: a clean release build of the daemon is about two gigabytes.
TARGET_LIMIT_MB="${MOHO_TARGET_LIMIT_MB:-8000}"
size_mb() { du -sm "$1" 2>/dev/null | cut -f1; }
# What is in dist/ besides the newest AppImages: older ones, Windows and
# Debian packages, the unpacked trees and their block maps.
if [ -d "$SRC_DIR/dist" ]; then
keep="$(ls -t "$SRC_DIR"/dist/*.AppImage 2>/dev/null | head -n "$KEEP_APPIMAGES")"
for f in "$SRC_DIR"/dist/* "$SRC_DIR"/dist/.[!.]*; do
[ -e "$f" ] || continue
case "$(basename "$f")" in builder-debug.yml|builder-effective-config.yaml|latest*.yml) continue ;; esac
printf '%s\n' "$keep" | grep -qxF "$f" && continue
rm -rf "$f"
done
fi
# A build directory left over from before the daemon lived in nobilis/.
rm -rf "$SRC_DIR/target"
TARGET="$SRC_DIR/nobilis/target"
if [ -d "$TARGET" ]; then
rm -rf "$TARGET/debug" "$TARGET"/x86_64-pc-windows-* "$TARGET"/aarch64-*
used="$(size_mb "$TARGET")"
if [ "${used:-0}" -gt "$TARGET_LIMIT_MB" ]; then
echo "tidy: nobilis/target is ${used} MB, over ${TARGET_LIMIT_MB} MB - emptying it; the next build starts it fresh"
rm -rf "$TARGET"
fi
fi
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env node
/**
* Checks the tray against the running app: what its icon, tooltip and menu say
* for what is waiting, what its entries do, and that "Restart moho" brings back
* moho and a daemon of its own.
*
* A tray cannot be photographed, so this reads what main last gave it through a
* hook that exists only under MOHO_UI_SHOTS, and presses its entries the same
* way. Main is reached over the Node inspector the app is started with.
*
* npm run test:tray (builds first; needs the daemon built too)
*/
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import { cdp, fakeIrc, freePort, launchApp, openIrcChannel, sleep, until } from './lib/harness.mjs'
let failures = 0
const check = (ok, what, detail = '') => {
console.log(`${ok ? 'ok ' : 'FAIL'} ${what}${ok ? '' : detail ? ` - ${detail}` : ''}`)
if (!ok) failures++
}
async function mainProcess(inspectPort) {
for (let i = 0; i < 60; i++) {
try {
const list = await (await fetch(`http://127.0.0.1:${inspectPort}/json/list`)).json()
const target = list.find((t) => t.webSocketDebuggerUrl)
if (target) return cdp(target.webSocketDebuggerUrl)
} catch {
/* not up yet */
}
await sleep(500)
}
throw new Error('main never offered its inspector')
}
/** The nobilis processes that belong to this run, by the runtime directory in their environment. */
function daemons(scratch) {
const out = []
for (const entry of fs.readdirSync('/proc')) {
if (!/^\d+$/.test(entry)) continue
try {
const exe = fs.readlinkSync(`/proc/${entry}/exe`)
if (!exe.endsWith('/nobilis')) continue
if (fs.readFileSync(`/proc/${entry}/environ`, 'utf8').includes(`XDG_RUNTIME_DIR=${scratch}/run`)) out.push(Number(entry))
} catch {
/* gone, or not ours */
}
}
return out
}
// A run that hangs is a failure, and must not leave an app behind it.
setTimeout(() => {
console.log('FAIL timed out')
process.exit(1)
}, 150000).unref()
const inspectPort = await freePort()
const { page, stop, port } = await launchApp({ env: { MOHO_UI_SHOTS: '1' }, args: [`--inspect=${inspectPort}`] })
let scratchDir = ''
try {
const main = await mainProcess(inspectPort)
const tray = (expr) => main.evaluate(`(() => { const t = globalThis.__mohoTray; return ${expr} })()`)
await until(main, 'globalThis.__mohoTray', 'the tray')
scratchDir = await main.evaluate('process.env.HOME')
// Nothing waiting.
await tray('t.update({ unread: 0, dms: 0, mentions: 0 })'.replace('t.', 't.'))
check(/^tray-(light|dark)\.png$/.test(await tray('t.icon()')), 'idle: the plain bubble', await tray('t.icon()'))
check((await tray('t.menu()[0].label')) === 'Nothing new', 'idle: the menu says nothing is new')
check((await tray('t.tooltip()')) === 'moho', 'idle: the tooltip is just moho')
// Something waiting: the count is on the icon, and the menu says what.
await tray('t.update({ unread: 3, dms: 2, mentions: 1 })')
check(/^tray-(light|dark)-3\.png$/.test(await tray('t.icon()')), 'waiting: the icon carries the count', await tray('t.icon()'))
check((await tray('t.tooltip()')) === 'moho - 2 direct messages, 1 mention', 'waiting: the tooltip says what', await tray('t.tooltip()'))
check((await tray('t.menu()[0].label')) === '2 direct messages, 1 mention - open', 'waiting: the menu says what', await tray('t.menu()[0].label'))
await tray('t.update({ unread: 40, dms: 40, mentions: 0 })')
check(/-more\.png$/.test(await tray('t.icon()')), 'a great many: the icon says 9+')
await tray('t.update({ unread: 0, dms: 0, mentions: 0 })')
// The entries, and that none of them is daemon plumbing.
const labels = await tray('t.menu().map((i) => i.label ?? "-")')
check(!labels.some((l) => /daemon/i.test(l)), 'the menu offers no daemon plumbing', labels.join(' | '))
check(labels.includes('Restart moho'), 'the menu offers Restart moho')
// Settings opens in the window.
await tray('t.click(["Settings"])')
await until(page, 'window.__mohoShots.state().activePanel === "settings"', 'Settings to open')
check(true, 'Settings opens the settings page')
// A status goes to the window to carry out.
await page.evaluate('window.__trayGot = []; window.moho.onTrayCommand((c, a) => window.__trayGot.push([c, a])); true')
await tray('t.click(["Status", "Do not disturb"])')
await until(page, 'window.__trayGot.length', 'the status to reach the window')
check(JSON.stringify(await page.evaluate('window.__trayGot[0]')) === '["status","dnd"]', 'a status is handed to the window')
// There is no notifications switch of its own: Do not disturb is that.
check(!labels.some((l) => /notification/i.test(l)), 'the menu has no notifications switch of its own', labels.join(' | '))
// With an IRC account connected - which has away and back, and no invisible -
// the menu offers what IRC can be set to, and the daemon takes it.
const 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')
const offered = await tray('t.menu().find((i) => i.label === "Status").submenu.map((i) => i.label)')
check(JSON.stringify(offered) === '["Online","Idle","Do not disturb"]', 'IRC alone: no Invisible is offered', JSON.stringify(offered))
const statusOf = () => page.evaluate('window.moho.rpc("listAccounts", {}).then((a) => a.find((x) => x.id.startsWith("checker")).status)')
await tray('t.click(["Status", "Idle"])')
await until(page, 'window.moho.rpc("listAccounts", {}).then((a) => a.find((x) => x.id.startsWith("checker")).status === "idle")', 'IRC to go idle')
check(true, 'Idle from the tray sets the IRC account idle')
await tray('t.click(["Status", "Do not disturb"])')
await until(page, 'window.moho.rpc("listAccounts", {}).then((a) => a.find((x) => x.id.startsWith("checker")).status === "dnd")', 'IRC to go to Do not disturb')
check((await statusOf()) === 'dnd', 'Do not disturb from the tray sets it')
// 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()
}
// The tray being redrawn - which it is whenever something unread changes - must
// not move the window: somebody who has chosen another server stays on it. The
// redraw tells the window the link is up, and taking each for a reconnection
// sent the pane back to the server of whatever conversation was open.
const away = await page.evaluate('window.__mohoShots.state().activeGroupId')
await page.evaluate('window.__mohoShots.store.selectGroup("~mentions")')
for (let i = 0; i < 6; i++) {
await tray(`t.update({ unread: ${i}, dms: ${i}, mentions: 0 })`)
await sleep(200)
}
await sleep(1500)
const stayed = await page.evaluate('window.__mohoShots.state().activeGroupId')
check(stayed === '~mentions', 'a redrawn tray leaves the chosen server chosen', `${away} -> ${stayed}`)
await tray('t.update({ unread: 0, dms: 0, mentions: 0 })')
// 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')
check(before.length === 1, 'one daemon is running', String(before))
// Not waited for: the app quits under this call, and the inspector with it.
void tray('t.click(["Restart moho"])').catch(() => {})
// And the inspector let go, because a process that is being inspected waits
// for the inspector to disconnect before it will exit.
await sleep(500)
main.close()
let pidAfter = pidBefore
let again = null
for (let i = 0; i < 80 && pidAfter === pidBefore; i++) {
await sleep(500)
try {
again = await mainProcess(inspectPort)
pidAfter = await again.evaluate('process.pid')
} catch {
/* between the two */
}
}
check(pidAfter !== pidBefore, 'moho came back as a new process', `${pidBefore} -> ${pidAfter}`)
await sleep(4000)
const after = daemons(scratchDir)
check(after.length === 1 && !before.includes(after[0]), 'with a daemon of its own, the old one gone', `${before} -> ${after}`)
// Whatever it started has to go with this run.
try {
for (const pid of daemons(scratchDir)) process.kill(pid, 'SIGTERM')
} catch {
/* gone */
}
} catch (e) {
console.log('FAIL', e.message)
failures++
} finally {
// The relaunched copy is not the one the harness started, so it is not in the
// group stop() ends. It is found by the debugging port it was told to use -
// it shows no environment to be found by - and what it started goes when it
// does. Ended first, and twice since it may still be starting, because it
// writes into the directory stop() is about to remove.
for (let i = 0; i < 2; i++) {
await sleep(1500)
try {
execFileSync('pkill', ['-9', '-f', `remote-debugging-port=${port}`])
} catch {
/* none left */
}
for (const entry of fs.readdirSync('/proc')) {
if (!/^\d+$/.test(entry) || Number(entry) === process.pid || !scratchDir) continue
try {
if (fs.readFileSync(`/proc/${entry}/environ`, 'utf8').includes(`HOME=${scratchDir}\0`)) process.kill(Number(entry), 'SIGKILL')
} catch {
/* gone, or not ours to read */
}
}
}
await sleep(1500)
await stop().catch(() => {})
if (scratchDir) fs.rmSync(scratchDir, { recursive: true, force: true })
}
console.log(failures ? `${failures} failed` : 'all passed')
process.exit(failures ? 1 : 0)
+132 -3
View File
@@ -94,14 +94,14 @@ const DISCORD = `(() => {
const ch = (id, name, category, extra = {}) => ({
id: acc + '|' + id, accountId: acc, kind: 'channel', name: 'Button Eye Crochet/#' + name,
lastActivityTs: now, groupId: g1, category, position: Number(id.replace(/\\D/g, '')) || 0,
unread: 0, highlight: false, ...extra
unread: 0, highlight: false, mentions: 0, ...extra
})
const buffers = [
ch('c1', '📜-rules', undefined),
ch('c2', '📢-announcements', undefined, { unread: 2 }),
ch('c3', '🤠-general', 'TEXT CHANNELS'),
ch('c4', '📹-clips', 'TEXT CHANNELS', { unread: 5 }),
ch('c5', '📷-screenshots', 'TEXT CHANNELS', { unread: 1, highlight: true }),
ch('c5', '📷-screenshots', 'TEXT CHANNELS', { unread: 1, highlight: true, mentions: 1 }),
ch('c6', '💩-shitpost', 'TEXT CHANNELS', { unread: 9 }),
ch('c7', '🎨-crafts', 'TEXT CHANNELS'),
ch('c8', '🐈-cats', 'ANIMALS', { unread: 3 }),
@@ -136,7 +136,7 @@ const DISCORD = `(() => {
{ id: g2, accountId: acc, service: 'discord', kind: 'guild', name: 'Moho Dev', position: 1 }
],
buffers: [...s.buffers.filter((b) => b.accountId !== acc), ...buffers,
{ id: acc + '|d1', accountId: acc, kind: 'channel', name: 'Moho Dev/#general', lastActivityTs: now, groupId: g2, unread: 2, highlight: true }],
{ id: acc + '|d1', accountId: acc, kind: 'channel', name: 'Moho Dev/#general', lastActivityTs: now, groupId: g2, unread: 33, highlight: true, mentions: 2 }],
messagesByBuffer: { ...s.messagesByBuffer, [general]: messages },
presenceByBuffer: { ...s.presenceByBuffer, [general]: members([['Gaunt King', 'online'], ['Clarence', 'idle'], ['Wren', 'dnd'], ['Salastil', 'online'], ['Old Friend', 'offline'], ['Quiet One', 'offline']]) },
activeGroupId: g1,
@@ -171,6 +171,76 @@ const discord = (inCall) => async (page) => {
await sleep(300)
}
// --- GIFs (#331) ---------------------------------------------------------------
/**
* Three messages that link to GIFs, with or without what the daemon or Discord
* says about them: a Tenor page, a Giphy page and a Klipy page. Without, they
* are the addresses they were before; with, the first two are the GIFs - the
* Klipy page is one nothing can read, and stays a link either way.
*
* The pictures are made with ffmpeg and handed to the window as blob and data
* URLs, since it plays only https media and nothing is fetched from anywhere.
*/
const stageGifs = (withEmbeds) => async (page) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'moho-gifs-'))
const clip = (name, source) => {
const file = path.join(dir, name)
execFileSync('ffmpeg', ['-v', 'error', '-f', 'lavfi', '-i', source, '-pix_fmt', 'yuv420p', '-y', file])
return fs.readFileSync(file).toString('base64')
}
const mp4 = clip('cat.mp4', 'gradients=s=300x200:d=3:r=15:speed=0.02:c0=0xe0a43a:c1=0xb0457a:c2=0x3a8fe0:nb_colors=3')
const gif = (() => {
const file = path.join(dir, 'wave.gif')
execFileSync('ffmpeg', ['-v', 'error', '-f', 'lavfi', '-i', 'gradients=s=260x180:d=2:r=12:speed=0.03:c0=0x3ae0a4:c1=0x4a2f9a:nb_colors=2', '-y', file])
return fs.readFileSync(file).toString('base64')
})()
fs.rmSync(dir, { recursive: true, force: true })
await page.evaluate(`(() => {
const shots = window.__mohoShots
const s = shots.state()
const id = s.activeBufferId
const now = Math.floor(Date.now() / 1000)
const bytes = Uint8Array.from(atob('${mp4}'), (c) => c.charCodeAt(0))
const MP4 = URL.createObjectURL(new Blob([bytes], { type: 'video/mp4' }))
const GIF = 'data:image/gif;base64,${gif}'
const msg = (n, from, body, embeds) => ({ id: 'gif' + n, bufferId: id, from, senderId: from, body, ts: now - (60 - n) * 20, isAction: false, isHighlight: false, kind: 'chat', isOwn: false, ...(embeds ? { embeds } : {}) })
const embeds = ${withEmbeds ? 'true' : 'false'}
shots.patch({ messagesByBuffer: { ...s.messagesByBuffer, [id]: [...(s.messagesByBuffer[id] || []),
msg(1, 'alice', 'this is how it feels on a monday https://tenor.com/view/monday-cat-gif-22954713', embeds ? [{ kind: 'gif', url: 'https://tenor.com/view/monday-cat-gif-22954713', mediaUrl: MP4, mediaKind: 'video', width: 300, height: 200, provider: 'Tenor' }] : undefined),
msg(2, 'bob', 'https://giphy.com/gifs/wave-hello-Z7wFi5b2VEAr6', embeds ? [{ kind: 'gif', url: 'https://giphy.com/gifs/wave-hello-Z7wFi5b2VEAr6', mediaUrl: GIF, mediaKind: 'image', width: 260, height: 180, provider: 'Giphy' }] : undefined),
msg(3, 'carol', 'and from klipy: https://klipy.com/gifs/friends-joey-53')
] } })
return true
})()`)
await sleep(900)
}
/** The GIF picker, open, with a handful of invented GIFs to look through. */
const openGifPicker = async (page) => {
await page.evaluate(`(() => {
const shots = window.__mohoShots
const s = shots.state()
const art = (a, b, label, w, h) => 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + ' ' + h + '"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="' + a + '"/><stop offset="1" stop-color="' + b + '"/></linearGradient></defs><rect width="' + w + '" height="' + h + '" fill="url(#g)"/><text x="' + (w / 2) + '" y="' + (h / 2 + 14) + '" font-size="44" font-family="sans-serif" fill="#fff" text-anchor="middle">' + label + '</text></svg>')
const gif = (id, title, a, b, w, h) => ({ id, title, url: 'https://tenor.com/view/' + id, src: '', gifSrc: '', preview: art(a, b, title, w, h), width: w, height: h })
window.__gifFixture = {
categories: [{ name: 'reaction', src: '' }, { name: 'happy', src: '' }, { name: 'dancing', src: '' }, { name: 'cats', src: '' }, { name: 'applause', src: '' }, { name: 'facepalm', src: '' }],
gifs: [
gif('1', 'cat dance', '#e0a43a', '#b0457a', 480, 300), gif('2', 'thumbs up', '#3a8fe0', '#4a2f9a', 300, 300),
gif('3', 'happy dog', '#3ae0a4', '#1f6f5a', 480, 270), gif('4', 'mind blown', '#e05a3a', '#7a2f2f', 300, 360),
gif('5', 'clapping', '#8a5ae0', '#3a3fa0', 480, 270), gif('6', 'wow', '#e0d03a', '#a07a1f', 300, 300)
]
}
shots.patch({ accounts: [...s.accounts, { id: 'discord:gifs', service: 'discord', displayName: 'x', state: 'connected', autojoin: '', currentNick: 'x', status: 'online', useTor: false, ssl: true }] })
return true
})()`)
await sleep(500)
await page.evaluate(click('button[title="GIFs"]'))
await until(page, 'document.querySelector(".gif-cell")', 'the GIFs')
await sleep(500)
}
// --- scenes ------------------------------------------------------------------
/** Messages with several pictures, one of them a spoiler (#304): two, three and four, then a lone spoiler. */
@@ -270,6 +340,52 @@ 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) }
},
{
// What somebody did on a Kick stream: KICKs sent, subscriptions gifted. Put
// on a channel's messages directly - a stream is not here to do it.
name: 'kick-events',
setup: async (p, mode = 'classic') => {
await setMode(p, mode)
await openChannel(p, '#general')
await p.evaluate(`(() => {
const shots = window.__mohoShots
const s = shots.state()
const id = s.activeBufferId
const now = Math.floor(Date.now() / 1000)
const row = (n, kind, body, embed, from = 'bob') => ({ id: 'k' + n, bufferId: id, from, senderId: from, body, ts: now - 60 + n, isAction: false, isHighlight: false, kind, isOwn: false, embeds: embed ? [embed] : [] })
const chat = (n, from, body) => row(n, 'chat', body, null, from)
shots.patch({ messagesByBuffer: { ...s.messagesByBuffer, [id]: [
chat(1, 'bob', 'that was a clean run'),
row(2, 'kicks', 'Currington sent 1 Kick', { kind: 'kicks', author: 'Currington', authorColor: '#75FD46', title: '1', provider: 'Hell Yeah', color: 0x53fc18 }),
chat(3, 'carol', 'lets gooo'),
row(4, 'kicks', 'Maverick sent 500 Kicks: take my money', { kind: 'kicks', author: 'Maverick', authorColor: '#E26EFF', title: '500', provider: 'Full Send', description: 'take my money', color: 0xb45cff }),
row(5, 'sub', 'ItsDez gifted 5 subscriptions', { kind: 'gift-sub', author: 'ItsDez', title: '5', description: 'Interstelar\\nRon_OSRS\\nAuzzzii\\nMeowtimeJay\\nstradivarius31' }),
row(6, 'sub', 'LurkinLeepa gifted a subscription', { kind: 'gift-sub', author: 'LurkinLeepa', title: '1', description: 'onkhell' }),
row(10, 'sub', 'bigkhernandez subscribed - 21 months', { kind: 'subscribe', author: 'bigkhernandez', title: '21' }),
row(11, 'moderation', 'troll was timed out by mod', { kind: 'moderation', author: 'troll', title: 'was timed out', description: 'mod' }),
row(12, 'stream', 'went live', { kind: 'stream', author: 'streamer', title: 'went live', icon: 'live' }),
row(7, 'raid', 'kreationX raided with 25 viewers', { kind: 'raid', author: 'kreationX', title: '25' }),
row(8, 'reward', 'onkhell redeemed 5 TONGUE TWISTERS', { kind: 'redeem', author: 'onkhell', title: '5 TONGUE TWISTERS', color: 0xff55b3 }),
chat(9, 'bob', 'thanks everybody')
] } })
})()`)
await sleep(500)
}
},
{ name: 'kick-events-comfy', setup: async (p) => { await SCENES.find((x) => x.name === 'kick-events').setup(p, 'comfy') } },
{ name: 'kick-events-bubbles', setup: async (p) => { await SCENES.find((x) => x.name === 'kick-events').setup(p, 'bubbles') } },
{ 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 +627,19 @@ const SCENES = [
await sleep(300)
}
},
{
name: 'settings-presence',
setup: async (p) => {
await p.evaluate(`window.__mohoShots.store.setActivePanel('settings')`)
await sleep(400)
await p.evaluate(`(() => {
const el = [...document.querySelectorAll('h1, h2, h3, h4, .settings-section-title')].find((e) => e.textContent.trim() === 'Presence')
el?.scrollIntoView({ block: 'start' })
return !!el
})()`)
await sleep(400)
}
},
{ name: 'settings-general', setup: async (p) => { await p.evaluate(`window.__mohoShots.store.setActivePanel('settings')`); await sleep(400) } },
{
name: 'settings-tor',
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest'
import { IdleWatcher } from './idle'
const MIN = 60_000
/** A watcher on a clock the test holds, with the system's idle time a figure it sets. */
function watcher(over: { enabled?: boolean; minutes?: number } = {}): {
w: IdleWatcher
clock: { now: number; system: number; enabled: boolean }
said: boolean[]
} {
const clock = { now: 1_000_000, system: 0, enabled: over.enabled ?? true }
const said: boolean[] = []
const w = new IdleWatcher({
now: () => clock.now,
systemIdleSeconds: () => clock.system,
enabled: () => clock.enabled,
thresholdMs: () => (over.minutes ?? 10) * MIN,
onChange: (idle) => said.push(idle)
})
return { w, clock, said }
}
describe('being away, by moho\'s own windows', () => {
it('is away after the threshold with nothing touched, and says so once', () => {
const { w, clock, said } = watcher()
clock.now += 9 * MIN
w.evaluate()
expect(w.isIdle()).toBe(false)
clock.now += 2 * MIN
w.evaluate()
w.evaluate()
expect(w.isIdle()).toBe(true)
expect(said).toEqual([true])
})
it('is back the moment anything is used', () => {
const { w, clock, said } = watcher()
clock.now += 11 * MIN
w.evaluate()
w.activity()
expect(w.isIdle()).toBe(false)
expect(said).toEqual([true, false])
})
it('counts use as starting the clock again', () => {
const { w, clock } = watcher()
clock.now += 9 * MIN
w.activity()
clock.now += 9 * MIN
w.evaluate()
expect(w.isIdle()).toBe(false)
})
it('does nothing at all when it is switched off', () => {
const { w, clock, said } = watcher({ enabled: false })
clock.now += 60 * MIN
w.evaluate()
expect(said).toEqual([])
})
it('comes back when it is switched off while away', () => {
const { w, clock, said } = watcher()
clock.now += 11 * MIN
w.evaluate()
clock.enabled = false
w.evaluate()
expect(said).toEqual([true, false])
})
})
describe('being away, by the system\'s idle time', () => {
it('does not take a zero that never changes for somebody who never stops', () => {
// A platform that cannot say reports zero for ever; moho's own windows decide.
const { w, clock } = watcher()
clock.system = 0
clock.now += 11 * MIN
w.evaluate()
expect(w.isIdle()).toBe(true)
})
it('believes the system once it has been seen to be anything but zero, and counts use elsewhere as being here', () => {
const { w, clock } = watcher()
clock.system = 3
w.evaluate()
// Eleven minutes of not touching moho, but the keyboard was used a minute ago.
clock.now += 11 * MIN
clock.system = 60
w.evaluate()
expect(w.isIdle()).toBe(false)
// And then it really is eleven minutes everywhere.
clock.system = 11 * 60
w.evaluate()
expect(w.isIdle()).toBe(true)
})
})
describe('locking', () => {
it('is away at once when the screen locks and back when it unlocks', () => {
const { w, said } = watcher()
w.lock()
expect(w.isIdle()).toBe(true)
w.unlock()
expect(w.isIdle()).toBe(false)
expect(said).toEqual([true, false])
})
})
+97
View File
@@ -0,0 +1,97 @@
/**
* Whether the person has stepped away, which is all this knows.
*
* Discord's own client and every IRC client that sets itself away work from the
* same one fact - how long since anything was touched - and nothing else: not
* what is on screen, not what was typed, not which program is in front. This is
* kept to that, and is deliberately small: a clock, two sources of "how long",
* and a threshold.
*
* The two sources are the system's own idle time, where the platform will say
* (Windows, macOS, X11), and moho's own windows being used, which every
* platform can say. Some Wayland desktops give Chromium no idle time at all and
* report zero for ever, which looks exactly like somebody who never stops
* typing; so the system's figure is only believed once it has been seen to be
* anything but zero, and until then - and where it never is - moho goes by its
* own windows.
*/
export interface IdleOptions {
now: () => number
/** Seconds since the last keyboard or mouse input anywhere; 0 where the platform cannot say. */
systemIdleSeconds: () => number
enabled: () => boolean
thresholdMs: () => number
/** Said when the answer changes, and only then. */
onChange: (idle: boolean) => void
}
export class IdleWatcher {
private lastActivity: number
private systemKnown = false
private away = false
/** The screen is locked or the machine asleep: away at once, whatever the clock says. */
private locked = false
private forced: boolean | null = null
constructor(private o: IdleOptions) {
this.lastActivity = o.now()
}
isIdle(): boolean {
return this.away
}
/** When something was last used in a moho window, for the harness to see that it was heard. */
lastActivityAt(): number {
return this.lastActivity
}
/** Something was used in a moho window - including coming back to one. */
activity(): void {
this.lastActivity = this.o.now()
this.locked = false
this.evaluate()
}
lock(): void {
this.locked = true
this.evaluate()
}
unlock(): void {
this.locked = false
this.lastActivity = this.o.now()
this.evaluate()
}
/** For the harness: an answer that the clock does not get to change. */
forceForTesting(idle: boolean | null): void {
this.forced = idle
this.evaluate()
}
/** Asks the clocks, and says so if the answer is different. Run on a timer, and when a setting moves. */
evaluate(): void {
this.set(this.decide())
}
private decide(): boolean {
if (this.forced !== null) return this.forced
if (!this.o.enabled()) return false
if (this.locked) return true
const system = this.o.systemIdleSeconds()
if (system > 0) this.systemKnown = true
const own = this.o.now() - this.lastActivity
// Whichever is shorter: a keystroke in another program counts as being
// here once the system can say so, and one in moho counts either way.
const idleFor = this.systemKnown ? Math.min(own, system * 1000) : own
return idleFor >= this.o.thresholdMs()
}
private set(idle: boolean): void {
if (idle === this.away) return
this.away = idle
this.o.onChange(idle)
}
}
+212 -71
View File
@@ -11,18 +11,23 @@ import {
globalShortcut,
ipcMain,
nativeImage,
nativeTheme,
net,
powerMonitor,
session,
Menu,
screen,
shell,
Tray
Tray,
type MenuItemConstructorOptions
} from 'electron'
import { electronApp, is, optimizer } from '@electron-toolkit/utils'
import { defaultSocketPath, NobilisClient } from './nobilis-client'
import { NobilisProcess } from './nobilis-process'
import { Prefs } from './prefs'
import { Notifier } from './notifications'
import { 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'
import { clearnetLinks } from '../shared/clearnet'
@@ -131,6 +136,7 @@ let prefs: Prefs
let nobilis: NobilisProcess
let client: NobilisClient
let notifier: Notifier
let idleWatcher: IdleWatcher
let registeredHotkey: string | null = null
/**
@@ -190,6 +196,52 @@ function sniffImage(head: Buffer): string | null {
}
/** Every window with a renderer in it, main and popped-out conversations alike. */
/**
* Sets a preference and tells whoever holds a copy of it.
*
* `except` is the window that made the change, which already knows and has
* drawn it. The tray's own entries come through here too, so a setting flipped
* from the tray moves its switch in Settings.
*/
function applyPref(key: string, value: unknown, except?: Electron.WebContents): void {
prefs.set(key, value)
// The badge and the flash are decided from these as they stand.
if (key === 'notifications.badge' || key === 'notifications.flash') updateTray(lastTray)
// Every window keeps its own cache of these, so a setting changed in one
// is stale in the others until they are told. That is not cosmetic once
// there are several windows: muting a conversation, or switching the log
// to compact, would apply to whichever window happened to be asked.
for (const w of liveWindows()) {
if (w.webContents !== except) w.webContents.send(IPC.prefsChanged, key, value)
}
// Pins and mutes feed the tray/notification rules, which live here.
if (key === 'pinnedBuffers' || key === 'mutedBuffers') notifier.publish()
if (key === 'hotkey.toggle') applyHotkey(String(value))
if (key === 'presence.autoIdle' || key === 'presence.autoIdleMinutes') idleWatcher?.evaluate()
}
/**
* Brings up a conversation: a click on its notification, or on the tray's
* "open" entry.
*
* If it has a window of its own, that window is what the click asked for.
* Raising the main one and switching it would move somebody away from whatever
* they were reading in order to show them a conversation that was already open
* on their screen.
*/
function openConversation(bufferId: string): void {
const popout = popouts.get(bufferId)
if (popout && !popout.isDestroyed()) {
if (popout.isMinimized()) popout.restore()
popout.show()
popout.focus()
return
}
mainWindow?.show()
mainWindow?.focus()
mainWindow?.webContents.send(IPC.activateBuffer, bufferId)
}
function liveWindows(): BrowserWindow[] {
const all = mainWindow ? [mainWindow, ...popouts.values()] : [...popouts.values()]
return all.filter((w) => !w.isDestroyed())
@@ -353,6 +405,9 @@ function createWindow(): void {
pendingDeepLink = null
mainWindow?.webContents.send(IPC.deepLink, url)
})
// The tray's first action is to show or hide this window, and says which.
mainWindow.on('show', refreshTrayMenu)
mainWindow.on('hide', refreshTrayMenu)
mainWindow.on('maximize', () => mainWindow?.webContents.send(IPC.maximizeChanged, true))
mainWindow.on('unmaximize', () => mainWindow?.webContents.send(IPC.maximizeChanged, false))
mainWindow.on('closed', () => {
@@ -562,61 +617,112 @@ function toggleWindow(): void {
}
}
function trayIcon(hasAlert: boolean): Electron.NativeImage {
const file = resourcePath('icons', hasAlert ? 'tray-alert.png' : 'tray.png')
const img = nativeImage.createFromPath(file)
function trayIcon(unread: number): Electron.NativeImage {
lastTrayIcon = trayIconFile(nativeTheme.shouldUseDarkColors, unread)
const img = nativeImage.createFromPath(resourcePath('icons', lastTrayIcon))
// A missing icon file would otherwise produce an invisible tray entry the
// user can never click; fall back to the app icon so the entry still exists.
return img.isEmpty() ? nativeImage.createFromPath(resourcePath('icons', 'moho.png')) : img
}
function createTray(): void {
tray = new Tray(trayIcon(false))
tray.setToolTip('moho')
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: 'Show/hide', click: toggleWindow },
{ label: 'Restart daemon', click: () => nobilis.restart() },
{
label: 'Stop daemon',
// Deliberately separate from Quit: stopping the daemon disconnects
// every account, which is worth asking for explicitly rather than
// making it a side effect of closing a window.
click: () => {
void stopDaemon()
}
/** What the window last said each connected account's status is. */
let accountStatus: Record<string, string> = {}
/** The status the menu shows as checked, and the ones it offers: what the connected accounts can be set to. */
let trayStatus: TrayStatus | null = null
let trayOffered: TrayStatus[] = []
let trayInvisibleName = 'Invisible'
const isTrayStatus = (v: unknown): v is TrayStatus => v === 'online' || v === 'idle' || v === 'dnd' || v === 'invisible'
/**
* Asks the window to do something, showing it first: the tray's entries that
* are about moho itself - settings, and a status set across every account -
* are the window's to carry out, since it is what holds the accounts.
*/
function tellWindow(command: 'settings' | 'status', arg?: string): void {
if (!mainWindow) createWindow()
if (command === 'settings') {
mainWindow?.show()
mainWindow?.focus()
}
mainWindow?.webContents.send(IPC.trayCommand, command, arg)
}
/**
* Starts moho again, daemon and all.
*
* Quitting already takes the daemon down cleanly - QUIT to every network, then
* gone - and the new process starts its own, so restarting is quit-and-relaunch.
* An AppImage has to be relaunched by the path it was started from: its
* executable is a temporary mount that disappears when it exits.
*/
function restartMoho(): void {
const appImage = process.env.APPIMAGE
app.relaunch(appImage ? { execPath: appImage, args: process.argv.slice(1) } : undefined)
app.quit()
}
/** What the tray last offered and showed, kept for the UI harness to read. */
let lastTrayMenu: MenuItemConstructorOptions[] = []
let lastTrayIcon = ''
let lastTrayTooltip = 'moho'
function refreshTrayMenu(): void {
if (!tray) return
lastTrayMenu = trayMenu(
{
state: lastTray,
windowVisible: !!mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible(),
status: trayStatus,
offered: trayOffered,
invisibleName: trayInvisibleName
},
{
toggleWindow,
openLatest: () => {
const id = lastTray.latest ?? notifier.latestUnread()
if (id) openConversation(id)
},
{ type: 'separator' },
{
label: 'Quit',
click: () => {
app.quit()
}
}
])
openSettings: () => tellWindow('settings'),
setStatus: (status) => {
trayStatus = status
tellWindow('status', status)
},
restart: restartMoho,
quit: () => app.quit()
}
)
tray.setContextMenu(Menu.buildFromTemplate(lastTrayMenu))
}
function createTray(): void {
tray = new Tray(trayIcon(0))
tray.setToolTip('moho')
refreshTrayMenu()
tray.on('click', toggleWindow)
// The icon is drawn for the panel it sits on, so it is drawn again when the
// system changes colour scheme.
nativeTheme.on('updated', () => updateTray(lastTray))
}
/** What the notifier last said, kept so a change of setting can redraw without waiting for the next message. */
let lastUnread = 0
let lastAlert = false
let lastTray: TrayState = NOTHING_WAITING
function updateTray(unreadCount: number, hasAlert: boolean): void {
lastUnread = unreadCount
lastAlert = hasAlert
// The unread badge: the tray's alert dot and count, and the launcher's
// count where the desktop has one. Off, the tray is just the tray.
function updateTray(state: TrayState): void {
lastTray = state
// The unread badge: the tray's count and the launcher's count where the
// desktop has one. Off, the tray is just the tray.
const badge = prefs.get<boolean>('notifications.badge', true)
app.setBadgeCount(badge ? unreadCount : 0)
app.setBadgeCount(badge ? state.unread : 0)
// The taskbar flash: asking for attention while the window is not in front.
const flash = prefs.get<boolean>('notifications.flash', true)
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.flashFrame(flash && hasAlert && !mainWindow.isFocused())
mainWindow.flashFrame(flash && state.unread > 0 && trayStatus !== 'dnd' && !mainWindow.isFocused())
}
refreshTrayMenu()
if (!tray) return
tray.setImage(trayIcon(badge && hasAlert))
tray.setToolTip(badge && unreadCount > 0 ? `moho - ${unreadCount} unread` : 'moho')
tray.setImage(trayIcon(badge ? state.unread : 0))
lastTrayTooltip = trayTooltip(state, badge)
tray.setToolTip(lastTrayTooltip)
send(IPC.link, client.linkUp)
}
@@ -667,21 +773,27 @@ function wireIpc(): void {
})
ipcMain.handle(IPC.prefsGetAll, () => prefs.all())
ipcMain.handle(IPC.prefsSet, (e, key: string, value: unknown) => {
prefs.set(key, value)
// The badge and the flash are decided from these as they stand.
if (key === 'notifications.badge' || key === 'notifications.flash') updateTray(lastUnread, lastAlert)
// Every window keeps its own cache of these, so a setting changed in one
// is stale in the others until they are told. That is not cosmetic once
// there are several windows: muting a conversation, or switching the log
// to compact, would apply to whichever window happened to be asked.
// Not echoed to the window that set it - it already knows, and has drawn.
for (const w of liveWindows()) {
if (w.webContents !== e.sender) w.webContents.send(IPC.prefsChanged, key, value)
}
// Pins and mutes feed the tray/notification rules, which live here.
if (key === 'pinnedBuffers' || key === 'mutedBuffers') notifier.publish()
if (key === 'hotkey.toggle') applyHotkey(String(value))
ipcMain.handle(IPC.prefsSet, (e, key: string, value: unknown) => applyPref(key, value, e.sender))
// The window says what status its accounts are at, for the tray's menu to check.
ipcMain.on(IPC.trayStatus, (_e, statuses: Record<string, string>, offered: string[], shown: string | null, invisibleName: string) => {
accountStatus = statuses && typeof statuses === 'object' ? statuses : {}
trayOffered = Array.isArray(offered) ? offered.filter(isTrayStatus) : []
trayStatus = isTrayStatus(shown) ? shown : null
trayInvisibleName = invisibleName === 'Offline' ? 'Offline' : 'Invisible'
refreshTrayMenu()
// Do not disturb is asking for quiet, including from the taskbar.
updateTray(lastTray)
})
ipcMain.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))
@@ -1137,32 +1249,38 @@ app.whenReady().then(() => {
nobilis = new NobilisProcess()
client = new NobilisClient()
// Going idle when the person steps away. What it says, and to whom, is
// decided in the daemon - which accounts are online, which are in a call -
// so all this says is whether they are here.
idleWatcher = new IdleWatcher({
now: () => Date.now(),
systemIdleSeconds: () => powerMonitor.getSystemIdleTime(),
enabled: () => prefs.get<boolean>('presence.autoIdle', true),
thresholdMs: () => (Number(prefs.get<unknown>('presence.autoIdleMinutes', '10')) || 10) * 60_000,
onChange: (idle) => void client.request('setUserIdle', { idle }).catch(() => {})
})
setInterval(() => idleWatcher.evaluate(), 10_000).unref()
app.on('browser-window-focus', () => idleWatcher.activity())
powerMonitor.on('lock-screen', () => idleWatcher.lock())
powerMonitor.on('suspend', () => idleWatcher.lock())
powerMonitor.on('unlock-screen', () => idleWatcher.unlock())
powerMonitor.on('resume', () => idleWatcher.unlock())
notifier = new Notifier(
prefs,
updateTray,
(bufferId) => {
// If this conversation has a window of its own, that window is what the
// click asked for. Raising the main one and switching it would move
// somebody away from whatever they were reading in order to show them a
// conversation that was already open on their screen.
const popout = popouts.get(bufferId)
if (popout && !popout.isDestroyed()) {
if (popout.isMinimized()) popout.restore()
popout.show()
popout.focus()
return
}
mainWindow?.show()
mainWindow?.focus()
mainWindow?.webContents.send(IPC.activateBuffer, bufferId)
},
// The tray's number is the window's, not this tally's - see IPC.trayUnread.
() => {},
openConversation,
() => mainWindow?.webContents ?? null,
(bufferId) => popoutState().watched.includes(bufferId)
(bufferId) => popoutState().watched.includes(bufferId),
(accountId) => accountStatus[accountId] === 'dnd'
)
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))
@@ -1204,6 +1322,29 @@ app.whenReady().then(() => {
createWindow()
createTray()
applyHotkey(prefs.get<string>('hotkey.toggle'))
// For the UI harness, which has no way to look inside a tray: what it shows
// and offers, and the entries themselves to be clicked. Only when it asks.
if (process.env.MOHO_UI_SHOTS === '1') {
;(globalThis as Record<string, unknown>).__mohoTray = {
update: updateTray,
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,
click: (path: string[]) => {
let items: MenuItemConstructorOptions[] = lastTrayMenu
let item: MenuItemConstructorOptions | undefined
for (const label of path) {
item = items.find((i) => i.label === label || (i.label ?? '').startsWith(label))
items = (item?.submenu as MenuItemConstructorOptions[]) ?? []
}
item?.click?.({ checked: !item.checked } as never, undefined, {} as never)
}
}
}
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
+49 -4
View File
@@ -19,13 +19,14 @@ vi.mock('electron', () => {
})
import { Notifier } from './notifications'
import type { TrayState } from './tray'
const payload = { accountId: 'a', bufferId: 'b', title: 'alice', body: 'hi' }
function notifier(settings: Record<string, unknown>): { n: Notifier; alerts: [number, boolean][] } {
const alerts: [number, boolean][] = []
function notifier(settings: Record<string, unknown>, dnd: string[] = []): { n: Notifier; alerts: TrayState[] } {
const alerts: TrayState[] = []
const prefs = { get: <T>(key: string, fallback: T): T => (key in settings ? (settings[key] as T) : fallback) }
const n = new Notifier(prefs as never, (count, alert) => alerts.push([count, alert]), () => {})
const n = new Notifier(prefs as never, (state) => alerts.push(state), () => {}, () => null, () => false, (account) => dnd.includes(account))
return { n, alerts }
}
@@ -44,7 +45,7 @@ describe('notification settings', () => {
const { n, alerts } = notifier({ 'notifications.desktop': false })
await n.handle(payload)
expect(shown).toHaveLength(0)
expect(alerts.at(-1)?.[0]).toBe(1)
expect(alerts.at(-1)?.unread).toBe(1)
})
it('makes them silent when the sound is off', async () => {
@@ -52,3 +53,47 @@ describe('notification settings', () => {
expect(shown[0].silent).toBe(true)
})
})
describe('what the tray is told is waiting', () => {
const buffer = (id: string, kind: string): never => ({ id, kind }) as never
it('counts direct messages and mentions apart, and lights for both', async () => {
const { n, alerts } = notifier({ 'notifications.desktop': false })
n.trackBuffer(buffer('dm', 'dm'), false)
n.trackBuffer(buffer('chan', 'channel'), false)
await n.handle({ ...payload, bufferId: 'dm' })
await n.handle({ ...payload, bufferId: 'chan' })
expect(alerts.at(-1)).toEqual({ unread: 2, dms: 1, mentions: 1 })
})
it('names the newest waiting conversation, and none once they are read', async () => {
const { n } = notifier({ 'notifications.desktop': false })
expect(n.latestUnread()).toBeNull()
await n.handle({ ...payload, bufferId: 'first' })
await n.handle({ ...payload, bufferId: 'second' })
expect(n.latestUnread()).toBe('second')
n.clear('second')
expect(n.latestUnread()).toBe('first')
n.clearAll()
expect(n.latestUnread()).toBeNull()
})
})
describe('Do not disturb', () => {
beforeEach(() => {
shown.length = 0
})
it('shows no notification for an account that is set to it, but still counts the conversation', async () => {
const { n, alerts } = notifier({}, ['a'])
await n.handle(payload)
expect(shown).toHaveLength(0)
expect(alerts.at(-1)?.unread).toBe(1)
})
it('leaves the other accounts alone', async () => {
const { n } = notifier({}, ['somebody-else'])
await n.handle(payload)
expect(shown).toHaveLength(1)
})
})
+31 -18
View File
@@ -4,6 +4,7 @@ import path from 'node:path'
import { Notification, nativeImage, net } from 'electron'
import type { WebContents } from 'electron'
import type { Prefs } from './prefs'
import type { TrayState } from './tray'
import type { Buffer as ChatBuffer } from '../shared/wire'
/**
@@ -48,7 +49,7 @@ export class Notifier {
constructor(
private prefs: Prefs,
private onAlertChange: (unreadCount: number, hasAlert: boolean) => void,
private onAlertChange: (state: TrayState) => void,
private onActivate: (bufferId: string) => void,
/** The renderer, borrowed only to decode formats nativeImage cannot. */
private renderer: () => WebContents | null = () => null,
@@ -60,7 +61,16 @@ export class Notifier {
* them what they can already see, and a tray badge counting it as unread
* is simply wrong.
*/
private isWatched: (bufferId: string) => boolean = () => false
private isWatched: (bufferId: string) => boolean = () => false,
/**
* Whether this account has been set to Do not disturb.
*
* That is the one switch for silence: an account that says so gets no popup
* and no sound, however its conversations behave. What it is sent still
* counts as unread - the tray and the badge keep their numbers, as Discord's
* own do - and is there to be read when it is looked at.
*/
private isDnd: (accountId: string) => boolean = () => false
) {}
trackBuffer(buffer: ChatBuffer, removed: boolean): void {
@@ -103,6 +113,8 @@ export class Notifier {
this.publish()
}
if (this.isDnd(payload.accountId)) return
// Switched off in Settings: the conversation still counts as unread above,
// it just does not pop up.
if (!this.prefs.get<boolean>('notifications.desktop', true)) return
@@ -288,24 +300,25 @@ export class Notifier {
}
/**
* Whether the tray should show something is waiting.
* What the tray should say is waiting.
*
* A direct message counts, and this is the change: the tray used to light up
* only for *pinned* buffers, on the reasoning that a DM had already had its
* desktop notification and the tray was for things important enough to pin.
* In practice that made the icon nearly inert - somebody messages you, and
* the one place still on screen after the notification has faded says
* nothing, unless you happened to have pinned that exact conversation.
*
* Pinned buffers still count, so a channel worth pinning can still raise it.
* There is no count here: a tray icon is around 22 pixels, which is room for
* "yes" and not for a number.
* Everything held here was worth a notification - a direct message, or
* somebody mentioning this account - so all of it lights the tray, and it
* says how many conversations and of which kind. It used to light only for
* direct messages and pinned conversations, on the reasoning that a mention
* had had its notification and the tray was for what mattered more; the
* effect was an icon that stayed dark while somebody was asking for you.
*/
publish(): void {
const pinned = this.prefs.get<string[]>('pinnedBuffers', [])
const hasAlert = [...this.unread].some(
(id) => this.buffers.get(id)?.kind === 'dm' || pinned.includes(id)
)
this.onAlertChange(this.unread.size, hasAlert)
let dms = 0
for (const id of this.unread) if (this.buffers.get(id)?.kind === 'dm') dms++
this.onAlertChange({ unread: this.unread.size, dms, mentions: this.unread.size - dms })
}
/** The conversation that has been waiting for you the shortest time. */
latestUnread(): string | null {
let latest: string | null = null
for (const id of this.unread) latest = id
return latest
}
}
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it, vi } from 'vitest'
import { trayIconFile, trayMenu, trayTooltip, waitingSummary, NOTHING_WAITING, type TrayActions } from './tray'
const acts = (): TrayActions => ({
toggleWindow: vi.fn(),
openLatest: vi.fn(),
openSettings: vi.fn(),
setStatus: vi.fn(),
restart: vi.fn(),
quit: vi.fn()
})
describe('the tray icon', () => {
it('is a light bubble on a dark system and a dark one on a light system', () => {
expect(trayIconFile(true, 0)).toBe('tray-light.png')
expect(trayIconFile(false, 0)).toBe('tray-dark.png')
})
it('carries the count to nine and says more after that', () => {
expect(trayIconFile(true, 1)).toBe('tray-light-1.png')
expect(trayIconFile(true, 9)).toBe('tray-light-9.png')
expect(trayIconFile(false, 10)).toBe('tray-dark-more.png')
expect(trayIconFile(false, 400)).toBe('tray-dark-more.png')
})
})
describe('what is said about what is waiting', () => {
it('names direct messages and mentions apart, and in the singular for one', () => {
expect(waitingSummary({ unread: 3, dms: 2, mentions: 1 })).toBe('2 direct messages, 1 mention')
expect(waitingSummary({ unread: 1, dms: 1, mentions: 0 })).toBe('1 direct message')
expect(waitingSummary({ unread: 4, dms: 0, mentions: 4 })).toBe('4 mentions')
})
it('leaves the tooltip plain with nothing waiting or the badge off', () => {
expect(trayTooltip(NOTHING_WAITING, true)).toBe('moho')
expect(trayTooltip({ unread: 2, dms: 1, mentions: 1 }, false)).toBe('moho')
expect(trayTooltip({ unread: 2, dms: 1, mentions: 1 }, true)).toBe('moho - 1 direct message, 1 mention')
})
})
describe('the menu', () => {
const model = { state: NOTHING_WAITING, windowVisible: true, status: 'idle' as const, offered: [], invisibleName: 'Invisible' }
const labels = (items: ReturnType<typeof trayMenu>): string[] => items.map((i) => i.label ?? '-')
it('offers no daemon plumbing, and a restart that is of moho as a whole', () => {
const l = labels(trayMenu(model, acts()))
expect(l).not.toContain('Restart daemon')
expect(l).not.toContain('Stop daemon')
expect(l).toContain('Restart moho')
})
it('says nothing is new, and offers nothing to open, when that is so', () => {
const [first] = trayMenu(model, acts())
expect(first.label).toBe('Nothing new')
expect(first.enabled).toBe(false)
})
it('says what is waiting and opens it', () => {
const a = acts()
const [first] = trayMenu({ ...model, state: { unread: 2, dms: 1, mentions: 1 } }, a)
expect(first.label).toBe('1 direct message, 1 mention - open')
first.click?.({} as never, undefined, {} as never)
expect(a.openLatest).toHaveBeenCalled()
})
it('has no notifications switch of its own: Do not disturb is that', () => {
expect(labels(trayMenu(model, acts())).join('|')).not.toMatch(/notification/i)
const status = trayMenu(model, acts()).find((i) => i.label === 'Status')
expect((status?.submenu as { label: string }[]).map((s) => s.label)).toContain('Do not disturb')
})
it('offers only what the connected accounts can be set to', () => {
const labelsOf = (offered: ('online' | 'idle' | 'dnd' | 'invisible')[]): string[] =>
(trayMenu({ ...model, offered }, acts()).find((i) => i.label === 'Status')?.submenu as { label: string }[]).map((s) => s.label)
expect(labelsOf(['online', 'dnd'])).toEqual(['Online', 'Do not disturb'])
expect(labelsOf(['online', 'idle', 'dnd', 'invisible'])).toHaveLength(4)
expect(labelsOf([])).toHaveLength(4)
})
it('calls invisible offline where only Matrix has it', () => {
const sub = trayMenu({ ...model, offered: ['online', 'invisible'], invisibleName: 'Offline' }, acts()).find((i) => i.label === 'Status')?.submenu as { label: string }[]
expect(sub.map((s) => s.label)).toEqual(['Online', 'Offline'])
})
it('asks for the window to be shown or hidden as it stands', () => {
expect(labels(trayMenu({ ...model, windowVisible: true }, acts()))).toContain('Hide moho')
expect(labels(trayMenu({ ...model, windowVisible: false }, acts()))).toContain('Show moho')
})
it('checks the status the accounts are at', () => {
const status = trayMenu(model, acts()).find((i) => i.label === 'Status')
const sub = status?.submenu as { label: string; checked: boolean }[]
expect(sub.filter((s) => s.checked).map((s) => s.label)).toEqual(['Idle'])
const none = trayMenu({ ...model, status: null }, acts()).find((i) => i.label === 'Status')?.submenu as { checked: boolean }[]
expect(none.some((s) => s.checked)).toBe(false)
})
})
+109
View File
@@ -0,0 +1,109 @@
/**
* What the tray says and offers, apart from the Electron objects that show it.
*
* Kept as plain functions of plain data so that what the icon, the tooltip and
* the menu say about a given set of unread conversations can be checked
* without a desktop to look at.
*/
import type { MenuItemConstructorOptions } from 'electron'
/** What is waiting, as the notifier counts it. */
export interface TrayState {
/** Conversations with something unread that was worth a notification. */
unread: number
/** Of those, direct messages. */
dms: number
/** Of those, everything else: somebody mentioning this account in a channel. */
mentions: number
/** 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 }
export type TrayStatus = 'online' | 'idle' | 'dnd' | 'invisible'
/**
* The icon file for a panel and a count.
*
* `dark` is the system's colour scheme, and a dark system wants the light
* bubble. The count is drawn into the icon up to nine; past that it says "9+",
* since a tray icon is about 22 pixels and has no room for a second digit.
*/
export function trayIconFile(dark: boolean, unread: number): string {
const ink = dark ? 'light' : 'dark'
if (unread <= 0) return `tray-${ink}.png`
return `tray-${ink}-${unread > 9 ? 'more' : unread}.png`
}
const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`
/** "2 direct messages, 1 mention" - what is waiting, in the words a person would use. */
export function waitingSummary(s: TrayState): string {
const parts: string[] = []
if (s.dms > 0) parts.push(plural(s.dms, 'direct message', 'direct messages'))
if (s.mentions > 0) parts.push(plural(s.mentions, 'mention', 'mentions'))
return parts.join(', ')
}
export function trayTooltip(s: TrayState, badge: boolean): string {
return badge && s.unread > 0 ? `moho - ${waitingSummary(s)}` : 'moho'
}
export interface TrayActions {
toggleWindow: () => void
openLatest: () => void
openSettings: () => void
setStatus: (status: TrayStatus) => void
restart: () => void
quit: () => void
}
export interface TrayModel {
state: TrayState
windowVisible: boolean
/** The status the accounts are at, where the window has said. */
status: TrayStatus | null
/** What the connected accounts can be set to; none reported yet means all of them. */
offered: TrayStatus[]
/** What invisible is called: Matrix, which has only offline presence, says offline. */
invisibleName: string
}
const STATUSES: { id: TrayStatus; label: string }[] = [
{ id: 'online', label: 'Online' },
{ id: 'idle', label: 'Idle' },
{ id: 'dnd', label: 'Do not disturb' },
{ id: 'invisible', label: 'Invisible' }
]
/**
* The right-click menu: what is new, how to get to moho, how to say what you
* are doing - which includes Do not disturb, the one switch for silencing it -
* and how to leave. Daemon plumbing - restart it, stop it - is
* deliberately not here; it is a thing for Settings, and "restart moho"
* covers what somebody reaching for it from a tray actually wants.
*/
export function trayMenu(model: TrayModel, act: TrayActions): MenuItemConstructorOptions[] {
const waiting = model.state.unread > 0
return [
waiting
? { label: `${waitingSummary(model.state)} - open`, click: act.openLatest }
: { label: 'Nothing new', enabled: false },
{ type: 'separator' },
{ label: model.windowVisible ? 'Hide moho' : 'Show moho', click: act.toggleWindow },
{
label: 'Status',
submenu: STATUSES.filter((s) => model.offered.length === 0 || model.offered.includes(s.id)).map((s) => ({
label: s.id === 'invisible' ? model.invisibleName : s.label,
type: 'radio' as const,
checked: model.status === s.id,
click: () => act.setStatus(s.id)
}))
},
{ label: 'Settings', click: act.openSettings },
{ type: 'separator' },
{ label: 'Restart moho', click: act.restart },
{ label: 'Quit', click: act.quit }
]
}
+40
View File
@@ -83,6 +83,27 @@ const api = {
return () => ipcRenderer.off(IPC.activateBuffer, handler)
},
/** The tray menu asking this window to open Settings, or to set a status on every account. */
onTrayCommand(cb: (command: 'settings' | 'status', arg?: string) => void): () => void {
const handler = (_e: unknown, command: 'settings' | 'status', arg?: string): void => cb(command, arg)
ipcRenderer.on(IPC.trayCommand, handler)
return () => ipcRenderer.off(IPC.trayCommand, handler)
},
/**
* Says what status each connected account is at, which statuses any of them
* can be set to, and the one to show as checked: for the tray menu, and for
* main to stay quiet for an account on Do not disturb.
*/
setAccountStatuses(statuses: Record<string, string>, offered: string[], shown: string | null, invisibleName: string): void {
ipcRenderer.send(IPC.trayStatus, statuses, offered, shown, invisibleName)
},
/** 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)
@@ -212,6 +233,25 @@ const api = {
}
}
/**
* Tells main that somebody is using this window.
*
* Only that it happened - the event's kind and what it touched are not looked
* at, and nothing about them is sent. It is what lets moho know the person has
* stepped away, which is the one thing it wants to know, and it is sent at most
* every few seconds however much is going on.
*/
let lastActivityPing = 0
const noteActivity = (): void => {
const now = Date.now()
if (now - lastActivityPing < 5000) return
lastActivityPing = now
ipcRenderer.send(IPC.activity)
}
for (const kind of ['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart']) {
;(globalThis as unknown as EventTarget).addEventListener(kind, noteActivity, { capture: true, passive: true })
}
contextBridge.exposeInMainWorld('moho', api)
export type MohoApi = typeof api
+17
View File
@@ -8,6 +8,7 @@ import { MessageList } from './components/MessageList'
import { MentionsInbox } from './components/MentionsInbox'
import { MentionsPage } from './components/MentionsPage'
import { INVITE_PREFIX, MENTIONS_GROUP_ID } from './lib/groups'
import { trayCount } from './lib/traycount'
import { Composer } from './components/Composer'
import { MembershipGate } from './components/MembershipGate'
import { NickList } from './components/NickList'
@@ -89,6 +90,22 @@ export default function App(): JSX.Element {
else delete document.documentElement.dataset.reduceMotion
}, [density, reduceMotion])
const [userListFolded, setUserListFolded] = usePref<boolean>('ui.userListFolded', false)
// What the tray says is waiting, which is whatever the rail would add up to:
// the same rule, from the same lists, said to the main process whenever the
// answer changes. Told only when it has, since most changes to the buffers
// are not to what is waiting.
const [trayMuted] = usePref<string[]>('mutedBuffers', [])
const [trayHidden] = usePref<string[]>('hiddenBuffers', [])
const [trayMutedGroups] = usePref<string[]>('mutedGroups', [])
const lastTray = useRef('')
useEffect(() => {
const count = trayCount(allBuffers, trayMuted, trayHidden, trayMutedGroups)
const said = JSON.stringify(count)
if (said === lastTray.current) return
lastTray.current = said
window.moho.setTrayUnread(count)
}, [allBuffers, trayMuted, trayHidden, trayMutedGroups])
const [savedBufferId] = usePref<string>('ui.activeBufferId', '')
const [savedGroupId] = usePref<string>('ui.activeGroupId', '')
+304 -1
View File
@@ -1572,6 +1572,208 @@ button.video-tile {
line-height: 1;
}
/* What somebody did on a Kick stream - KICKs sent, subscriptions gifted - as
the card Kick draws it: a bar down the edge, who and what at the left, and
the thing itself at the right. On this client's own surfaces; the accent is
the gift's tier (Kick green for an ordinary one), tinted into the plate so a
large gift is bigger at a glance without the card leaving the theme. */
.message-row.has-kick-event {
align-items: center;
}
/* Where the card sits in the log, which is where the picture of the person
speaking would: it starts at the picture's edge, in the column a join's mark
would have taken, and what is written inside it starts where a name does. So
the indent is the picture, the gap after it, and - in a bubble - the
bubble's own padding. A message row that is not a person speaking gets no
avatar, and a card with none lets the content start at the edge. */
.message-row.comfy:not(.bubbles).has-kick-event {
--kick-lead: var(--avatar);
--kick-indent: calc(var(--avatar) + var(--spacing-m));
}
.message-row.bubbles.system.has-kick-event {
--kick-lead: 28px;
--kick-indent: calc(28px + var(--spacing-s) + var(--spacing-s));
justify-content: flex-start;
margin-top: var(--spacing-s);
}
.message-row.bubbles.system.has-kick-event .message-content {
flex: 1 1 auto;
max-width: 100%;
padding: 0;
background: none;
text-align: left;
}
.message-row.classic.has-kick-event,
.message-row:not(.comfy).has-kick-event {
margin-top: var(--spacing-xs);
}
.message-row.comfy.has-kick-event .message-content {
flex: 1 1 auto;
min-width: 0;
}
.message-row.comfy.has-kick-event .kick-event {
position: relative;
max-width: min(100%, 560px);
/* Less the bar, which is in the card's own edge. */
padding-left: calc(var(--kick-indent) - var(--kick-border, 3px));
}
.message-row.comfy.has-kick-event .kick-event.raid,
.message-row.comfy.has-kick-event .kick-event.stream {
--kick-border: 1px;
}
/* The mark at the left takes the picture's column - centred on where the
picture would be - instead of sitting in the text's way. */
.message-row.comfy.has-kick-event .kick-event > .kick-event-badge.lead {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: var(--kick-lead);
}
.kick-event {
--kick-accent: #53fc18;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-l);
min-width: 260px;
max-width: min(100%, 520px);
padding: var(--spacing-s) var(--spacing-m);
border: 1px solid var(--outline-medium);
border-left: 3px solid var(--kick-accent);
border-radius: var(--corner-radius);
background: color-mix(in srgb, var(--kick-accent) 7%, var(--surface-container-high));
color: var(--surface-text);
font-style: normal;
}
/* Kick draws a gifted subscription in purple and a raid with no bar at all; a
redeemed reward takes the streamer's colour, set inline. */
.kick-event.gift {
--kick-accent: #a970ff;
justify-content: flex-start;
}
.kick-event.redeem,
.kick-event.subscribe,
.kick-event.moderation {
justify-content: flex-start;
}
.kick-event.subscribe {
--kick-accent: #a970ff;
}
/* A ban, a timeout, a chat cleared: said in the room's own danger colour. */
.kick-event.moderation {
--kick-accent: var(--danger);
}
/* The stream going live or ending is a plate like a raid's, and the mark is
the part that says which. */
.kick-event.stream {
justify-content: flex-start;
border: 1px solid var(--outline-medium);
border-radius: var(--radius-pill);
padding-inline: var(--spacing-m);
background: var(--surface-container-high);
}
.kick-event.stream.live .kick-event-badge {
color: var(--danger);
}
.kick-event.stream.offline .kick-event-badge {
color: var(--text-muted);
}
.kick-event.raid {
justify-content: flex-start;
border: 1px solid var(--outline-medium);
border-radius: var(--radius-pill);
padding-inline: var(--spacing-m);
background: var(--surface-container-high);
}
.kick-event.raid .kick-event-badge {
color: var(--text-muted);
}
.kick-event-main {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
min-width: 0;
}
.kick-event-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-xs);
}
.kick-event-name {
font-weight: var(--weight-bold);
}
.kick-event-verb {
color: var(--text-muted);
}
.kick-event-amount {
font-size: var(--font-size-large, 1.15em);
font-variant-numeric: tabular-nums;
}
.kick-event-message {
color: var(--surface-text);
overflow-wrap: anywhere;
}
.kick-event-recipients {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-xs);
}
.kick-event-recipient {
padding: 0 var(--spacing-s);
border-radius: var(--radius-pill);
background: var(--surface-container-highest, var(--surface-hover));
font-weight: var(--weight-medium);
}
.kick-event-badge {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
color: var(--kick-accent);
}
.kicks-mark {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--kick-accent);
color: #0b0e0a;
font-weight: 900;
line-height: 1;
}
.message-row.comfy {
align-items: flex-start;
padding-top: var(--spacing-xxs);
@@ -1590,7 +1792,8 @@ button.video-tile {
gap: var(--spacing-m);
}
.message-row.comfy:not(.bubbles):not(.grouped):not(.system) {
.message-row.comfy:not(.bubbles):not(.grouped):not(.system),
.message-row.comfy:not(.bubbles):not(.grouped).has-kick-event {
margin-top: var(--spacing-l);
}
@@ -10405,3 +10608,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;
}
+30 -12
View File
@@ -19,6 +19,7 @@ import {
MENTIONS_GROUP_ID,
PINNED_GROUP_ID,
pinnedGroup,
waitingCount,
visibleGroups,
type RailGroup
} from '../lib/groups'
@@ -248,6 +249,11 @@ export function BufferList(): JSX.Element {
// The menu still toggles a buffer's *own* flag independently of a muted
// rail entry above it, the same way muting a channel inside an
// already-muted Slack workspace works.
/** The call that sets a mute on the account itself, for a service that keeps one. */
const accountMute = (accountId: string): string | null => {
const service = accounts.find((a) => a.id === accountId)?.service
return service === 'matrix' ? 'setMatrixRoomMuted' : service === 'discord' ? 'setDiscordChannelMuted' : null
}
const isEffectivelyMuted = (buffer: BufferEntry): boolean =>
isMutedBuffer(buffer, muted, mutedGroups)
@@ -304,15 +310,23 @@ 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={
accountMute(b.accountId)
? () =>
void window.moho
.rpc(accountMute(b.accountId)!, { bufferId: b.id, muted: false })
.catch((e: Error) => store.toast('error', e.message))
: undefined
}
onToggleAutojoin={
b.kind === 'channel' && accounts.find((a) => a.id === b.accountId)?.service === 'irc'
? () => store.toggleAutojoin(b.accountId, bufferDisplayName(b.name))
@@ -585,12 +599,12 @@ export function BufferList(): JSX.Element {
// What is waiting in what is folded away, which is
// the thing worth knowing about a closed heading;
// how many channels it has is the fallback.
const waiting = section.buffers.filter((b) => b.unread > 0 && (!isEffectivelyMuted(b) || b.highlight))
const unread = waiting.reduce((n, b) => n + b.unread, 0)
const waiting = section.buffers.filter((b) => waitingCount(b, isEffectivelyMuted(b)) > 0)
const unread = waiting.reduce((n, b) => n + waitingCount(b, isEffectivelyMuted(b)), 0)
return unread > 0 ? (
<span
className={classes('unread-badge category-count', waiting.some((b) => b.highlight) && 'highlight')}
title={`${unread} unread in ${waiting.length} ${waiting.length === 1 ? 'channel' : 'channels'}`}
title={`${unread} ${unread === 1 ? 'mention' : 'mentions'} in ${waiting.length} ${waiting.length === 1 ? 'channel' : 'channels'}`}
>
{unread > 99 ? '99+' : unread}
</span>
@@ -829,6 +843,8 @@ interface BufferRowProps {
onSelect: () => void
onTogglePin: () => void
onToggleMute: () => void
/** Matrix rooms muted on the account: take that mute off. */
onUnmuteAccount?: () => void
/** IRC channels only: rejoin this on every connect, or stop doing so. */
onToggleAutojoin?: () => void
/** Kick channels only: watch the stream where streams are watched. */
@@ -878,6 +894,7 @@ function BufferRow({
onSelect,
onTogglePin,
onToggleMute,
onUnmuteAccount,
onToggleAutojoin,
autojoins,
onOpenInBrowser,
@@ -982,6 +999,7 @@ function BufferRow({
spaces,
onTogglePin,
onToggleMute,
onUnmuteAccount,
onToggleAutojoin,
onSpace,
onWatch,
@@ -1050,9 +1068,9 @@ function BufferRow({
arrive is worse than either on its own. */}
{live ? (
<span className="live-badge">LIVE</span>
) : buffer.unread > 0 && (!muted || buffer.highlight) ? (
<span className={classes('unread-badge', buffer.highlight && 'highlight')}>
{buffer.unread > 99 ? '99+' : buffer.unread}
) : waitingCount(buffer, muted) > 0 ? (
<span className={classes('unread-badge', (buffer.highlight || muted) && 'highlight')}>
{waitingCount(buffer, muted) > 99 ? '99+' : waitingCount(buffer, muted)}
</span>
) : (
// Left unread on purpose, with nothing new in it. A dot rather than
+34
View File
@@ -9,6 +9,7 @@ import torMark from '../assets/tor.svg'
import { PollComposer } from './PollComposer'
import { Avatar } from './Avatar'
import { COMMON_EMOJI, emojiToken, EmojiPicker, type StickerEntry } from './EmojiPicker'
import { GifPicker } from './GifPicker'
import { PlaceField } from './PlaceField'
import { VoiceRecorder } from './VoiceRecorder'
import { useActiveBuffer, useChat, useStore } from '../state/hooks'
@@ -133,6 +134,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
/** The account's sticker packs, fetched when the picker is first opened. */
const [stickers, setStickers] = useState<StickerEntry[]>([])
const [stickerPicker, setStickerPicker] = useState(false)
const [gifPicker, setGifPicker] = useState(false)
/**
* What to do with files dropped on the window: they land in the tray, to be
* looked over and sent with the next message rather than going out on the
@@ -218,6 +220,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
const [roles, setRoles] = useState<{ id: string; name: string; colour?: string }[]>([])
const emojiButtonRef = useRef<HTMLButtonElement>(null)
const stickerButtonRef = useRef<HTMLButtonElement>(null)
const gifButtonRef = useRef<HTMLButtonElement>(null)
const plusButtonRef = useRef<HTMLButtonElement>(null)
/** The "+" menu, where it opened. */
const [plusMenu, setPlusMenu] = useState<{ x: number; y: number } | null>(null)
@@ -1259,6 +1262,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
title="Emoji"
onClick={() => {
setStickerPicker(false)
setGifPicker(false)
if (!pickerOpen) loadStickers()
setPickerOpen(!pickerOpen)
}}
@@ -1266,6 +1270,25 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
<Icon name="mood" size={18} />
</button>
{/* GIFs, wherever there is a Discord account to ask for them: the
picker borrows that account's way of looking, and what it finds can
be sent in any conversation. */}
{!thread && accounts.some((a) => a.service === 'discord') && (
<button
ref={gifButtonRef}
type="button"
className="icon-button"
title="GIFs"
onClick={() => {
setPickerOpen(false)
setStickerPicker(false)
setGifPicker(!gifPicker)
}}
>
<Icon name="gif_box" size={18} />
</button>
)}
{/* Its own button beside the emoji one, because it is its own
gesture: an emoji goes into the line being written and a sticker
is the message. Only where the service has them: Matrix, where a
@@ -1279,6 +1302,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
onClick={() => {
const opening = !stickerPicker
setPickerOpen(false)
setGifPicker(false)
setStickerPicker(opening)
if (opening) loadStickers()
}}
@@ -1323,6 +1347,16 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
{polling && <PollComposer bufferId={buffer.id} onClose={() => setPolling(false)} />}
{gifPicker && (
<GifPicker
anchor={gifButtonRef.current}
bufferId={buffer.id}
accountId={buffer.accountId}
discord={service === 'discord'}
onClose={() => setGifPicker(false)}
/>
)}
{(pickerOpen || stickerPicker) && (
<EmojiPicker
// Remounted when the other button is pressed, so it opens on that
@@ -75,6 +75,13 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
.catch((e: Error) => store.toast('error', e.message))
}
},
onUnmuteAccount:
account?.service === 'matrix'
? () =>
void window.moho
.rpc('setMatrixRoomMuted', { bufferId: buffer.id, muted: false })
.catch((e: Error) => store.toast('error', e.message))
: undefined,
onToggleAutojoin:
buffer.kind === 'channel' && account?.service === 'irc'
? () => store.toggleAutojoin(buffer.accountId, name)
+238
View File
@@ -0,0 +1,238 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Icon } from './Icon'
import { useEscapeLayer } from '../lib/layers'
import { useMediaUrl } from '../lib/route'
import { useStore } from '../state/hooks'
/** One of the GIFs the daemon lists: what to play, and what to send. */
export interface GifEntry {
id: string
title: string
/** The page on Tenor or Klipy, which is what Discord is sent. */
url: string
/** The video, to play in the grid. */
src: string
/** The file, which is what everything that is not Discord is sent. */
gifSrc: string
preview: string
width: number
height: number
}
interface GifCategory {
name: string
src: string
}
/** How long after the last keystroke before a search is made. */
const SEARCH_DELAY = 450
/**
* GIFs to browse and send, as Discord's own picker has them: what is trending,
* a search, and a row of categories to start from.
*
* They come from Discord - any account will do, whichever conversation this is
* open in - and go anywhere: to a Discord conversation as the link Discord's
* own client sends, so that Discord unfurls it; to every other service as the
* file itself, which a window of this kind draws as the GIF and any other
* client shows as a link.
*/
export function GifPicker({
anchor,
bufferId,
accountId,
discord,
onClose
}: {
anchor: HTMLElement | null
bufferId: string
accountId: string
/** Whether the conversation is on Discord, which decides what is sent. */
discord: boolean
onClose: () => void
}): JSX.Element {
const store = useStore()
const media = useMediaUrl(accountId)
const ref = useRef<HTMLDivElement>(null)
const scroll = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState({ left: 0, top: 0 })
const [query, setQuery] = useState('')
const [gifs, setGifs] = useState<GifEntry[]>([])
const [categories, setCategories] = useState<GifCategory[]>([])
const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading')
const [error, setError] = useState('')
useEscapeLayer(onClose)
// Above and right-aligned to its button, and kept in view, as the emoji
// picker is.
useLayoutEffect(() => {
const el = ref.current
if (!el || !anchor) return
const place = (): void => {
const a = anchor.getBoundingClientRect()
const r = el.getBoundingClientRect()
const left = Math.max(4, Math.min(a.right - r.width, window.innerWidth - r.width - 4))
const top = Math.max(4, Math.min(a.top - r.height - 6, window.innerHeight - r.height - 4))
setPos((was) => (was.left === left && was.top === top ? was : { left, top }))
}
place()
const ro = new ResizeObserver(place)
ro.observe(el)
window.addEventListener('resize', place)
return () => {
ro.disconnect()
window.removeEventListener('resize', place)
}
}, [anchor])
// Pressing anywhere else closes it; pressing inside, or on its button, does not.
useEffect(() => {
const onDown = (e: MouseEvent): void => {
const target = e.target as Node
if (ref.current?.contains(target) || anchor?.contains(target)) return
onClose()
}
window.addEventListener('mousedown', onDown, true)
return () => window.removeEventListener('mousedown', onDown, true)
}, [anchor, onClose])
// What is trending when it opens, and a search once typing has stopped. An
// answer that arrives after a newer question was asked is not shown.
useEffect(() => {
// The harness has no Discord to ask, so it can leave the answer here. Only
// a window it started has the hook it is read from.
const fixture = (window as unknown as { __mohoShots?: unknown; __gifFixture?: { gifs: GifEntry[]; categories: GifCategory[] } })
if (fixture.__mohoShots && fixture.__gifFixture) {
const wanted = query.trim().toLowerCase()
setGifs(fixture.__gifFixture.gifs.filter((g) => !wanted || g.title.toLowerCase().includes(wanted)))
setCategories(fixture.__gifFixture.categories)
setState('ready')
return
}
let current = true
setState('loading')
const wait = setTimeout(
() => {
window.moho
.rpc<{ gifs: GifEntry[]; categories: GifCategory[] }>('listGifs', { query: query.trim() })
.then((answer) => {
if (!current) return
setGifs(answer.gifs)
setCategories(answer.categories)
setState('ready')
})
.catch((e: Error) => {
if (!current) return
setError(e.message)
setState('failed')
})
},
query.trim() ? SEARCH_DELAY : 0
)
return () => {
current = false
clearTimeout(wait)
}
}, [query])
const pick = (gif: GifEntry): void => {
onClose()
// Discord's own client sends the link, which Discord turns into the GIF.
// Everything else is sent the file, since a page address would be only
// that.
void store.sendMessage(bufferId, discord ? gif.url : gif.gifSrc)
}
// Two columns, each GIF into whichever is shorter, so differently shaped ones
// fit together the way Discord's do.
const columns: GifEntry[][] = [[], []]
const heights = [0, 0]
for (const gif of gifs) {
const into = heights[0] <= heights[1] ? 0 : 1
columns[into].push(gif)
heights[into] += gif.width && gif.height ? gif.height / gif.width : 0.75
}
return createPortal(
<div ref={ref} className="gif-picker" style={{ left: pos.left, top: pos.top }} role="dialog" aria-label="GIFs">
<div className="gif-search">
<Icon name="search" size={18} />
<input
autoFocus
className="text-field"
value={query}
placeholder="Search for a GIF"
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<div className="gif-scroll" ref={scroll}>
{!query.trim() && categories.length > 0 && (
<div className="gif-categories">
{categories.map((c) => (
<button key={c.name} type="button" className="gif-category" onClick={() => setQuery(c.name)}>
{c.name}
</button>
))}
</div>
)}
{state === 'failed' && <div className="gif-empty muted">{error || 'GIFs could not be loaded'}</div>}
{state === 'loading' && gifs.length === 0 && <div className="gif-empty muted">Loading…</div>}
{state === 'ready' && gifs.length === 0 && <div className="gif-empty muted">No GIFs found</div>}
{gifs.length > 0 && (
<div className="gif-grid">
{columns.map((column, i) => (
<div key={i} className="gif-column">
{column.map((gif) => (
<GifCell key={gif.id} gif={gif} media={media} root={scroll} onPick={pick} />
))}
</div>
))}
</div>
)}
</div>
</div>,
document.body
)
}
/**
* One GIF. Played only while it is on screen: a grid of forty videos all
* running is a great deal of work for a list nobody is looking at the bottom
* of, so out of view it is a still picture and nothing more.
*/
function GifCell({
gif,
media,
root,
onPick
}: {
gif: GifEntry
media: (url: string) => string
root: React.RefObject<HTMLDivElement | null>
onPick: (gif: GifEntry) => void
}): JSX.Element {
const cell = useRef<HTMLButtonElement>(null)
const [visible, setVisible] = useState(false)
useEffect(() => {
const el = cell.current
if (!el) return
const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting), { root: root.current, rootMargin: '80px' })
observer.observe(el)
return () => observer.disconnect()
}, [root])
const ratio = gif.width && gif.height ? `${gif.width} / ${gif.height}` : '4 / 3'
return (
<button ref={cell} type="button" className="gif-cell" title={gif.title} style={{ aspectRatio: ratio }} onClick={() => onPick(gif)}>
{visible ? (
<video src={media(gif.src)} poster={gif.preview ? media(gif.preview) : undefined} autoPlay loop muted playsInline />
) : gif.preview ? (
<img src={media(gif.preview)} alt="" draggable={false} />
) : null}
</button>
)
}
@@ -0,0 +1,194 @@
import { Icon } from './Icon'
import { embedColor } from '../lib/format'
import { nickColor } from '../lib/util'
import type { Embed } from '../../../shared/wire'
/** The embed kinds the daemon draws as these cards instead of as a link preview. */
export const KICK_EVENT_KINDS = ['kicks', 'gift-sub', 'raid', 'redeem', 'subscribe', 'moderation', 'stream']
export function isKickEvent(embed: Embed): boolean {
return !!embed.kind && KICK_EVENT_KINDS.includes(embed.kind)
}
/** Kick's own mark: a K on a disc. Drawn rather than fetched - it is a few shapes. */
function KicksMark({ size = 16 }: { size?: number }): JSX.Element {
return (
<span className="kicks-mark" style={{ width: size, height: size, fontSize: size * 0.68 }} aria-hidden="true">
K
</span>
)
}
/**
* What somebody did on a Kick stream, as the card Kick draws it - a bar down
* the edge, a mark for what it was, who did it in bold, and the words - on this
* client's own surfaces and tokens rather than Kick's page.
*
* Every kind of event a Kick channel has, all from the daemon
* (nobilis/src/backend/kick/chat.rs): KICKs sent, a subscription and one gifted
* to somebody, a raid, a channel reward redeemed - which keeps the colour the
* streamer gave it - a ban, a timeout or a chat cleared, and the stream going
* live or ending.
*/
export function KickEventCard({ embed }: { embed: Embed }): JSX.Element | null {
const who = embed.author
if (!who) return null
const name = (colour?: string): JSX.Element => (
<strong className="kick-event-name" style={{ color: colour ?? embed.authorColor ?? nickColor(who) }}>
{who}
</strong>
)
const accent = embedColor(embed.color) || undefined
const tint = accent ? ({ '--kick-accent': accent } as React.CSSProperties) : undefined
if (embed.kind === 'kicks') {
return (
<div className="kick-event kicks selectable" style={tint}>
<div className="kick-event-main">
<div className="kick-event-line">
{name()}
<span className="kick-event-verb">sent</span>
<KicksMark />
<strong className="kick-event-amount">{embed.title}</strong>
{embed.provider && <span className="kick-event-gift small muted">{embed.provider}</span>}
</div>
{embed.description && <div className="kick-event-message">{embed.description}</div>}
</div>
<span className="kick-event-badge">
<KicksMark size={30} />
</span>
</div>
)
}
if (embed.kind === 'raid') {
return (
<div className="kick-event raid selectable">
<span className="kick-event-badge lead">
<Icon name="groups" size={20} />
</span>
<div className="kick-event-main">
<div className="kick-event-line">
{name()}
<span className="kick-event-verb">
{embed.title ? 'has raided with' : 'has raided the channel'}
</span>
{embed.title && (
<>
<strong className="kick-event-amount">{embed.title}</strong>
<span className="kick-event-verb">{embed.title === '1' ? 'viewer.' : 'viewers.'}</span>
</>
)}
</div>
{embed.description && <div className="kick-event-message">{embed.description}</div>}
</div>
</div>
)
}
if (embed.kind === 'redeem') {
return (
<div className="kick-event redeem selectable" style={tint}>
<span className="kick-event-badge lead">
<Icon name="stars" size={22} />
</span>
<div className="kick-event-main">
{name(accent)}
<div className="kick-event-line">
<span className="kick-event-verb">has redeemed</span>
<strong>{embed.title}</strong>
</div>
{embed.description && <div className="kick-event-message">{embed.description}</div>}
</div>
</div>
)
}
if (embed.kind === 'subscribe') {
return (
<div className="kick-event subscribe selectable">
<span className="kick-event-badge lead">
<Icon name="workspace_premium" size={22} />
</span>
<div className="kick-event-line">
{name()}
<span className="kick-event-verb">subscribed</span>
{embed.title && (
<>
<span className="kick-event-verb">-</span>
<strong>{embed.title}</strong>
<span className="kick-event-verb">months</span>
</>
)}
</div>
</div>
)
}
if (embed.kind === 'moderation') {
return (
<div className="kick-event moderation selectable">
<span className="kick-event-badge lead">
<Icon name="gavel" size={20} />
</span>
<div className="kick-event-line">
<strong className="kick-event-name">{who}</strong>
<span className="kick-event-verb">{embed.title}</span>
{embed.description && (
<>
<span className="kick-event-verb">by</span>
<strong>{embed.description}</strong>
</>
)}
</div>
</div>
)
}
if (embed.kind === 'stream') {
const live = embed.icon === 'live'
return (
<div className={`kick-event stream selectable ${live ? 'live' : 'offline'}`}>
<span className="kick-event-badge lead">
<Icon name={live ? 'sensors' : 'sensors_off'} size={20} />
</span>
<div className="kick-event-line">
<strong className="kick-event-name">{who}</strong>
<span className="kick-event-verb">{embed.title}</span>
</div>
</div>
)
}
const count = Number(embed.title) || 1
const recipients = (embed.description ?? '').split('\n').filter(Boolean)
return (
<div className="kick-event gift selectable">
<span className="kick-event-badge lead">
<Icon name="redeem" size={22} />
</span>
<div className="kick-event-main">
<div className="kick-event-line">
{name()}
<span className="kick-event-verb">gifted {count === 1 ? 'a sub' : `${count} subs`}</span>
{recipients.length === 1 && (
<>
<span className="kick-event-verb">to</span>
<strong style={{ color: nickColor(recipients[0]) }}>{recipients[0]}</strong>
</>
)}
</div>
{recipients.length > 1 && (
<div className="kick-event-recipients">
<span className="kick-event-verb">to</span>
{recipients.map((r) => (
<strong key={r} className="kick-event-recipient" style={{ color: nickColor(r) }}>
{r}
</strong>
))}
</div>
)}
</div>
</div>
)
}
+13 -2
View File
@@ -433,11 +433,22 @@ export function MediaEmbed({
className="media-embed"
style={ratio}
src={media(fullSrc)}
controls
// A GIF is silent, runs on its own and goes round, and has no
// player to operate: it plays when it is shown if autoplay is on,
// and otherwise while the pointer is over it. Any other video is a
// video, with its controls.
{...(attachment?.gif
? {
muted: true,
autoPlay: autoplay,
loop: true,
onMouseEnter: (e: React.MouseEvent<HTMLVideoElement>) => !autoplay && void e.currentTarget.play().catch(() => {}),
onMouseLeave: (e: React.MouseEvent<HTMLVideoElement>) => !autoplay && e.currentTarget.pause()
}
: { controls: true, loop })}
// Enough to draw the first frame and know how long it is, without
// pulling megabytes for every video in a scrolled-past backlog.
preload="metadata"
loop={loop}
playsInline
onError={() => setFailed(true)}
/>
+66 -14
View File
@@ -52,6 +52,9 @@ const REANCHOR_THRESHOLD = 100
*/
const SCROLL_JITTER = 2
/** How far down from the top edge to look for the row being read, nearest first. */
const PLACE_PROBES = [1, 24, 48, 80, 120, 180]
/**
* How long after the reader last touched the list a scroll still counts as
* theirs.
@@ -162,6 +165,16 @@ const RENDER_WINDOW = 120
const RENDER_STEP = 120
/** Rows kept below a jumped-to message, so it has somewhere to sit. */
const JUMP_MARGIN = 40
/**
* The most unread a conversation can have and still open at its first unread
* message. Discord's rule, and for the same reason: its first screenful of
* history is fifty messages, so an unread run that fits in it is somewhere to
* start reading, and one that does not is somewhere nobody wants to be taken -
* hundreds or thousands of messages up, with the conversation they came for
* a long scroll away. Past it the log opens at its end, as any other does,
* and the bar across the top says how much there is and goes back to it.
*/
const OPEN_AT_UNREAD_LIMIT = 50
export function MessageList(): JSX.Element {
const store = useStore()
@@ -363,17 +376,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
}
}
}, [])
@@ -512,13 +537,21 @@ export function MessageList(): JSX.Element {
// of switching, because what has been read from the daemon may not have
// arrived yet - the line has to be in the drawn part of the log, which is a
// tail, before it can be scrolled to.
// The divider marks the first message newer than the snapshot taken when the
// buffer was opened; it deliberately doesn't move as more arrive.
const dividerIndex = dividerTs > 0 ? messages.findIndex((m) => m.ts > dividerTs) : -1
// Where it is against what is drawn, for the callbacks below that have to
// know whether the line exists on the page or only in the log.
const dividerRef = useRef({ index: -1, start: 0, length: 0 })
dividerRef.current = { index: dividerIndex, start, length: messages.length }
const openedAtRef = useRef('')
useLayoutEffect(() => {
const key = `${bufferId}|${dividerTs}`
if (openedAtRef.current === key || messages.length === 0) return
openedAtRef.current = key
const first = dividerTs > 0 ? messages.findIndex((m) => m.ts > dividerTs) : -1
if (first >= 0 && messages.length - first > 8) {
if (first >= 0 && messages.length - first > 8 && messages.length - first <= OPEN_AT_UNREAD_LIMIT) {
wantDividerRef.current = true
setShown((n) => Math.max(n, messages.length - first + JUMP_MARGIN))
}
@@ -528,8 +561,13 @@ export function MessageList(): JSX.Element {
const measureDivider = useCallback(() => {
const el = scrollRef.current
const line = el?.querySelector<HTMLElement>('.new-divider')
if (!el || !line) {
setDividerAbove(false)
if (!el) return
if (!line) {
// Not drawn is not the same as not there. A conversation opened with a
// long unread run is at its end, and its line is up in the log beyond
// what is built - which is exactly when the bar is wanted.
const { index, start } = dividerRef.current
setDividerAbove(index >= 0 && index < start)
return
}
// Clearly above, not merely just off the top: with the line a few pixels
@@ -541,7 +579,18 @@ export function MessageList(): JSX.Element {
const toDivider = useCallback((smooth: boolean) => {
const el = scrollRef.current
const line = el?.querySelector<HTMLElement>('.new-divider')
if (!el || !line) return
if (!el) return
if (!line) {
// In the log and not on the page: build the log back to it, and the
// layout effect below scrolls to it once it is there.
const { index, length } = dividerRef.current
if (index < 0) return
anchor(false)
setMissedCount(0)
wantDividerRef.current = true
setShown((n) => Math.max(n, length - index + JUMP_MARGIN))
return
}
anchor(false)
// Going back to the line is not missing anything: what is below is what
// was already there, not news.
@@ -551,6 +600,13 @@ export function MessageList(): JSX.Element {
el.scrollTo({ top: Math.max(0, target), behavior: smooth ? 'smooth' : 'auto' })
}, [anchor, messages])
// Whether the line is out of sight, asked as the log changes as well as when
// it is scrolled: a conversation opened at its end never scrolls, and is
// the one with the most to say about what is above it.
useLayoutEffect(() => {
measureDivider()
}, [bufferId, dividerTs, messages.length, shown, measureDivider])
// Once the line is drawn, start there.
useLayoutEffect(() => {
if (!wantDividerRef.current) return
@@ -713,10 +769,6 @@ export function MessageList(): JSX.Element {
return () => window.removeEventListener('moho:jump-to-present', go)
}, [])
// The divider marks the first message newer than the snapshot taken when the
// buffer was opened; it deliberately doesn't move as more arrive.
const dividerIndex = dividerTs > 0 ? messages.findIndex((m) => m.ts > dividerTs) : -1
return (
// Whose conversation this is, for everything drawn inside it: a strictly
// routed account's media goes through its route (#257). The attribute is
+37 -5
View File
@@ -7,6 +7,7 @@ import { EventSource } from './EventSource'
import { ForwardPicker } from './ForwardPicker'
import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu'
import { MediaEmbed } from './MediaEmbed'
import { KickEventCard, isKickEvent } from './KickEventCard'
import { COMMON_EMOJI, EmojiPicker, readRecent } from './EmojiPicker'
import { reactionLabel, reactorsSentence } from '../lib/reactions'
import { UploadMeter } from './UploadMeter'
@@ -17,6 +18,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,
@@ -405,6 +407,9 @@ function MessageRowBody({
// label on.
const [highlightNotices] = usePref<boolean>('matrix.highlightNotices', true)
const reward = isReward(message.kind)
// KICKs sent, or subscriptions gifted: the daemon says which and to whom in
// a card, and the line of text is what a copy or a search finds.
const kickEvent = (message.embeds ?? []).find(isKickEvent)
// Both of the modes that draw an avatar column. Bubbles is otherwise
// nothing like comfy, but it wants the same picture beside the same first
// line of a group.
@@ -418,7 +423,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 +470,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)
@@ -763,6 +790,7 @@ function MessageRowBody({
message.failed && 'failed',
isSystem && 'system',
reward && 'reward',
kickEvent && 'has-kick-event',
grouped && 'grouped',
fresh && 'fresh',
comfy && 'comfy',
@@ -805,7 +833,7 @@ function MessageRowBody({
{/* A join, a part, a topic change: not somebody speaking, so no face -
but a small mark in the picture's own column, so the line's text
starts where every other line's does instead of at the edge. */}
{mode === 'comfy' && isSystem && (
{mode === 'comfy' && isSystem && !kickEvent && (
<span className="message-avatar system-mark" aria-hidden>
<Icon name={SYSTEM_MARKS[message.kind] ?? 'info'} size={16} />
</span>
@@ -1035,6 +1063,8 @@ function MessageRowBody({
</div>
)}
</div>
) : kickEvent ? (
<KickEventCard embed={kickEvent} />
) : (
parts.html && (
<span className="message-body selectable">
@@ -1077,7 +1107,8 @@ function MessageRowBody({
</pre>
))}
{(message.embeds || []).map((embed, i) => (
{(message.embeds || []).map((embed, i) =>
embed.kind === 'gif' || isKickEvent(embed) ? null : (
<div
key={`e${i}`}
className={classes('rich-embed selectable', embed.kind === 'notice' && 'notice')}
@@ -1170,7 +1201,8 @@ function MessageRowBody({
</div>
)}
</div>
))}
)
)}
{/* Pictures and videos that came together are laid out together:
two side by side, three with the first tall, four in a square,
@@ -2,7 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Avatar } from './Avatar'
import { Icon } from './Icon'
import { useChat, useStore } from '../state/hooks'
import { useChat, useIdSetPref, usePref, useStore } from '../state/hooks'
import { isMutedBuffer, waitingCount } from '../lib/groups'
import { rankBuffers, whereText } from '../lib/switcher'
import { bufferKindGlyph, classes } from '../lib/util'
@@ -18,6 +19,8 @@ export function QuickSwitcher(): JSX.Element | null {
const store = useStore()
const buffers = useChat((s) => s.buffers)
const accounts = useChat((s) => s.accounts)
const [muted] = useIdSetPref('mutedBuffers')
const [mutedGroups] = usePref<string[]>('mutedGroups', [])
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [index, setIndex] = useState(0)
@@ -103,6 +106,7 @@ export function QuickSwitcher(): JSX.Element | null {
{hits.length === 0 && <div className="switcher-empty muted">Nothing by that name.</div>}
{hits.map((hit, i) => {
const b = hit.buffer
const waiting = waitingCount(b, isMutedBuffer(b, muted, mutedGroups))
return (
<button
key={b.id}
@@ -122,9 +126,9 @@ export function QuickSwitcher(): JSX.Element | null {
</span>
<span className="switcher-title ellipsis">{hit.title}</span>
<span className="switcher-where muted small ellipsis">{hit.where}</span>
{b.unread > 0 && (
{waiting > 0 && (
<span className={classes('unread-badge', b.highlight && 'highlight')}>
{b.unread > 99 ? '99+' : b.unread}
{waiting > 99 ? '99+' : waiting}
</span>
)}
</button>
+36 -22
View File
@@ -28,7 +28,8 @@ import {
reorderFolder,
FOLDER_DRAG_PREFIX,
visibleGroups,
countsTowardRail,
countedFor,
isMutedBuffer,
dmGroup,
DM_GROUP_ID,
isDirectMessage,
@@ -60,6 +61,8 @@ interface TileProps {
active: boolean
unread: number
highlight: boolean
/** How many unread messages mention this account: the number the tile shows. */
mentions: number
draggable: boolean
dropTarget: boolean
/** Being dragged right now: it leaves a gap where it was. */
@@ -179,7 +182,7 @@ function isMerge(e: React.DragEvent): boolean {
}
function RailTile(props: TileProps): JSX.Element {
const { group, active, unread, highlight, draggable, dropTarget, customIcon, muted, lifted, mergeTarget } = props
const { group, active, unread, highlight, mentions, draggable, dropTarget, customIcon, muted, lifted, mergeTarget } = props
const { menu, open, close } = useContextMenu()
const store = useStore()
// What is waiting inside this server, for "Mark as read".
@@ -299,15 +302,17 @@ function RailTile(props: TileProps): JSX.Element {
onClose={close}
/>
)}
{/* A mention waiting in here says how many are waiting, in the corner
the service mark sits in - the mark gives way, since a count is what
somebody scanning this column came to see. */}
{highlight && unread > 0 && (
<span className="rail-count highlight" title={`${unread} unread, with a mention`}>
{unread > 99 ? '99+' : unread}
{/* Mentions say how many are waiting, in the corner the service mark sits
in - the mark gives way, since a count is what somebody scanning this
column came to see. Only mentions: how many messages there are in a
busy channel is not what this column is for, and the pill beside the
tile already says there is something to read. */}
{mentions > 0 && (
<span className="rail-count highlight" title={`${mentions} mention${mentions === 1 ? '' : 's'}`}>
{mentions > 99 ? '99+' : mentions}
</span>
)}
{badge && !(highlight && unread > 0) && (
{badge && mentions === 0 && (
<span className={`rail-service${highlight ? ' highlight' : ''}`}>
{badge.colour && badge.mark ? (
<img src={badge.mark} alt="" draggable={false} />
@@ -419,18 +424,24 @@ export function ServerRail(): JSX.Element | null {
// Unread rolls up from the buffers under each entry, so a guild whose
// channels are all collapsed away still shows it has something waiting.
const totals = new Map<string, { unread: number; highlight: boolean }>()
const totals = new Map<string, { unread: number; highlight: boolean; mentions: number }>()
// 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 t = totals.get(key) || { unread: 0, highlight: false }
t.unread += b.unread
t.highlight = t.highlight || b.highlight
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 += 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)
@@ -520,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))
@@ -552,6 +563,7 @@ export function ServerRail(): JSX.Element | null {
onBrowse={g.kind === 'space' ? () => setBrowsing(g) : undefined}
unread={t?.unread ?? 0}
highlight={t?.highlight ?? false}
mentions={t?.mentions ?? 0}
// Direct messages and pinned lead the rail by definition, so
// there is nowhere for them to be dragged to.
draggable={!isFixedEntry(g)}
@@ -599,6 +611,7 @@ export function ServerRail(): JSX.Element | null {
open={open}
unread={entry.members.reduce((n, m) => n + (totals.get(m.id)?.unread ?? 0), 0)}
highlight={entry.members.some((m) => totals.get(m.id)?.highlight)}
mentions={entry.members.reduce((n, m) => n + (totals.get(m.id)?.mentions ?? 0), 0)}
onToggle={() => toggleFolder(entry.id)}
lifted={dragging === `${FOLDER_DRAG_PREFIX}${entry.id}`}
onDragStart={() => beginDrag(`${FOLDER_DRAG_PREFIX}${entry.id}`)}
@@ -788,6 +801,7 @@ function FolderTile(props: {
/** What is waiting in the servers inside, so a closed folder can say so. */
unread: number
highlight: boolean
mentions: number
onToggle: () => void
onContextMenu: (e: React.MouseEvent) => void
onDragStart: () => void
@@ -795,7 +809,7 @@ function FolderTile(props: {
onDrop: () => void
onDragEnd: () => void
}): JSX.Element {
const { folder, members, customIcons, dropTarget, lifted, open, unread, highlight } = props
const { folder, members, customIcons, dropTarget, lifted, open, unread, mentions } = props
// Open, its servers show their own; closed, what is inside would be
// invisible without this.
const waiting = !open && unread > 0
@@ -862,14 +876,14 @@ function FolderTile(props: {
{/* Once the face is a grid of other people's icons, nothing says it is
a folder any more - so it takes the same corner badge a guild uses
to name its service. An empty folder is already unmistakably one. */}
{members.length > 0 && !(waiting && highlight) && (
{members.length > 0 && !(!open && mentions > 0) && (
<span className="rail-service">
<Icon name="folder" size={11} />
</span>
)}
{waiting && highlight && (
<span className="rail-count highlight" title={`${unread} unread, with a mention`}>
{unread > 99 ? '99+' : unread}
{!open && mentions > 0 && (
<span className="rail-count highlight" title={`${mentions} mention${mentions === 1 ? '' : 's'}`}>
{mentions > 99 ? '99+' : mentions}
</span>
)}
</button>
+4 -3
View File
@@ -6,6 +6,7 @@ import { useEscapeLayer } from '../lib/layers'
import { presenceClass } from '../lib/presence'
import { classes } from '../lib/util'
import type { Account } from '../../../shared/wire'
import { statusName, supportsStatus } from '../lib/status'
import type { Status } from './UserFooter'
/** The statuses that can be chosen, with the dot each is drawn with. Signing out is separate, below them. */
@@ -89,7 +90,7 @@ export function StatusPopout({
aria-label="Set your status"
style={pos ? { left: pos.left, bottom: pos.bottom } : { visibility: 'hidden' }}
>
{CHOICES.map((c) => (
{CHOICES.filter((c) => supportsStatus(account.service, c.id)).map((c) => (
<button
key={c.id}
type="button"
@@ -100,8 +101,8 @@ export function StatusPopout({
>
<span className={classes('status-dot', presenceClass(c.dot))} />
<span className="status-choice-text">
<span>{c.label}</span>
{c.hint && <span className="small muted">{c.hint}</span>}
<span>{statusName(account.service, c.id)}</span>
{c.hint && <span className="small muted">{account.service === 'matrix' ? 'Your presence is set to offline' : c.hint}</span>}
</span>
{c.id === status && <Icon name="check" size={16} className="status-check" />}
</button>
+8 -5
View File
@@ -4,6 +4,7 @@ import { Icon } from './Icon'
import { Avatar } from './Avatar'
import { useStore, useChat } from '../state/hooks'
import { presenceLabel } from '../lib/presence'
import { statusName } from '../lib/status'
import { serviceLabel } from '../lib/util'
import type { Account } from '../../../shared/wire'
@@ -34,8 +35,10 @@ export function effectiveStatus(account: Account): Status | 'connecting' {
return (account.status as Status) || 'online'
}
function label(status: Status | 'connecting'): string {
return status === 'connecting' ? 'Connecting…' : presenceLabel(status)
function label(status: Status | 'connecting', service: string, auto = false): string {
if (status === 'connecting') return 'Connecting…'
if (status === 'idle' && auto) return 'Idle (away)'
return status === 'invisible' ? statusName(service, 'invisible') : presenceLabel(status)
}
export function UserFooter({ account }: { account?: Account }): JSX.Element {
@@ -71,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)}\n${detail}` : `${name} — ${label(status)}`}
title={detail ? `${name} — ${label(status, account.service, account.autoIdle)}\n${detail}` : `${name} — ${label(status, account.service, account.autoIdle)}`}
>
<Avatar name={name} url={account.avatarUrl} size={28} status={status} accountId={account.id} />
<span className="user-identity">
<span className="ellipsis user-name">{name}</span>
<span className="ellipsis small muted">{watching ? 'Watching, signed out' : label(status)}</span>
<span className="ellipsis small muted">{watching ? 'Watching, signed out' : label(status, account.service, account.autoIdle)}</span>
</span>
</button>
@@ -117,7 +120,7 @@ export function UserFooter({ account }: { account?: Account }): JSX.Element {
</div>
{open && (
<StatusPopout account={account} status={status} anchor={identity.current} onClose={() => setOpen(false)} />
<StatusPopout account={account} status={account.autoIdle ? 'online' : status} anchor={identity.current} onClose={() => setOpen(false)} />
)}
</>
)
@@ -855,6 +855,27 @@ function GeneralSettings(): JSX.Element {
<GlobalHighlightKeywords />
</SettingsSection>
<SettingsSection title="Presence">
<ToggleSetting
settingKey="presence.autoIdle"
label="Go idle when I step away"
description="Shows you as Idle on Discord and IRC after a while with nothing touched, and back to Online when you return. Only while your status is Online: Idle, Do not disturb and Invisible stay where you put them until you set Online again. Not while you are in a call. moho looks only at how long it has been since the keyboard or mouse was used - never at what you do. Where the system will not say (some Wayland desktops), it goes by your use of moho's own windows."
defaultValue={true}
/>
<SelectionSetting
settingKey="presence.autoIdleMinutes"
label="Idle after"
defaultValue="10"
options={[
{ label: '5 min', value: '5' },
{ label: '10 min', value: '10' },
{ label: '15 min', value: '15' },
{ label: '30 min', value: '30' },
{ label: '1 hour', value: '60' }
]}
/>
</SettingsSection>
<SettingsSection title="Updates">
<ToggleSetting
settingKey="updates.notify"
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from 'vitest'
import { bufferMenuEntries } from './buffermenu'
const noop = (): void => {}
function entries(over: Record<string, unknown>) {
return bufferMenuEntries({
buffer: { id: 'b', accountId: 'matrix:@me:x', name: '!r:x', kind: 'channel', serverMuted: true },
account: { id: 'matrix:@me:x', service: 'matrix' },
muted: false,
pinned: false,
filed: false,
inCall: false,
poppedOut: false,
canCall: false,
live: false,
watched: false,
onTogglePin: noop,
onToggleMute: noop,
onHide: noop,
onClose: noop,
onCall: noop,
onHangUp: noop,
onFile: noop,
onPopOut: noop,
onDock: noop,
...over
} as never) as { label?: string; disabled?: boolean; onClick?: () => void }[]
}
describe('a mute held on the account', () => {
it('can be taken off where the service lets it be, and that is what the entry does', () => {
const unmute = vi.fn()
const toggle = vi.fn()
const entry = entries({ onUnmuteAccount: unmute, onToggleMute: toggle }).find((e) => e.label === 'Unmute')
expect(entry).toBeDefined()
expect(entry?.disabled).toBeFalsy()
entry?.onClick?.()
expect(unmute).toHaveBeenCalled()
// Not the window's own mute, which would only have added a second one.
expect(toggle).not.toHaveBeenCalled()
})
it('says where it is where it cannot be taken off from here', () => {
const entry = entries({}).find((e) => (e.label ?? '').startsWith('Muted on'))
expect(entry?.label).toBe('Muted on Matrix')
expect(entry?.disabled).toBe(true)
})
})
+16 -9
View File
@@ -32,6 +32,8 @@ export interface BufferMenuInput {
spaces?: BufferGroup[]
onTogglePin: () => void
onToggleMute: () => void
/** Takes off a mute the account itself holds, where the service lets it be taken off from here. */
onUnmuteAccount?: () => void
/**
* Filing the conversation on the account rather than in this window -
* Matrix's room tags. Absent for every service that has no such idea, which
@@ -86,6 +88,7 @@ export function bufferMenuEntries({
spaces,
onTogglePin,
onToggleMute,
onUnmuteAccount,
onTag,
onToggleAutojoin,
onSpace,
@@ -204,16 +207,20 @@ export function bufferMenuEntries({
}
] as MenuEntry[])
: []),
// A mute made on the account itself is not this window's to undo, and an
// "Unmute" that quietly did nothing would be worse than no entry at all -
// so it says where the mute is, and where to go and take it off.
// A mute made on the account itself - Matrix keeps it on the server, as a
// push rule - is the account's to take off, and where the service lets it
// be taken off from here that is what this does. Where it does not, the
// entry says where the mute is rather than offering an "Unmute" that quietly
// did nothing.
buffer.serverMuted
? {
label: account ? `Muted on ${serviceLabel(account.service)}` : 'Muted on this account',
icon: 'notifications_off',
disabled: true,
onClick: () => {}
}
? onUnmuteAccount
? { label: 'Unmute', icon: 'notifications', onClick: onUnmuteAccount }
: {
label: account ? `Muted on ${serviceLabel(account.service)}` : 'Muted on this account',
icon: 'notifications_off',
disabled: true,
onClick: () => {}
}
: { label: muted ? 'Unmute' : 'Mute', icon: muted ? 'notifications' : 'notifications_off', onClick: onToggleMute },
{ separator: true },
// Filing is a drag onto the heading now, not an entry per heading. That
+54
View File
@@ -9,6 +9,8 @@ import {
extractMedia,
extractQuoteBlocks,
formatMessage,
isForumUpload,
isGifPage,
hostnameOf,
ircFormat,
normalizeBBCode,
@@ -253,3 +255,55 @@ describe('blocks', () => {
expect(embedColor(undefined)).toBeUndefined()
})
})
describe('links to the pages GIFs are shared from', () => {
it('knows Tenor, Giphy and Klipy pages when it sees one', () => {
for (const url of [
'https://tenor.com/view/cat-gif-22954713',
'https://tenor.com/de/view/cat-gif-22954713',
'https://giphy.com/gifs/cat-Z7wFi5b2VEAr6',
'https://giphy.com/embed/Z7wFi5b2VEAr6',
'https://klipy.com/gifs/friends-joey-53',
'https://klipy.com/clips/angry-8'
]) {
expect(isGifPage(url), url).toBe(true)
}
expect(isGifPage('https://tenor.com/')).toBe(false)
expect(isGifPage('https://example.com/view/cat')).toBe(false)
})
it('does not ask a page what it is, which is a request to a third party for nothing', () => {
const asked: string[] = []
const media = extractMedia('look https://tenor.com/view/cat-gif-1 and https://klipy.com/gifs/x-1', {
contentSniffing: true,
onNeedSniff: (url) => asked.push(url)
})
expect(media).toEqual([])
expect(asked).toEqual([])
})
it('still draws a direct file from those hosts, like any other', () => {
const media = extractMedia('https://media.tenor.com/abc/cat.mp4 https://media.giphy.com/media/Z7wFi5b2VEAr6/giphy.gif', {})
expect(media.map((m) => m.kind)).toEqual(['video', 'image'])
})
})
describe('the forum\'s own files', () => {
const picture = 'https://uploads.kiwifarms.st/data/attachments/9671/9671535-a2e5ae0d55aadd338162770b3fa79f97-alt.avif?hash=-d_TarFo41'
it('are known by where they are', () => {
expect(isForumUpload(picture)).toBe(true)
expect(isForumUpload('https://kiwifarms.st/data/video/9667/9667943-abc.mp4?hash=x')).toBe(true)
expect(isForumUpload('https://kiwifarms.st/threads/a-thread.123/')).toBe(false)
expect(isForumUpload('https://example.com/data/attachments/1/2-a.avif')).toBe(false)
})
it('are not loaded by the window, which would ask for them from the reader\'s own address', () => {
expect(extractMedia(`[img]${picture}[/img]`, {})).toEqual([])
expect(extractMedia(`look ${picture}`, { contentSniffing: true, onNeedSniff: () => { throw new Error('asked') } })).toEqual([])
})
it('leave other pictures and an AVIF anywhere else as they were', () => {
expect(extractMedia('https://i.ibb.co/JRkN8BFv/photo.avif', {}).map((m) => m.kind)).toEqual(['image'])
})
})
+41 -1
View File
@@ -583,6 +583,43 @@ export const OPAQUE_IMAGE_HOSTS = ['i.ddos.lgbt']
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|avif)(\?\S*)?$/i
const VIDEO_EXT_RE = /\.(mp4|webm|mov|mkv|ogv)(\?\S*)?$/i
/**
* A page of one of the sites GIFs are shared from - Tenor, Giphy, Klipy.
*
* Not a picture, whatever it shows: a page. What it shows reaches the message
* as an embed, from Discord or from the daemon having read the page, so this
* need not be asked what it is - which, for a probe, is a request to a third
* party the moment a message arrives, and for Klipy one it answers with a
* challenge.
*/
const GIF_PAGE_RE = /^https?:\/\/(?:www\.)?(?:tenor\.com\/(?:[a-z]{2}(?:-[A-Za-z]{2})?\/)?view\/|giphy\.com\/(?:gifs|embed)\/|klipy\.com\/(?:gifs?|clips?|stickers?|memes?)\/)/i
/**
* One of the forum's own files: what its uploads host serves, a picture or a
* video.
*
* Not loaded by the window. The daemon fetches these through the account's own
* route and puts the file on the message, which is what draws; left to the
* window they would be asked for from the reader's own address - which the host
* turns away, and which for somebody on Tor is the leak routing is for. Between
* the message arriving and the file being kept the link is simply a link, and if
* it cannot be kept it stays one.
*
* The onion address is here as the clearnet one because the text has been
* through `clearnetLinks` by the time it reaches this.
*/
const FORUM_UPLOAD_RE = /^https?:\/\/(?:uploads\.|www\.)?kiwifarms\.st\/data\/(?:attachments|video)\//i
/** Whether this is a link to one of the forum's own files. */
export function isForumUpload(url: string): boolean {
return FORUM_UPLOAD_RE.test(url)
}
/** Whether this is a link to a GIF's page on one of those sites. */
export function isGifPage(url: string): boolean {
return GIF_PAGE_RE.test(url)
}
/**
* What a thumbnail was linked to, told apart by what it is.
*
@@ -652,7 +689,7 @@ export function extractMedia(
const url = raw.replace(/[),.;!?]+$/, '')
const yt = youtubeId(url)
const identity = yt ? `yt:${yt}` : url
if (seen.has(identity) || targets.has(url)) continue
if (seen.has(identity) || targets.has(url) || isForumUpload(url)) continue
if (yt) {
seen.add(identity)
@@ -666,6 +703,9 @@ export function extractMedia(
} else if (OPAQUE_IMAGE_HOSTS.includes(hostnameOf(url))) {
seen.add(identity)
result.push({ url, kind: 'image', ...biggerCopy(url, opts.thumbnails) })
} else if (isGifPage(url)) {
// A page, not a file: see GIF_PAGE_RE. Left as the link it is.
continue
} else if (opts.contentSniffing && /^https?:\/\//i.test(url)) {
// Nothing else could classify this. Not for file:// URLs: those are
// always nobilis's own already-classified attachment paths.
+25 -9
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { BufferEntry } from '../state/store'
import {
compareInBand,
countsTowardRail,
countedFor,
fileInFolder,
foldTogether,
orderedGroups,
@@ -10,6 +10,7 @@ import {
removeFromFolders,
reorder,
reorderFolder,
waitingCount,
type RailFolder,
type RailGroup
} from './groups'
@@ -81,20 +82,23 @@ describe('folders', () => {
describe('what a muted place still says', () => {
const buf = (extra: Partial<BufferEntry>): BufferEntry =>
({ id: 'a|#x', accountId: 'a', kind: 'channel', name: '#x', lastActivityTs: 0, groupId: 'g', unread: 3, highlight: false, ...extra }) as 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 quiet 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('still counts it once somebody has used your name', () => {
expect(countsTowardRail(buf({ highlight: true }), ['a|#x'], [])).toBe(true)
expect(countsTowardRail(buf({ highlight: true }), [], [], ['g'])).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)
})
})
@@ -118,3 +122,15 @@ describe('compareInBand', () => {
expect([dm('old', 1), dm('new', 9)].sort(compareInBand).map((c) => c.name)).toEqual(['new', 'old'])
})
})
describe('waitingCount', () => {
const buffer = (over: Partial<BufferEntry>): BufferEntry =>
({ id: 'a|#x', kind: 'channel', unread: 97, mentions: 0, highlight: false, ...over }) as BufferEntry
it('is the mentions in a channel, not everything said in it', () => {
expect(waitingCount(buffer({}))).toBe(0)
expect(waitingCount(buffer({ mentions: 2, highlight: true }))).toBe(2)
})
it('is each unread message in a direct message, which are all for you', () => {
expect(waitingCount(buffer({ kind: 'dm', unread: 4 }))).toBe(4)
})
})
+31 -8
View File
@@ -95,6 +95,24 @@ export function isDirectMessage(buffer: BufferEntry): boolean {
return buffer.kind === 'dm'
}
/**
* The number to show beside a conversation: what is addressed to you.
*
* Mentions, not everything said - a bold name already says there is activity,
* and a count of it is the noise the rail and tray were rid of. A direct
* message is the exception that is the same rule: every line in one is
* addressed to you, so it is counted by each unread message - unless it is
* muted, which is asking not to hear about it at all.
*
* A muted channel still counts the times your name was used in it. Muting is
* about being interrupted - a popup, a sound, a phone buzzing - not about
* hiding that you were asked for; Discord and Element both keep that badge.
*/
export function waitingCount(buffer: BufferEntry, muted = false): number {
if (isDirectMessage(buffer)) return muted ? 0 : buffer.unread
return buffer.mentions
}
export function pinnedGroup(): RailGroup {
return {
id: PINNED_GROUP_ID,
@@ -261,19 +279,24 @@ export function isMutedBuffer(
}
/**
* The buffers whose unread should reach a rail tile: what the pane below it
* would actually list. A hidden buffer has no row to click through to, and a
* muted one deliberately does not ask for attention - except to say that
* somebody used its owner's name, which muting a place has never meant not
* wanting to hear (the clients this one is measured against all keep that).
* 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) && (buffer.highlight || !isMutedBuffer(buffer, muted, mutedGroups))
): number {
if (hidden.includes(buffer.id)) return 0
return waitingCount(buffer, isMutedBuffer(buffer, muted, mutedGroups))
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { shouldOffer } from './matrixcall'
describe('which end of a Matrix call offers', () => {
it('is exactly one end of every pair, whichever way round they ask', () => {
// Both ends compute this about each other and must not agree, or the call
// collides with itself.
expect(shouldOffer('@a:example.org|AAA', '@b:example.org|BBB')).toBe(true)
expect(shouldOffer('@b:example.org|BBB', '@a:example.org|AAA')).toBe(false)
})
it('tells two devices of the same person apart', () => {
// The case that makes a user-id comparison alone wrong.
expect(shouldOffer('@a:example.org|AAA', '@a:example.org|ZZZ')).toBe(true)
expect(shouldOffer('@a:example.org|ZZZ', '@a:example.org|AAA')).toBe(false)
})
it('never has an end offer to itself', () => {
expect(shouldOffer('@a:example.org|AAA', '@a:example.org|AAA')).toBe(false)
})
})
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { statusesFor, statusName, supportsStatus } from './status'
describe('the statuses a service has', () => {
it('gives Discord all four', () => {
expect(statusesFor('discord')).toEqual(['online', 'idle', 'dnd', 'invisible'])
})
it('gives IRC away and back, and no invisible', () => {
expect(statusesFor('irc')).toEqual(['online', 'idle', 'dnd'])
})
it('gives Matrix offline presence as invisible and no idle', () => {
expect(statusesFor('matrix')).toEqual(['online', 'dnd', 'invisible'])
})
it('gives Kick and Sneedchat only the one that is moho\'s own', () => {
expect(statusesFor('kick')).toEqual(['online', 'dnd'])
expect(statusesFor('sneedchat')).toEqual(['online', 'dnd'])
expect(supportsStatus('sneedchat', 'idle')).toBe(false)
})
})
describe('what a status is called', () => {
it('calls Matrix\'s invisible what it is, offline presence', () => {
expect(statusName('matrix', 'invisible')).toBe('Offline')
expect(statusName('discord', 'invisible')).toBe('Invisible')
expect(statusName('matrix', 'dnd')).toBe('Do not disturb')
})
})
+44
View File
@@ -0,0 +1,44 @@
/**
* Which statuses a service has, kept in step with `status_supported` in the
* daemon, which also refuses what a service has no state for.
*
* Offered by what each can actually express rather than by what could be faked
* from something nearby: Discord has all four; IRC has away and back - idle and
* online; Matrix has online and offline presence, so invisible is real and idle
* is not; Kick and Sneedchat have none. Do not disturb is online with moho's own
* desktop notifications silenced, which any service can have, so it is on all of
* them - and only Discord has a real state for it, which also holds back its
* phone notifications.
*/
export type Choosable = 'online' | 'idle' | 'dnd' | 'invisible'
export const ALL_STATUSES: Choosable[] = ['online', 'idle', 'dnd', 'invisible']
export function supportsStatus(service: string, status: Choosable): boolean {
switch (status) {
case 'online':
case 'dnd':
return true
case 'idle':
return service === 'discord' || service === 'irc'
case 'invisible':
return service === 'discord' || service === 'matrix'
}
}
/** What an account of this service can be set to, in the order they are listed. */
export function statusesFor(service: string): Choosable[] {
return ALL_STATUSES.filter((s) => supportsStatus(service, s))
}
/**
* What a status is called on this service.
*
* Matrix has no invisible mode: what is sent for it is offline presence, which
* is what it is, so that is what it says there. Everything else is called what
* the others call it.
*/
export function statusName(service: string, status: Choosable): string {
if (status === 'invisible' && service === 'matrix') return 'Offline'
return { online: 'Online', idle: 'Idle', dnd: 'Do not disturb', invisible: 'Invisible' }[status]
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { trayCount } from './traycount'
import type { BufferEntry } from '../state/store'
const buf = (over: Partial<BufferEntry>): BufferEntry =>
({ id: 'a|#x', accountId: 'a', kind: 'channel', name: '#x', lastActivityTs: 0, groupId: 'g', unread: 0, highlight: false, mentions: 0, ...over }) as BufferEntry
describe('the number on the tray', () => {
it('is nothing when nothing is waiting', () => {
expect(trayCount([buf({}), buf({ id: 'a|#y', unread: 40 })], [], [], [])).toEqual({ dms: 0, mentions: 0, latest: null })
})
it('counts a channel by its mentions and not by its messages', () => {
expect(trayCount([buf({ unread: 33, mentions: 2, highlight: true })], [], [], [])).toEqual({ dms: 0, mentions: 2, latest: 'a|#x' })
})
it('counts a direct message by each unread message in it', () => {
expect(trayCount([buf({ id: 'a|bob', kind: 'dm', unread: 3 })], [], [], [])).toEqual({ dms: 3, mentions: 0, latest: 'a|bob' })
})
it('does not count what is hidden', () => {
expect(trayCount([buf({ id: 'a|#h', mentions: 5 })], [], ['a|#h'], [])).toEqual({ dms: 0, mentions: 0, latest: null })
})
it('counts the mentions in what is muted, and nothing else there', () => {
const rows = [buf({ id: 'a|#m', mentions: 4, unread: 90 }), buf({ id: 'a|gm', mentions: 6, groupId: 'muted-group' })]
expect(trayCount(rows, ['a|#m'], [], ['muted-group'])).toEqual({ dms: 0, mentions: 10, latest: 'a|#m' })
expect(trayCount([buf({ mentions: 2, serverMuted: true })], [], [], [])).toMatchObject({ mentions: 2 })
expect(trayCount([buf({ unread: 50, serverMuted: true })], [], [], [])).toMatchObject({ mentions: 0 })
})
it('counts a muted direct message for nothing', () => {
expect(trayCount([buf({ id: 'a|bob', kind: 'dm', unread: 3 })], ['a|bob'], [], [])).toEqual({ dms: 0, mentions: 0, latest: null })
})
it('names the most recently active conversation that has something, for the tray to open', () => {
const rows = [buf({ id: 'a|old', mentions: 1, lastActivityTs: 10 }), buf({ id: 'a|new', mentions: 1, lastActivityTs: 20 }), buf({ id: 'a|quiet', lastActivityTs: 99 })]
expect(trayCount(rows, [], [], []).latest).toBe('a|new')
})
it('adds the two kinds together and keeps them apart', () => {
const rows = [buf({ id: 'a|bob', kind: 'dm', unread: 2 }), buf({ mentions: 3 })]
expect(trayCount(rows, [], [], [])).toMatchObject({ dms: 2, mentions: 3 })
})
})
+41
View File
@@ -0,0 +1,41 @@
import { countedFor, isDirectMessage } from './groups'
import type { BufferEntry } from '../state/store'
/** What the tray is told is waiting. */
export interface TrayCount {
/** Unread direct messages, counted by the message. */
dms: number
/** Mentions in everywhere that is not a direct message, counted by the mention. */
mentions: number
/** The conversation to open from the tray: the most recently active one with something counted. */
latest: string | null
}
/**
* The number on the tray, worked out the way the rail's is.
*
* The tray used to keep a count of its own in the main process, from the
* notifications it was sent and a handful of "this was read" messages, and the
* two drifted: a read on another device cleared the window and not the tray, a
* mention in the conversation already open raised the tray and was never
* counted by the window, and a room muted afterwards stayed in the tray's
* number. It also counted conversations where the rail counts messages.
*
* So the window - which already knows every one of those things, because it is
* what draws them - says what is waiting, by the same rule as the rail: a
* muted or hidden conversation does not count, a direct message counts for each
* unread message in it, and anywhere else counts for its mentions.
*/
export function trayCount(buffers: BufferEntry[], muted: string[], hidden: string[], mutedGroups: string[]): TrayCount {
let dms = 0
let mentions = 0
let latest: BufferEntry | null = null
for (const b of buffers) {
const waiting = countedFor(b, muted, hidden, mutedGroups)
if (waiting <= 0) continue
if (isDirectMessage(b)) dms += waiting
else mentions += waiting
if (!latest || b.lastActivityTs > latest.lastActivityTs) latest = b
}
return { dms, mentions, latest: latest?.id ?? null }
}
+1 -1
View File
@@ -166,7 +166,7 @@ export function isWhisper(kind: string | undefined): boolean {
* does not have.
*/
export function isReward(kind: string | undefined): boolean {
return kind === 'reward' || kind === 'sub' || kind === 'raid' || kind === 'poll'
return kind === 'reward' || kind === 'sub' || kind === 'raid' || kind === 'poll' || kind === 'kicks'
}
export function bufferKindGlyph(kind: string): string {
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { ChatStore } from './store'
/** The store, with a conversation already in its list, and the daemon's update for it applied. */
function after(update: Record<string, unknown>, before: Record<string, unknown> = {}): Record<string, unknown> {
const store = new ChatStore() as unknown as {
state: { buffers: unknown[] }
handleBufferListChange: (data: unknown) => void
}
const base = { id: 'm|!r', accountId: 'm', kind: 'channel', name: '!r', lastActivityTs: 0, unread: 0, highlight: false, mentions: 0 }
store.state = { ...store.state, buffers: [{ ...base, ...before }] }
// What the daemon sends carries no unread tally: that is this window's own.
store.handleBufferListChange({ id: base.id, accountId: base.accountId, kind: base.kind, name: base.name, lastActivityTs: 0, ...update })
return store.state.buffers[0] as Record<string, unknown>
}
describe('an update to a conversation the window already has', () => {
it('takes a mute off when the daemon leaves it out', () => {
// The daemon omits a flag that is off, so absent has to mean cleared: a
// room unmuted on the account stayed drawn as muted, with an Unmute that
// had nothing left to do.
expect(after({}, { serverMuted: true }).serverMuted).toBe(false)
expect(after({ serverMuted: true }, {}).serverMuted).toBe(true)
})
it('clears the other flags the same way, and keeps the unread count it holds', () => {
const b = after({}, { favourite: true, lowPriority: true, readOnly: 'No permission', forum: true, unread: 4, mentions: 2, highlight: true })
expect([b.favourite, b.lowPriority, b.readOnly, b.forum]).toEqual([false, false, undefined, false])
expect([b.unread, b.mentions, b.highlight]).toEqual([4, 2, true])
})
})
+101 -11
View File
@@ -22,6 +22,7 @@ import type {
VoiceSession
} from '../../../shared/wire'
import { addToast, type ToastAction, type ToastItem } from '../lib/toasts'
import { ALL_STATUSES, statusesFor, supportsStatus } from '../lib/status'
import { buildSmilieIndex, type SmilieEntry, type SmilieIndex } from '../lib/format'
import { bufferDisplayName, isChatKind, isImageFile, resolveMediaUrl } from '../lib/util'
import { runExport } from '../lib/exporter'
@@ -71,6 +72,12 @@ export interface ForumPageCache {
export interface BufferEntry extends WireBuffer {
unread: number
highlight: boolean
/**
* How many of the unread messages mention this account. The count the rail
* shows: a tile that says 33 for thirty-three messages in a busy channel is
* noise, where one that says 2 for two people asking for you is the point.
*/
mentions: number
/**
* Left unread on purpose, rather than merely not read yet.
*
@@ -1179,6 +1186,33 @@ export class ChatStore {
private set(patch: Partial<ChatState>): void {
this.state = { ...this.state, ...patch }
for (const l of this.listeners) l()
if (patch.accounts && !this.state.pinnedBufferId) this.reportTrayStatus()
}
/**
* Tells main what status each connected account is at, and which statuses
* any of them can be set to. The tray menu offers those and checks the one of
* the account with the most to choose from - Discord's rather than a
* service that has no idle to be at - and main stays quiet, with no popup and
* no sound, for an account that is on Do not disturb.
*/
/** What main was last told, so an account list that changed in nothing that matters is not told again. */
private lastTrayReport = ''
private reportTrayStatus(): void {
const statuses: Record<string, string> = {}
const connected = this.state.accounts.filter((a) => a.state === 'connected')
// 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?.autoIdle ? 'online' : widest?.status) ?? null, invisibleName])
if (report === this.lastTrayReport) return
this.lastTrayReport = report
window.moho.setAccountStatuses(statuses, offered, (widest?.autoIdle ? 'online' : widest?.status) ?? null, invisibleName)
}
// --- lifecycle ------------------------------------------------------
@@ -1244,8 +1278,14 @@ export class ChatStore {
window.moho.onScreenPick(async (sources) => (await this.askForScreenSource(sources))?.id ?? null)
window.moho.onLinkChange((up) => {
// Only the link coming up is news. Main says it again whenever the tray
// is redrawn, and taking each of those for a reconnection refreshed
// everything and put the pane back on the open conversation's server -
// which is how a Discord account retrying every few seconds kept pulling
// somebody off the IRC network they had just chosen.
const wasUp = this.state.linkUp
this.set({ linkUp: up })
if (up) {
if (up && !wasUp) {
void this.refreshAll()
void this.refreshNetSettings()
}
@@ -1256,6 +1296,11 @@ export class ChatStore {
// window gets them instead; main routes them there.
if (!this.state.pinnedBufferId) {
window.moho.onActivateBuffer((id) => void this.selectBuffer(id))
// The tray's menu: Settings, and a status for every account.
window.moho.onTrayCommand((command, arg) => {
if (command === 'settings') this.setActivePanel('settings')
else if (arg === 'online' || arg === 'idle' || arg === 'dnd' || arg === 'invisible') void this.setStatusEverywhere(arg)
})
window.moho.onDeepLink((url) => this.followDeepLink(url))
}
@@ -1344,7 +1389,10 @@ export class ChatStore {
])
// Not awaited: GitHub being slow is no reason to hold the window up.
void this.checkForRelease()
if (this.state.activeBufferId) await this.selectBuffer(this.state.activeBufferId)
// The open conversation is read again, and subscribed to again, where it is.
// Not followed to its server: a refresh is not somebody choosing it, and
// the rail may have been moved to another since.
if (this.state.activeBufferId) await this.selectBuffer(this.state.activeBufferId, false)
}
/**
@@ -2119,7 +2167,9 @@ export class ChatStore {
* left alone - choosing a status is not a request to connect it.
*/
async setStatusEverywhere(status: 'online' | 'idle' | 'dnd' | 'invisible', statusText?: string): Promise<void> {
const connected = this.state.accounts.filter((a) => a.state === 'connected')
// Only the accounts of a service that has this status: an idle for the
// others is not asked of them, and they stay as they were.
const connected = this.state.accounts.filter((a) => a.state === 'connected' && supportsStatus(a.service, status))
await Promise.all(connected.map((a) => this.setAccountStatus(a.id, status, a.service === 'discord' || a.service === 'matrix' ? statusText : undefined)))
}
@@ -2149,6 +2199,7 @@ export class ChatStore {
const buffers = wire.map((b) => ({
unread: existing.get(b.id)?.unread ?? 0,
highlight: existing.get(b.id)?.highlight ?? false,
mentions: existing.get(b.id)?.mentions ?? 0,
...b
}))
this.set({ buffers })
@@ -3196,7 +3247,7 @@ export class ChatStore {
// Reading a room is how a deliberate mark is taken off, so the
// flag clears with the count. Anything else would need a second
// gesture to undo the first, and nobody would find it.
b.id === data.bufferId ? { ...b, unread: 0, highlight: false, markedUnread: false } : b
b.id === data.bufferId ? { ...b, unread: 0, highlight: false, mentions: 0, markedUnread: false } : b
)
})
break
@@ -3462,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 }
@@ -3608,7 +3666,8 @@ export class ChatStore {
remoteId: roomIdOrAlias,
syncing: true,
unread: 0,
highlight: false
highlight: false,
mentions: 0
}
this.set({ buffers: [...this.state.buffers, stand_in] })
try {
@@ -3681,13 +3740,33 @@ export class ChatStore {
// leaves an optional field out when it is not set, so a plain spread
// kept the last value it ever had - a room that reconnected stayed
// marked as interrupted, and the banner over it stayed up.
//
// The same for every flag the daemon leaves out when it is off - muted on
// the account, favourite, low priority, read-only, a forum. A merge that
// kept the last value it ever had left a room unmuted on the server still
// drawn as muted here, with an Unmute that had nothing left to do.
this.set({
buffers: buffers.map((b) => (b.id === data.id ? { ...b, ...data, link: data.link, syncing: data.syncing, topic: data.topic } : b))
buffers: buffers.map((b) =>
b.id === data.id
? {
...b,
...data,
link: data.link,
syncing: data.syncing,
topic: data.topic,
serverMuted: data.serverMuted ?? false,
favourite: data.favourite ?? false,
lowPriority: data.lowPriority ?? false,
readOnly: data.readOnly,
forum: data.forum ?? false
}
: b
)
})
this.followPeekJoin(data)
return
}
this.set({ buffers: [...buffers, { unread: 0, highlight: false, ...data }] })
this.set({ buffers: [...buffers, { unread: 0, highlight: false, mentions: 0, ...data }] })
// Filed under a rail entry this window has never been told of. An
// account's own entry is not announced when the account is made - the
// daemon lists one for every account when asked - so the first
@@ -3836,7 +3915,7 @@ export class ChatStore {
this.set({
buffers: this.state.buffers.map((b) =>
b.id === bufferId
? { ...b, unread: b.unread + 1, highlight: b.highlight || !!msg.isHighlight }
? { ...b, unread: b.unread + 1, highlight: b.highlight || !!msg.isHighlight, mentions: b.mentions + (msg.isHighlight ? 1 : 0) }
: b
)
})
@@ -3901,9 +3980,20 @@ export class ChatStore {
replyingTo: null,
editingId: '',
buffers: this.state.buffers.map((b) =>
b.id === bufferId ? { ...b, unread: 0, highlight: false } : b
b.id === bufferId ? { ...b, unread: 0, highlight: false, mentions: 0 } : b
)
}
// The line a conversation was last opened with is that visit's, and goes
// when it is left. Kept, it was found again on every return: a buffer read
// to its end, left, and come back to had the old line still in it with
// everything said since below it - so it opened far up the history at a
// place that was no longer where anything was unread, instead of at the
// end. A conversation opened again works out its line afresh, from what
// is unread now.
if (leaving && leaving !== bufferId && this.state.dividerTsByBuffer[leaving] !== undefined) {
const { [leaving]: _gone, ...rest } = this.state.dividerTsByBuffer
patch.dividerTsByBuffer = rest
}
if (dividerTs === undefined && buffer.unread > 0) {
const list = this.state.messagesByBuffer[bufferId] || []
const firstUnread = list.length >= buffer.unread ? list[list.length - buffer.unread] : list[0]
@@ -3913,7 +4003,7 @@ export class ChatStore {
// time it was last marked read.
const stoppedAt = list.length < buffer.unread ? this.state.lastReadTs?.[bufferId] : undefined
patch.dividerTsByBuffer = {
...this.state.dividerTsByBuffer,
...(patch.dividerTsByBuffer ?? this.state.dividerTsByBuffer),
[bufferId]: stoppedAt ?? (firstUnread ? firstUnread.ts - 1 : 0)
}
}
@@ -4087,7 +4177,7 @@ export class ChatStore {
// here too - the stored timestamp only decides what counts next time.
this.set({
buffers: this.state.buffers.map((b) =>
bufferIds.includes(b.id) ? { ...b, unread: 0, highlight: false } : b
bufferIds.includes(b.id) ? { ...b, unread: 0, highlight: false, mentions: 0 } : b
)
})
} catch (e) {
+9 -1
View File
@@ -52,7 +52,15 @@ export const IPC = {
/** main -> window: a right click landed on somewhere text is typed. */
editMenu: 'moho:editMenu',
deepLink: 'moho:deepLink',
popoutsChanged: 'moho:popout:changed'
popoutsChanged: 'moho:popout:changed',
/** main -> window: something from the tray menu that the window carries out. */
trayCommand: 'moho:tray:command',
/** window -> main: the status each connected account is at. */
trayStatus: 'moho:tray:status',
/** 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
/**
+26 -1
View File
@@ -12,6 +12,11 @@ export interface Account {
status?: string
/** What it says beside the status, where the service has a place for that. */
statusText?: string
/**
* Shown as idle because the person stepped away, not because they chose it:
* `status` then reads idle while what they chose is online.
*/
autoIdle?: boolean
state: string
autojoin: string
hasNickservPassword: boolean
@@ -466,14 +471,29 @@ export interface Embed {
provider?: string
/** Who made it: a video's channel. */
author?: string
/** The colour the service draws that name in, as `#rrggbb`. */
authorColor?: string
/** The labelled lines under the description. */
fields?: { name: string; value: string }[]
/** The small line along the bottom. */
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; and
* "kicks" and "gift-sub" for what somebody did on a Kick stream - `author`
* is who, `title` the amount, and for a gift `description` is who received
* it, one name to a line - drawn as the card Kick draws it.
*/
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 +531,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 {