Author SHA1 Message Date
SalastilandClaude Sonnet 5.5 042f7b6116 nobilis: Kick chat on the new realtime service
Kick turned its Pusher application off; chat moves to the service kick.com now uses.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 20:56:11 -04:00
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
22 changed files with 1027 additions and 305 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
+9 -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.
@@ -290,6 +289,12 @@ jobs:
- 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
@@ -334,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 |
+190 -92
View File
@@ -17,6 +17,196 @@ Each section has two parts:
that made it, including dependency updates
(`node scripts/changelog-deps.mjs <previous tag>` lists those).
## [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))
- 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))
- 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))
- Added a right-click menu to every text field, with spelling suggestions, Undo, Cut, Copy, Paste and Select all ([moho `b2a6210`](https://github.com/Moho-Chat/moho/commit/b2a6210))
- Fixed YouTube cards not appearing on Sneedchat when YouTube refused a Tor exit, so most links there had none ([moho `0368796`](https://github.com/Moho-Chat/moho/commit/0368796), [nobilis `71e6036`](https://github.com/Moho-Chat/nobilis/commit/71e6036))
- Fixed live and premiere YouTube videos vanishing because they have no thumbnail yet ([moho `916abd4`](https://github.com/Moho-Chat/moho/commit/916abd4))
- Fixed YouTube links with the video id after other parameters, `music.youtube.com` and `youtube-nocookie.com` links getting no card ([nobilis `f8409fc`](https://github.com/Moho-Chat/nobilis/commit/f8409fc))
- Patched a flaw that let a message's content make moho load a local file ([moho `7138201`](https://github.com/Moho-Chat/moho/commit/7138201), [nobilis `f972611`](https://github.com/Moho-Chat/nobilis/commit/f972611))
- Strict routing now sends a Tor or proxy account's pictures, links and embeds through its route instead of directly ([moho `cee8604`](https://github.com/Moho-Chat/moho/commit/cee8604), [nobilis `a3897ba`](https://github.com/Moho-Chat/nobilis/commit/a3897ba))
- Discord voice and soundboard audio is now checked before it reaches the C decoder ([nobilis `8eca16e`](https://github.com/Moho-Chat/nobilis/commit/8eca16e))
- Fixed Discord showing "connecting" after every successful resume ([nobilis `52b0001`](https://github.com/Moho-Chat/nobilis/commit/52b0001))
- Fixed reconnect waits growing to a minute and never resetting ([nobilis `52b0001`](https://github.com/Moho-Chat/nobilis/commit/52b0001))
- The interruption banner now counts down to the next reconnect attempt ([moho `89da9ba`](https://github.com/Moho-Chat/moho/commit/89da9ba))
- Fixed Kick over Tor failing every channel lookup until a reconnect ([nobilis `5755c4e`](https://github.com/Moho-Chat/nobilis/commit/5755c4e))
- Fixed failed Kick joins vanishing without a message ([nobilis `5755c4e`](https://github.com/Moho-Chat/nobilis/commit/5755c4e))
- IRC now shows the server's own reason when it refuses a connection ([nobilis `9eeab7e`](https://github.com/Moho-Chat/nobilis/commit/9eeab7e))
- Fixed Disconnect and Remove showing a "cancelled" error ([nobilis `8bcba8b`](https://github.com/Moho-Chat/nobilis/commit/8bcba8b))
- Fixed new accounts not appearing on the server rail until a restart ([moho `e207224`](https://github.com/Moho-Chat/moho/commit/e207224))
- Joining a channel now opens it ([moho `e207224`](https://github.com/Moho-Chat/moho/commit/e207224))
- Removing an account now asks first ([moho `16fa8a2`](https://github.com/Moho-Chat/moho/commit/16fa8a2))
- Fixed the Matrix form saying matrix.org takes no new accounts ([moho `16fa8a2`](https://github.com/Moho-Chat/moho/commit/16fa8a2))
- Fixed Kick's mark drawing as a blank square in the accounts pane ([moho `0ea63f5`](https://github.com/Moho-Chat/moho/commit/0ea63f5))
- Fixed a cleared service interruption staying on screen ([moho `fd09b6b`](https://github.com/Moho-Chat/moho/commit/fd09b6b))
- Conversations now show at start, before their account has connected ([nobilis `7976452`](https://github.com/Moho-Chat/nobilis/commit/7976452))
- Fixed Kick follows being saved before Kick confirmed the channel exists ([nobilis `c2e40dd`](https://github.com/Moho-Chat/nobilis/commit/c2e40dd), [nobilis `4d8f17b`](https://github.com/Moho-Chat/nobilis/commit/4d8f17b))
- Kick requests are now spaced out, and a rate limit pauses every Kick request instead of retrying at once ([nobilis `f592a22`](https://github.com/Moho-Chat/nobilis/commit/f592a22), [nobilis `c99d52b`](https://github.com/Moho-Chat/nobilis/commit/c99d52b), [nobilis `2732d08`](https://github.com/Moho-Chat/nobilis/commit/2732d08))
- Fixed a changed Sneedchat avatar never being fetched again ([nobilis `04cb759`](https://github.com/Moho-Chat/nobilis/commit/04cb759))
- Every media cache now expires old files, with a way to fetch removed pictures again ([nobilis `25a0912`](https://github.com/Moho-Chat/nobilis/commit/25a0912), [moho `9e67ca6`](https://github.com/Moho-Chat/moho/commit/9e67ca6))
- Fixed Kick emotes removed by the cache sweep not drawing again ([moho `70cd8d7`](https://github.com/Moho-Chat/moho/commit/70cd8d7))
- Fixed Matrix reactions being counted more than once ([nobilis `8ea9a70`](https://github.com/Moho-Chat/nobilis/commit/8ea9a70))
- Fixed the scrollback being written to disk once per message ([nobilis `bd33f88`](https://github.com/Moho-Chat/nobilis/commit/bd33f88))
- Fixed the migration deleting the client's own config directory ([nobilis `9e885a4`](https://github.com/Moho-Chat/nobilis/commit/9e885a4), [nobilis `207de58`](https://github.com/Moho-Chat/nobilis/commit/207de58))
- Fixed a removed account leaving its data behind ([nobilis `2dd755e`](https://github.com/Moho-Chat/nobilis/commit/2dd755e))
- Fixed new members of an encrypted Matrix room not receiving its keys ([nobilis `b894dea`](https://github.com/Moho-Chat/nobilis/commit/b894dea))
- Fixed two ways a Matrix sign-in could leave an account permanently unusable ([nobilis `ded9cc5`](https://github.com/Moho-Chat/nobilis/commit/ded9cc5))
- Fixed sliding sync failing against Synapse ([nobilis `e2bb988`](https://github.com/Moho-Chat/nobilis/commit/e2bb988))
- Fixed Discord media links expiring on screen; they now refresh themselves ([moho `dc15c4b`](https://github.com/Moho-Chat/moho/commit/dc15c4b), [nobilis `f5a455b`](https://github.com/Moho-Chat/nobilis/commit/f5a455b))
- Repaired Discord messages stored with attachments in their text ([nobilis `15007d6`](https://github.com/Moho-Chat/nobilis/commit/15007d6))
- Fixed Discord streams opening a second connection ([nobilis `7b64414`](https://github.com/Moho-Chat/nobilis/commit/7b64414))
- Fixed lost video packets, phone streams and stream sound in Discord calls ([nobilis `92dffca`](https://github.com/Moho-Chat/nobilis/commit/92dffca))
- Fixed the Discord stream heartbeat using the wrong protocol version ([nobilis `64dfa08`](https://github.com/Moho-Chat/nobilis/commit/64dfa08))
- Fixed a running upload being reported as failed ([moho `ee60271`](https://github.com/Moho-Chat/moho/commit/ee60271))
- Fixed thumbnails opening as the thumbnail instead of the picture ([moho `6af6ac3`](https://github.com/Moho-Chat/moho/commit/6af6ac3))
- Fixed avif links not drawing as pictures ([moho `1511c4b`](https://github.com/Moho-Chat/moho/commit/1511c4b), [nobilis `4e3c65d`](https://github.com/Moho-Chat/nobilis/commit/4e3c65d))
- Fixed links in brackets not being recognised ([nobilis `dd450eb`](https://github.com/Moho-Chat/nobilis/commit/dd450eb))
- Fixed a full-size photo being downloaded to draw a thumbnail ([nobilis `873a908`](https://github.com/Moho-Chat/nobilis/commit/873a908))
- Fixed a crash in the room preview panel ([moho `04a6b7d`](https://github.com/Moho-Chat/moho/commit/04a6b7d))
- Fixed call renegotiation not waiting for its answer ([moho `db0f651`](https://github.com/Moho-Chat/moho/commit/db0f651))
- Fixed a screen share on Wayland asking twice ([moho `2c146d9`](https://github.com/Moho-Chat/moho/commit/2c146d9))
- Discord no longer attempts what it puts behind a captcha ([nobilis `8da2e7a`](https://github.com/Moho-Chat/nobilis/commit/8da2e7a))
- The renderer now runs sandboxed ([moho `e791fcf`](https://github.com/Moho-Chat/moho/commit/e791fcf))
- The installer warns when Windows Smart App Control will block moho ([moho `5a8d7f7`](https://github.com/Moho-Chat/moho/commit/5a8d7f7))
- The uninstaller now offers to remove saved sign-ins and history, and stops the daemon first ([moho `76eabac`](https://github.com/Moho-Chat/moho/commit/76eabac))
- Linux builds now run on Ubuntu 22.04 and later ([moho `4c6e24b`](https://github.com/Moho-Chat/moho/commit/4c6e24b))
- Updated Rust crate h2 from 0.4.15 to 0.4.19 ([nobilis `2f5918e`](https://github.com/Moho-Chat/nobilis/commit/2f5918e))
- Added Rust crate opus2 0.4.0 ([nobilis `f9b5be5`](https://github.com/Moho-Chat/nobilis/commit/f9b5be5))
- Added Rust crate aes-gcm 0.10.3 ([nobilis `9d50723`](https://github.com/Moho-Chat/nobilis/commit/9d50723))
- Added Rust crate chacha20poly1305 0.10.1 ([nobilis `9d50723`](https://github.com/Moho-Chat/nobilis/commit/9d50723))
- Added Rust crate davey 0.1.4 ([nobilis `8ae2148`](https://github.com/Moho-Chat/nobilis/commit/8ae2148))
- Removed Rust crate async-trait 0.1.92 ([nobilis `cbb044d`](https://github.com/Moho-Chat/nobilis/commit/cbb044d))
- Removed Rust crate songbird 0.6.0 ([nobilis `cbb044d`](https://github.com/Moho-Chat/nobilis/commit/cbb044d))
- Added Rust crate socket2 0.6.5 ([nobilis `92dffca`](https://github.com/Moho-Chat/nobilis/commit/92dffca))
- Added Rust crate sonora 0.2.0 ([nobilis `72ef1b5`](https://github.com/Moho-Chat/nobilis/commit/72ef1b5))
- Updated Rust crate arti-client from 0.44.0 to 0.47.0 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
- Updated Rust crate matrix-sdk-common from 0.18.0 to 0.19.1 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
- Updated Rust crate matrix-sdk-crypto from 0.18.0 to 0.19.1 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
- Updated Rust crate matrix-sdk-sqlite from 0.18.0 to 0.19.1 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
- Updated Rust crate ruma-client-api from 0.24.0 to 0.25.0 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
- Updated Rust crate ruma-common from 0.19.0 to 0.20.0 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
- Updated Rust crate ruma-events from 0.34.0 to 0.35.0 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
- Updated Rust crate rusqlite from 0.37.0 to 0.40.2 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
- Updated Rust crate tor-rtcompat from 0.44.0 to 0.47.0 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
- Updated Rust crate rustls from 0.23.43 to 0.23.45 ([nobilis `2f5918e`](https://github.com/Moho-Chat/nobilis/commit/2f5918e))
- Updated npm package electron-builder from 25.1.8 to 26.15.3 ([moho `e791fcf`](https://github.com/Moho-Chat/moho/commit/e791fcf))
- Updated npm package electron from 33.4.11 to 44.4.4 ([moho `932a07a`](https://github.com/Moho-Chat/moho/commit/932a07a))
- 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
@@ -124,95 +314,3 @@ 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))
- Added a right-click menu to every text field, with spelling suggestions, Undo, Cut, Copy, Paste and Select all ([moho `b2a6210`](https://github.com/Moho-Chat/moho/commit/b2a6210))
- Fixed YouTube cards not appearing on Sneedchat when YouTube refused a Tor exit, so most links there had none ([moho `0368796`](https://github.com/Moho-Chat/moho/commit/0368796), [nobilis `71e6036`](https://github.com/Moho-Chat/nobilis/commit/71e6036))
- Fixed live and premiere YouTube videos vanishing because they have no thumbnail yet ([moho `916abd4`](https://github.com/Moho-Chat/moho/commit/916abd4))
- Fixed YouTube links with the video id after other parameters, `music.youtube.com` and `youtube-nocookie.com` links getting no card ([nobilis `f8409fc`](https://github.com/Moho-Chat/nobilis/commit/f8409fc))
- Patched a flaw that let a message's content make moho load a local file ([moho `7138201`](https://github.com/Moho-Chat/moho/commit/7138201), [nobilis `f972611`](https://github.com/Moho-Chat/nobilis/commit/f972611))
- Strict routing now sends a Tor or proxy account's pictures, links and embeds through its route instead of directly ([moho `cee8604`](https://github.com/Moho-Chat/moho/commit/cee8604), [nobilis `a3897ba`](https://github.com/Moho-Chat/nobilis/commit/a3897ba))
- Discord voice and soundboard audio is now checked before it reaches the C decoder ([nobilis `8eca16e`](https://github.com/Moho-Chat/nobilis/commit/8eca16e))
- Fixed Discord showing "connecting" after every successful resume ([nobilis `52b0001`](https://github.com/Moho-Chat/nobilis/commit/52b0001))
- Fixed reconnect waits growing to a minute and never resetting ([nobilis `52b0001`](https://github.com/Moho-Chat/nobilis/commit/52b0001))
- The interruption banner now counts down to the next reconnect attempt ([moho `89da9ba`](https://github.com/Moho-Chat/moho/commit/89da9ba))
- Fixed Kick over Tor failing every channel lookup until a reconnect ([nobilis `5755c4e`](https://github.com/Moho-Chat/nobilis/commit/5755c4e))
- Fixed failed Kick joins vanishing without a message ([nobilis `5755c4e`](https://github.com/Moho-Chat/nobilis/commit/5755c4e))
- IRC now shows the server's own reason when it refuses a connection ([nobilis `9eeab7e`](https://github.com/Moho-Chat/nobilis/commit/9eeab7e))
- Fixed Disconnect and Remove showing a "cancelled" error ([nobilis `8bcba8b`](https://github.com/Moho-Chat/nobilis/commit/8bcba8b))
- Fixed new accounts not appearing on the server rail until a restart ([moho `e207224`](https://github.com/Moho-Chat/moho/commit/e207224))
- Joining a channel now opens it ([moho `e207224`](https://github.com/Moho-Chat/moho/commit/e207224))
- Removing an account now asks first ([moho `16fa8a2`](https://github.com/Moho-Chat/moho/commit/16fa8a2))
- Fixed the Matrix form saying matrix.org takes no new accounts ([moho `16fa8a2`](https://github.com/Moho-Chat/moho/commit/16fa8a2))
- Fixed Kick's mark drawing as a blank square in the accounts pane ([moho `0ea63f5`](https://github.com/Moho-Chat/moho/commit/0ea63f5))
- Fixed a cleared service interruption staying on screen ([moho `fd09b6b`](https://github.com/Moho-Chat/moho/commit/fd09b6b))
- Conversations now show at start, before their account has connected ([nobilis `7976452`](https://github.com/Moho-Chat/nobilis/commit/7976452))
- Fixed Kick follows being saved before Kick confirmed the channel exists ([nobilis `c2e40dd`](https://github.com/Moho-Chat/nobilis/commit/c2e40dd), [nobilis `4d8f17b`](https://github.com/Moho-Chat/nobilis/commit/4d8f17b))
- Kick requests are now spaced out, and a rate limit pauses every Kick request instead of retrying at once ([nobilis `f592a22`](https://github.com/Moho-Chat/nobilis/commit/f592a22), [nobilis `c99d52b`](https://github.com/Moho-Chat/nobilis/commit/c99d52b), [nobilis `2732d08`](https://github.com/Moho-Chat/nobilis/commit/2732d08))
- Fixed a changed Sneedchat avatar never being fetched again ([nobilis `04cb759`](https://github.com/Moho-Chat/nobilis/commit/04cb759))
- Every media cache now expires old files, with a way to fetch removed pictures again ([nobilis `25a0912`](https://github.com/Moho-Chat/nobilis/commit/25a0912), [moho `9e67ca6`](https://github.com/Moho-Chat/moho/commit/9e67ca6))
- Fixed Kick emotes removed by the cache sweep not drawing again ([moho `70cd8d7`](https://github.com/Moho-Chat/moho/commit/70cd8d7))
- Fixed Matrix reactions being counted more than once ([nobilis `8ea9a70`](https://github.com/Moho-Chat/nobilis/commit/8ea9a70))
- Fixed the scrollback being written to disk once per message ([nobilis `bd33f88`](https://github.com/Moho-Chat/nobilis/commit/bd33f88))
- Fixed the migration deleting the client's own config directory ([nobilis `9e885a4`](https://github.com/Moho-Chat/nobilis/commit/9e885a4), [nobilis `207de58`](https://github.com/Moho-Chat/nobilis/commit/207de58))
- Fixed a removed account leaving its data behind ([nobilis `2dd755e`](https://github.com/Moho-Chat/nobilis/commit/2dd755e))
- Fixed new members of an encrypted Matrix room not receiving its keys ([nobilis `b894dea`](https://github.com/Moho-Chat/nobilis/commit/b894dea))
- Fixed two ways a Matrix sign-in could leave an account permanently unusable ([nobilis `ded9cc5`](https://github.com/Moho-Chat/nobilis/commit/ded9cc5))
- Fixed sliding sync failing against Synapse ([nobilis `e2bb988`](https://github.com/Moho-Chat/nobilis/commit/e2bb988))
- Fixed Discord media links expiring on screen; they now refresh themselves ([moho `dc15c4b`](https://github.com/Moho-Chat/moho/commit/dc15c4b), [nobilis `f5a455b`](https://github.com/Moho-Chat/nobilis/commit/f5a455b))
- Repaired Discord messages stored with attachments in their text ([nobilis `15007d6`](https://github.com/Moho-Chat/nobilis/commit/15007d6))
- Fixed Discord streams opening a second connection ([nobilis `7b64414`](https://github.com/Moho-Chat/nobilis/commit/7b64414))
- Fixed lost video packets, phone streams and stream sound in Discord calls ([nobilis `92dffca`](https://github.com/Moho-Chat/nobilis/commit/92dffca))
- Fixed the Discord stream heartbeat using the wrong protocol version ([nobilis `64dfa08`](https://github.com/Moho-Chat/nobilis/commit/64dfa08))
- Fixed a running upload being reported as failed ([moho `ee60271`](https://github.com/Moho-Chat/moho/commit/ee60271))
- Fixed thumbnails opening as the thumbnail instead of the picture ([moho `6af6ac3`](https://github.com/Moho-Chat/moho/commit/6af6ac3))
- Fixed avif links not drawing as pictures ([moho `1511c4b`](https://github.com/Moho-Chat/moho/commit/1511c4b), [nobilis `4e3c65d`](https://github.com/Moho-Chat/nobilis/commit/4e3c65d))
- Fixed links in brackets not being recognised ([nobilis `dd450eb`](https://github.com/Moho-Chat/nobilis/commit/dd450eb))
- Fixed a full-size photo being downloaded to draw a thumbnail ([nobilis `873a908`](https://github.com/Moho-Chat/nobilis/commit/873a908))
- Fixed a crash in the room preview panel ([moho `04a6b7d`](https://github.com/Moho-Chat/moho/commit/04a6b7d))
- Fixed call renegotiation not waiting for its answer ([moho `db0f651`](https://github.com/Moho-Chat/moho/commit/db0f651))
- Fixed a screen share on Wayland asking twice ([moho `2c146d9`](https://github.com/Moho-Chat/moho/commit/2c146d9))
- Discord no longer attempts what it puts behind a captcha ([nobilis `8da2e7a`](https://github.com/Moho-Chat/nobilis/commit/8da2e7a))
- The renderer now runs sandboxed ([moho `e791fcf`](https://github.com/Moho-Chat/moho/commit/e791fcf))
- The installer warns when Windows Smart App Control will block moho ([moho `5a8d7f7`](https://github.com/Moho-Chat/moho/commit/5a8d7f7))
- The uninstaller now offers to remove saved sign-ins and history, and stops the daemon first ([moho `76eabac`](https://github.com/Moho-Chat/moho/commit/76eabac))
- Linux builds now run on Ubuntu 22.04 and later ([moho `4c6e24b`](https://github.com/Moho-Chat/moho/commit/4c6e24b))
- Updated Rust crate h2 from 0.4.15 to 0.4.19 ([nobilis `2f5918e`](https://github.com/Moho-Chat/nobilis/commit/2f5918e))
- Added Rust crate opus2 0.4.0 ([nobilis `f9b5be5`](https://github.com/Moho-Chat/nobilis/commit/f9b5be5))
- Added Rust crate aes-gcm 0.10.3 ([nobilis `9d50723`](https://github.com/Moho-Chat/nobilis/commit/9d50723))
- Added Rust crate chacha20poly1305 0.10.1 ([nobilis `9d50723`](https://github.com/Moho-Chat/nobilis/commit/9d50723))
- Added Rust crate davey 0.1.4 ([nobilis `8ae2148`](https://github.com/Moho-Chat/nobilis/commit/8ae2148))
- Removed Rust crate async-trait 0.1.92 ([nobilis `cbb044d`](https://github.com/Moho-Chat/nobilis/commit/cbb044d))
- Removed Rust crate songbird 0.6.0 ([nobilis `cbb044d`](https://github.com/Moho-Chat/nobilis/commit/cbb044d))
- Added Rust crate socket2 0.6.5 ([nobilis `92dffca`](https://github.com/Moho-Chat/nobilis/commit/92dffca))
- Added Rust crate sonora 0.2.0 ([nobilis `72ef1b5`](https://github.com/Moho-Chat/nobilis/commit/72ef1b5))
- Updated Rust crate arti-client from 0.44.0 to 0.47.0 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
- Updated Rust crate matrix-sdk-common from 0.18.0 to 0.19.1 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
- Updated Rust crate matrix-sdk-crypto from 0.18.0 to 0.19.1 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
- Updated Rust crate matrix-sdk-sqlite from 0.18.0 to 0.19.1 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
- Updated Rust crate ruma-client-api from 0.24.0 to 0.25.0 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
- Updated Rust crate ruma-common from 0.19.0 to 0.20.0 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
- Updated Rust crate ruma-events from 0.34.0 to 0.35.0 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
- Updated Rust crate rusqlite from 0.37.0 to 0.40.2 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
- Updated Rust crate tor-rtcompat from 0.44.0 to 0.47.0 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
- Updated Rust crate rustls from 0.23.43 to 0.23.45 ([nobilis `2f5918e`](https://github.com/Moho-Chat/nobilis/commit/2f5918e))
- Updated npm package electron-builder from 25.1.8 to 26.15.3 ([moho `e791fcf`](https://github.com/Moho-Chat/moho/commit/e791fcf))
- Updated npm package electron from 33.4.11 to 44.4.4 ([moho `932a07a`](https://github.com/Moho-Chat/moho/commit/932a07a))
- 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
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: d232be9c5e...c26f968e19
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "moho",
"version": "1.0.0-rc.5",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "moho",
"version": "1.0.0-rc.5",
"version": "1.0.0",
"license": "GPL-3.0-or-later",
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
+4 -3
View File
@@ -1,8 +1,8 @@
{
"name": "moho",
"version": "1.0.0-rc.5",
"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": {
@@ -24,6 +24,7 @@
"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",
@@ -78,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",
+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)
-117
View File
@@ -1,117 +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)"
# Through Cloudflare by default, which refuses a request body over 100 MB. The
# files that are bigger go to Gitea directly across the network, which is what
# MIRROR_GITEA is for - the same instance, reached by its own address:
#
# MIRROR_GITEA=http://192.168.1.212:8991/api/v1 MIRROR_MAX_BYTES=1000000000 \
# scripts/mirror-release.sh v1.0.0
GITEA="${MIRROR_GITEA:-https://git.salastil.com/api/v1}"
TOKEN="$(cat "$HOME/.config/gitea/token")"
# What Cloudflare in front of Gitea will take in one request.
MAX_UPLOAD="${MIRROR_MAX_BYTES:-100000000}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
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 is behind Cloudflare, which refuses a request body over 100 MB (a
# 413, or a 502 when the connection is cut mid-upload). A file over that
# stays on GitHub only, said plainly rather than failing the whole mirror
# and leaving everything after it - nobilis's releases - undone.
if [ "$(stat -c %s "$f")" -gt "$MAX_UPLOAD" ]; then
echo " SKIPPED $(basename "$f") ($(( $(stat -c %s "$f") / 1000000 )) MB) - over Cloudflare's ${MAX_UPLOAD} byte limit; it is on GitHub only" >&2
continue
fi
gitea POST "/repos/$repo/releases/$id/assets?name=$(basename "$f")" -F "attachment=@$f" >/dev/null
echo " uploaded $(basename "$f") -> Gitea $repo"
done
}
# --- 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
+68 -2
View File
@@ -30,7 +30,7 @@ const irc = await fakeIrc({
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
try {
await until(page, 'window.__mohoShots', 'the harness hook')
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#lobby'], waitFor: 'hello there' })
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(`(() => {
@@ -48,7 +48,19 @@ try {
dividerTsByBuffer: { ...s.dividerTsByBuffer, [id]: list[99].ts }
})
})()`)
await until(page, `!!document.querySelector('.new-divider')`, 'the New line')
// 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.
@@ -93,6 +105,60 @@ try {
// 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()
+34
View File
@@ -352,6 +352,40 @@ const SCENES = [
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
+204 -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);
}
@@ -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>
)
}
+45 -8
View File
@@ -165,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()
@@ -527,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))
}
@@ -543,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
@@ -556,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.
@@ -566,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
@@ -728,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
+9 -2
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'
@@ -406,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.
@@ -786,6 +790,7 @@ function MessageRowBody({
message.failed && 'failed',
isSystem && 'system',
reward && 'reward',
kickEvent && 'has-kick-event',
grouped && 'grouped',
fresh && 'fresh',
comfy && 'comfy',
@@ -828,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>
@@ -1058,6 +1063,8 @@ function MessageRowBody({
</div>
)}
</div>
) : kickEvent ? (
<KickEventCard embed={kickEvent} />
) : (
parts.html && (
<span className="message-body selectable">
@@ -1101,7 +1108,7 @@ function MessageRowBody({
))}
{(message.embeds || []).map((embed, i) =>
embed.kind === 'gif' ? null : (
embed.kind === 'gif' || isKickEvent(embed) ? null : (
<div
key={`e${i}`}
className={classes('rich-embed selectable', embed.kind === 'notice' && 'notice')}
+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)
})
})
+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 {
+12 -1
View File
@@ -3983,6 +3983,17 @@ export class ChatStore {
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]
@@ -3992,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)
}
}
+6 -1
View File
@@ -471,6 +471,8 @@ 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. */
@@ -479,7 +481,10 @@ export interface Embed {
cta?: { label: string; url?: string }
/**
* "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.
* 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. */