Compare commits
@@ -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
|
||||
@@ -99,9 +99,8 @@ jobs:
|
||||
build: ${{ steps.stamp.outputs.build }}
|
||||
|
||||
steps:
|
||||
# nobilis is a submodule, and on master its URL is the public GitHub
|
||||
# one - on development it points at Gitea. That is the other reason this
|
||||
# workflow only ever runs on master.
|
||||
# nobilis is a submodule at a commit its own master holds, which is why
|
||||
# this workflow only ever runs on master.
|
||||
#
|
||||
# Depth 1 is enough: nobilis/build.rs only asks git for a short sha and
|
||||
# a dirty flag, and falls back to "unknown" rather than failing.
|
||||
@@ -270,6 +269,32 @@ jobs:
|
||||
- name: layout check
|
||||
run: xvfb-run -a npm run test:ui
|
||||
|
||||
# The tray cannot be looked at, so this reads what main gave it - icon,
|
||||
# tooltip, menu - and presses its entries, including Restart moho, which
|
||||
# has to bring back both the window and a daemon of its own.
|
||||
- name: tray check
|
||||
run: xvfb-run -a npm run test:tray
|
||||
|
||||
# GIFs: that a message carrying one is drawn as the picture, that a page
|
||||
# nothing could read stays a link, and that the picker finds and sends.
|
||||
# Needs ffmpeg, for the pictures it plays.
|
||||
- name: gif check
|
||||
run: |
|
||||
sudo apt-get install -y --no-install-recommends ffmpeg
|
||||
xvfb-run -a npm run test:gif
|
||||
|
||||
# Scrolling through a long unread run: that the view stays where it is
|
||||
# read when something above it changes size, with the "new messages" bar
|
||||
# up over the top of the log.
|
||||
- name: scroll check
|
||||
run: xvfb-run -a npm run test:scroll
|
||||
|
||||
# Kick event cards: where they sit in each layout, measured against a
|
||||
# chat line beside them - the picture's column, the name's place, and the
|
||||
# air above.
|
||||
- name: kick card check
|
||||
run: xvfb-run -a npm run test:kick
|
||||
|
||||
# MOHO_BUILD is computed inside the npm script - a shell assignment on
|
||||
# the electron-builder line - so it cannot be injected from out here.
|
||||
# The only way to control what the artifact is called is to guarantee
|
||||
@@ -314,7 +339,7 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
# The daemon on its own, for nobilis's release - attached to that by
|
||||
# scripts/mirror-release.sh, since this job's token can only release
|
||||
# scripts/release-nobilis.sh, since this job's token can only release
|
||||
# here. Not an asset of moho's release: the AppImage already carries it.
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: github.ref_type == 'tag'
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -17,18 +17,63 @@ Each section has two parts:
|
||||
that made it, including dependency updates
|
||||
(`node scripts/changelog-deps.mjs <previous tag>` lists those).
|
||||
|
||||
## [1.0.0-rc.3]
|
||||
## [1.0.0]
|
||||
|
||||
### Major features
|
||||
|
||||
- **Five protocols in one window:** IRC, Discord, Matrix with end-to-end encryption, Sneedchat, and Kick.
|
||||
- **Discord voice and video:** calls, cameras, screen sharing and Go Live, stage channels, the soundboard and per-person volume, all with Discord's DAVE end-to-end encryption.
|
||||
- **Matrix:** encrypted rooms with device verification and key backup, Element Call, sliding sync, spaces, and signing in by QR code from a phone.
|
||||
- **Per-account Tor and SOCKS5 routing:** Clearnet, Service tunnel or Strict - Strict sends every picture, link check and embed through the account's route too. Or send everything through Tor at once.
|
||||
- **One mentions inbox across every service,** link previews, YouTube cards that play in place, voice messages, polls, and exporting a conversation to disk.
|
||||
- **A background daemon** (nobilis) that keeps every connection alive with the window closed.
|
||||
- **Discord forums:** forum and media channels appear in the channel list and open as a pane of posts - title, tags, the first message's words, replies, age, picture and top reaction - with search, sorting, older posts, and a New Post form
|
||||
- **A redesigned interface:** Discord-shaped messages with date separators and an unread bar, red mention badges, a Ctrl+K quick switcher, keyboard navigation, a rail with mention counts and folders, a member list with faces, and motion throughout
|
||||
- **Settings rebuilt:** one click from the gear, a grouped rail with a search over every setting, and new Appearance (layout, spacing, zoom, clock, reduce motion), Notifications and Keybinds pages with a hotkey recorder
|
||||
- **A one-box message composer:** a + menu, Send only when there is something to send, Shift+Enter for a new line on every service, an attachment tray with spoilers, and dropped files going into the tray
|
||||
- **Discord notices and permissions:** Discord's own safety notices drawn as the cards they are, and a banner in place of the message box wherever nothing can be written
|
||||
- **A rebuilt tray:** an icon drawn for the system's colour scheme with the number of conversations waiting on it, a tooltip that says what is waiting, and a menu of what is new, Show/Hide, Status, Settings, Restart moho and Quit - Restart moho brings back both the window and the daemon
|
||||
- **Status by service:** the plaque and the tray offer only the statuses each service has - Discord all four, IRC away and back, Matrix online and offline, Kick and Sneedchat none - and Do not disturb silences desktop notifications on every service, and is Discord's real mode there
|
||||
- **GIFs on every service:** a linked GIF is drawn as the picture - silent, looping, without the player - whichever service it came from, and a GIF picker with trending, categories and search sends one from the composer. Klipy, Tenor and Giphy pages are never probed for what they are.
|
||||
- **Idle detection:** moho notices when nothing has been used for a while, from the system where it can say and from its own windows where it cannot, and puts an account that has an idle on Idle and back. It has a setting for it and for how long, and the plaque says Idle (away) while keeping what you chose checked.
|
||||
- **Link cards on every service:** a YouTube link gets a titled card with its channel on IRC, Matrix, Kick and Sneedchat as it does on Discord - including through Tor, which YouTube refuses the usual way - and one added in an edit, read back from history, or posted before cards existed gets its card too.
|
||||
- **Kick events as cards:** KICKs sent to a streamer, gifted subscriptions with who received them, subscriptions, raids, redeemed rewards, bans and timeouts, and the stream going live or ending are each a card in moho's own theme, sitting where a person's message does in every layout.
|
||||
- **Conversations open where you stopped reading,** as Discord's do: at the first unread when the run is short, otherwise at the end with a bar to go back by, and a return to a conversation no longer lands hundreds of messages up.
|
||||
|
||||
### Fixes, patches and changes
|
||||
|
||||
- Fixed Kick's KICKs never being heard - they arrive on a subscription that was never opened - and drew KICKs, gifted subscriptions with their recipients, raids and redeemed rewards as cards ([moho `7bae0ea`](https://github.com/Moho-Chat/moho/commit/7bae0ea), [nobilis `95ee3b1`](https://github.com/Moho-Chat/nobilis/commit/95ee3b1))
|
||||
- Every other Kick event is a card too: subscriptions, bans, timeouts, unbans, a cleared chat, and the stream going live or ending ([moho `f118395`](https://github.com/Moho-Chat/moho/commit/f118395), [nobilis `23489f8`](https://github.com/Moho-Chat/nobilis/commit/23489f8))
|
||||
- Kick cards start at the picture's edge with the name where a name is in the log, in comfy and bubbles, with the same air above as a message ([moho `64ade92`](https://github.com/Moho-Chat/moho/commit/64ade92))
|
||||
- A conversation opens as Discord's does, and the line from a visit is dropped when the conversation is left ([moho `39dcba5`](https://github.com/Moho-Chat/moho/commit/39dcba5))
|
||||
- Fixed an unpinned Kick message never leaving the screen ([nobilis `81a33a1`](https://github.com/Moho-Chat/nobilis/commit/81a33a1))
|
||||
- Fixed cancelling an outgoing IRC file offer that nobody had accepted doing nothing for the three minutes the offer lasts ([nobilis `b3e92b5`](https://github.com/Moho-Chat/nobilis/commit/b3e92b5))
|
||||
- Fixed the IRC capability list being cut short when a server sends it over several lines with the continuation mark in the other field ([nobilis `b3e92b5`](https://github.com/Moho-Chat/nobilis/commit/b3e92b5))
|
||||
- Fixed a removed account leaving six lists behind in memory, among them its silenced channels and what its network said about MONITOR ([nobilis `b3e92b5`](https://github.com/Moho-Chat/nobilis/commit/b3e92b5))
|
||||
- Fixed a raid of one viewer reading "1 viewers" ([nobilis `b3e92b5`](https://github.com/Moho-Chat/nobilis/commit/b3e92b5))
|
||||
- Every test was read; those that could not fail now can, tests that checked a copy of the code now check the code, and the untested cases mutation testing found are covered ([moho `8a7e3e7`](https://github.com/Moho-Chat/moho/commit/8a7e3e7), [moho `b0e6775`](https://github.com/Moho-Chat/moho/commit/b0e6775), [moho `47653c5`](https://github.com/Moho-Chat/moho/commit/47653c5), [moho `09c060d`](https://github.com/Moho-Chat/moho/commit/09c060d), [nobilis `2477514`](https://github.com/Moho-Chat/nobilis/commit/2477514), [nobilis `b522fe6`](https://github.com/Moho-Chat/nobilis/commit/b522fe6), [nobilis `b3e92b5`](https://github.com/Moho-Chat/nobilis/commit/b3e92b5))
|
||||
- Nothing in the code or documentation refers to the maintainer's own server any more ([moho `97611c8`](https://github.com/Moho-Chat/moho/commit/97611c8), [nobilis `1c70d32`](https://github.com/Moho-Chat/nobilis/commit/1c70d32))
|
||||
- Documentation brought up to date with the settings pages, how a conversation opens, presence, link cards, Kick events and the daemon as it is ([moho `d99ad2b`](https://github.com/Moho-Chat/moho/commit/d99ad2b), [nobilis `6a1bb8e`](https://github.com/Moho-Chat/nobilis/commit/6a1bb8e))
|
||||
- Added idle detection with a setting for it and for how long ([moho `23c87dd`](https://github.com/Moho-Chat/moho/commit/23c87dd), [nobilis `35ea0b7`](https://github.com/Moho-Chat/nobilis/commit/35ea0b7))
|
||||
- Added GIFs drawn as pictures on every service, and a GIF picker ([moho `55248eb`](https://github.com/Moho-Chat/moho/commit/55248eb), [nobilis `046fbb4`](https://github.com/Moho-Chat/nobilis/commit/046fbb4))
|
||||
- Fixed a Sneedchat picture from the forum's uploads host - an AVIF, or an MP4 - not drawing: its domain is taken off and it is fetched through the account's route, clearnet or Tor ([moho `07f4fc2`](https://github.com/Moho-Chat/moho/commit/07f4fc2), [nobilis `ca29a63`](https://github.com/Moho-Chat/nobilis/commit/ca29a63))
|
||||
- Fixed the tray's number drifting from the client's: it is now counted by the window, by the rail's rule, and told to the tray whenever it changes ([moho `1e314f8`](https://github.com/Moho-Chat/moho/commit/1e314f8))
|
||||
- Fixed YouTube cards never appearing for an account on Tor: YouTube answers every Tor exit with a 403 on its card lookup, so the video's own page is read when that happens ([moho `8064e79`](https://github.com/Moho-Chat/moho/commit/8064e79), [nobilis `340342b`](https://github.com/Moho-Chat/nobilis/commit/340342b))
|
||||
- Fixed link cards being lost when a message was edited, and missing from Matrix history, Discord catch-up and older messages ([moho `638b02e`](https://github.com/Moho-Chat/moho/commit/638b02e), [nobilis `dab394f`](https://github.com/Moho-Chat/nobilis/commit/dab394f))
|
||||
- Fixed scrolling through a long run of unread messages snapping back to where the "new messages" bar appeared ([moho `336335a`](https://github.com/Moho-Chat/moho/commit/336335a))
|
||||
- The number beside a conversation is what is addressed to you - mentions in a channel, each unread message in a direct message - and the bold name is the sign of activity ([moho `987f31c`](https://github.com/Moho-Chat/moho/commit/987f31c))
|
||||
- A muted channel keeps its mention count in the list, the rail, the switcher and the tray, and stops the popups; muting a Discord channel or direct message is now set on the account, so a phone is quiet too ([moho `7803cc3`](https://github.com/Moho-Chat/moho/commit/7803cc3), [nobilis `b0fa250`](https://github.com/Moho-Chat/nobilis/commit/b0fa250))
|
||||
- Reworked the tray icon and menu, and dropped Restart daemon and Stop daemon from it ([moho `24106e5`](https://github.com/Moho-Chat/moho/commit/24106e5))
|
||||
- Do not disturb now silences an account's desktop notifications and the taskbar flash; the tray no longer has a switch of its own for it ([moho `53fb0f3`](https://github.com/Moho-Chat/moho/commit/53fb0f3))
|
||||
- Idle from the tray reaches Discord and IRC and leaves Matrix, Kick and Sneedchat as they were; a service is no longer asked for a status it does not have ([moho `c1bcb72`](https://github.com/Moho-Chat/moho/commit/c1bcb72), [nobilis `a8441ab`](https://github.com/Moho-Chat/nobilis/commit/a8441ab))
|
||||
- Fixed Matrix's rate limit on changing status showing as M_LIMIT_EXCEEDED: a limited request now waits the time the server names, and a longer wait is applied when it is over ([moho `0dfff0d`](https://github.com/Moho-Chat/moho/commit/0dfff0d), [nobilis `43135c1`](https://github.com/Moho-Chat/nobilis/commit/43135c1))
|
||||
- Fixed the window being pulled back to the open conversation's server whenever you chose another: the daemon link coming up is acted on once, a refresh no longer follows the open conversation, and the tray is told only what changed ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a))
|
||||
- Matrix's invisible is called Offline in the plaque, its popout and the tray, which is what it sets ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a))
|
||||
- A room muted on a Matrix account can now be unmuted from the menu, including a mute set from Element ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a), [nobilis `59db82c`](https://github.com/Moho-Chat/nobilis/commit/59db82c))
|
||||
- Server rail tiles now count the mentions waiting in them rather than the messages, and a muted room counts for nothing, mentions included ([moho `0dc048c`](https://github.com/Moho-Chat/moho/commit/0dc048c))
|
||||
- Fixed a room unmuted on a Matrix account still being drawn as muted; a mute or unmute the server did not take is now reported with its reason, and Matrix requests to a server that does not answer give up ([moho `95f1784`](https://github.com/Moho-Chat/moho/commit/95f1784), [nobilis `d1bbcaf`](https://github.com/Moho-Chat/nobilis/commit/d1bbcaf))
|
||||
- Discord requests now give up on a connection that has gone quiet, a history catch-up always releases its place, and the gateway logs what it receives and why a message is dropped ([moho `b151ef8`](https://github.com/Moho-Chat/moho/commit/b151ef8), [nobilis `a34821e`](https://github.com/Moho-Chat/nobilis/commit/a34821e))
|
||||
- Fixed Discord reconnecting every few seconds after being told it is rate limited: it now waits out the minute Discord asks for ([nobilis `59db82c`](https://github.com/Moho-Chat/nobilis/commit/59db82c))
|
||||
- Fixed Sneedchat pictures posted back to back by a script merging into one unusable link ([moho `6edae7a`](https://github.com/Moho-Chat/moho/commit/6edae7a))
|
||||
- Discord files staged together now go as one message, with an upload ring that fills as it uploads ([moho `9888c80`](https://github.com/Moho-Chat/moho/commit/9888c80), [nobilis `9690905`](https://github.com/Moho-Chat/nobilis/commit/9690905))
|
||||
- Fixed the release mirror script printing a traceback while the release did not exist yet ([moho `a8d9f41`](https://github.com/Moho-Chat/moho/commit/a8d9f41))
|
||||
@@ -83,20 +128,6 @@ Each section has two parts:
|
||||
- A window whose page dies is reloaded and the reason logged, instead of being left empty ([moho `6899694`](https://github.com/Moho-Chat/moho/commit/6899694), [moho `06bb812`](https://github.com/Moho-Chat/moho/commit/06bb812))
|
||||
- Discord forums, with live and archived posts read together ([moho `dd0c25e`](https://github.com/Moho-Chat/moho/commit/dd0c25e), [nobilis `d02bba0`](https://github.com/Moho-Chat/nobilis/commit/d02bba0), [nobilis `8363003`](https://github.com/Moho-Chat/nobilis/commit/8363003), [nobilis `3cf1a5b`](https://github.com/Moho-Chat/nobilis/commit/3cf1a5b), [nobilis `6abcb8e`](https://github.com/Moho-Chat/nobilis/commit/6abcb8e))
|
||||
- Added Rust crate tokio-util 0.7.19 ([nobilis `9690905`](https://github.com/Moho-Chat/nobilis/commit/9690905))
|
||||
|
||||
## [1.0.0]
|
||||
|
||||
### Major features
|
||||
|
||||
- **Five protocols in one window:** IRC, Discord, Matrix with end-to-end encryption, Sneedchat, and Kick.
|
||||
- **Discord voice and video:** calls, cameras, screen sharing and Go Live, stage channels, the soundboard and per-person volume, all with Discord's DAVE end-to-end encryption.
|
||||
- **Matrix:** encrypted rooms with device verification and key backup, Element Call, sliding sync, spaces, and signing in by QR code from a phone.
|
||||
- **Per-account Tor and SOCKS5 routing:** Clearnet, Service tunnel or Strict - Strict sends every picture, link check and embed through the account's route too. Or send everything through Tor at once.
|
||||
- **One mentions inbox across every service,** link previews, YouTube cards that play in place, voice messages, polls, and exporting a conversation to disk.
|
||||
- **A background daemon** (nobilis) that keeps every connection alive with the window closed.
|
||||
|
||||
### Fixes, patches and changes
|
||||
|
||||
- Channel rows in the sidebar now show a single `#` instead of two ([moho `ccdf090`](https://github.com/Moho-Chat/moho/commit/ccdf090))
|
||||
- Typing anywhere in the window now goes into the message box, without clicking it first ([moho `b5f920f`](https://github.com/Moho-Chat/moho/commit/b5f920f))
|
||||
- Added a formatting strip over selected text in the message box, offering the formats each service can carry ([moho `9ca477c`](https://github.com/Moho-Chat/moho/commit/9ca477c))
|
||||
@@ -175,3 +206,111 @@ Each section has two parts:
|
||||
- Added npm package lottie-web 5.13.0 ([moho `f67c753`](https://github.com/Moho-Chat/moho/commit/f67c753))
|
||||
- Added npm package happy-dom 15.11.7 ([moho `53a6f92`](https://github.com/Moho-Chat/moho/commit/53a6f92))
|
||||
- Added npm package vitest 2.1.9 ([moho `53a6f92`](https://github.com/Moho-Chat/moho/commit/53a6f92))
|
||||
|
||||
## [1.0.0-rc.5]
|
||||
|
||||
### Major features
|
||||
|
||||
- **GIFs on every service:** a linked GIF is drawn as the picture - silent, looping, without the player - whichever service it came from, and a GIF picker with trending, categories and search sends one from the composer. Klipy, Tenor and Giphy pages are never probed for what they are.
|
||||
- **Idle detection:** moho notices when nothing has been used for a while, from the system where it can say and from its own windows where it cannot, and puts an account that has an idle on Idle and back. It has a setting for it and for how long, and the plaque says Idle (away) while keeping what you chose checked.
|
||||
- **Link cards on every service:** a YouTube link gets a titled card with its channel on IRC, Matrix, Kick and Sneedchat as it does on Discord - including through Tor, which YouTube refuses the usual way - and one added in an edit, read back from history, or posted before cards existed gets its card too.
|
||||
|
||||
### Fixes, patches and changes
|
||||
|
||||
- Added idle detection with a setting for it and for how long ([moho `23c87dd`](https://github.com/Moho-Chat/moho/commit/23c87dd), [nobilis `35ea0b7`](https://github.com/Moho-Chat/nobilis/commit/35ea0b7))
|
||||
- Added GIFs drawn as pictures on every service, and a GIF picker ([moho `55248eb`](https://github.com/Moho-Chat/moho/commit/55248eb), [nobilis `046fbb4`](https://github.com/Moho-Chat/nobilis/commit/046fbb4))
|
||||
- Fixed a Sneedchat picture from the forum's uploads host - an AVIF, or an MP4 - not drawing: its domain is taken off and it is fetched through the account's route, clearnet or Tor ([moho `07f4fc2`](https://github.com/Moho-Chat/moho/commit/07f4fc2), [nobilis `ca29a63`](https://github.com/Moho-Chat/nobilis/commit/ca29a63))
|
||||
- Fixed the tray's number drifting from the client's: it is now counted by the window, by the rail's rule, and told to the tray whenever it changes ([moho `1e314f8`](https://github.com/Moho-Chat/moho/commit/1e314f8))
|
||||
- Fixed YouTube cards never appearing for an account on Tor: YouTube answers every Tor exit with a 403 on its card lookup, so the video's own page is read when that happens ([moho `8064e79`](https://github.com/Moho-Chat/moho/commit/8064e79), [nobilis `340342b`](https://github.com/Moho-Chat/nobilis/commit/340342b))
|
||||
- Fixed link cards being lost when a message was edited, and missing from Matrix history, Discord catch-up and older messages ([moho `638b02e`](https://github.com/Moho-Chat/moho/commit/638b02e), [nobilis `dab394f`](https://github.com/Moho-Chat/nobilis/commit/dab394f))
|
||||
- Fixed scrolling through a long run of unread messages snapping back to where the "new messages" bar appeared ([moho `336335a`](https://github.com/Moho-Chat/moho/commit/336335a))
|
||||
- The number beside a conversation is what is addressed to you - mentions in a channel, each unread message in a direct message - and the bold name is the sign of activity ([moho `987f31c`](https://github.com/Moho-Chat/moho/commit/987f31c))
|
||||
- A muted channel keeps its mention count in the list, the rail, the switcher and the tray, and stops the popups; muting a Discord channel or direct message is now set on the account, so a phone is quiet too ([moho `7803cc3`](https://github.com/Moho-Chat/moho/commit/7803cc3), [nobilis `b0fa250`](https://github.com/Moho-Chat/nobilis/commit/b0fa250))
|
||||
|
||||
## [1.0.0-rc.4]
|
||||
|
||||
### Major features
|
||||
|
||||
- **A rebuilt tray:** an icon drawn for the system's colour scheme with the number of conversations waiting on it, a tooltip that says what is waiting, and a menu of what is new, Show/Hide, Status, Settings, Restart moho and Quit - Restart moho brings back both the window and the daemon
|
||||
- **Status by service:** the plaque and the tray offer only the statuses each service has - Discord all four, IRC away and back, Matrix online and offline, Kick and Sneedchat none - and Do not disturb silences desktop notifications on every service, and is Discord's real mode there
|
||||
|
||||
### Fixes, patches and changes
|
||||
|
||||
- Reworked the tray icon and menu, and dropped Restart daemon and Stop daemon from it ([moho `24106e5`](https://github.com/Moho-Chat/moho/commit/24106e5))
|
||||
- Do not disturb now silences an account's desktop notifications and the taskbar flash; the tray no longer has a switch of its own for it ([moho `53fb0f3`](https://github.com/Moho-Chat/moho/commit/53fb0f3))
|
||||
- Idle from the tray reaches Discord and IRC and leaves Matrix, Kick and Sneedchat as they were; a service is no longer asked for a status it does not have ([moho `c1bcb72`](https://github.com/Moho-Chat/moho/commit/c1bcb72), [nobilis `a8441ab`](https://github.com/Moho-Chat/nobilis/commit/a8441ab))
|
||||
- Fixed Matrix's rate limit on changing status showing as M_LIMIT_EXCEEDED: a limited request now waits the time the server names, and a longer wait is applied when it is over ([moho `0dfff0d`](https://github.com/Moho-Chat/moho/commit/0dfff0d), [nobilis `43135c1`](https://github.com/Moho-Chat/nobilis/commit/43135c1))
|
||||
- Fixed the window being pulled back to the open conversation's server whenever you chose another: the daemon link coming up is acted on once, a refresh no longer follows the open conversation, and the tray is told only what changed ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a))
|
||||
- Matrix's invisible is called Offline in the plaque, its popout and the tray, which is what it sets ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a))
|
||||
- A room muted on a Matrix account can now be unmuted from the menu, including a mute set from Element ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a), [nobilis `59db82c`](https://github.com/Moho-Chat/nobilis/commit/59db82c))
|
||||
- Server rail tiles now count the mentions waiting in them rather than the messages, and a muted room counts for nothing, mentions included ([moho `0dc048c`](https://github.com/Moho-Chat/moho/commit/0dc048c))
|
||||
- Fixed a room unmuted on a Matrix account still being drawn as muted; a mute or unmute the server did not take is now reported with its reason, and Matrix requests to a server that does not answer give up ([moho `95f1784`](https://github.com/Moho-Chat/moho/commit/95f1784), [nobilis `d1bbcaf`](https://github.com/Moho-Chat/nobilis/commit/d1bbcaf))
|
||||
- Discord requests now give up on a connection that has gone quiet, a history catch-up always releases its place, and the gateway logs what it receives and why a message is dropped ([moho `b151ef8`](https://github.com/Moho-Chat/moho/commit/b151ef8), [nobilis `a34821e`](https://github.com/Moho-Chat/nobilis/commit/a34821e))
|
||||
- Fixed Discord reconnecting every few seconds after being told it is rate limited: it now waits out the minute Discord asks for ([nobilis `59db82c`](https://github.com/Moho-Chat/nobilis/commit/59db82c))
|
||||
|
||||
## [1.0.0-rc.3]
|
||||
|
||||
### Major features
|
||||
|
||||
- **Discord forums:** forum and media channels appear in the channel list and open as a pane of posts - title, tags, the first message's words, replies, age, picture and top reaction - with search, sorting, older posts, and a New Post form
|
||||
- **A redesigned interface:** Discord-shaped messages with date separators and an unread bar, red mention badges, a Ctrl+K quick switcher, keyboard navigation, a rail with mention counts and folders, a member list with faces, and motion throughout
|
||||
- **Settings rebuilt:** one click from the gear, a grouped rail with a search over every setting, and new Appearance (layout, spacing, zoom, clock, reduce motion), Notifications and Keybinds pages with a hotkey recorder
|
||||
- **A one-box message composer:** a + menu, Send only when there is something to send, Shift+Enter for a new line on every service, an attachment tray with spoilers, and dropped files going into the tray
|
||||
- **Discord notices and permissions:** Discord's own safety notices drawn as the cards they are, and a banner in place of the message box wherever nothing can be written
|
||||
|
||||
### Fixes, patches and changes
|
||||
|
||||
- Fixed Sneedchat pictures posted back to back by a script merging into one unusable link ([moho `6edae7a`](https://github.com/Moho-Chat/moho/commit/6edae7a))
|
||||
- Discord files staged together now go as one message, with an upload ring that fills as it uploads ([moho `9888c80`](https://github.com/Moho-Chat/moho/commit/9888c80), [nobilis `9690905`](https://github.com/Moho-Chat/nobilis/commit/9690905))
|
||||
- Fixed the release mirror script printing a traceback while the release did not exist yet ([moho `a8d9f41`](https://github.com/Moho-Chat/moho/commit/a8d9f41))
|
||||
- Defined the design tokens the stylesheet used but never had, and applied one set across it ([moho `9db25c8`](https://github.com/Moho-Chat/moho/commit/9db25c8), [moho `3cdfe37`](https://github.com/Moho-Chat/moho/commit/3cdfe37))
|
||||
- Added a screenshot harness for before-and-after pictures of the interface ([moho `88375ba`](https://github.com/Moho-Chat/moho/commit/88375ba))
|
||||
- Unread channels now read as unread, mentions are red, and every badge is one style ([moho `4ca37dd`](https://github.com/Moho-Chat/moho/commit/4ca37dd))
|
||||
- Added date separators to the message list ([moho `b2464e4`](https://github.com/Moho-Chat/moho/commit/b2464e4))
|
||||
- Added Mark as read on channels, categories and servers, and Escape to read what you are in ([moho `2745a2f`](https://github.com/Moho-Chat/moho/commit/2745a2f))
|
||||
- Added a Ctrl+K quick switcher and keyboard navigation between channels ([moho `1eef37e`](https://github.com/Moho-Chat/moho/commit/1eef37e))
|
||||
- Voice panel: a horizontal microphone bar, the connection's real latency, and a share button ([moho `ad0f0c0`](https://github.com/Moho-Chat/moho/commit/ad0f0c0), [nobilis `e0f6169`](https://github.com/Moho-Chat/nobilis/commit/e0f6169))
|
||||
- Replies jump to the message they answer, and names and pictures open the profile ([moho `bf45147`](https://github.com/Moho-Chat/moho/commit/bf45147))
|
||||
- Added Copy and Copy text on every message, and Copy message link on Discord ([moho `0033955`](https://github.com/Moho-Chat/moho/commit/0033955))
|
||||
- The hover toolbar fades in, can be reached by keyboard, and offers Edit, Copy and your recent reactions ([moho `57e1856`](https://github.com/Moho-Chat/moho/commit/57e1856))
|
||||
- Fixed six interface bugs: a nested voice button, folded categories hiding unread, muted channels hiding mentions, overlapping corners, the typing strip moving the log, and pinned rows missing their service mark ([moho `6598043`](https://github.com/Moho-Chat/moho/commit/6598043))
|
||||
- Pending sends dim only their text, failed ones can be deleted, and Up in an empty box edits your last message in a multi-line editor ([moho `6a82c32`](https://github.com/Moho-Chat/moho/commit/6a82c32))
|
||||
- Server rail: mention counts, folders that say what is waiting and stay open, an instant name flyout and a red close button ([moho `8072b24`](https://github.com/Moho-Chat/moho/commit/8072b24))
|
||||
- Member list: faces, presence dots, and a click for the profile; Discord's member pictures now come with the list ([moho `c912ed3`](https://github.com/Moho-Chat/moho/commit/c912ed3), [nobilis `4099305`](https://github.com/Moho-Chat/nobilis/commit/4099305))
|
||||
- Voice channel list: faces, muted and deafened marks, a speaking ring and a collapsible heading ([moho `843288d`](https://github.com/Moho-Chat/moho/commit/843288d))
|
||||
- Comfy messages in Discord's shape, amber mention rows, one code and quote style, and marked system lines ([moho `3a7677e`](https://github.com/Moho-Chat/moho/commit/3a7677e))
|
||||
- Buttons are primary, subtle or the one danger style, and the restarts ask first ([moho `2c8609c`](https://github.com/Moho-Chat/moho/commit/2c8609c))
|
||||
- Escape closes only the top layer, and full-page panels close on Escape ([moho `c5e7619`](https://github.com/Moho-Chat/moho/commit/c5e7619))
|
||||
- Thread panel reads the display settings, groups replies and follows the newest ([moho `33129f9`](https://github.com/Moho-Chat/moho/commit/33129f9))
|
||||
- Toasts slide in and out, come in three kinds, pause on hover and fold repeats ([moho `2d1b8be`](https://github.com/Moho-Chat/moho/commit/2d1b8be))
|
||||
- Context menu: keyboard navigation, checked items, shortcut hints, and placement that holds while it animates ([moho `4065963`](https://github.com/Moho-Chat/moho/commit/4065963), [moho `fcb7df5`](https://github.com/Moho-Chat/moho/commit/fcb7df5))
|
||||
- A conversation opens at its first unread, with a bar saying how many are above ([moho `55fd192`](https://github.com/Moho-Chat/moho/commit/55fd192))
|
||||
- Added hover fades, turning fold arrows, arriving messages, a reaction pop and growing overlays ([moho `6d8a050`](https://github.com/Moho-Chat/moho/commit/6d8a050))
|
||||
- Every dialog is built from one shared frame with a close button, kept focus and a scrim below the title bar ([moho `5cb30ce`](https://github.com/Moho-Chat/moho/commit/5cb30ce), [moho `420a2f7`](https://github.com/Moho-Chat/moho/commit/420a2f7), [moho `6a329c6`](https://github.com/Moho-Chat/moho/commit/6a329c6))
|
||||
- Shift+Enter makes a new line on every service and pasted line breaks stay; IRC sends them as a batch or separate lines, Sneedchat as [br], Kick as one line ([moho `9ba630c`](https://github.com/Moho-Chat/moho/commit/9ba630c), [nobilis `ccc8de6`](https://github.com/Moho-Chat/nobilis/commit/ccc8de6))
|
||||
- Reply bar: its own surface, the author's face, Esc to cancel, and an @ switch to answer without pinging on Discord ([moho `f48185e`](https://github.com/Moho-Chat/moho/commit/f48185e), [nobilis `918fcfa`](https://github.com/Moho-Chat/nobilis/commit/918fcfa))
|
||||
- One style for the name, command and emoji lists, with member pictures and :shortcode emoji completion ([moho `28d8b3c`](https://github.com/Moho-Chat/moho/commit/28d8b3c))
|
||||
- Lightbox: step through a conversation's pictures, wheel and pinch zoom with panning, and Copy picture ([moho `817cb1e`](https://github.com/Moho-Chat/moho/commit/817cb1e))
|
||||
- Emoji picker: walk it with the arrow keys, Enter picks the top result, and skin tones are remembered ([moho `99fd0d3`](https://github.com/Moho-Chat/moho/commit/99fd0d3))
|
||||
- Attachment tray cards with name, size and a Discord spoiler toggle; dropped files go into the tray instead of being sent at once ([moho `15e7061`](https://github.com/Moho-Chat/moho/commit/15e7061), [nobilis `0f5669b`](https://github.com/Moho-Chat/nobilis/commit/0f5669b))
|
||||
- Composer: one box with the + on the left and emoji, stickers, voice and Send on the right ([moho `a59c1ab`](https://github.com/Moho-Chat/moho/commit/a59c1ab))
|
||||
- Several pictures in one message lay out as a grid, and SPOILER_ files are blurred until clicked ([moho `87ea3b8`](https://github.com/Moho-Chat/moho/commit/87ea3b8))
|
||||
- Pinned messages show as a banner over the log for Discord and Matrix, with a count on the header ([moho `3a74cf0`](https://github.com/Moho-Chat/moho/commit/3a74cf0))
|
||||
- Reactions say what they are and who gave them on hover ([moho `506140d`](https://github.com/Moho-Chat/moho/commit/506140d), [nobilis `1da3ad8`](https://github.com/Moho-Chat/nobilis/commit/1da3ad8))
|
||||
- User footer: muted and deafened are red, voice buttons stay on every service, and the status is a popout with a custom status for Discord and Matrix and Apply to all accounts ([moho `c026609`](https://github.com/Moho-Chat/moho/commit/c026609), [nobilis `f280563`](https://github.com/Moho-Chat/nobilis/commit/f280563))
|
||||
- The footer's join button is gone; the rail's + is the one way in, pinned under a rule above the settings cog while the server list scrolls ([moho `b4c1ced`](https://github.com/Moho-Chat/moho/commit/b4c1ced), [moho `dad5543`](https://github.com/Moho-Chat/moho/commit/dad5543))
|
||||
- Header: one 48px row across every column, filled toggles, and the channel topic from IRC, Matrix and Discord ([moho `2ef9e35`](https://github.com/Moho-Chat/moho/commit/2ef9e35), [nobilis `44d1611`](https://github.com/Moho-Chat/nobilis/commit/44d1611))
|
||||
- "(edited)" says when on hover, for edits seen live ([moho `7f4c356`](https://github.com/Moho-Chat/moho/commit/7f4c356), [nobilis `e51f139`](https://github.com/Moho-Chat/nobilis/commit/e51f139))
|
||||
- Incoming Discord and Matrix calls share one card style and stack ([moho `59d4893`](https://github.com/Moho-Chat/moho/commit/59d4893))
|
||||
- Settings: the gear opens it directly, with Accounts and Downloads as pages, a search, one switch for every boolean and a saved tick ([moho `66a0c0f`](https://github.com/Moho-Chat/moho/commit/66a0c0f), [moho `cc4c9f6`](https://github.com/Moho-Chat/moho/commit/cc4c9f6))
|
||||
- Settings: added Appearance, Notifications and Keybinds pages, the last with a recorder for the global hotkey ([moho `fe6ad97`](https://github.com/Moho-Chat/moho/commit/fe6ad97), [moho `adca84d`](https://github.com/Moho-Chat/moho/commit/adca84d), [moho `82a0bdf`](https://github.com/Moho-Chat/moho/commit/82a0bdf))
|
||||
- Accounts open by default with few accounts, Remove sits in a danger zone, and Join picks its account and says Join ([moho `f8dcccd`](https://github.com/Moho-Chat/moho/commit/f8dcccd))
|
||||
- Mentions page: service and unread filters, day groups, two-line bodies and put-away ([moho `078fe41`](https://github.com/Moho-Chat/moho/commit/078fe41))
|
||||
- Room settings is a tabbed dialog instead of a popover ([moho `f8936c6`](https://github.com/Moho-Chat/moho/commit/f8936c6))
|
||||
- Quiet buttons such as Cancel have their own fill, so they no longer vanish into a dialog ([moho `50aaa97`](https://github.com/Moho-Chat/moho/commit/50aaa97))
|
||||
- Fixed browsing an IRC network's channels freezing or blanking the window: the daemon holds the list and sends a page, it times out after a minute, and a refusal is reported ([moho `2b41c5e`](https://github.com/Moho-Chat/moho/commit/2b41c5e), [nobilis `152fdf1`](https://github.com/Moho-Chat/nobilis/commit/152fdf1))
|
||||
- Channels in the list no longer jump to the top as messages arrive ([moho `9f03d6b`](https://github.com/Moho-Chat/moho/commit/9f03d6b))
|
||||
- Fixed Discord's notices showing only a stand-in title: they are read as cards, and a banner replaces the message box in them and in channels this account cannot write in ([moho `ff7d313`](https://github.com/Moho-Chat/moho/commit/ff7d313), [moho `d7c4215`](https://github.com/Moho-Chat/moho/commit/d7c4215), [moho `2a4f797`](https://github.com/Moho-Chat/moho/commit/2a4f797), [nobilis `1b270be`](https://github.com/Moho-Chat/nobilis/commit/1b270be), [nobilis `97ac6bc`](https://github.com/Moho-Chat/nobilis/commit/97ac6bc), [nobilis `7561b88`](https://github.com/Moho-Chat/nobilis/commit/7561b88))
|
||||
- A window whose page dies is reloaded and the reason logged, instead of being left empty ([moho `6899694`](https://github.com/Moho-Chat/moho/commit/6899694), [moho `06bb812`](https://github.com/Moho-Chat/moho/commit/06bb812))
|
||||
- Discord forums, with live and archived posts read together ([moho `dd0c25e`](https://github.com/Moho-Chat/moho/commit/dd0c25e), [nobilis `d02bba0`](https://github.com/Moho-Chat/nobilis/commit/d02bba0), [nobilis `8363003`](https://github.com/Moho-Chat/nobilis/commit/8363003), [nobilis `3cf1a5b`](https://github.com/Moho-Chat/nobilis/commit/3cf1a5b), [nobilis `6abcb8e`](https://github.com/Moho-Chat/nobilis/commit/6abcb8e))
|
||||
- Added Rust crate tokio-util 0.7.19 ([nobilis `9690905`](https://github.com/Moho-Chat/nobilis/commit/9690905))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# Maintainer: Salastil <[email protected]>
|
||||
pkgname=moho-git
|
||||
pkgver=r1.268751e
|
||||
pkgrel=1
|
||||
|
||||
@@ -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,12 +1,12 @@
|
||||
{
|
||||
"name": "moho",
|
||||
"version": "1.0.0-rc.3",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "moho",
|
||||
"version": "1.0.0-rc.3",
|
||||
"version": "1.0.0",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "moho",
|
||||
"version": "1.0.0-rc.3",
|
||||
"version": "1.0.0",
|
||||
"description": "Unified desktop chat client for IRC, Discord, Sneedchat and Matrix",
|
||||
"author": "Salastil",
|
||||
"author": "Salastil <[email protected]>",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"homepage": "https://github.com/Moho-Chat/moho",
|
||||
"repository": {
|
||||
@@ -21,13 +21,18 @@
|
||||
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
|
||||
"test": "vitest run",
|
||||
"test:ui": "electron-vite build && node scripts/ui-check.mjs",
|
||||
"test:tray": "electron-vite build && node scripts/tray-check.mjs",
|
||||
"test:gif": "electron-vite build && node scripts/gif-check.mjs",
|
||||
"test:scroll": "electron-vite build && node scripts/scroll-check.mjs",
|
||||
"test:kick": "electron-vite build && node scripts/kick-check.mjs",
|
||||
"shots": "electron-vite build && node scripts/ui-shots.mjs",
|
||||
"pack": "npm run daemon && npm run build && MOHO_BUILD=$(git rev-parse --short HEAD)$(git diff --quiet HEAD || echo -dirty) electron-builder --linux AppImage",
|
||||
"pack:deb": "npm run daemon && npm run build && MOHO_BUILD=$(git rev-parse --short HEAD)$(git diff --quiet HEAD || echo -dirty) electron-builder --linux deb",
|
||||
"pack:linux": "npm run daemon && npm run build && MOHO_BUILD=$(git rev-parse --short HEAD)$(git diff --quiet HEAD || echo -dirty) electron-builder --linux AppImage deb",
|
||||
"pack:dir": "npm run daemon && npm run build && MOHO_BUILD=$(git rev-parse --short HEAD)$(git diff --quiet HEAD || echo -dirty) electron-builder --linux dir",
|
||||
"daemon": "./scripts/build-daemon.sh",
|
||||
"deploy": "npm run pack && ./scripts/install-desktop.sh",
|
||||
"tidy": "./scripts/tidy.sh",
|
||||
"deploy": "./scripts/tidy.sh && npm run pack && ./scripts/install-desktop.sh",
|
||||
"daemon:win": "./scripts/build-daemon-win.sh",
|
||||
"pack:win": "npm run daemon:win && npm run build && MOHO_BUILD=$(git rev-parse --short HEAD)$(git diff --quiet HEAD || echo -dirty) electron-builder --win nsis"
|
||||
},
|
||||
@@ -74,7 +79,7 @@
|
||||
"AppImage",
|
||||
"deb"
|
||||
],
|
||||
"maintainer": "Salastil <salastil@users.noreply.github.com>",
|
||||
"maintainer": "Salastil <salastil@cock.li>",
|
||||
"category": "Network",
|
||||
"synopsis": "Unified desktop chat client",
|
||||
"icon": "resources/icons/moho.png",
|
||||
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 797 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 771 B |
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Checks GIFs against the running app: that one a message carries is drawn as
|
||||
* the picture and not as its address, that a link to a page nothing could read
|
||||
* is still a link, and that the picker finds, filters and sends.
|
||||
*
|
||||
* What it stands in for, and why: Discord is not here, so the picker's list is
|
||||
* left for it (a fixture only a harness window reads) and the embeds are put on
|
||||
* messages directly. What is real is everything the window does with them, and
|
||||
* the send, which goes through the daemon to a fake IRC server and is read back
|
||||
* off the wire.
|
||||
*
|
||||
* npm run test:gif (builds first; needs the daemon built too)
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fakeIrc, launchApp, openIrcChannel, sleep, until, click } from './lib/harness.mjs'
|
||||
|
||||
let failures = 0
|
||||
const check = (ok, what, detail = '') => {
|
||||
console.log(`${ok ? 'ok ' : 'FAIL'} ${what}${ok ? '' : detail ? ` - ${detail}` : ''}`)
|
||||
if (!ok) failures++
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('FAIL timed out')
|
||||
process.exit(1)
|
||||
}, 120000).unref()
|
||||
|
||||
// A GIF and a video to be served, so what is drawn has something to load: made
|
||||
// with ffmpeg, a second of a flat colour. The window only plays https, blob and
|
||||
// data media, so they are handed to it as the last two.
|
||||
const media = fs.mkdtempSync(path.join(os.tmpdir(), 'moho-gif-'))
|
||||
execFileSync('ffmpeg', ['-v', 'error', '-f', 'lavfi', '-i', 'color=c=0x3a8fe0:s=96x54:d=1:r=10', '-pix_fmt', 'yuv420p', '-y', path.join(media, 'cat.mp4')])
|
||||
execFileSync('ffmpeg', ['-v', 'error', '-f', 'lavfi', '-i', 'color=c=0xe0a43a:s=96x54:d=1:r=10', '-y', path.join(media, 'giphy.gif')])
|
||||
const MP4 = fs.readFileSync(path.join(media, 'cat.mp4')).toString('base64')
|
||||
const GIF = fs.readFileSync(path.join(media, 'giphy.gif')).toString('base64')
|
||||
|
||||
const sent = []
|
||||
const irc = await fakeIrc({
|
||||
people: ['bob'],
|
||||
onJoin: (channel, _nick, send) => send(`:bob!b@h PRIVMSG ${channel} :hello there`),
|
||||
onLine: (line) => {
|
||||
if (/^PRIVMSG/i.test(line)) sent.push(line)
|
||||
}
|
||||
})
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
let home = ''
|
||||
try {
|
||||
home = await page.evaluate('window.moho.diagnostics?.home ?? ""').catch(() => '')
|
||||
await until(page, 'window.__mohoShots', 'the harness hook')
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#lobby'], waitFor: 'hello there' })
|
||||
|
||||
// The same GIF, as a message that carries it.
|
||||
const SIZE = (a, b, label) =>
|
||||
'data:image/svg+xml;utf8,' +
|
||||
encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="480" height="270"><rect width="480" height="270" fill="${a}"/><text x="240" y="150" font-size="60" fill="${b}" text-anchor="middle">${label}</text></svg>`)
|
||||
await page.evaluate(`(() => {
|
||||
const shots = window.__mohoShots
|
||||
const s = shots.state()
|
||||
const id = s.activeBufferId
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const bytes = Uint8Array.from(atob('${MP4}'), (c) => c.charCodeAt(0))
|
||||
const MP4_URL = URL.createObjectURL(new Blob([bytes], { type: 'video/mp4' }))
|
||||
const GIF_URL = 'data:image/gif;base64,${GIF}'
|
||||
const msg = (n, body, embeds) => ({ id: 'g' + n, bufferId: id, from: 'bob', senderId: 'bob', body, ts: now - (10 - n), isAction: false, isHighlight: false, kind: 'chat', isOwn: false, ...(embeds ? { embeds } : {}) })
|
||||
shots.patch({
|
||||
accounts: [...s.accounts, { id: 'discord:gifcheck', service: 'discord', displayName: 'x', state: 'connected', autojoin: '', currentNick: 'x', status: 'online', useTor: false, ssl: true }],
|
||||
messagesByBuffer: { ...s.messagesByBuffer, [id]: [...(s.messagesByBuffer[id] || []),
|
||||
msg(1, 'https://tenor.com/view/cat-gif-123', [{ kind: 'gif', url: 'https://tenor.com/view/cat-gif-123', mediaUrl: MP4_URL, mediaKind: 'video', width: 480, height: 270, provider: 'Tenor' }]),
|
||||
msg(2, 'look at this https://giphy.com/gifs/dog-Z7wFi5b2VEAr6 so good', [{ kind: 'gif', url: 'https://giphy.com/gifs/dog-Z7wFi5b2VEAr6', mediaUrl: GIF_URL, mediaKind: 'image', provider: 'Giphy' }]),
|
||||
msg(3, 'https://klipy.com/gifs/friends-joey-53')
|
||||
] }
|
||||
})
|
||||
window.__gifFixture = {
|
||||
categories: [{ name: 'reaction', src: '' }, { name: 'happy', src: '' }],
|
||||
gifs: [
|
||||
{ id: 'a', title: 'cat dancing', url: 'https://tenor.com/view/cat-dancing-gif-1', src: 'http://127.0.0.1:9/a.mp4', gifSrc: 'http://127.0.0.1:9/a.gif', preview: '', width: 480, height: 270 },
|
||||
{ id: 'b', title: 'dog running', url: 'https://tenor.com/view/dog-running-gif-2', src: 'http://127.0.0.1:9/b.mp4', gifSrc: 'http://127.0.0.1:9/b.gif', preview: '', width: 270, height: 480 },
|
||||
{ id: 'c', title: 'cat sleeping', url: 'https://tenor.com/view/cat-sleeping-gif-3', src: 'http://127.0.0.1:9/c.mp4', gifSrc: 'http://127.0.0.1:9/c.gif', preview: '', width: 480, height: 480 }
|
||||
]
|
||||
}
|
||||
return true
|
||||
})()`)
|
||||
await sleep(800)
|
||||
const row = (n) => `[...document.querySelectorAll('.message-row')].find((r) => r.innerText.includes('${n}') || r.querySelector('[data-id="g${n}"]'))`
|
||||
|
||||
// A message that carries a GIF shows the GIF, and not the address it came from.
|
||||
const rows = await page.evaluate(`[...document.querySelectorAll('.message-row')].map((r) => ({ text: r.innerText, videos: [...r.querySelectorAll('video')].map((v) => v.getAttribute('src')), plays: [...r.querySelectorAll('video')].map((v) => ({ muted: v.muted, loop: v.loop, controls: v.controls, autoplay: v.autoplay })), images: [...r.querySelectorAll('img')].map((i) => i.getAttribute('src')) }))`)
|
||||
if (process.env.GIF_DEBUG) console.log(JSON.stringify(rows.slice(-4), null, 1))
|
||||
const tenor = rows.find((r) => r.videos.some((v) => v?.startsWith('blob:')))
|
||||
check(!!tenor, 'a GIF message is drawn as a video')
|
||||
check(!!tenor && !/tenor\.com/.test(tenor.text), 'and its page address is not drawn as well', tenor?.text)
|
||||
const play = tenor?.plays?.[0]
|
||||
check(!!play && play.muted && play.loop && !play.controls && play.autoplay, 'played as a GIF: silent, going round, with no player to work', JSON.stringify(play))
|
||||
const giphy = rows.find((r) => r.text.includes('look at this'))
|
||||
check(!!giphy && /look at this/.test(giphy.text) && /so good/.test(giphy.text) && !/giphy\.com/.test(giphy.text), 'words around a GIF link stay, and the link goes', giphy?.text)
|
||||
check(!!giphy && (giphy.images.some((i) => i?.startsWith('data:image/gif'))), 'and a GIF file is drawn as a picture')
|
||||
const klipy = rows.find((r) => r.text.includes('klipy.com'))
|
||||
check(!!klipy && klipy.videos.length === 0, 'a Klipy page nothing could read stays a link', JSON.stringify(klipy))
|
||||
|
||||
// The picker.
|
||||
check(await page.evaluate('!!document.querySelector(\'button[title="GIFs"]\')'), 'a GIF button is offered where there is a Discord account')
|
||||
await page.evaluate(click('button[title="GIFs"]'))
|
||||
await until(page, 'document.querySelector(".gif-picker")', 'the picker')
|
||||
check((await page.evaluate('document.querySelectorAll(".gif-cell").length')) === 3, 'it lists what it was given')
|
||||
check((await page.evaluate('document.querySelectorAll(".gif-category").length')) === 2, 'with categories to start from')
|
||||
const inView = await page.evaluate(`(() => { const r = document.querySelector('.gif-picker').getBoundingClientRect(); return r.left >= 0 && r.top >= 0 && r.right <= window.innerWidth && r.bottom <= window.innerHeight })()`)
|
||||
check(inView, 'inside the window')
|
||||
|
||||
await page.evaluate(`(() => { const i = document.querySelector('.gif-search input'); const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; set.call(i, 'cat'); i.dispatchEvent(new Event('input', { bubbles: true })); return true })()`)
|
||||
await sleep(700)
|
||||
check((await page.evaluate('document.querySelectorAll(".gif-cell").length')) === 2, 'a search narrows it')
|
||||
|
||||
// Choosing one sends it - to IRC, the file, which anything can show as a link.
|
||||
await page.evaluate(`document.querySelector('.gif-cell[title="cat dancing"]').click()`)
|
||||
await sleep(1200)
|
||||
check(sent.some((l) => /PRIVMSG #lobby :?http:\/\/127\.0\.0\.1:9\/a\.gif/.test(l)), 'choosing one sends the file to a service that is not Discord', JSON.stringify(sent))
|
||||
check(!(await page.evaluate('!!document.querySelector(".gif-picker")')), 'and the picker closes')
|
||||
} catch (e) {
|
||||
console.log('FAIL', e.message)
|
||||
failures++
|
||||
} finally {
|
||||
await stop().catch(() => {})
|
||||
irc.close()
|
||||
fs.rmSync(media, { recursive: true, force: true })
|
||||
}
|
||||
console.log(failures ? `${failures} failed` : 'all passed')
|
||||
process.exit(failures ? 1 : 0)
|
||||
@@ -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)
|
||||
@@ -207,7 +207,7 @@ function moveAside(pid) {
|
||||
* The built app, against a scratch home, with a debugging port. Returns the
|
||||
* page and a `stop()` that takes everything down again.
|
||||
*/
|
||||
export async function launchApp({ env = {} } = {}) {
|
||||
export async function launchApp({ env = {}, args = [] } = {}) {
|
||||
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'moho-ui-'))
|
||||
const run = path.join(scratch, 'run')
|
||||
fs.mkdirSync(run, { mode: 0o700 })
|
||||
@@ -216,7 +216,7 @@ export async function launchApp({ env = {} } = {}) {
|
||||
// A CI runner's kernel refuses Chromium's sandbox (Ubuntu 24.04 restricts
|
||||
// the unprivileged namespaces it needs); there it runs without, as an
|
||||
// ordinary Chromium in a container does.
|
||||
const flags = [`--remote-debugging-port=${port}`, ...(process.env.CI ? ['--no-sandbox'] : [])]
|
||||
const flags = [`--remote-debugging-port=${port}`, ...(process.env.CI ? ['--no-sandbox'] : []), ...args]
|
||||
const app = spawn(electronPath, [root, ...flags], {
|
||||
cwd: root,
|
||||
detached: true,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
// Draws the tray icons into resources/icons/tray-*.png.
|
||||
//
|
||||
// A tray cannot be recoloured by the desktop the way a themed icon can, so
|
||||
// there is one set for a dark panel (a light bubble) and one for a light panel
|
||||
// (a dark bubble), and main picks by the system's colour scheme. Each set is
|
||||
// the bubble alone, and the bubble with a red badge carrying the number of
|
||||
// conversations waiting - 1 to 9, then 9+ - because a tray is the one place
|
||||
// still in view when a notification has faded, and "something" is a poorer
|
||||
// answer than "three things".
|
||||
//
|
||||
// Run when the artwork changes; the PNGs are committed. Needs rsvg-convert.
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const out = join(dirname(fileURLToPath(import.meta.url)), '..', 'resources', 'icons')
|
||||
const SIZE = 64
|
||||
// The app's mention red, so the badge is the same colour as the one in the window.
|
||||
const BADGE = '#e5484d'
|
||||
|
||||
const BUBBLE = 'M14 8h36a8 8 0 0 1 8 8v22a8 8 0 0 1-8 8H34L22 58V46h-8a8 8 0 0 1-8-8V16a8 8 0 0 1 8-8z'
|
||||
|
||||
function svg(ink, label) {
|
||||
const badge = label !== null
|
||||
// The badge sits in a gap cut from the bubble, so it reads against any panel
|
||||
// without a ring of a colour that panel may not be.
|
||||
// Without one, three dots are cut from it, which is what makes the shape a
|
||||
// conversation and not a label.
|
||||
const mask = badge
|
||||
? `<mask id="m"><rect width="64" height="64" fill="#fff"/><circle cx="46" cy="18" r="19" fill="#000"/></mask>`
|
||||
: `<mask id="m"><rect width="64" height="64" fill="#fff"/><circle cx="21" cy="27" r="4" fill="#000"/><circle cx="32" cy="27" r="4" fill="#000"/><circle cx="43" cy="27" r="4" fill="#000"/></mask>`
|
||||
const size = label && label.length > 1 ? 24 : 30
|
||||
const text = badge
|
||||
? `<circle cx="46" cy="18" r="16" fill="${BADGE}"/><text x="46" y="${18 + size * 0.36}" font-family="DejaVu Sans" font-weight="bold" font-size="${size}" text-anchor="middle" fill="#fff">${label}</text>`
|
||||
: ''
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${SIZE}" height="${SIZE}" viewBox="0 0 64 64">${mask}<path d="${BUBBLE}" fill="${ink}" mask="url(#m)"/>${text}</svg>`
|
||||
}
|
||||
|
||||
mkdirSync(out, { recursive: true })
|
||||
const labels = [null, '1', '2', '3', '4', '5', '6', '7', '8', '9', '9+']
|
||||
for (const [name, ink] of [['light', '#f1f3f5'], ['dark', '#25282e']]) {
|
||||
for (const label of labels) {
|
||||
const file = join(out, `tray-${name}${label === null ? '' : `-${label === '9+' ? 'more' : label}`}.png`)
|
||||
const tmp = `${file}.svg`
|
||||
writeFileSync(tmp, svg(ink, label))
|
||||
execFileSync('rsvg-convert', ['-w', String(SIZE), '-h', String(SIZE), '-o', file, tmp])
|
||||
rmSync(tmp)
|
||||
}
|
||||
}
|
||||
console.log('tray icons written to', out)
|
||||
@@ -1,101 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Mirrors a published GitHub release of moho to Gitea, and makes nobilis's
|
||||
# release of the same version on both hosts. Run here, by hand, once the
|
||||
# release workflow has published the tag on GitHub - Gitea releases are never
|
||||
# made by Actions.
|
||||
#
|
||||
# scripts/mirror-release.sh v1.0.0
|
||||
#
|
||||
# moho on Gitea: the same title, notes, pre-release flag and files as GitHub.
|
||||
# nobilis, on GitHub and Gitea: tagged at the commit moho's tag points to,
|
||||
# named after the same version, its notes the version's major features and
|
||||
# the nobilis lines of the changelog, with the two daemon binaries the
|
||||
# release's run built.
|
||||
#
|
||||
# Safe to run again: a release that exists is updated rather than repeated.
|
||||
set -euo pipefail
|
||||
|
||||
TAG="${1:?usage: scripts/mirror-release.sh vX.Y.Z}"
|
||||
VERSION="${TAG#v}"
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
GITEA="https://git.salastil.com/api/v1"
|
||||
TOKEN="$(cat "$HOME/.config/gitea/token")"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
gitea() { # method path [curl args...]
|
||||
local method="$1" path="$2"; shift 2
|
||||
curl -fsS -X "$method" -H "Authorization: token $TOKEN" "$GITEA$path" "$@"
|
||||
}
|
||||
|
||||
# Creates or updates a Gitea release and makes its files exactly those given.
|
||||
gitea_release() { # repo title notes-file prerelease file...
|
||||
local repo="$1" title="$2" notes="$3" pre="$4"; shift 4
|
||||
local body id
|
||||
body="$(python3 -c 'import json,sys; print(json.dumps({"tag_name": sys.argv[1], "name": sys.argv[2], "body": open(sys.argv[3]).read(), "prerelease": sys.argv[4] == "true"}))' "$TAG" "$title" "$notes" "$pre")"
|
||||
if id="$(gitea GET "/repos/$repo/releases/tags/$TAG" 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' 2>/dev/null)"; then
|
||||
gitea PATCH "/repos/$repo/releases/$id" -H 'Content-Type: application/json' -d "$body" >/dev/null
|
||||
for asset in $(gitea GET "/repos/$repo/releases/$id/assets" | python3 -c 'import json,sys; print(" ".join(str(a["id"]) for a in json.load(sys.stdin)))'); do
|
||||
gitea DELETE "/repos/$repo/releases/$id/assets/$asset" >/dev/null
|
||||
done
|
||||
else
|
||||
id="$(gitea POST "/repos/$repo/releases" -H 'Content-Type: application/json' -d "$body" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
|
||||
fi
|
||||
for f in "$@"; do
|
||||
gitea POST "/repos/$repo/releases/$id/assets?name=$(basename "$f")" -F "attachment=@$f" >/dev/null
|
||||
echo " uploaded $(basename "$f") -> Gitea $repo"
|
||||
done
|
||||
}
|
||||
|
||||
# --- moho -------------------------------------------------------------------
|
||||
echo "moho $TAG: GitHub -> Gitea"
|
||||
read -r PRE TITLE < <(gh release view "$TAG" --repo Moho-Chat/moho --json isPrerelease,name -q '"\(.isPrerelease) \(.name)"')
|
||||
gh release view "$TAG" --repo Moho-Chat/moho --json body -q .body > "$WORK/moho-notes.md"
|
||||
mkdir -p "$WORK/moho"
|
||||
gh release download "$TAG" --repo Moho-Chat/moho --dir "$WORK/moho"
|
||||
git -C "$ROOT" push -q origin "refs/tags/$TAG" 2>/dev/null || true
|
||||
gitea_release Salastil/moho "$TITLE" "$WORK/moho-notes.md" "$PRE" "$WORK"/moho/*
|
||||
|
||||
# --- nobilis ------------------------------------------------------------------
|
||||
NOBILIS_SHA="$(git -C "$ROOT" rev-parse "$TAG:nobilis")"
|
||||
echo "nobilis $TAG at ${NOBILIS_SHA:0:7}"
|
||||
if ! git -C "$ROOT/nobilis" rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
|
||||
git -C "$ROOT/nobilis" tag -a "$TAG" "$NOBILIS_SHA" -m "nobilis $VERSION"
|
||||
fi
|
||||
git -C "$ROOT/nobilis" push -q github "refs/tags/$TAG"
|
||||
git -C "$ROOT/nobilis" push -q origin "refs/tags/$TAG"
|
||||
|
||||
# Its notes: the version's major features, and the nobilis lines only.
|
||||
SECTION="$VERSION"
|
||||
grep -q "^## \[$VERSION\]" "$ROOT/CHANGELOG.md" || SECTION="${VERSION%%-*}"
|
||||
awk -v v="$SECTION" '
|
||||
index($0, "## [" v "]") == 1 { on = 1; next }
|
||||
on && /^## \[/ { exit }
|
||||
!on { next }
|
||||
/^### / { fixes = ($0 ~ /^### Fixes/); print; next }
|
||||
/^- / { if (!fixes || /\[nobilis /) print; next }
|
||||
{ print }
|
||||
' "$ROOT/CHANGELOG.md" > "$WORK/nobilis-notes.md"
|
||||
printf '\nnobilis is the daemon inside moho %s; these are its own builds, for running it on its own.\n' "$VERSION" >> "$WORK/nobilis-notes.md"
|
||||
|
||||
# The daemon binaries, from the run that built this release.
|
||||
RUN="$(gh run list --repo Moho-Chat/moho --workflow release.yml --branch "$TAG" --status success --limit 1 --json databaseId -q '.[0].databaseId')"
|
||||
[ -n "$RUN" ] || { echo "no successful release run for $TAG" >&2; exit 1; }
|
||||
mkdir -p "$WORK/nobilis"
|
||||
gh run download "$RUN" --repo Moho-Chat/moho -n daemon-linux -D "$WORK/d-linux"
|
||||
gh run download "$RUN" --repo Moho-Chat/moho -n daemon-windows -D "$WORK/d-win"
|
||||
install -m 755 "$WORK/d-linux/nobilis" "$WORK/nobilis/nobilis-$VERSION-linux-x86_64"
|
||||
install -m 644 "$WORK/d-win/nobilis.exe" "$WORK/nobilis/nobilis-$VERSION-windows-x86_64.exe"
|
||||
|
||||
NOBILIS_FLAGS=(--latest)
|
||||
[ "$PRE" = "true" ] && NOBILIS_FLAGS=(--prerelease --latest=false)
|
||||
if gh release view "$TAG" --repo Moho-Chat/nobilis >/dev/null 2>&1; then
|
||||
gh release edit "$TAG" --repo Moho-Chat/nobilis --notes-file "$WORK/nobilis-notes.md"
|
||||
gh release upload "$TAG" --repo Moho-Chat/nobilis "$WORK"/nobilis/* --clobber
|
||||
else
|
||||
gh release create "$TAG" --repo Moho-Chat/nobilis --verify-tag --title "nobilis $VERSION" \
|
||||
--notes-file "$WORK/nobilis-notes.md" "${NOBILIS_FLAGS[@]}" "$WORK"/nobilis/*
|
||||
fi
|
||||
echo " GitHub Moho-Chat/nobilis $TAG"
|
||||
gitea_release Salastil/nobilis "nobilis $VERSION" "$WORK/nobilis-notes.md" "$PRE" "$WORK"/nobilis/*
|
||||
echo "done: moho and nobilis $TAG on GitHub and Gitea"
|
||||
@@ -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
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Checks that a reader scrolling through a long unread run is not put back.
|
||||
*
|
||||
* The "N new messages since" bar sits over the top of the log. What the list
|
||||
* remembers about where the reader is came from asking the page what is at its
|
||||
* top edge - which, while the bar is up, is the bar. So the place stopped being
|
||||
* kept up to date the moment the bar appeared, and the next thing to change
|
||||
* size (a card arriving, a picture loading) put the view back where the
|
||||
* reader had been when it appeared.
|
||||
*
|
||||
* npm run test:scroll (builds first; needs the daemon built too)
|
||||
*/
|
||||
import { fakeIrc, launchApp, openIrcChannel, sleep, until } from './lib/harness.mjs'
|
||||
|
||||
let failures = 0
|
||||
const check = (ok, what, detail = '') => {
|
||||
console.log(`${ok ? 'ok ' : 'FAIL'} ${what}${ok ? '' : detail ? ` - ${detail}` : ''}`)
|
||||
if (!ok) failures++
|
||||
}
|
||||
setTimeout(() => {
|
||||
console.log('FAIL timed out')
|
||||
process.exit(1)
|
||||
}, 90000).unref()
|
||||
|
||||
const irc = await fakeIrc({
|
||||
people: ['bob'],
|
||||
onJoin: (channel, _nick, send) => send(`:bob!b@h PRIVMSG ${channel} :hello there`)
|
||||
})
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
try {
|
||||
await until(page, 'window.__mohoShots', 'the harness hook')
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#lobby', '#other'], waitFor: 'hello there' })
|
||||
|
||||
// Three hundred messages, the last two hundred of them unread.
|
||||
await page.evaluate(`(() => {
|
||||
const shots = window.__mohoShots
|
||||
const s = shots.state()
|
||||
const id = s.activeBufferId
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const list = Array.from({ length: 300 }, (_, n) => ({
|
||||
id: 'm' + n, bufferId: id, from: n % 3 ? 'bob' : 'carol', senderId: n % 3 ? 'bob' : 'carol',
|
||||
body: 'message number ' + n + ' with a little text in it', ts: now - 40000 + n * 60,
|
||||
isAction: false, isHighlight: false, kind: 'chat', isOwn: false
|
||||
}))
|
||||
shots.patch({
|
||||
messagesByBuffer: { ...s.messagesByBuffer, [id]: list },
|
||||
dividerTsByBuffer: { ...s.dividerTsByBuffer, [id]: list[99].ts }
|
||||
})
|
||||
})()`)
|
||||
// Two hundred unread is more than a screenful: opened at its end, as
|
||||
// Discord does, with the bar saying how much is above and not the log
|
||||
// dropped hundreds of messages up.
|
||||
await until(page, `!!document.querySelector('.unread-bar')`, 'the unread bar')
|
||||
await sleep(600)
|
||||
const opened = await page.evaluate(`(() => {
|
||||
const el = document.querySelector('.messagelist-scroll')
|
||||
return { fromBottom: el.scrollHeight - el.scrollTop - el.clientHeight, line: !!document.querySelector('.new-divider'), bar: document.querySelector('.unread-bar-text')?.textContent }
|
||||
})()`)
|
||||
check(opened.fromBottom < 8 && !opened.line, 'a long unread run opens at the end of the conversation', JSON.stringify(opened))
|
||||
check(/^200 new messages/.test(opened.bar ?? ''), 'and the bar says how much there is', opened.bar)
|
||||
await page.evaluate(`document.querySelector('.unread-bar-text').click()`)
|
||||
await until(page, `!!document.querySelector('.new-divider')`, 'the New line after pressing the bar')
|
||||
await sleep(600)
|
||||
|
||||
// Reading down past the line, the way a wheel does: the bar comes up.
|
||||
await page.evaluate(`(async () => {
|
||||
const el = document.querySelector('.messagelist-scroll')
|
||||
el.dispatchEvent(new WheelEvent('wheel', { deltaY: 100, bubbles: true }))
|
||||
for (let i = 0; i < 8; i++) {
|
||||
el.scrollTop += 120
|
||||
el.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true }))
|
||||
await new Promise((r) => setTimeout(r, 60))
|
||||
}
|
||||
})()`)
|
||||
await until(page, `!!document.querySelector('.unread-bar')`, 'the unread bar')
|
||||
|
||||
// Further down, with the bar over the top of the log the whole way.
|
||||
await page.evaluate(`(async () => {
|
||||
const el = document.querySelector('.messagelist-scroll')
|
||||
for (let i = 0; i < 12; i++) {
|
||||
el.scrollTop += 150
|
||||
el.dispatchEvent(new WheelEvent('wheel', { deltaY: 150, bubbles: true }))
|
||||
await new Promise((r) => setTimeout(r, 60))
|
||||
}
|
||||
})()`)
|
||||
await sleep(300)
|
||||
const before = await page.evaluate(`document.querySelector('.messagelist-scroll').scrollTop`)
|
||||
|
||||
// Something above the reader settles taller - a card arriving, a picture
|
||||
// loading. The row under the reader's eye is what must stay put.
|
||||
const moved = await page.evaluate(`(async () => {
|
||||
const el = document.querySelector('.messagelist-scroll')
|
||||
const box = el.getBoundingClientRect()
|
||||
const rows = [...el.querySelectorAll('[data-msg-id]')]
|
||||
const watched = rows.find((r) => r.getBoundingClientRect().top > box.top + 60)
|
||||
const was = watched.getBoundingClientRect().top - box.top
|
||||
const above = rows.find((r) => r.getBoundingClientRect().bottom < box.top - 50)
|
||||
above.style.minHeight = (above.getBoundingClientRect().height + 240) + 'px'
|
||||
await new Promise((r) => setTimeout(r, 400))
|
||||
return { id: watched.dataset.msgId, drift: watched.getBoundingClientRect().top - box.top - was }
|
||||
})()`)
|
||||
check(Math.abs(moved.drift) < 2, 'a row above growing leaves the row being read where it was', `moved ${moved.drift}px`)
|
||||
|
||||
// And the position itself did not fall back towards where the bar appeared.
|
||||
const after = await page.evaluate(`document.querySelector('.messagelist-scroll').scrollTop`)
|
||||
check(after >= before - 4, 'the view did not go back up the log', `${before} -> ${after}`)
|
||||
|
||||
// Leaving, and coming back after more has been said. The line was kept from
|
||||
// the first visit, so the return went up the log to a place nothing was
|
||||
// unread any more.
|
||||
const ids = await page.evaluate(`(() => {
|
||||
const s = window.__mohoShots.state()
|
||||
return { lobby: s.activeBufferId, other: s.buffers.find((b) => b.name.endsWith('#other')).id }
|
||||
})()`)
|
||||
const said = (count) => `(() => {
|
||||
const shots = window.__mohoShots
|
||||
const s = shots.state()
|
||||
const id = ${JSON.stringify(ids.lobby)}
|
||||
const base = Math.max(...s.messagesByBuffer[id].map((m) => m.ts)) + 1
|
||||
const more = Array.from({ length: ${count} }, (_, n) => ({
|
||||
id: 'later' + Date.now() + '-' + n, bufferId: id, from: 'bob', senderId: 'bob', body: 'said while away ' + n, ts: base + n,
|
||||
isAction: false, isHighlight: false, kind: 'chat', isOwn: false
|
||||
}))
|
||||
shots.patch({
|
||||
messagesByBuffer: { ...s.messagesByBuffer, [id]: [...s.messagesByBuffer[id], ...more] },
|
||||
buffers: s.buffers.map((b) => (b.id === id ? { ...b, unread: ${count} } : b))
|
||||
})
|
||||
})()`
|
||||
const away = async (count) => {
|
||||
await page.evaluate(`window.__mohoShots.store.selectBuffer(${JSON.stringify(ids.other)}, false)`)
|
||||
await sleep(500)
|
||||
await page.evaluate(said(count))
|
||||
await page.evaluate(`window.__mohoShots.store.selectBuffer(${JSON.stringify(ids.lobby)}, false)`)
|
||||
await sleep(1200)
|
||||
return page.evaluate(`(() => {
|
||||
const el = document.querySelector('.messagelist-scroll')
|
||||
const line = document.querySelector('.new-divider')
|
||||
const rows = [...el.querySelectorAll('[data-msg-id]')]
|
||||
const after = line ? rows.filter((r) => line.compareDocumentPosition(r) & Node.DOCUMENT_POSITION_FOLLOWING).length : -1
|
||||
return { fromBottom: el.scrollHeight - el.scrollTop - el.clientHeight, line: !!line, after, bar: document.querySelector('.unread-bar-text')?.textContent ?? '' }
|
||||
})()`)
|
||||
}
|
||||
|
||||
// A few unread: opens at the first of them, and the line is this visit's -
|
||||
// thirty rows under it, not the two hundred of the visit before.
|
||||
const few = await away(30)
|
||||
check(few.line && few.after === 30, 'a short unread run opens at its first message, with the line where this visit began', JSON.stringify(few))
|
||||
|
||||
// A great many: opens at the end, with the bar to go back by.
|
||||
const many = await away(100)
|
||||
check(many.fromBottom < 8, 'a long one opens at the end and not hundreds of messages up', JSON.stringify(many))
|
||||
check(/^100 new messages/.test(many.bar), 'with the bar to go back by', many.bar)
|
||||
|
||||
// Nothing unread: the end.
|
||||
await page.evaluate(`window.__mohoShots.store.selectBuffer(${JSON.stringify(ids.other)}, false)`)
|
||||
await sleep(500)
|
||||
await page.evaluate(`window.__mohoShots.store.selectBuffer(${JSON.stringify(ids.lobby)}, false)`)
|
||||
await sleep(1000)
|
||||
const nothing = await page.evaluate(`(() => { const el = document.querySelector('.messagelist-scroll'); return { fromBottom: el.scrollHeight - el.scrollTop - el.clientHeight, line: !!document.querySelector('.new-divider') } })()`)
|
||||
check(nothing.fromBottom < 8 && !nothing.line, 'a conversation left read and returned to is at its end', JSON.stringify(nothing))
|
||||
} finally {
|
||||
await stop()
|
||||
irc.close()
|
||||
}
|
||||
console.log(failures ? `${failures} FAILED` : 'all passed')
|
||||
process.exit(failures ? 1 : 0)
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env sh
|
||||
# Keeps a development checkout from filling the disk.
|
||||
#
|
||||
# Everything this removes is build output that the next build makes again, and
|
||||
# none of it is the project: it is what grew to a hundred gigabytes without
|
||||
# anybody choosing it.
|
||||
#
|
||||
# - dist/ got one AppImage per deploy and never lost one. Only the newest few
|
||||
# are kept; the installed copy lives in ~/.local/bin.
|
||||
# - Cargo never removes what a build no longer uses. Every change to a
|
||||
# dependency, a feature or the compiler leaves the old build beside the new
|
||||
# one, so nobilis/target held fifty copies of each library. Past a size it
|
||||
# is emptied, and the build that follows starts it again with only what is
|
||||
# current. Run before a build, never after one: after, it would throw away
|
||||
# the thing just made.
|
||||
# - Debug builds and the Windows cross-build are not what a deploy uses and
|
||||
# are made again when somebody asks for them.
|
||||
#
|
||||
# scripts/tidy.sh (also run first by `npm run deploy`)
|
||||
set -eu
|
||||
|
||||
SRC_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
KEEP_APPIMAGES="${MOHO_KEEP_APPIMAGES:-2}"
|
||||
# In megabytes: a clean release build of the daemon is about two gigabytes.
|
||||
TARGET_LIMIT_MB="${MOHO_TARGET_LIMIT_MB:-8000}"
|
||||
|
||||
size_mb() { du -sm "$1" 2>/dev/null | cut -f1; }
|
||||
|
||||
# What is in dist/ besides the newest AppImages: older ones, Windows and
|
||||
# Debian packages, the unpacked trees and their block maps.
|
||||
if [ -d "$SRC_DIR/dist" ]; then
|
||||
keep="$(ls -t "$SRC_DIR"/dist/*.AppImage 2>/dev/null | head -n "$KEEP_APPIMAGES")"
|
||||
for f in "$SRC_DIR"/dist/* "$SRC_DIR"/dist/.[!.]*; do
|
||||
[ -e "$f" ] || continue
|
||||
case "$(basename "$f")" in builder-debug.yml|builder-effective-config.yaml|latest*.yml) continue ;; esac
|
||||
printf '%s\n' "$keep" | grep -qxF "$f" && continue
|
||||
rm -rf "$f"
|
||||
done
|
||||
fi
|
||||
|
||||
# A build directory left over from before the daemon lived in nobilis/.
|
||||
rm -rf "$SRC_DIR/target"
|
||||
|
||||
TARGET="$SRC_DIR/nobilis/target"
|
||||
if [ -d "$TARGET" ]; then
|
||||
rm -rf "$TARGET/debug" "$TARGET"/x86_64-pc-windows-* "$TARGET"/aarch64-*
|
||||
used="$(size_mb "$TARGET")"
|
||||
if [ "${used:-0}" -gt "$TARGET_LIMIT_MB" ]; then
|
||||
echo "tidy: nobilis/target is ${used} MB, over ${TARGET_LIMIT_MB} MB - emptying it; the next build starts it fresh"
|
||||
rm -rf "$TARGET"
|
||||
fi
|
||||
fi
|
||||
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Checks the tray against the running app: what its icon, tooltip and menu say
|
||||
* for what is waiting, what its entries do, and that "Restart moho" brings back
|
||||
* moho and a daemon of its own.
|
||||
*
|
||||
* A tray cannot be photographed, so this reads what main last gave it through a
|
||||
* hook that exists only under MOHO_UI_SHOTS, and presses its entries the same
|
||||
* way. Main is reached over the Node inspector the app is started with.
|
||||
*
|
||||
* npm run test:tray (builds first; needs the daemon built too)
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import { cdp, fakeIrc, freePort, launchApp, openIrcChannel, sleep, until } from './lib/harness.mjs'
|
||||
|
||||
let failures = 0
|
||||
const check = (ok, what, detail = '') => {
|
||||
console.log(`${ok ? 'ok ' : 'FAIL'} ${what}${ok ? '' : detail ? ` - ${detail}` : ''}`)
|
||||
if (!ok) failures++
|
||||
}
|
||||
|
||||
async function mainProcess(inspectPort) {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try {
|
||||
const list = await (await fetch(`http://127.0.0.1:${inspectPort}/json/list`)).json()
|
||||
const target = list.find((t) => t.webSocketDebuggerUrl)
|
||||
if (target) return cdp(target.webSocketDebuggerUrl)
|
||||
} catch {
|
||||
/* not up yet */
|
||||
}
|
||||
await sleep(500)
|
||||
}
|
||||
throw new Error('main never offered its inspector')
|
||||
}
|
||||
|
||||
/** The nobilis processes that belong to this run, by the runtime directory in their environment. */
|
||||
function daemons(scratch) {
|
||||
const out = []
|
||||
for (const entry of fs.readdirSync('/proc')) {
|
||||
if (!/^\d+$/.test(entry)) continue
|
||||
try {
|
||||
const exe = fs.readlinkSync(`/proc/${entry}/exe`)
|
||||
if (!exe.endsWith('/nobilis')) continue
|
||||
if (fs.readFileSync(`/proc/${entry}/environ`, 'utf8').includes(`XDG_RUNTIME_DIR=${scratch}/run`)) out.push(Number(entry))
|
||||
} catch {
|
||||
/* gone, or not ours */
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A run that hangs is a failure, and must not leave an app behind it.
|
||||
setTimeout(() => {
|
||||
console.log('FAIL timed out')
|
||||
process.exit(1)
|
||||
}, 150000).unref()
|
||||
|
||||
const inspectPort = await freePort()
|
||||
const { page, stop, port } = await launchApp({ env: { MOHO_UI_SHOTS: '1' }, args: [`--inspect=${inspectPort}`] })
|
||||
let scratchDir = ''
|
||||
try {
|
||||
const main = await mainProcess(inspectPort)
|
||||
const tray = (expr) => main.evaluate(`(() => { const t = globalThis.__mohoTray; return ${expr} })()`)
|
||||
await until(main, 'globalThis.__mohoTray', 'the tray')
|
||||
scratchDir = await main.evaluate('process.env.HOME')
|
||||
|
||||
// Nothing waiting.
|
||||
await tray('t.update({ unread: 0, dms: 0, mentions: 0 })'.replace('t.', 't.'))
|
||||
check(/^tray-(light|dark)\.png$/.test(await tray('t.icon()')), 'idle: the plain bubble', await tray('t.icon()'))
|
||||
check((await tray('t.menu()[0].label')) === 'Nothing new', 'idle: the menu says nothing is new')
|
||||
check((await tray('t.tooltip()')) === 'moho', 'idle: the tooltip is just moho')
|
||||
|
||||
// Something waiting: the count is on the icon, and the menu says what.
|
||||
await tray('t.update({ unread: 3, dms: 2, mentions: 1 })')
|
||||
check(/^tray-(light|dark)-3\.png$/.test(await tray('t.icon()')), 'waiting: the icon carries the count', await tray('t.icon()'))
|
||||
check((await tray('t.tooltip()')) === 'moho - 2 direct messages, 1 mention', 'waiting: the tooltip says what', await tray('t.tooltip()'))
|
||||
check((await tray('t.menu()[0].label')) === '2 direct messages, 1 mention - open', 'waiting: the menu says what', await tray('t.menu()[0].label'))
|
||||
await tray('t.update({ unread: 40, dms: 40, mentions: 0 })')
|
||||
check(/-more\.png$/.test(await tray('t.icon()')), 'a great many: the icon says 9+')
|
||||
await tray('t.update({ unread: 0, dms: 0, mentions: 0 })')
|
||||
|
||||
// The entries, and that none of them is daemon plumbing.
|
||||
const labels = await tray('t.menu().map((i) => i.label ?? "-")')
|
||||
check(!labels.some((l) => /daemon/i.test(l)), 'the menu offers no daemon plumbing', labels.join(' | '))
|
||||
check(labels.includes('Restart moho'), 'the menu offers Restart moho')
|
||||
|
||||
// Settings opens in the window.
|
||||
await tray('t.click(["Settings"])')
|
||||
await until(page, 'window.__mohoShots.state().activePanel === "settings"', 'Settings to open')
|
||||
check(true, 'Settings opens the settings page')
|
||||
|
||||
// A status goes to the window to carry out.
|
||||
await page.evaluate('window.__trayGot = []; window.moho.onTrayCommand((c, a) => window.__trayGot.push([c, a])); true')
|
||||
await tray('t.click(["Status", "Do not disturb"])')
|
||||
await until(page, 'window.__trayGot.length', 'the status to reach the window')
|
||||
check(JSON.stringify(await page.evaluate('window.__trayGot[0]')) === '["status","dnd"]', 'a status is handed to the window')
|
||||
|
||||
// There is no notifications switch of its own: Do not disturb is that.
|
||||
check(!labels.some((l) => /notification/i.test(l)), 'the menu has no notifications switch of its own', labels.join(' | '))
|
||||
|
||||
// With an IRC account connected - which has away and back, and no invisible -
|
||||
// the menu offers what IRC can be set to, and the daemon takes it.
|
||||
const aways = []
|
||||
const irc = await fakeIrc({
|
||||
people: ['bob'],
|
||||
onJoin: (channel, _nick, send) => send(`:bob!b@h PRIVMSG ${channel} :hello there`),
|
||||
onLine: (line) => {
|
||||
if (/^AWAY/i.test(line)) aways.push(line)
|
||||
}
|
||||
})
|
||||
try {
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#lobby'], waitFor: 'hello there' })
|
||||
await until(main, 'globalThis.__mohoTray.menu().find((i) => i.label === "Status").submenu.length === 3', 'the menu to offer only IRC\'s statuses')
|
||||
const offered = await tray('t.menu().find((i) => i.label === "Status").submenu.map((i) => i.label)')
|
||||
check(JSON.stringify(offered) === '["Online","Idle","Do not disturb"]', 'IRC alone: no Invisible is offered', JSON.stringify(offered))
|
||||
const statusOf = () => page.evaluate('window.moho.rpc("listAccounts", {}).then((a) => a.find((x) => x.id.startsWith("checker")).status)')
|
||||
await tray('t.click(["Status", "Idle"])')
|
||||
await until(page, 'window.moho.rpc("listAccounts", {}).then((a) => a.find((x) => x.id.startsWith("checker")).status === "idle")', 'IRC to go idle')
|
||||
check(true, 'Idle from the tray sets the IRC account idle')
|
||||
await tray('t.click(["Status", "Do not disturb"])')
|
||||
await until(page, 'window.moho.rpc("listAccounts", {}).then((a) => a.find((x) => x.id.startsWith("checker")).status === "dnd")', 'IRC to go to Do not disturb')
|
||||
check((await statusOf()) === 'dnd', 'Do not disturb from the tray sets it')
|
||||
|
||||
// Going idle when the person steps away, and only when they are online.
|
||||
const shown = () => page.evaluate('window.moho.rpc("listAccounts", {}).then((a) => { const x = a.find((y) => y.id.startsWith("checker")); return { status: x.status, auto: !!x.autoIdle } })')
|
||||
const waitFor = (expr, what) => until(page, `window.moho.rpc("listAccounts", {}).then((a) => { const x = a.find((y) => y.id.startsWith("checker")); return ${expr} })`, what)
|
||||
await tray('t.click(["Status", "Online"])')
|
||||
await waitFor('x.status === "online"', 'online again')
|
||||
await tray('t.idle(true)')
|
||||
await waitFor('x.status === "idle" && x.autoIdle === true', 'an online account to go idle when its person steps away')
|
||||
check(true, 'stepping away turns an online account idle, as automatic')
|
||||
await sleep(500)
|
||||
check(aways.some((l) => /AWAY :?Idle/i.test(l)), 'and IRC is told it is away', JSON.stringify(aways))
|
||||
await tray('t.idle(false)')
|
||||
await waitFor('x.status === "online" && !x.autoIdle', 'it to come back')
|
||||
check(aways.some((l) => /^AWAY\s*$/i.test(l)), 'coming back puts it right without anybody touching it', JSON.stringify(aways))
|
||||
|
||||
// What was chosen is never moved.
|
||||
await tray('t.click(["Status", "Do not disturb"])')
|
||||
await waitFor('x.status === "dnd"', 'Do not disturb')
|
||||
await tray('t.idle(true)')
|
||||
await sleep(800)
|
||||
let now = await shown()
|
||||
check(now.status === 'dnd' && !now.auto, 'Do not disturb is kept when they step away', JSON.stringify(now))
|
||||
await tray('t.idle(false)')
|
||||
await sleep(500)
|
||||
now = await shown()
|
||||
check(now.status === 'dnd', 'and when they come back', JSON.stringify(now))
|
||||
await tray('t.click(["Status", "Idle"])')
|
||||
await waitFor('x.status === "idle" && !x.autoIdle', 'a chosen Idle')
|
||||
await tray('t.idle(true)')
|
||||
await tray('t.idle(false)')
|
||||
await sleep(500)
|
||||
now = await shown()
|
||||
check(now.status === 'idle' && !now.auto, 'a chosen Idle stays until Online is chosen', JSON.stringify(now))
|
||||
await tray('t.click(["Status", "Online"])')
|
||||
await waitFor('x.status === "online"', 'online again')
|
||||
|
||||
// The tray's number is the window's: it follows what is waiting, by the rail's rule.
|
||||
const stage = (patch) => page.evaluate(`(() => { const s = window.__mohoShots.state(); const id = s.buffers.find((b) => b.kind === 'channel' && b.id.startsWith('checker')).id; window.__chan = id; window.__mohoShots.patch({ buffers: s.buffers.map((b) => (b.id === id ? { ...b, ...(${JSON.stringify(patch)}) } : b)) }); return id })()`)
|
||||
const counted = async (want, what) => {
|
||||
await until(main, `(() => { const s = globalThis.__mohoTray.state(); return s.mentions === ${want.mentions} && s.dms === ${want.dms} })()`, what)
|
||||
check(true, what)
|
||||
}
|
||||
await tray('t.update({ unread: 0, dms: 0, mentions: 0 })')
|
||||
const chan = await stage({ unread: 33, mentions: 2, highlight: true })
|
||||
await counted({ mentions: 2, dms: 0 }, 'two mentions in a busy channel make the tray say two, not thirty-three')
|
||||
check((await tray('t.state().latest')) === chan, 'and the menu opens that channel')
|
||||
// Said the way another window would say it, through main.
|
||||
const chanId = await page.evaluate('window.__chan')
|
||||
await tray(`t.pref('mutedBuffers', [${JSON.stringify(chanId)}])`)
|
||||
await counted({ mentions: 2, dms: 0 }, 'muting it leaves its mentions on the tray - a mute stops the popups, not the count')
|
||||
await stage({ unread: 40, mentions: 0, highlight: false })
|
||||
await counted({ mentions: 0, dms: 0 }, 'and what else is said in it counts for nothing')
|
||||
await tray("t.pref('mutedBuffers', [])")
|
||||
await stage({ unread: 33, mentions: 2, highlight: true })
|
||||
await counted({ mentions: 2, dms: 0 }, 'unmuting changes nothing about the number')
|
||||
await stage({ unread: 0, mentions: 0, highlight: false })
|
||||
await counted({ mentions: 0, dms: 0 }, 'reading it somewhere else clears the tray as it clears the window')
|
||||
await page.evaluate(`(() => { const s = window.__mohoShots.state(); window.__mohoShots.patch({ buffers: [...s.buffers, { id: 'checker@localhost|bobdm', accountId: s.accounts[0].id, kind: 'dm', name: 'bob', lastActivityTs: 1, unread: 3, highlight: false, mentions: 0 }] }); return true })()`)
|
||||
await counted({ mentions: 0, dms: 3 }, 'a direct message counts for each unread message in it')
|
||||
check(/3 direct messages/.test(await tray('t.tooltip()')), 'and the tooltip says so', await tray('t.tooltip()'))
|
||||
await page.evaluate(`(() => { const s = window.__mohoShots.state(); window.__mohoShots.patch({ buffers: s.buffers.filter((b) => b.id !== 'checker@localhost|bobdm') }); return true })()`)
|
||||
await counted({ mentions: 0, dms: 0 }, 'and the tray is empty again')
|
||||
} finally {
|
||||
irc.close()
|
||||
}
|
||||
|
||||
// The tray being redrawn - which it is whenever something unread changes - must
|
||||
// not move the window: somebody who has chosen another server stays on it. The
|
||||
// redraw tells the window the link is up, and taking each for a reconnection
|
||||
// sent the pane back to the server of whatever conversation was open.
|
||||
const away = await page.evaluate('window.__mohoShots.state().activeGroupId')
|
||||
await page.evaluate('window.__mohoShots.store.selectGroup("~mentions")')
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await tray(`t.update({ unread: ${i}, dms: ${i}, mentions: 0 })`)
|
||||
await sleep(200)
|
||||
}
|
||||
await sleep(1500)
|
||||
const stayed = await page.evaluate('window.__mohoShots.state().activeGroupId')
|
||||
check(stayed === '~mentions', 'a redrawn tray leaves the chosen server chosen', `${away} -> ${stayed}`)
|
||||
await tray('t.update({ unread: 0, dms: 0, mentions: 0 })')
|
||||
|
||||
// Using a window is heard by main - and only that it happened.
|
||||
const heardBefore = await tray('t.lastActivity()')
|
||||
await sleep(6000)
|
||||
await page.call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 300, y: 300, button: 'none', clickCount: 0 })
|
||||
await sleep(500)
|
||||
const heardAfter = await tray('t.lastActivity()')
|
||||
check(heardAfter > heardBefore, 'moving the pointer in a window is heard as being here', `${heardBefore} -> ${heardAfter}`)
|
||||
|
||||
// Restart brings back moho and a daemon of its own.
|
||||
const before = daemons(scratchDir)
|
||||
const pidBefore = await main.evaluate('process.pid')
|
||||
check(before.length === 1, 'one daemon is running', String(before))
|
||||
// Not waited for: the app quits under this call, and the inspector with it.
|
||||
void tray('t.click(["Restart moho"])').catch(() => {})
|
||||
// And the inspector let go, because a process that is being inspected waits
|
||||
// for the inspector to disconnect before it will exit.
|
||||
await sleep(500)
|
||||
main.close()
|
||||
let pidAfter = pidBefore
|
||||
let again = null
|
||||
for (let i = 0; i < 80 && pidAfter === pidBefore; i++) {
|
||||
await sleep(500)
|
||||
try {
|
||||
again = await mainProcess(inspectPort)
|
||||
pidAfter = await again.evaluate('process.pid')
|
||||
} catch {
|
||||
/* between the two */
|
||||
}
|
||||
}
|
||||
check(pidAfter !== pidBefore, 'moho came back as a new process', `${pidBefore} -> ${pidAfter}`)
|
||||
await sleep(4000)
|
||||
const after = daemons(scratchDir)
|
||||
check(after.length === 1 && !before.includes(after[0]), 'with a daemon of its own, the old one gone', `${before} -> ${after}`)
|
||||
// Whatever it started has to go with this run.
|
||||
try {
|
||||
for (const pid of daemons(scratchDir)) process.kill(pid, 'SIGTERM')
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('FAIL', e.message)
|
||||
failures++
|
||||
} finally {
|
||||
// The relaunched copy is not the one the harness started, so it is not in the
|
||||
// group stop() ends. It is found by the debugging port it was told to use -
|
||||
// it shows no environment to be found by - and what it started goes when it
|
||||
// does. Ended first, and twice since it may still be starting, because it
|
||||
// writes into the directory stop() is about to remove.
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await sleep(1500)
|
||||
try {
|
||||
execFileSync('pkill', ['-9', '-f', `remote-debugging-port=${port}`])
|
||||
} catch {
|
||||
/* none left */
|
||||
}
|
||||
for (const entry of fs.readdirSync('/proc')) {
|
||||
if (!/^\d+$/.test(entry) || Number(entry) === process.pid || !scratchDir) continue
|
||||
try {
|
||||
if (fs.readFileSync(`/proc/${entry}/environ`, 'utf8').includes(`HOME=${scratchDir}\0`)) process.kill(Number(entry), 'SIGKILL')
|
||||
} catch {
|
||||
/* gone, or not ours to read */
|
||||
}
|
||||
}
|
||||
}
|
||||
await sleep(1500)
|
||||
await stop().catch(() => {})
|
||||
if (scratchDir) fs.rmSync(scratchDir, { recursive: true, force: true })
|
||||
}
|
||||
console.log(failures ? `${failures} failed` : 'all passed')
|
||||
process.exit(failures ? 1 : 0)
|
||||
@@ -94,14 +94,14 @@ const DISCORD = `(() => {
|
||||
const ch = (id, name, category, extra = {}) => ({
|
||||
id: acc + '|' + id, accountId: acc, kind: 'channel', name: 'Button Eye Crochet/#' + name,
|
||||
lastActivityTs: now, groupId: g1, category, position: Number(id.replace(/\\D/g, '')) || 0,
|
||||
unread: 0, highlight: false, ...extra
|
||||
unread: 0, highlight: false, mentions: 0, ...extra
|
||||
})
|
||||
const buffers = [
|
||||
ch('c1', '📜-rules', undefined),
|
||||
ch('c2', '📢-announcements', undefined, { unread: 2 }),
|
||||
ch('c3', '🤠-general', 'TEXT CHANNELS'),
|
||||
ch('c4', '📹-clips', 'TEXT CHANNELS', { unread: 5 }),
|
||||
ch('c5', '📷-screenshots', 'TEXT CHANNELS', { unread: 1, highlight: true }),
|
||||
ch('c5', '📷-screenshots', 'TEXT CHANNELS', { unread: 1, highlight: true, mentions: 1 }),
|
||||
ch('c6', '💩-shitpost', 'TEXT CHANNELS', { unread: 9 }),
|
||||
ch('c7', '🎨-crafts', 'TEXT CHANNELS'),
|
||||
ch('c8', '🐈-cats', 'ANIMALS', { unread: 3 }),
|
||||
@@ -136,7 +136,7 @@ const DISCORD = `(() => {
|
||||
{ id: g2, accountId: acc, service: 'discord', kind: 'guild', name: 'Moho Dev', position: 1 }
|
||||
],
|
||||
buffers: [...s.buffers.filter((b) => b.accountId !== acc), ...buffers,
|
||||
{ id: acc + '|d1', accountId: acc, kind: 'channel', name: 'Moho Dev/#general', lastActivityTs: now, groupId: g2, unread: 2, highlight: true }],
|
||||
{ id: acc + '|d1', accountId: acc, kind: 'channel', name: 'Moho Dev/#general', lastActivityTs: now, groupId: g2, unread: 33, highlight: true, mentions: 2 }],
|
||||
messagesByBuffer: { ...s.messagesByBuffer, [general]: messages },
|
||||
presenceByBuffer: { ...s.presenceByBuffer, [general]: members([['Gaunt King', 'online'], ['Clarence', 'idle'], ['Wren', 'dnd'], ['Salastil', 'online'], ['Old Friend', 'offline'], ['Quiet One', 'offline']]) },
|
||||
activeGroupId: g1,
|
||||
@@ -171,6 +171,76 @@ const discord = (inCall) => async (page) => {
|
||||
await sleep(300)
|
||||
}
|
||||
|
||||
|
||||
// --- GIFs (#331) ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Three messages that link to GIFs, with or without what the daemon or Discord
|
||||
* says about them: a Tenor page, a Giphy page and a Klipy page. Without, they
|
||||
* are the addresses they were before; with, the first two are the GIFs - the
|
||||
* Klipy page is one nothing can read, and stays a link either way.
|
||||
*
|
||||
* The pictures are made with ffmpeg and handed to the window as blob and data
|
||||
* URLs, since it plays only https media and nothing is fetched from anywhere.
|
||||
*/
|
||||
const stageGifs = (withEmbeds) => async (page) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'moho-gifs-'))
|
||||
const clip = (name, source) => {
|
||||
const file = path.join(dir, name)
|
||||
execFileSync('ffmpeg', ['-v', 'error', '-f', 'lavfi', '-i', source, '-pix_fmt', 'yuv420p', '-y', file])
|
||||
return fs.readFileSync(file).toString('base64')
|
||||
}
|
||||
const mp4 = clip('cat.mp4', 'gradients=s=300x200:d=3:r=15:speed=0.02:c0=0xe0a43a:c1=0xb0457a:c2=0x3a8fe0:nb_colors=3')
|
||||
const gif = (() => {
|
||||
const file = path.join(dir, 'wave.gif')
|
||||
execFileSync('ffmpeg', ['-v', 'error', '-f', 'lavfi', '-i', 'gradients=s=260x180:d=2:r=12:speed=0.03:c0=0x3ae0a4:c1=0x4a2f9a:nb_colors=2', '-y', file])
|
||||
return fs.readFileSync(file).toString('base64')
|
||||
})()
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
await page.evaluate(`(() => {
|
||||
const shots = window.__mohoShots
|
||||
const s = shots.state()
|
||||
const id = s.activeBufferId
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const bytes = Uint8Array.from(atob('${mp4}'), (c) => c.charCodeAt(0))
|
||||
const MP4 = URL.createObjectURL(new Blob([bytes], { type: 'video/mp4' }))
|
||||
const GIF = 'data:image/gif;base64,${gif}'
|
||||
const msg = (n, from, body, embeds) => ({ id: 'gif' + n, bufferId: id, from, senderId: from, body, ts: now - (60 - n) * 20, isAction: false, isHighlight: false, kind: 'chat', isOwn: false, ...(embeds ? { embeds } : {}) })
|
||||
const embeds = ${withEmbeds ? 'true' : 'false'}
|
||||
shots.patch({ messagesByBuffer: { ...s.messagesByBuffer, [id]: [...(s.messagesByBuffer[id] || []),
|
||||
msg(1, 'alice', 'this is how it feels on a monday https://tenor.com/view/monday-cat-gif-22954713', embeds ? [{ kind: 'gif', url: 'https://tenor.com/view/monday-cat-gif-22954713', mediaUrl: MP4, mediaKind: 'video', width: 300, height: 200, provider: 'Tenor' }] : undefined),
|
||||
msg(2, 'bob', 'https://giphy.com/gifs/wave-hello-Z7wFi5b2VEAr6', embeds ? [{ kind: 'gif', url: 'https://giphy.com/gifs/wave-hello-Z7wFi5b2VEAr6', mediaUrl: GIF, mediaKind: 'image', width: 260, height: 180, provider: 'Giphy' }] : undefined),
|
||||
msg(3, 'carol', 'and from klipy: https://klipy.com/gifs/friends-joey-53')
|
||||
] } })
|
||||
return true
|
||||
})()`)
|
||||
await sleep(900)
|
||||
}
|
||||
|
||||
/** The GIF picker, open, with a handful of invented GIFs to look through. */
|
||||
const openGifPicker = async (page) => {
|
||||
await page.evaluate(`(() => {
|
||||
const shots = window.__mohoShots
|
||||
const s = shots.state()
|
||||
const art = (a, b, label, w, h) => 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + ' ' + h + '"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="' + a + '"/><stop offset="1" stop-color="' + b + '"/></linearGradient></defs><rect width="' + w + '" height="' + h + '" fill="url(#g)"/><text x="' + (w / 2) + '" y="' + (h / 2 + 14) + '" font-size="44" font-family="sans-serif" fill="#fff" text-anchor="middle">' + label + '</text></svg>')
|
||||
const gif = (id, title, a, b, w, h) => ({ id, title, url: 'https://tenor.com/view/' + id, src: '', gifSrc: '', preview: art(a, b, title, w, h), width: w, height: h })
|
||||
window.__gifFixture = {
|
||||
categories: [{ name: 'reaction', src: '' }, { name: 'happy', src: '' }, { name: 'dancing', src: '' }, { name: 'cats', src: '' }, { name: 'applause', src: '' }, { name: 'facepalm', src: '' }],
|
||||
gifs: [
|
||||
gif('1', 'cat dance', '#e0a43a', '#b0457a', 480, 300), gif('2', 'thumbs up', '#3a8fe0', '#4a2f9a', 300, 300),
|
||||
gif('3', 'happy dog', '#3ae0a4', '#1f6f5a', 480, 270), gif('4', 'mind blown', '#e05a3a', '#7a2f2f', 300, 360),
|
||||
gif('5', 'clapping', '#8a5ae0', '#3a3fa0', 480, 270), gif('6', 'wow', '#e0d03a', '#a07a1f', 300, 300)
|
||||
]
|
||||
}
|
||||
shots.patch({ accounts: [...s.accounts, { id: 'discord:gifs', service: 'discord', displayName: 'x', state: 'connected', autojoin: '', currentNick: 'x', status: 'online', useTor: false, ssl: true }] })
|
||||
return true
|
||||
})()`)
|
||||
await sleep(500)
|
||||
await page.evaluate(click('button[title="GIFs"]'))
|
||||
await until(page, 'document.querySelector(".gif-cell")', 'the GIFs')
|
||||
await sleep(500)
|
||||
}
|
||||
|
||||
// --- scenes ------------------------------------------------------------------
|
||||
|
||||
/** Messages with several pictures, one of them a spoiler (#304): two, three and four, then a lone spoiler. */
|
||||
@@ -270,6 +340,52 @@ const setMode = async (page, mode) => {
|
||||
* `video` scenes are recorded instead, while `act` runs.
|
||||
*/
|
||||
const SCENES = [
|
||||
{
|
||||
name: 'gif-links-before',
|
||||
setup: async (p) => { await setMode(p, 'comfy'); await openChannel(p, '#general'); await stageGifs(false)(p) }
|
||||
},
|
||||
{
|
||||
name: 'gif-links-after',
|
||||
setup: async (p) => { await setMode(p, 'comfy'); await openChannel(p, '#general'); await stageGifs(true)(p) }
|
||||
},
|
||||
{
|
||||
name: 'gif-picker',
|
||||
setup: async (p) => { await setMode(p, 'comfy'); await openChannel(p, '#general'); await openGifPicker(p) }
|
||||
},
|
||||
{
|
||||
// What somebody did on a Kick stream: KICKs sent, subscriptions gifted. Put
|
||||
// on a channel's messages directly - a stream is not here to do it.
|
||||
name: 'kick-events',
|
||||
setup: async (p, mode = 'classic') => {
|
||||
await setMode(p, mode)
|
||||
await openChannel(p, '#general')
|
||||
await p.evaluate(`(() => {
|
||||
const shots = window.__mohoShots
|
||||
const s = shots.state()
|
||||
const id = s.activeBufferId
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const row = (n, kind, body, embed, from = 'bob') => ({ id: 'k' + n, bufferId: id, from, senderId: from, body, ts: now - 60 + n, isAction: false, isHighlight: false, kind, isOwn: false, embeds: embed ? [embed] : [] })
|
||||
const chat = (n, from, body) => row(n, 'chat', body, null, from)
|
||||
shots.patch({ messagesByBuffer: { ...s.messagesByBuffer, [id]: [
|
||||
chat(1, 'bob', 'that was a clean run'),
|
||||
row(2, 'kicks', 'Currington sent 1 Kick', { kind: 'kicks', author: 'Currington', authorColor: '#75FD46', title: '1', provider: 'Hell Yeah', color: 0x53fc18 }),
|
||||
chat(3, 'carol', 'lets gooo'),
|
||||
row(4, 'kicks', 'Maverick sent 500 Kicks: take my money', { kind: 'kicks', author: 'Maverick', authorColor: '#E26EFF', title: '500', provider: 'Full Send', description: 'take my money', color: 0xb45cff }),
|
||||
row(5, 'sub', 'ItsDez gifted 5 subscriptions', { kind: 'gift-sub', author: 'ItsDez', title: '5', description: 'Interstelar\\nRon_OSRS\\nAuzzzii\\nMeowtimeJay\\nstradivarius31' }),
|
||||
row(6, 'sub', 'LurkinLeepa gifted a subscription', { kind: 'gift-sub', author: 'LurkinLeepa', title: '1', description: 'onkhell' }),
|
||||
row(10, 'sub', 'bigkhernandez subscribed - 21 months', { kind: 'subscribe', author: 'bigkhernandez', title: '21' }),
|
||||
row(11, 'moderation', 'troll was timed out by mod', { kind: 'moderation', author: 'troll', title: 'was timed out', description: 'mod' }),
|
||||
row(12, 'stream', 'went live', { kind: 'stream', author: 'streamer', title: 'went live', icon: 'live' }),
|
||||
row(7, 'raid', 'kreationX raided with 25 viewers', { kind: 'raid', author: 'kreationX', title: '25' }),
|
||||
row(8, 'reward', 'onkhell redeemed 5 TONGUE TWISTERS', { kind: 'redeem', author: 'onkhell', title: '5 TONGUE TWISTERS', color: 0xff55b3 }),
|
||||
chat(9, 'bob', 'thanks everybody')
|
||||
] } })
|
||||
})()`)
|
||||
await sleep(500)
|
||||
}
|
||||
},
|
||||
{ name: 'kick-events-comfy', setup: async (p) => { await SCENES.find((x) => x.name === 'kick-events').setup(p, 'comfy') } },
|
||||
{ name: 'kick-events-bubbles', setup: async (p) => { await SCENES.find((x) => x.name === 'kick-events').setup(p, 'bubbles') } },
|
||||
{ name: 'irc-channel-comfy', setup: async (p) => { await setMode(p, 'comfy'); await openChannel(p, '#general') } },
|
||||
{
|
||||
// History from several days, scrolled to its start: where date
|
||||
@@ -511,6 +627,19 @@ const SCENES = [
|
||||
await sleep(300)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'settings-presence',
|
||||
setup: async (p) => {
|
||||
await p.evaluate(`window.__mohoShots.store.setActivePanel('settings')`)
|
||||
await sleep(400)
|
||||
await p.evaluate(`(() => {
|
||||
const el = [...document.querySelectorAll('h1, h2, h3, h4, .settings-section-title')].find((e) => e.textContent.trim() === 'Presence')
|
||||
el?.scrollIntoView({ block: 'start' })
|
||||
return !!el
|
||||
})()`)
|
||||
await sleep(400)
|
||||
}
|
||||
},
|
||||
{ name: 'settings-general', setup: async (p) => { await p.evaluate(`window.__mohoShots.store.setActivePanel('settings')`); await sleep(400) } },
|
||||
{
|
||||
name: 'settings-tor',
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { IdleWatcher } from './idle'
|
||||
|
||||
const MIN = 60_000
|
||||
|
||||
/** A watcher on a clock the test holds, with the system's idle time a figure it sets. */
|
||||
function watcher(over: { enabled?: boolean; minutes?: number } = {}): {
|
||||
w: IdleWatcher
|
||||
clock: { now: number; system: number; enabled: boolean }
|
||||
said: boolean[]
|
||||
} {
|
||||
const clock = { now: 1_000_000, system: 0, enabled: over.enabled ?? true }
|
||||
const said: boolean[] = []
|
||||
const w = new IdleWatcher({
|
||||
now: () => clock.now,
|
||||
systemIdleSeconds: () => clock.system,
|
||||
enabled: () => clock.enabled,
|
||||
thresholdMs: () => (over.minutes ?? 10) * MIN,
|
||||
onChange: (idle) => said.push(idle)
|
||||
})
|
||||
return { w, clock, said }
|
||||
}
|
||||
|
||||
describe('being away, by moho\'s own windows', () => {
|
||||
it('is away after the threshold with nothing touched, and says so once', () => {
|
||||
const { w, clock, said } = watcher()
|
||||
clock.now += 9 * MIN
|
||||
w.evaluate()
|
||||
expect(w.isIdle()).toBe(false)
|
||||
clock.now += 2 * MIN
|
||||
w.evaluate()
|
||||
w.evaluate()
|
||||
expect(w.isIdle()).toBe(true)
|
||||
expect(said).toEqual([true])
|
||||
})
|
||||
|
||||
it('is back the moment anything is used', () => {
|
||||
const { w, clock, said } = watcher()
|
||||
clock.now += 11 * MIN
|
||||
w.evaluate()
|
||||
w.activity()
|
||||
expect(w.isIdle()).toBe(false)
|
||||
expect(said).toEqual([true, false])
|
||||
})
|
||||
|
||||
it('counts use as starting the clock again', () => {
|
||||
const { w, clock } = watcher()
|
||||
clock.now += 9 * MIN
|
||||
w.activity()
|
||||
clock.now += 9 * MIN
|
||||
w.evaluate()
|
||||
expect(w.isIdle()).toBe(false)
|
||||
})
|
||||
|
||||
it('does nothing at all when it is switched off', () => {
|
||||
const { w, clock, said } = watcher({ enabled: false })
|
||||
clock.now += 60 * MIN
|
||||
w.evaluate()
|
||||
expect(said).toEqual([])
|
||||
})
|
||||
|
||||
it('comes back when it is switched off while away', () => {
|
||||
const { w, clock, said } = watcher()
|
||||
clock.now += 11 * MIN
|
||||
w.evaluate()
|
||||
clock.enabled = false
|
||||
w.evaluate()
|
||||
expect(said).toEqual([true, false])
|
||||
})
|
||||
})
|
||||
|
||||
describe('being away, by the system\'s idle time', () => {
|
||||
it('does not take a zero that never changes for somebody who never stops', () => {
|
||||
// A platform that cannot say reports zero for ever; moho's own windows decide.
|
||||
const { w, clock } = watcher()
|
||||
clock.system = 0
|
||||
clock.now += 11 * MIN
|
||||
w.evaluate()
|
||||
expect(w.isIdle()).toBe(true)
|
||||
})
|
||||
|
||||
it('believes the system once it has been seen to be anything but zero, and counts use elsewhere as being here', () => {
|
||||
const { w, clock } = watcher()
|
||||
clock.system = 3
|
||||
w.evaluate()
|
||||
// Eleven minutes of not touching moho, but the keyboard was used a minute ago.
|
||||
clock.now += 11 * MIN
|
||||
clock.system = 60
|
||||
w.evaluate()
|
||||
expect(w.isIdle()).toBe(false)
|
||||
// And then it really is eleven minutes everywhere.
|
||||
clock.system = 11 * 60
|
||||
w.evaluate()
|
||||
expect(w.isIdle()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('locking', () => {
|
||||
it('is away at once when the screen locks and back when it unlocks', () => {
|
||||
const { w, said } = watcher()
|
||||
w.lock()
|
||||
expect(w.isIdle()).toBe(true)
|
||||
w.unlock()
|
||||
expect(w.isIdle()).toBe(false)
|
||||
expect(said).toEqual([true, false])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Whether the person has stepped away, which is all this knows.
|
||||
*
|
||||
* Discord's own client and every IRC client that sets itself away work from the
|
||||
* same one fact - how long since anything was touched - and nothing else: not
|
||||
* what is on screen, not what was typed, not which program is in front. This is
|
||||
* kept to that, and is deliberately small: a clock, two sources of "how long",
|
||||
* and a threshold.
|
||||
*
|
||||
* The two sources are the system's own idle time, where the platform will say
|
||||
* (Windows, macOS, X11), and moho's own windows being used, which every
|
||||
* platform can say. Some Wayland desktops give Chromium no idle time at all and
|
||||
* report zero for ever, which looks exactly like somebody who never stops
|
||||
* typing; so the system's figure is only believed once it has been seen to be
|
||||
* anything but zero, and until then - and where it never is - moho goes by its
|
||||
* own windows.
|
||||
*/
|
||||
|
||||
export interface IdleOptions {
|
||||
now: () => number
|
||||
/** Seconds since the last keyboard or mouse input anywhere; 0 where the platform cannot say. */
|
||||
systemIdleSeconds: () => number
|
||||
enabled: () => boolean
|
||||
thresholdMs: () => number
|
||||
/** Said when the answer changes, and only then. */
|
||||
onChange: (idle: boolean) => void
|
||||
}
|
||||
|
||||
export class IdleWatcher {
|
||||
private lastActivity: number
|
||||
private systemKnown = false
|
||||
private away = false
|
||||
/** The screen is locked or the machine asleep: away at once, whatever the clock says. */
|
||||
private locked = false
|
||||
private forced: boolean | null = null
|
||||
|
||||
constructor(private o: IdleOptions) {
|
||||
this.lastActivity = o.now()
|
||||
}
|
||||
|
||||
isIdle(): boolean {
|
||||
return this.away
|
||||
}
|
||||
|
||||
/** When something was last used in a moho window, for the harness to see that it was heard. */
|
||||
lastActivityAt(): number {
|
||||
return this.lastActivity
|
||||
}
|
||||
|
||||
/** Something was used in a moho window - including coming back to one. */
|
||||
activity(): void {
|
||||
this.lastActivity = this.o.now()
|
||||
this.locked = false
|
||||
this.evaluate()
|
||||
}
|
||||
|
||||
lock(): void {
|
||||
this.locked = true
|
||||
this.evaluate()
|
||||
}
|
||||
|
||||
unlock(): void {
|
||||
this.locked = false
|
||||
this.lastActivity = this.o.now()
|
||||
this.evaluate()
|
||||
}
|
||||
|
||||
/** For the harness: an answer that the clock does not get to change. */
|
||||
forceForTesting(idle: boolean | null): void {
|
||||
this.forced = idle
|
||||
this.evaluate()
|
||||
}
|
||||
|
||||
/** Asks the clocks, and says so if the answer is different. Run on a timer, and when a setting moves. */
|
||||
evaluate(): void {
|
||||
this.set(this.decide())
|
||||
}
|
||||
|
||||
private decide(): boolean {
|
||||
if (this.forced !== null) return this.forced
|
||||
if (!this.o.enabled()) return false
|
||||
if (this.locked) return true
|
||||
const system = this.o.systemIdleSeconds()
|
||||
if (system > 0) this.systemKnown = true
|
||||
const own = this.o.now() - this.lastActivity
|
||||
// Whichever is shorter: a keystroke in another program counts as being
|
||||
// here once the system can say so, and one in moho counts either way.
|
||||
const idleFor = this.systemKnown ? Math.min(own, system * 1000) : own
|
||||
return idleFor >= this.o.thresholdMs()
|
||||
}
|
||||
|
||||
private set(idle: boolean): void {
|
||||
if (idle === this.away) return
|
||||
this.away = idle
|
||||
this.o.onChange(idle)
|
||||
}
|
||||
}
|
||||
@@ -11,18 +11,23 @@ import {
|
||||
globalShortcut,
|
||||
ipcMain,
|
||||
nativeImage,
|
||||
nativeTheme,
|
||||
net,
|
||||
powerMonitor,
|
||||
session,
|
||||
Menu,
|
||||
screen,
|
||||
shell,
|
||||
Tray
|
||||
Tray,
|
||||
type MenuItemConstructorOptions
|
||||
} from 'electron'
|
||||
import { electronApp, is, optimizer } from '@electron-toolkit/utils'
|
||||
import { defaultSocketPath, NobilisClient } from './nobilis-client'
|
||||
import { NobilisProcess } from './nobilis-process'
|
||||
import { Prefs } from './prefs'
|
||||
import { Notifier } from './notifications'
|
||||
import { IdleWatcher } from './idle'
|
||||
import { NOTHING_WAITING, trayIconFile, trayMenu, trayTooltip, type TrayState, type TrayStatus } from './tray'
|
||||
import { browserLogin, LOGIN_FLOWS } from './browser-login'
|
||||
import { EDIT_ACTIONS, IPC, POPOUT_FLAG, UI_SHOTS_FLAG, type EditAction, type EditMenuRequest, type PopoutState } from '../shared/ipc'
|
||||
import { clearnetLinks } from '../shared/clearnet'
|
||||
@@ -131,6 +136,7 @@ let prefs: Prefs
|
||||
let nobilis: NobilisProcess
|
||||
let client: NobilisClient
|
||||
let notifier: Notifier
|
||||
let idleWatcher: IdleWatcher
|
||||
let registeredHotkey: string | null = null
|
||||
|
||||
/**
|
||||
@@ -190,6 +196,52 @@ function sniffImage(head: Buffer): string | null {
|
||||
}
|
||||
|
||||
/** Every window with a renderer in it, main and popped-out conversations alike. */
|
||||
/**
|
||||
* Sets a preference and tells whoever holds a copy of it.
|
||||
*
|
||||
* `except` is the window that made the change, which already knows and has
|
||||
* drawn it. The tray's own entries come through here too, so a setting flipped
|
||||
* from the tray moves its switch in Settings.
|
||||
*/
|
||||
function applyPref(key: string, value: unknown, except?: Electron.WebContents): void {
|
||||
prefs.set(key, value)
|
||||
// The badge and the flash are decided from these as they stand.
|
||||
if (key === 'notifications.badge' || key === 'notifications.flash') updateTray(lastTray)
|
||||
// Every window keeps its own cache of these, so a setting changed in one
|
||||
// is stale in the others until they are told. That is not cosmetic once
|
||||
// there are several windows: muting a conversation, or switching the log
|
||||
// to compact, would apply to whichever window happened to be asked.
|
||||
for (const w of liveWindows()) {
|
||||
if (w.webContents !== except) w.webContents.send(IPC.prefsChanged, key, value)
|
||||
}
|
||||
// Pins and mutes feed the tray/notification rules, which live here.
|
||||
if (key === 'pinnedBuffers' || key === 'mutedBuffers') notifier.publish()
|
||||
if (key === 'hotkey.toggle') applyHotkey(String(value))
|
||||
if (key === 'presence.autoIdle' || key === 'presence.autoIdleMinutes') idleWatcher?.evaluate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings up a conversation: a click on its notification, or on the tray's
|
||||
* "open" entry.
|
||||
*
|
||||
* If it has a window of its own, that window is what the click asked for.
|
||||
* Raising the main one and switching it would move somebody away from whatever
|
||||
* they were reading in order to show them a conversation that was already open
|
||||
* on their screen.
|
||||
*/
|
||||
function openConversation(bufferId: string): void {
|
||||
const popout = popouts.get(bufferId)
|
||||
if (popout && !popout.isDestroyed()) {
|
||||
if (popout.isMinimized()) popout.restore()
|
||||
popout.show()
|
||||
popout.focus()
|
||||
return
|
||||
}
|
||||
mainWindow?.show()
|
||||
mainWindow?.focus()
|
||||
mainWindow?.webContents.send(IPC.activateBuffer, bufferId)
|
||||
}
|
||||
|
||||
function liveWindows(): BrowserWindow[] {
|
||||
const all = mainWindow ? [mainWindow, ...popouts.values()] : [...popouts.values()]
|
||||
return all.filter((w) => !w.isDestroyed())
|
||||
@@ -353,6 +405,9 @@ function createWindow(): void {
|
||||
pendingDeepLink = null
|
||||
mainWindow?.webContents.send(IPC.deepLink, url)
|
||||
})
|
||||
// The tray's first action is to show or hide this window, and says which.
|
||||
mainWindow.on('show', refreshTrayMenu)
|
||||
mainWindow.on('hide', refreshTrayMenu)
|
||||
mainWindow.on('maximize', () => mainWindow?.webContents.send(IPC.maximizeChanged, true))
|
||||
mainWindow.on('unmaximize', () => mainWindow?.webContents.send(IPC.maximizeChanged, false))
|
||||
mainWindow.on('closed', () => {
|
||||
@@ -562,61 +617,112 @@ function toggleWindow(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function trayIcon(hasAlert: boolean): Electron.NativeImage {
|
||||
const file = resourcePath('icons', hasAlert ? 'tray-alert.png' : 'tray.png')
|
||||
const img = nativeImage.createFromPath(file)
|
||||
function trayIcon(unread: number): Electron.NativeImage {
|
||||
lastTrayIcon = trayIconFile(nativeTheme.shouldUseDarkColors, unread)
|
||||
const img = nativeImage.createFromPath(resourcePath('icons', lastTrayIcon))
|
||||
// A missing icon file would otherwise produce an invisible tray entry the
|
||||
// user can never click; fall back to the app icon so the entry still exists.
|
||||
return img.isEmpty() ? nativeImage.createFromPath(resourcePath('icons', 'moho.png')) : img
|
||||
}
|
||||
|
||||
function createTray(): void {
|
||||
tray = new Tray(trayIcon(false))
|
||||
tray.setToolTip('moho')
|
||||
tray.setContextMenu(
|
||||
Menu.buildFromTemplate([
|
||||
{ label: 'Show/hide', click: toggleWindow },
|
||||
{ label: 'Restart daemon', click: () => nobilis.restart() },
|
||||
{
|
||||
label: 'Stop daemon',
|
||||
// Deliberately separate from Quit: stopping the daemon disconnects
|
||||
// every account, which is worth asking for explicitly rather than
|
||||
// making it a side effect of closing a window.
|
||||
click: () => {
|
||||
void stopDaemon()
|
||||
}
|
||||
/** What the window last said each connected account's status is. */
|
||||
let accountStatus: Record<string, string> = {}
|
||||
/** The status the menu shows as checked, and the ones it offers: what the connected accounts can be set to. */
|
||||
let trayStatus: TrayStatus | null = null
|
||||
let trayOffered: TrayStatus[] = []
|
||||
let trayInvisibleName = 'Invisible'
|
||||
const isTrayStatus = (v: unknown): v is TrayStatus => v === 'online' || v === 'idle' || v === 'dnd' || v === 'invisible'
|
||||
|
||||
/**
|
||||
* Asks the window to do something, showing it first: the tray's entries that
|
||||
* are about moho itself - settings, and a status set across every account -
|
||||
* are the window's to carry out, since it is what holds the accounts.
|
||||
*/
|
||||
function tellWindow(command: 'settings' | 'status', arg?: string): void {
|
||||
if (!mainWindow) createWindow()
|
||||
if (command === 'settings') {
|
||||
mainWindow?.show()
|
||||
mainWindow?.focus()
|
||||
}
|
||||
mainWindow?.webContents.send(IPC.trayCommand, command, arg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts moho again, daemon and all.
|
||||
*
|
||||
* Quitting already takes the daemon down cleanly - QUIT to every network, then
|
||||
* gone - and the new process starts its own, so restarting is quit-and-relaunch.
|
||||
* An AppImage has to be relaunched by the path it was started from: its
|
||||
* executable is a temporary mount that disappears when it exits.
|
||||
*/
|
||||
function restartMoho(): void {
|
||||
const appImage = process.env.APPIMAGE
|
||||
app.relaunch(appImage ? { execPath: appImage, args: process.argv.slice(1) } : undefined)
|
||||
app.quit()
|
||||
}
|
||||
|
||||
/** What the tray last offered and showed, kept for the UI harness to read. */
|
||||
let lastTrayMenu: MenuItemConstructorOptions[] = []
|
||||
let lastTrayIcon = ''
|
||||
let lastTrayTooltip = 'moho'
|
||||
|
||||
function refreshTrayMenu(): void {
|
||||
if (!tray) return
|
||||
lastTrayMenu = trayMenu(
|
||||
{
|
||||
state: lastTray,
|
||||
windowVisible: !!mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible(),
|
||||
status: trayStatus,
|
||||
offered: trayOffered,
|
||||
invisibleName: trayInvisibleName
|
||||
},
|
||||
{
|
||||
toggleWindow,
|
||||
openLatest: () => {
|
||||
const id = lastTray.latest ?? notifier.latestUnread()
|
||||
if (id) openConversation(id)
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Quit',
|
||||
click: () => {
|
||||
app.quit()
|
||||
}
|
||||
}
|
||||
])
|
||||
openSettings: () => tellWindow('settings'),
|
||||
setStatus: (status) => {
|
||||
trayStatus = status
|
||||
tellWindow('status', status)
|
||||
},
|
||||
restart: restartMoho,
|
||||
quit: () => app.quit()
|
||||
}
|
||||
)
|
||||
tray.setContextMenu(Menu.buildFromTemplate(lastTrayMenu))
|
||||
}
|
||||
|
||||
function createTray(): void {
|
||||
tray = new Tray(trayIcon(0))
|
||||
tray.setToolTip('moho')
|
||||
refreshTrayMenu()
|
||||
tray.on('click', toggleWindow)
|
||||
// The icon is drawn for the panel it sits on, so it is drawn again when the
|
||||
// system changes colour scheme.
|
||||
nativeTheme.on('updated', () => updateTray(lastTray))
|
||||
}
|
||||
|
||||
/** What the notifier last said, kept so a change of setting can redraw without waiting for the next message. */
|
||||
let lastUnread = 0
|
||||
let lastAlert = false
|
||||
let lastTray: TrayState = NOTHING_WAITING
|
||||
|
||||
function updateTray(unreadCount: number, hasAlert: boolean): void {
|
||||
lastUnread = unreadCount
|
||||
lastAlert = hasAlert
|
||||
// The unread badge: the tray's alert dot and count, and the launcher's
|
||||
// count where the desktop has one. Off, the tray is just the tray.
|
||||
function updateTray(state: TrayState): void {
|
||||
lastTray = state
|
||||
// The unread badge: the tray's count and the launcher's count where the
|
||||
// desktop has one. Off, the tray is just the tray.
|
||||
const badge = prefs.get<boolean>('notifications.badge', true)
|
||||
app.setBadgeCount(badge ? unreadCount : 0)
|
||||
app.setBadgeCount(badge ? state.unread : 0)
|
||||
// The taskbar flash: asking for attention while the window is not in front.
|
||||
const flash = prefs.get<boolean>('notifications.flash', true)
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.flashFrame(flash && hasAlert && !mainWindow.isFocused())
|
||||
mainWindow.flashFrame(flash && state.unread > 0 && trayStatus !== 'dnd' && !mainWindow.isFocused())
|
||||
}
|
||||
refreshTrayMenu()
|
||||
if (!tray) return
|
||||
tray.setImage(trayIcon(badge && hasAlert))
|
||||
tray.setToolTip(badge && unreadCount > 0 ? `moho - ${unreadCount} unread` : 'moho')
|
||||
tray.setImage(trayIcon(badge ? state.unread : 0))
|
||||
lastTrayTooltip = trayTooltip(state, badge)
|
||||
tray.setToolTip(lastTrayTooltip)
|
||||
send(IPC.link, client.linkUp)
|
||||
}
|
||||
|
||||
@@ -667,21 +773,27 @@ function wireIpc(): void {
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.prefsGetAll, () => prefs.all())
|
||||
ipcMain.handle(IPC.prefsSet, (e, key: string, value: unknown) => {
|
||||
prefs.set(key, value)
|
||||
// The badge and the flash are decided from these as they stand.
|
||||
if (key === 'notifications.badge' || key === 'notifications.flash') updateTray(lastUnread, lastAlert)
|
||||
// Every window keeps its own cache of these, so a setting changed in one
|
||||
// is stale in the others until they are told. That is not cosmetic once
|
||||
// there are several windows: muting a conversation, or switching the log
|
||||
// to compact, would apply to whichever window happened to be asked.
|
||||
// Not echoed to the window that set it - it already knows, and has drawn.
|
||||
for (const w of liveWindows()) {
|
||||
if (w.webContents !== e.sender) w.webContents.send(IPC.prefsChanged, key, value)
|
||||
}
|
||||
// Pins and mutes feed the tray/notification rules, which live here.
|
||||
if (key === 'pinnedBuffers' || key === 'mutedBuffers') notifier.publish()
|
||||
if (key === 'hotkey.toggle') applyHotkey(String(value))
|
||||
ipcMain.handle(IPC.prefsSet, (e, key: string, value: unknown) => applyPref(key, value, e.sender))
|
||||
|
||||
// The window says what status its accounts are at, for the tray's menu to check.
|
||||
ipcMain.on(IPC.trayStatus, (_e, statuses: Record<string, string>, offered: string[], shown: string | null, invisibleName: string) => {
|
||||
accountStatus = statuses && typeof statuses === 'object' ? statuses : {}
|
||||
trayOffered = Array.isArray(offered) ? offered.filter(isTrayStatus) : []
|
||||
trayStatus = isTrayStatus(shown) ? shown : null
|
||||
trayInvisibleName = invisibleName === 'Offline' ? 'Offline' : 'Invisible'
|
||||
refreshTrayMenu()
|
||||
// Do not disturb is asking for quiet, including from the taskbar.
|
||||
updateTray(lastTray)
|
||||
})
|
||||
|
||||
ipcMain.on(IPC.activity, () => idleWatcher.activity())
|
||||
|
||||
// What is waiting, as the window counts it. The tray's number is the window's
|
||||
// own: counted here from notifications it drifted from what the window showed.
|
||||
ipcMain.on(IPC.trayUnread, (_e, count: { dms: number; mentions: number; latest: string | null }) => {
|
||||
const dms = Math.max(0, Number(count?.dms) || 0)
|
||||
const mentions = Math.max(0, Number(count?.mentions) || 0)
|
||||
updateTray({ unread: dms + mentions, dms, mentions, latest: typeof count?.latest === 'string' ? count.latest : null })
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.markBufferRead, (_e, bufferId: string) => notifier.clear(bufferId))
|
||||
@@ -1137,32 +1249,38 @@ app.whenReady().then(() => {
|
||||
nobilis = new NobilisProcess()
|
||||
client = new NobilisClient()
|
||||
|
||||
// Going idle when the person steps away. What it says, and to whom, is
|
||||
// decided in the daemon - which accounts are online, which are in a call -
|
||||
// so all this says is whether they are here.
|
||||
idleWatcher = new IdleWatcher({
|
||||
now: () => Date.now(),
|
||||
systemIdleSeconds: () => powerMonitor.getSystemIdleTime(),
|
||||
enabled: () => prefs.get<boolean>('presence.autoIdle', true),
|
||||
thresholdMs: () => (Number(prefs.get<unknown>('presence.autoIdleMinutes', '10')) || 10) * 60_000,
|
||||
onChange: (idle) => void client.request('setUserIdle', { idle }).catch(() => {})
|
||||
})
|
||||
setInterval(() => idleWatcher.evaluate(), 10_000).unref()
|
||||
app.on('browser-window-focus', () => idleWatcher.activity())
|
||||
powerMonitor.on('lock-screen', () => idleWatcher.lock())
|
||||
powerMonitor.on('suspend', () => idleWatcher.lock())
|
||||
powerMonitor.on('unlock-screen', () => idleWatcher.unlock())
|
||||
powerMonitor.on('resume', () => idleWatcher.unlock())
|
||||
|
||||
notifier = new Notifier(
|
||||
prefs,
|
||||
updateTray,
|
||||
(bufferId) => {
|
||||
// If this conversation has a window of its own, that window is what the
|
||||
// click asked for. Raising the main one and switching it would move
|
||||
// somebody away from whatever they were reading in order to show them a
|
||||
// conversation that was already open on their screen.
|
||||
const popout = popouts.get(bufferId)
|
||||
if (popout && !popout.isDestroyed()) {
|
||||
if (popout.isMinimized()) popout.restore()
|
||||
popout.show()
|
||||
popout.focus()
|
||||
return
|
||||
}
|
||||
mainWindow?.show()
|
||||
mainWindow?.focus()
|
||||
mainWindow?.webContents.send(IPC.activateBuffer, bufferId)
|
||||
},
|
||||
// The tray's number is the window's, not this tally's - see IPC.trayUnread.
|
||||
() => {},
|
||||
openConversation,
|
||||
() => mainWindow?.webContents ?? null,
|
||||
(bufferId) => popoutState().watched.includes(bufferId)
|
||||
(bufferId) => popoutState().watched.includes(bufferId),
|
||||
(accountId) => accountStatus[accountId] === 'dnd'
|
||||
)
|
||||
|
||||
client.on('link', (up) => {
|
||||
send(IPC.link, up)
|
||||
if (!up) return
|
||||
// A daemon that has just started knows nothing of this.
|
||||
if (idleWatcher.isIdle()) void client.request('setUserIdle', { idle: true }).catch(() => {})
|
||||
// Before anything is fetched for the window: whether its own traffic goes
|
||||
// through Tor or the proxy is the daemon's setting, asked of it here.
|
||||
void applyTunnel((method, params) => client.request(method, params))
|
||||
@@ -1204,6 +1322,29 @@ app.whenReady().then(() => {
|
||||
createWindow()
|
||||
createTray()
|
||||
applyHotkey(prefs.get<string>('hotkey.toggle'))
|
||||
// For the UI harness, which has no way to look inside a tray: what it shows
|
||||
// and offers, and the entries themselves to be clicked. Only when it asks.
|
||||
if (process.env.MOHO_UI_SHOTS === '1') {
|
||||
;(globalThis as Record<string, unknown>).__mohoTray = {
|
||||
update: updateTray,
|
||||
idle: (v: boolean | null) => idleWatcher.forceForTesting(v),
|
||||
lastActivity: () => idleWatcher.lastActivityAt(),
|
||||
state: () => lastTray,
|
||||
pref: (key: string, value: unknown) => applyPref(key, value),
|
||||
icon: () => lastTrayIcon,
|
||||
tooltip: () => lastTrayTooltip,
|
||||
menu: () => lastTrayMenu,
|
||||
click: (path: string[]) => {
|
||||
let items: MenuItemConstructorOptions[] = lastTrayMenu
|
||||
let item: MenuItemConstructorOptions | undefined
|
||||
for (const label of path) {
|
||||
item = items.find((i) => i.label === label || (i.label ?? '').startsWith(label))
|
||||
items = (item?.submenu as MenuItemConstructorOptions[]) ?? []
|
||||
}
|
||||
item?.click?.({ checked: !item.checked } as never, undefined, {} as never)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
|
||||
@@ -19,13 +19,14 @@ vi.mock('electron', () => {
|
||||
})
|
||||
|
||||
import { Notifier } from './notifications'
|
||||
import type { TrayState } from './tray'
|
||||
|
||||
const payload = { accountId: 'a', bufferId: 'b', title: 'alice', body: 'hi' }
|
||||
|
||||
function notifier(settings: Record<string, unknown>): { n: Notifier; alerts: [number, boolean][] } {
|
||||
const alerts: [number, boolean][] = []
|
||||
function notifier(settings: Record<string, unknown>, dnd: string[] = []): { n: Notifier; alerts: TrayState[] } {
|
||||
const alerts: TrayState[] = []
|
||||
const prefs = { get: <T>(key: string, fallback: T): T => (key in settings ? (settings[key] as T) : fallback) }
|
||||
const n = new Notifier(prefs as never, (count, alert) => alerts.push([count, alert]), () => {})
|
||||
const n = new Notifier(prefs as never, (state) => alerts.push(state), () => {}, () => null, () => false, (account) => dnd.includes(account))
|
||||
return { n, alerts }
|
||||
}
|
||||
|
||||
@@ -44,7 +45,7 @@ describe('notification settings', () => {
|
||||
const { n, alerts } = notifier({ 'notifications.desktop': false })
|
||||
await n.handle(payload)
|
||||
expect(shown).toHaveLength(0)
|
||||
expect(alerts.at(-1)?.[0]).toBe(1)
|
||||
expect(alerts.at(-1)?.unread).toBe(1)
|
||||
})
|
||||
|
||||
it('makes them silent when the sound is off', async () => {
|
||||
@@ -52,3 +53,47 @@ describe('notification settings', () => {
|
||||
expect(shown[0].silent).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('what the tray is told is waiting', () => {
|
||||
const buffer = (id: string, kind: string): never => ({ id, kind }) as never
|
||||
|
||||
it('counts direct messages and mentions apart, and lights for both', async () => {
|
||||
const { n, alerts } = notifier({ 'notifications.desktop': false })
|
||||
n.trackBuffer(buffer('dm', 'dm'), false)
|
||||
n.trackBuffer(buffer('chan', 'channel'), false)
|
||||
await n.handle({ ...payload, bufferId: 'dm' })
|
||||
await n.handle({ ...payload, bufferId: 'chan' })
|
||||
expect(alerts.at(-1)).toEqual({ unread: 2, dms: 1, mentions: 1 })
|
||||
})
|
||||
|
||||
it('names the newest waiting conversation, and none once they are read', async () => {
|
||||
const { n } = notifier({ 'notifications.desktop': false })
|
||||
expect(n.latestUnread()).toBeNull()
|
||||
await n.handle({ ...payload, bufferId: 'first' })
|
||||
await n.handle({ ...payload, bufferId: 'second' })
|
||||
expect(n.latestUnread()).toBe('second')
|
||||
n.clear('second')
|
||||
expect(n.latestUnread()).toBe('first')
|
||||
n.clearAll()
|
||||
expect(n.latestUnread()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Do not disturb', () => {
|
||||
beforeEach(() => {
|
||||
shown.length = 0
|
||||
})
|
||||
|
||||
it('shows no notification for an account that is set to it, but still counts the conversation', async () => {
|
||||
const { n, alerts } = notifier({}, ['a'])
|
||||
await n.handle(payload)
|
||||
expect(shown).toHaveLength(0)
|
||||
expect(alerts.at(-1)?.unread).toBe(1)
|
||||
})
|
||||
|
||||
it('leaves the other accounts alone', async () => {
|
||||
const { n } = notifier({}, ['somebody-else'])
|
||||
await n.handle(payload)
|
||||
expect(shown).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from 'node:path'
|
||||
import { Notification, nativeImage, net } from 'electron'
|
||||
import type { WebContents } from 'electron'
|
||||
import type { Prefs } from './prefs'
|
||||
import type { TrayState } from './tray'
|
||||
import type { Buffer as ChatBuffer } from '../shared/wire'
|
||||
|
||||
/**
|
||||
@@ -48,7 +49,7 @@ export class Notifier {
|
||||
|
||||
constructor(
|
||||
private prefs: Prefs,
|
||||
private onAlertChange: (unreadCount: number, hasAlert: boolean) => void,
|
||||
private onAlertChange: (state: TrayState) => void,
|
||||
private onActivate: (bufferId: string) => void,
|
||||
/** The renderer, borrowed only to decode formats nativeImage cannot. */
|
||||
private renderer: () => WebContents | null = () => null,
|
||||
@@ -60,7 +61,16 @@ export class Notifier {
|
||||
* them what they can already see, and a tray badge counting it as unread
|
||||
* is simply wrong.
|
||||
*/
|
||||
private isWatched: (bufferId: string) => boolean = () => false
|
||||
private isWatched: (bufferId: string) => boolean = () => false,
|
||||
/**
|
||||
* Whether this account has been set to Do not disturb.
|
||||
*
|
||||
* That is the one switch for silence: an account that says so gets no popup
|
||||
* and no sound, however its conversations behave. What it is sent still
|
||||
* counts as unread - the tray and the badge keep their numbers, as Discord's
|
||||
* own do - and is there to be read when it is looked at.
|
||||
*/
|
||||
private isDnd: (accountId: string) => boolean = () => false
|
||||
) {}
|
||||
|
||||
trackBuffer(buffer: ChatBuffer, removed: boolean): void {
|
||||
@@ -103,6 +113,8 @@ export class Notifier {
|
||||
this.publish()
|
||||
}
|
||||
|
||||
if (this.isDnd(payload.accountId)) return
|
||||
|
||||
// Switched off in Settings: the conversation still counts as unread above,
|
||||
// it just does not pop up.
|
||||
if (!this.prefs.get<boolean>('notifications.desktop', true)) return
|
||||
@@ -288,24 +300,25 @@ export class Notifier {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the tray should show something is waiting.
|
||||
* What the tray should say is waiting.
|
||||
*
|
||||
* A direct message counts, and this is the change: the tray used to light up
|
||||
* only for *pinned* buffers, on the reasoning that a DM had already had its
|
||||
* desktop notification and the tray was for things important enough to pin.
|
||||
* In practice that made the icon nearly inert - somebody messages you, and
|
||||
* the one place still on screen after the notification has faded says
|
||||
* nothing, unless you happened to have pinned that exact conversation.
|
||||
*
|
||||
* Pinned buffers still count, so a channel worth pinning can still raise it.
|
||||
* There is no count here: a tray icon is around 22 pixels, which is room for
|
||||
* "yes" and not for a number.
|
||||
* Everything held here was worth a notification - a direct message, or
|
||||
* somebody mentioning this account - so all of it lights the tray, and it
|
||||
* says how many conversations and of which kind. It used to light only for
|
||||
* direct messages and pinned conversations, on the reasoning that a mention
|
||||
* had had its notification and the tray was for what mattered more; the
|
||||
* effect was an icon that stayed dark while somebody was asking for you.
|
||||
*/
|
||||
publish(): void {
|
||||
const pinned = this.prefs.get<string[]>('pinnedBuffers', [])
|
||||
const hasAlert = [...this.unread].some(
|
||||
(id) => this.buffers.get(id)?.kind === 'dm' || pinned.includes(id)
|
||||
)
|
||||
this.onAlertChange(this.unread.size, hasAlert)
|
||||
let dms = 0
|
||||
for (const id of this.unread) if (this.buffers.get(id)?.kind === 'dm') dms++
|
||||
this.onAlertChange({ unread: this.unread.size, dms, mentions: this.unread.size - dms })
|
||||
}
|
||||
|
||||
/** The conversation that has been waiting for you the shortest time. */
|
||||
latestUnread(): string | null {
|
||||
let latest: string | null = null
|
||||
for (const id of this.unread) latest = id
|
||||
return latest
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { trayIconFile, trayMenu, trayTooltip, waitingSummary, NOTHING_WAITING, type TrayActions } from './tray'
|
||||
|
||||
const acts = (): TrayActions => ({
|
||||
toggleWindow: vi.fn(),
|
||||
openLatest: vi.fn(),
|
||||
openSettings: vi.fn(),
|
||||
setStatus: vi.fn(),
|
||||
restart: vi.fn(),
|
||||
quit: vi.fn()
|
||||
})
|
||||
|
||||
describe('the tray icon', () => {
|
||||
it('is a light bubble on a dark system and a dark one on a light system', () => {
|
||||
expect(trayIconFile(true, 0)).toBe('tray-light.png')
|
||||
expect(trayIconFile(false, 0)).toBe('tray-dark.png')
|
||||
})
|
||||
|
||||
it('carries the count to nine and says more after that', () => {
|
||||
expect(trayIconFile(true, 1)).toBe('tray-light-1.png')
|
||||
expect(trayIconFile(true, 9)).toBe('tray-light-9.png')
|
||||
expect(trayIconFile(false, 10)).toBe('tray-dark-more.png')
|
||||
expect(trayIconFile(false, 400)).toBe('tray-dark-more.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('what is said about what is waiting', () => {
|
||||
it('names direct messages and mentions apart, and in the singular for one', () => {
|
||||
expect(waitingSummary({ unread: 3, dms: 2, mentions: 1 })).toBe('2 direct messages, 1 mention')
|
||||
expect(waitingSummary({ unread: 1, dms: 1, mentions: 0 })).toBe('1 direct message')
|
||||
expect(waitingSummary({ unread: 4, dms: 0, mentions: 4 })).toBe('4 mentions')
|
||||
})
|
||||
|
||||
it('leaves the tooltip plain with nothing waiting or the badge off', () => {
|
||||
expect(trayTooltip(NOTHING_WAITING, true)).toBe('moho')
|
||||
expect(trayTooltip({ unread: 2, dms: 1, mentions: 1 }, false)).toBe('moho')
|
||||
expect(trayTooltip({ unread: 2, dms: 1, mentions: 1 }, true)).toBe('moho - 1 direct message, 1 mention')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the menu', () => {
|
||||
const model = { state: NOTHING_WAITING, windowVisible: true, status: 'idle' as const, offered: [], invisibleName: 'Invisible' }
|
||||
const labels = (items: ReturnType<typeof trayMenu>): string[] => items.map((i) => i.label ?? '-')
|
||||
|
||||
it('offers no daemon plumbing, and a restart that is of moho as a whole', () => {
|
||||
const l = labels(trayMenu(model, acts()))
|
||||
expect(l).not.toContain('Restart daemon')
|
||||
expect(l).not.toContain('Stop daemon')
|
||||
expect(l).toContain('Restart moho')
|
||||
})
|
||||
|
||||
it('says nothing is new, and offers nothing to open, when that is so', () => {
|
||||
const [first] = trayMenu(model, acts())
|
||||
expect(first.label).toBe('Nothing new')
|
||||
expect(first.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('says what is waiting and opens it', () => {
|
||||
const a = acts()
|
||||
const [first] = trayMenu({ ...model, state: { unread: 2, dms: 1, mentions: 1 } }, a)
|
||||
expect(first.label).toBe('1 direct message, 1 mention - open')
|
||||
first.click?.({} as never, undefined, {} as never)
|
||||
expect(a.openLatest).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('has no notifications switch of its own: Do not disturb is that', () => {
|
||||
expect(labels(trayMenu(model, acts())).join('|')).not.toMatch(/notification/i)
|
||||
const status = trayMenu(model, acts()).find((i) => i.label === 'Status')
|
||||
expect((status?.submenu as { label: string }[]).map((s) => s.label)).toContain('Do not disturb')
|
||||
})
|
||||
|
||||
it('offers only what the connected accounts can be set to', () => {
|
||||
const labelsOf = (offered: ('online' | 'idle' | 'dnd' | 'invisible')[]): string[] =>
|
||||
(trayMenu({ ...model, offered }, acts()).find((i) => i.label === 'Status')?.submenu as { label: string }[]).map((s) => s.label)
|
||||
expect(labelsOf(['online', 'dnd'])).toEqual(['Online', 'Do not disturb'])
|
||||
expect(labelsOf(['online', 'idle', 'dnd', 'invisible'])).toHaveLength(4)
|
||||
expect(labelsOf([])).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('calls invisible offline where only Matrix has it', () => {
|
||||
const sub = trayMenu({ ...model, offered: ['online', 'invisible'], invisibleName: 'Offline' }, acts()).find((i) => i.label === 'Status')?.submenu as { label: string }[]
|
||||
expect(sub.map((s) => s.label)).toEqual(['Online', 'Offline'])
|
||||
})
|
||||
|
||||
it('asks for the window to be shown or hidden as it stands', () => {
|
||||
expect(labels(trayMenu({ ...model, windowVisible: true }, acts()))).toContain('Hide moho')
|
||||
expect(labels(trayMenu({ ...model, windowVisible: false }, acts()))).toContain('Show moho')
|
||||
})
|
||||
|
||||
it('checks the status the accounts are at', () => {
|
||||
const status = trayMenu(model, acts()).find((i) => i.label === 'Status')
|
||||
const sub = status?.submenu as { label: string; checked: boolean }[]
|
||||
expect(sub.filter((s) => s.checked).map((s) => s.label)).toEqual(['Idle'])
|
||||
const none = trayMenu({ ...model, status: null }, acts()).find((i) => i.label === 'Status')?.submenu as { checked: boolean }[]
|
||||
expect(none.some((s) => s.checked)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* What the tray says and offers, apart from the Electron objects that show it.
|
||||
*
|
||||
* Kept as plain functions of plain data so that what the icon, the tooltip and
|
||||
* the menu say about a given set of unread conversations can be checked
|
||||
* without a desktop to look at.
|
||||
*/
|
||||
import type { MenuItemConstructorOptions } from 'electron'
|
||||
|
||||
/** What is waiting, as the notifier counts it. */
|
||||
export interface TrayState {
|
||||
/** Conversations with something unread that was worth a notification. */
|
||||
unread: number
|
||||
/** Of those, direct messages. */
|
||||
dms: number
|
||||
/** Of those, everything else: somebody mentioning this account in a channel. */
|
||||
mentions: number
|
||||
/** The conversation the menu's first line opens, where the window has said. */
|
||||
latest?: string | null
|
||||
}
|
||||
|
||||
export const NOTHING_WAITING: TrayState = { unread: 0, dms: 0, mentions: 0 }
|
||||
|
||||
export type TrayStatus = 'online' | 'idle' | 'dnd' | 'invisible'
|
||||
|
||||
/**
|
||||
* The icon file for a panel and a count.
|
||||
*
|
||||
* `dark` is the system's colour scheme, and a dark system wants the light
|
||||
* bubble. The count is drawn into the icon up to nine; past that it says "9+",
|
||||
* since a tray icon is about 22 pixels and has no room for a second digit.
|
||||
*/
|
||||
export function trayIconFile(dark: boolean, unread: number): string {
|
||||
const ink = dark ? 'light' : 'dark'
|
||||
if (unread <= 0) return `tray-${ink}.png`
|
||||
return `tray-${ink}-${unread > 9 ? 'more' : unread}.png`
|
||||
}
|
||||
|
||||
const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`
|
||||
|
||||
/** "2 direct messages, 1 mention" - what is waiting, in the words a person would use. */
|
||||
export function waitingSummary(s: TrayState): string {
|
||||
const parts: string[] = []
|
||||
if (s.dms > 0) parts.push(plural(s.dms, 'direct message', 'direct messages'))
|
||||
if (s.mentions > 0) parts.push(plural(s.mentions, 'mention', 'mentions'))
|
||||
return parts.join(', ')
|
||||
}
|
||||
|
||||
export function trayTooltip(s: TrayState, badge: boolean): string {
|
||||
return badge && s.unread > 0 ? `moho - ${waitingSummary(s)}` : 'moho'
|
||||
}
|
||||
|
||||
export interface TrayActions {
|
||||
toggleWindow: () => void
|
||||
openLatest: () => void
|
||||
openSettings: () => void
|
||||
setStatus: (status: TrayStatus) => void
|
||||
restart: () => void
|
||||
quit: () => void
|
||||
}
|
||||
|
||||
export interface TrayModel {
|
||||
state: TrayState
|
||||
windowVisible: boolean
|
||||
/** The status the accounts are at, where the window has said. */
|
||||
status: TrayStatus | null
|
||||
/** What the connected accounts can be set to; none reported yet means all of them. */
|
||||
offered: TrayStatus[]
|
||||
/** What invisible is called: Matrix, which has only offline presence, says offline. */
|
||||
invisibleName: string
|
||||
}
|
||||
|
||||
const STATUSES: { id: TrayStatus; label: string }[] = [
|
||||
{ id: 'online', label: 'Online' },
|
||||
{ id: 'idle', label: 'Idle' },
|
||||
{ id: 'dnd', label: 'Do not disturb' },
|
||||
{ id: 'invisible', label: 'Invisible' }
|
||||
]
|
||||
|
||||
/**
|
||||
* The right-click menu: what is new, how to get to moho, how to say what you
|
||||
* are doing - which includes Do not disturb, the one switch for silencing it -
|
||||
* and how to leave. Daemon plumbing - restart it, stop it - is
|
||||
* deliberately not here; it is a thing for Settings, and "restart moho"
|
||||
* covers what somebody reaching for it from a tray actually wants.
|
||||
*/
|
||||
export function trayMenu(model: TrayModel, act: TrayActions): MenuItemConstructorOptions[] {
|
||||
const waiting = model.state.unread > 0
|
||||
return [
|
||||
waiting
|
||||
? { label: `${waitingSummary(model.state)} - open`, click: act.openLatest }
|
||||
: { label: 'Nothing new', enabled: false },
|
||||
{ type: 'separator' },
|
||||
{ label: model.windowVisible ? 'Hide moho' : 'Show moho', click: act.toggleWindow },
|
||||
{
|
||||
label: 'Status',
|
||||
submenu: STATUSES.filter((s) => model.offered.length === 0 || model.offered.includes(s.id)).map((s) => ({
|
||||
label: s.id === 'invisible' ? model.invisibleName : s.label,
|
||||
type: 'radio' as const,
|
||||
checked: model.status === s.id,
|
||||
click: () => act.setStatus(s.id)
|
||||
}))
|
||||
},
|
||||
{ label: 'Settings', click: act.openSettings },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Restart moho', click: act.restart },
|
||||
{ label: 'Quit', click: act.quit }
|
||||
]
|
||||
}
|
||||
@@ -83,6 +83,27 @@ const api = {
|
||||
return () => ipcRenderer.off(IPC.activateBuffer, handler)
|
||||
},
|
||||
|
||||
/** The tray menu asking this window to open Settings, or to set a status on every account. */
|
||||
onTrayCommand(cb: (command: 'settings' | 'status', arg?: string) => void): () => void {
|
||||
const handler = (_e: unknown, command: 'settings' | 'status', arg?: string): void => cb(command, arg)
|
||||
ipcRenderer.on(IPC.trayCommand, handler)
|
||||
return () => ipcRenderer.off(IPC.trayCommand, handler)
|
||||
},
|
||||
|
||||
/**
|
||||
* Says what status each connected account is at, which statuses any of them
|
||||
* can be set to, and the one to show as checked: for the tray menu, and for
|
||||
* main to stay quiet for an account on Do not disturb.
|
||||
*/
|
||||
setAccountStatuses(statuses: Record<string, string>, offered: string[], shown: string | null, invisibleName: string): void {
|
||||
ipcRenderer.send(IPC.trayStatus, statuses, offered, shown, invisibleName)
|
||||
},
|
||||
|
||||
/** Says what is waiting, counted the way the rail counts it, for the tray's number. */
|
||||
setTrayUnread(count: { dms: number; mentions: number; latest: string | null }): void {
|
||||
ipcRenderer.send(IPC.trayUnread, count)
|
||||
},
|
||||
|
||||
onMaximizeChange(cb: (maximized: boolean) => void): () => void {
|
||||
const handler = (_e: unknown, v: boolean): void => cb(v)
|
||||
ipcRenderer.on(IPC.maximizeChanged, handler)
|
||||
@@ -212,6 +233,25 @@ const api = {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells main that somebody is using this window.
|
||||
*
|
||||
* Only that it happened - the event's kind and what it touched are not looked
|
||||
* at, and nothing about them is sent. It is what lets moho know the person has
|
||||
* stepped away, which is the one thing it wants to know, and it is sent at most
|
||||
* every few seconds however much is going on.
|
||||
*/
|
||||
let lastActivityPing = 0
|
||||
const noteActivity = (): void => {
|
||||
const now = Date.now()
|
||||
if (now - lastActivityPing < 5000) return
|
||||
lastActivityPing = now
|
||||
ipcRenderer.send(IPC.activity)
|
||||
}
|
||||
for (const kind of ['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart']) {
|
||||
;(globalThis as unknown as EventTarget).addEventListener(kind, noteActivity, { capture: true, passive: true })
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('moho', api)
|
||||
|
||||
export type MohoApi = typeof api
|
||||
|
||||
@@ -8,6 +8,7 @@ import { MessageList } from './components/MessageList'
|
||||
import { MentionsInbox } from './components/MentionsInbox'
|
||||
import { MentionsPage } from './components/MentionsPage'
|
||||
import { INVITE_PREFIX, MENTIONS_GROUP_ID } from './lib/groups'
|
||||
import { trayCount } from './lib/traycount'
|
||||
import { Composer } from './components/Composer'
|
||||
import { MembershipGate } from './components/MembershipGate'
|
||||
import { NickList } from './components/NickList'
|
||||
@@ -89,6 +90,22 @@ export default function App(): JSX.Element {
|
||||
else delete document.documentElement.dataset.reduceMotion
|
||||
}, [density, reduceMotion])
|
||||
const [userListFolded, setUserListFolded] = usePref<boolean>('ui.userListFolded', false)
|
||||
|
||||
// What the tray says is waiting, which is whatever the rail would add up to:
|
||||
// the same rule, from the same lists, said to the main process whenever the
|
||||
// answer changes. Told only when it has, since most changes to the buffers
|
||||
// are not to what is waiting.
|
||||
const [trayMuted] = usePref<string[]>('mutedBuffers', [])
|
||||
const [trayHidden] = usePref<string[]>('hiddenBuffers', [])
|
||||
const [trayMutedGroups] = usePref<string[]>('mutedGroups', [])
|
||||
const lastTray = useRef('')
|
||||
useEffect(() => {
|
||||
const count = trayCount(allBuffers, trayMuted, trayHidden, trayMutedGroups)
|
||||
const said = JSON.stringify(count)
|
||||
if (said === lastTray.current) return
|
||||
lastTray.current = said
|
||||
window.moho.setTrayUnread(count)
|
||||
}, [allBuffers, trayMuted, trayHidden, trayMutedGroups])
|
||||
const [savedBufferId] = usePref<string>('ui.activeBufferId', '')
|
||||
const [savedGroupId] = usePref<string>('ui.activeGroupId', '')
|
||||
|
||||
|
||||
@@ -1572,6 +1572,208 @@ button.video-tile {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* What somebody did on a Kick stream - KICKs sent, subscriptions gifted - as
|
||||
the card Kick draws it: a bar down the edge, who and what at the left, and
|
||||
the thing itself at the right. On this client's own surfaces; the accent is
|
||||
the gift's tier (Kick green for an ordinary one), tinted into the plate so a
|
||||
large gift is bigger at a glance without the card leaving the theme. */
|
||||
.message-row.has-kick-event {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Where the card sits in the log, which is where the picture of the person
|
||||
speaking would: it starts at the picture's edge, in the column a join's mark
|
||||
would have taken, and what is written inside it starts where a name does. So
|
||||
the indent is the picture, the gap after it, and - in a bubble - the
|
||||
bubble's own padding. A message row that is not a person speaking gets no
|
||||
avatar, and a card with none lets the content start at the edge. */
|
||||
.message-row.comfy:not(.bubbles).has-kick-event {
|
||||
--kick-lead: var(--avatar);
|
||||
--kick-indent: calc(var(--avatar) + var(--spacing-m));
|
||||
}
|
||||
|
||||
.message-row.bubbles.system.has-kick-event {
|
||||
--kick-lead: 28px;
|
||||
--kick-indent: calc(28px + var(--spacing-s) + var(--spacing-s));
|
||||
justify-content: flex-start;
|
||||
margin-top: var(--spacing-s);
|
||||
}
|
||||
|
||||
.message-row.bubbles.system.has-kick-event .message-content {
|
||||
flex: 1 1 auto;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
background: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.message-row.classic.has-kick-event,
|
||||
.message-row:not(.comfy).has-kick-event {
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.message-row.comfy.has-kick-event .message-content {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-row.comfy.has-kick-event .kick-event {
|
||||
position: relative;
|
||||
max-width: min(100%, 560px);
|
||||
/* Less the bar, which is in the card's own edge. */
|
||||
padding-left: calc(var(--kick-indent) - var(--kick-border, 3px));
|
||||
}
|
||||
|
||||
.message-row.comfy.has-kick-event .kick-event.raid,
|
||||
.message-row.comfy.has-kick-event .kick-event.stream {
|
||||
--kick-border: 1px;
|
||||
}
|
||||
|
||||
/* The mark at the left takes the picture's column - centred on where the
|
||||
picture would be - instead of sitting in the text's way. */
|
||||
.message-row.comfy.has-kick-event .kick-event > .kick-event-badge.lead {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: var(--kick-lead);
|
||||
}
|
||||
|
||||
.kick-event {
|
||||
--kick-accent: #53fc18;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-l);
|
||||
min-width: 260px;
|
||||
max-width: min(100%, 520px);
|
||||
padding: var(--spacing-s) var(--spacing-m);
|
||||
border: 1px solid var(--outline-medium);
|
||||
border-left: 3px solid var(--kick-accent);
|
||||
border-radius: var(--corner-radius);
|
||||
background: color-mix(in srgb, var(--kick-accent) 7%, var(--surface-container-high));
|
||||
color: var(--surface-text);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* Kick draws a gifted subscription in purple and a raid with no bar at all; a
|
||||
redeemed reward takes the streamer's colour, set inline. */
|
||||
.kick-event.gift {
|
||||
--kick-accent: #a970ff;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.kick-event.redeem,
|
||||
.kick-event.subscribe,
|
||||
.kick-event.moderation {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.kick-event.subscribe {
|
||||
--kick-accent: #a970ff;
|
||||
}
|
||||
|
||||
/* A ban, a timeout, a chat cleared: said in the room's own danger colour. */
|
||||
.kick-event.moderation {
|
||||
--kick-accent: var(--danger);
|
||||
}
|
||||
|
||||
/* The stream going live or ending is a plate like a raid's, and the mark is
|
||||
the part that says which. */
|
||||
.kick-event.stream {
|
||||
justify-content: flex-start;
|
||||
border: 1px solid var(--outline-medium);
|
||||
border-radius: var(--radius-pill);
|
||||
padding-inline: var(--spacing-m);
|
||||
background: var(--surface-container-high);
|
||||
}
|
||||
|
||||
.kick-event.stream.live .kick-event-badge {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.kick-event.stream.offline .kick-event-badge {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.kick-event.raid {
|
||||
justify-content: flex-start;
|
||||
border: 1px solid var(--outline-medium);
|
||||
border-radius: var(--radius-pill);
|
||||
padding-inline: var(--spacing-m);
|
||||
background: var(--surface-container-high);
|
||||
}
|
||||
|
||||
.kick-event.raid .kick-event-badge {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.kick-event-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kick-event-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.kick-event-name {
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.kick-event-verb {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.kick-event-amount {
|
||||
font-size: var(--font-size-large, 1.15em);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.kick-event-message {
|
||||
color: var(--surface-text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.kick-event-recipients {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.kick-event-recipient {
|
||||
padding: 0 var(--spacing-s);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-container-highest, var(--surface-hover));
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
|
||||
.kick-event-badge {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--kick-accent);
|
||||
}
|
||||
|
||||
.kicks-mark {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--kick-accent);
|
||||
color: #0b0e0a;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.message-row.comfy {
|
||||
align-items: flex-start;
|
||||
padding-top: var(--spacing-xxs);
|
||||
@@ -1590,7 +1792,8 @@ button.video-tile {
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
|
||||
.message-row.comfy:not(.bubbles):not(.grouped):not(.system) {
|
||||
.message-row.comfy:not(.bubbles):not(.grouped):not(.system),
|
||||
.message-row.comfy:not(.bubbles):not(.grouped).has-kick-event {
|
||||
margin-top: var(--spacing-l);
|
||||
}
|
||||
|
||||
@@ -10405,3 +10608,103 @@ html[data-reduce-motion] *::after {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
|
||||
/* GIFs to browse and send. */
|
||||
.gif-picker {
|
||||
position: fixed;
|
||||
z-index: var(--z-picker);
|
||||
width: min(420px, calc(100vw - 8px));
|
||||
height: min(480px, calc(100vh - 8px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-s);
|
||||
overflow: hidden;
|
||||
border-radius: var(--corner-radius);
|
||||
background: var(--surface-container-high);
|
||||
border: 1px solid var(--outline-medium);
|
||||
box-shadow: var(--shadow-2);
|
||||
animation: overlay-in var(--motion) var(--ease);
|
||||
}
|
||||
|
||||
.gif-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: 0 var(--spacing-xs);
|
||||
color: var(--surface-variant-text);
|
||||
}
|
||||
|
||||
.gif-search .text-field {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gif-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.gif-categories {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-xs);
|
||||
padding-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.gif-category {
|
||||
padding: var(--spacing-xxs) var(--spacing-s);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-hover);
|
||||
color: var(--surface-text);
|
||||
font-size: var(--font-size-small);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.gif-category:hover {
|
||||
background: var(--surface-selected);
|
||||
}
|
||||
|
||||
.gif-empty {
|
||||
padding: var(--spacing-l);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gif-grid {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.gif-column {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.gif-cell {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-s, 6px);
|
||||
background: var(--surface-container);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gif-cell:hover,
|
||||
.gif-cell:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
}
|
||||
|
||||
.gif-cell video,
|
||||
.gif-cell img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
MENTIONS_GROUP_ID,
|
||||
PINNED_GROUP_ID,
|
||||
pinnedGroup,
|
||||
waitingCount,
|
||||
visibleGroups,
|
||||
type RailGroup
|
||||
} from '../lib/groups'
|
||||
@@ -248,6 +249,11 @@ export function BufferList(): JSX.Element {
|
||||
// The menu still toggles a buffer's *own* flag independently of a muted
|
||||
// rail entry above it, the same way muting a channel inside an
|
||||
// already-muted Slack workspace works.
|
||||
/** The call that sets a mute on the account itself, for a service that keeps one. */
|
||||
const accountMute = (accountId: string): string | null => {
|
||||
const service = accounts.find((a) => a.id === accountId)?.service
|
||||
return service === 'matrix' ? 'setMatrixRoomMuted' : service === 'discord' ? 'setDiscordChannelMuted' : null
|
||||
}
|
||||
const isEffectivelyMuted = (buffer: BufferEntry): boolean =>
|
||||
isMutedBuffer(buffer, muted, mutedGroups)
|
||||
|
||||
@@ -304,15 +310,23 @@ export function BufferList(): JSX.Element {
|
||||
onTogglePin={() => togglePin(b.id)}
|
||||
onToggleMute={() => {
|
||||
toggleMute(b.id)
|
||||
// On Matrix the account itself can hold the answer, so muting here
|
||||
// mutes on a phone too. Everywhere else this stays what it always
|
||||
// was: a preference belonging to this window.
|
||||
if (accounts.find((a) => a.id === b.accountId)?.service === 'matrix') {
|
||||
// On Matrix and Discord the account itself can hold the answer, so
|
||||
// muting here mutes on a phone too. Everywhere else this stays what
|
||||
// it always was: a preference belonging to this window.
|
||||
const writeBack = accountMute(b.accountId)
|
||||
if (writeBack)
|
||||
void window.moho
|
||||
.rpc('setMatrixRoomMuted', { bufferId: b.id, muted: !isEffectivelyMuted(b) })
|
||||
.rpc(writeBack, { bufferId: b.id, muted: !isEffectivelyMuted(b) })
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
}}
|
||||
onUnmuteAccount={
|
||||
accountMute(b.accountId)
|
||||
? () =>
|
||||
void window.moho
|
||||
.rpc(accountMute(b.accountId)!, { bufferId: b.id, muted: false })
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
: undefined
|
||||
}
|
||||
onToggleAutojoin={
|
||||
b.kind === 'channel' && accounts.find((a) => a.id === b.accountId)?.service === 'irc'
|
||||
? () => store.toggleAutojoin(b.accountId, bufferDisplayName(b.name))
|
||||
@@ -585,12 +599,12 @@ export function BufferList(): JSX.Element {
|
||||
// What is waiting in what is folded away, which is
|
||||
// the thing worth knowing about a closed heading;
|
||||
// how many channels it has is the fallback.
|
||||
const waiting = section.buffers.filter((b) => b.unread > 0 && (!isEffectivelyMuted(b) || b.highlight))
|
||||
const unread = waiting.reduce((n, b) => n + b.unread, 0)
|
||||
const waiting = section.buffers.filter((b) => waitingCount(b, isEffectivelyMuted(b)) > 0)
|
||||
const unread = waiting.reduce((n, b) => n + waitingCount(b, isEffectivelyMuted(b)), 0)
|
||||
return unread > 0 ? (
|
||||
<span
|
||||
className={classes('unread-badge category-count', waiting.some((b) => b.highlight) && 'highlight')}
|
||||
title={`${unread} unread in ${waiting.length} ${waiting.length === 1 ? 'channel' : 'channels'}`}
|
||||
title={`${unread} ${unread === 1 ? 'mention' : 'mentions'} in ${waiting.length} ${waiting.length === 1 ? 'channel' : 'channels'}`}
|
||||
>
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</span>
|
||||
@@ -829,6 +843,8 @@ interface BufferRowProps {
|
||||
onSelect: () => void
|
||||
onTogglePin: () => void
|
||||
onToggleMute: () => void
|
||||
/** Matrix rooms muted on the account: take that mute off. */
|
||||
onUnmuteAccount?: () => void
|
||||
/** IRC channels only: rejoin this on every connect, or stop doing so. */
|
||||
onToggleAutojoin?: () => void
|
||||
/** Kick channels only: watch the stream where streams are watched. */
|
||||
@@ -878,6 +894,7 @@ function BufferRow({
|
||||
onSelect,
|
||||
onTogglePin,
|
||||
onToggleMute,
|
||||
onUnmuteAccount,
|
||||
onToggleAutojoin,
|
||||
autojoins,
|
||||
onOpenInBrowser,
|
||||
@@ -982,6 +999,7 @@ function BufferRow({
|
||||
spaces,
|
||||
onTogglePin,
|
||||
onToggleMute,
|
||||
onUnmuteAccount,
|
||||
onToggleAutojoin,
|
||||
onSpace,
|
||||
onWatch,
|
||||
@@ -1050,9 +1068,9 @@ function BufferRow({
|
||||
arrive is worse than either on its own. */}
|
||||
{live ? (
|
||||
<span className="live-badge">LIVE</span>
|
||||
) : buffer.unread > 0 && (!muted || buffer.highlight) ? (
|
||||
<span className={classes('unread-badge', buffer.highlight && 'highlight')}>
|
||||
{buffer.unread > 99 ? '99+' : buffer.unread}
|
||||
) : waitingCount(buffer, muted) > 0 ? (
|
||||
<span className={classes('unread-badge', (buffer.highlight || muted) && 'highlight')}>
|
||||
{waitingCount(buffer, muted) > 99 ? '99+' : waitingCount(buffer, muted)}
|
||||
</span>
|
||||
) : (
|
||||
// Left unread on purpose, with nothing new in it. A dot rather than
|
||||
|
||||
@@ -9,6 +9,7 @@ import torMark from '../assets/tor.svg'
|
||||
import { PollComposer } from './PollComposer'
|
||||
import { Avatar } from './Avatar'
|
||||
import { COMMON_EMOJI, emojiToken, EmojiPicker, type StickerEntry } from './EmojiPicker'
|
||||
import { GifPicker } from './GifPicker'
|
||||
import { PlaceField } from './PlaceField'
|
||||
import { VoiceRecorder } from './VoiceRecorder'
|
||||
import { useActiveBuffer, useChat, useStore } from '../state/hooks'
|
||||
@@ -133,6 +134,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
/** The account's sticker packs, fetched when the picker is first opened. */
|
||||
const [stickers, setStickers] = useState<StickerEntry[]>([])
|
||||
const [stickerPicker, setStickerPicker] = useState(false)
|
||||
const [gifPicker, setGifPicker] = useState(false)
|
||||
/**
|
||||
* What to do with files dropped on the window: they land in the tray, to be
|
||||
* looked over and sent with the next message rather than going out on the
|
||||
@@ -218,6 +220,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
const [roles, setRoles] = useState<{ id: string; name: string; colour?: string }[]>([])
|
||||
const emojiButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const stickerButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const gifButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const plusButtonRef = useRef<HTMLButtonElement>(null)
|
||||
/** The "+" menu, where it opened. */
|
||||
const [plusMenu, setPlusMenu] = useState<{ x: number; y: number } | null>(null)
|
||||
@@ -1259,6 +1262,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
title="Emoji"
|
||||
onClick={() => {
|
||||
setStickerPicker(false)
|
||||
setGifPicker(false)
|
||||
if (!pickerOpen) loadStickers()
|
||||
setPickerOpen(!pickerOpen)
|
||||
}}
|
||||
@@ -1266,6 +1270,25 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
<Icon name="mood" size={18} />
|
||||
</button>
|
||||
|
||||
{/* GIFs, wherever there is a Discord account to ask for them: the
|
||||
picker borrows that account's way of looking, and what it finds can
|
||||
be sent in any conversation. */}
|
||||
{!thread && accounts.some((a) => a.service === 'discord') && (
|
||||
<button
|
||||
ref={gifButtonRef}
|
||||
type="button"
|
||||
className="icon-button"
|
||||
title="GIFs"
|
||||
onClick={() => {
|
||||
setPickerOpen(false)
|
||||
setStickerPicker(false)
|
||||
setGifPicker(!gifPicker)
|
||||
}}
|
||||
>
|
||||
<Icon name="gif_box" size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Its own button beside the emoji one, because it is its own
|
||||
gesture: an emoji goes into the line being written and a sticker
|
||||
is the message. Only where the service has them: Matrix, where a
|
||||
@@ -1279,6 +1302,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
onClick={() => {
|
||||
const opening = !stickerPicker
|
||||
setPickerOpen(false)
|
||||
setGifPicker(false)
|
||||
setStickerPicker(opening)
|
||||
if (opening) loadStickers()
|
||||
}}
|
||||
@@ -1323,6 +1347,16 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
{polling && <PollComposer bufferId={buffer.id} onClose={() => setPolling(false)} />}
|
||||
|
||||
|
||||
{gifPicker && (
|
||||
<GifPicker
|
||||
anchor={gifButtonRef.current}
|
||||
bufferId={buffer.id}
|
||||
accountId={buffer.accountId}
|
||||
discord={service === 'discord'}
|
||||
onClose={() => setGifPicker(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(pickerOpen || stickerPicker) && (
|
||||
<EmojiPicker
|
||||
// Remounted when the other button is pressed, so it opens on that
|
||||
|
||||
@@ -75,6 +75,13 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
},
|
||||
onUnmuteAccount:
|
||||
account?.service === 'matrix'
|
||||
? () =>
|
||||
void window.moho
|
||||
.rpc('setMatrixRoomMuted', { bufferId: buffer.id, muted: false })
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
: undefined,
|
||||
onToggleAutojoin:
|
||||
buffer.kind === 'channel' && account?.service === 'irc'
|
||||
? () => store.toggleAutojoin(buffer.accountId, name)
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Icon } from './Icon'
|
||||
import { useEscapeLayer } from '../lib/layers'
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { useStore } from '../state/hooks'
|
||||
|
||||
/** One of the GIFs the daemon lists: what to play, and what to send. */
|
||||
export interface GifEntry {
|
||||
id: string
|
||||
title: string
|
||||
/** The page on Tenor or Klipy, which is what Discord is sent. */
|
||||
url: string
|
||||
/** The video, to play in the grid. */
|
||||
src: string
|
||||
/** The file, which is what everything that is not Discord is sent. */
|
||||
gifSrc: string
|
||||
preview: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
interface GifCategory {
|
||||
name: string
|
||||
src: string
|
||||
}
|
||||
|
||||
/** How long after the last keystroke before a search is made. */
|
||||
const SEARCH_DELAY = 450
|
||||
|
||||
/**
|
||||
* GIFs to browse and send, as Discord's own picker has them: what is trending,
|
||||
* a search, and a row of categories to start from.
|
||||
*
|
||||
* They come from Discord - any account will do, whichever conversation this is
|
||||
* open in - and go anywhere: to a Discord conversation as the link Discord's
|
||||
* own client sends, so that Discord unfurls it; to every other service as the
|
||||
* file itself, which a window of this kind draws as the GIF and any other
|
||||
* client shows as a link.
|
||||
*/
|
||||
export function GifPicker({
|
||||
anchor,
|
||||
bufferId,
|
||||
accountId,
|
||||
discord,
|
||||
onClose
|
||||
}: {
|
||||
anchor: HTMLElement | null
|
||||
bufferId: string
|
||||
accountId: string
|
||||
/** Whether the conversation is on Discord, which decides what is sent. */
|
||||
discord: boolean
|
||||
onClose: () => void
|
||||
}): JSX.Element {
|
||||
const store = useStore()
|
||||
const media = useMediaUrl(accountId)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const scroll = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState({ left: 0, top: 0 })
|
||||
const [query, setQuery] = useState('')
|
||||
const [gifs, setGifs] = useState<GifEntry[]>([])
|
||||
const [categories, setCategories] = useState<GifCategory[]>([])
|
||||
const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEscapeLayer(onClose)
|
||||
|
||||
// Above and right-aligned to its button, and kept in view, as the emoji
|
||||
// picker is.
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el || !anchor) return
|
||||
const place = (): void => {
|
||||
const a = anchor.getBoundingClientRect()
|
||||
const r = el.getBoundingClientRect()
|
||||
const left = Math.max(4, Math.min(a.right - r.width, window.innerWidth - r.width - 4))
|
||||
const top = Math.max(4, Math.min(a.top - r.height - 6, window.innerHeight - r.height - 4))
|
||||
setPos((was) => (was.left === left && was.top === top ? was : { left, top }))
|
||||
}
|
||||
place()
|
||||
const ro = new ResizeObserver(place)
|
||||
ro.observe(el)
|
||||
window.addEventListener('resize', place)
|
||||
return () => {
|
||||
ro.disconnect()
|
||||
window.removeEventListener('resize', place)
|
||||
}
|
||||
}, [anchor])
|
||||
|
||||
// Pressing anywhere else closes it; pressing inside, or on its button, does not.
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent): void => {
|
||||
const target = e.target as Node
|
||||
if (ref.current?.contains(target) || anchor?.contains(target)) return
|
||||
onClose()
|
||||
}
|
||||
window.addEventListener('mousedown', onDown, true)
|
||||
return () => window.removeEventListener('mousedown', onDown, true)
|
||||
}, [anchor, onClose])
|
||||
|
||||
// What is trending when it opens, and a search once typing has stopped. An
|
||||
// answer that arrives after a newer question was asked is not shown.
|
||||
useEffect(() => {
|
||||
// The harness has no Discord to ask, so it can leave the answer here. Only
|
||||
// a window it started has the hook it is read from.
|
||||
const fixture = (window as unknown as { __mohoShots?: unknown; __gifFixture?: { gifs: GifEntry[]; categories: GifCategory[] } })
|
||||
if (fixture.__mohoShots && fixture.__gifFixture) {
|
||||
const wanted = query.trim().toLowerCase()
|
||||
setGifs(fixture.__gifFixture.gifs.filter((g) => !wanted || g.title.toLowerCase().includes(wanted)))
|
||||
setCategories(fixture.__gifFixture.categories)
|
||||
setState('ready')
|
||||
return
|
||||
}
|
||||
let current = true
|
||||
setState('loading')
|
||||
const wait = setTimeout(
|
||||
() => {
|
||||
window.moho
|
||||
.rpc<{ gifs: GifEntry[]; categories: GifCategory[] }>('listGifs', { query: query.trim() })
|
||||
.then((answer) => {
|
||||
if (!current) return
|
||||
setGifs(answer.gifs)
|
||||
setCategories(answer.categories)
|
||||
setState('ready')
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (!current) return
|
||||
setError(e.message)
|
||||
setState('failed')
|
||||
})
|
||||
},
|
||||
query.trim() ? SEARCH_DELAY : 0
|
||||
)
|
||||
return () => {
|
||||
current = false
|
||||
clearTimeout(wait)
|
||||
}
|
||||
}, [query])
|
||||
|
||||
const pick = (gif: GifEntry): void => {
|
||||
onClose()
|
||||
// Discord's own client sends the link, which Discord turns into the GIF.
|
||||
// Everything else is sent the file, since a page address would be only
|
||||
// that.
|
||||
void store.sendMessage(bufferId, discord ? gif.url : gif.gifSrc)
|
||||
}
|
||||
|
||||
// Two columns, each GIF into whichever is shorter, so differently shaped ones
|
||||
// fit together the way Discord's do.
|
||||
const columns: GifEntry[][] = [[], []]
|
||||
const heights = [0, 0]
|
||||
for (const gif of gifs) {
|
||||
const into = heights[0] <= heights[1] ? 0 : 1
|
||||
columns[into].push(gif)
|
||||
heights[into] += gif.width && gif.height ? gif.height / gif.width : 0.75
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div ref={ref} className="gif-picker" style={{ left: pos.left, top: pos.top }} role="dialog" aria-label="GIFs">
|
||||
<div className="gif-search">
|
||||
<Icon name="search" size={18} />
|
||||
<input
|
||||
autoFocus
|
||||
className="text-field"
|
||||
value={query}
|
||||
placeholder="Search for a GIF"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="gif-scroll" ref={scroll}>
|
||||
{!query.trim() && categories.length > 0 && (
|
||||
<div className="gif-categories">
|
||||
{categories.map((c) => (
|
||||
<button key={c.name} type="button" className="gif-category" onClick={() => setQuery(c.name)}>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'failed' && <div className="gif-empty muted">{error || 'GIFs could not be loaded'}</div>}
|
||||
{state === 'loading' && gifs.length === 0 && <div className="gif-empty muted">Loading…</div>}
|
||||
{state === 'ready' && gifs.length === 0 && <div className="gif-empty muted">No GIFs found</div>}
|
||||
|
||||
{gifs.length > 0 && (
|
||||
<div className="gif-grid">
|
||||
{columns.map((column, i) => (
|
||||
<div key={i} className="gif-column">
|
||||
{column.map((gif) => (
|
||||
<GifCell key={gif.id} gif={gif} media={media} root={scroll} onPick={pick} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One GIF. Played only while it is on screen: a grid of forty videos all
|
||||
* running is a great deal of work for a list nobody is looking at the bottom
|
||||
* of, so out of view it is a still picture and nothing more.
|
||||
*/
|
||||
function GifCell({
|
||||
gif,
|
||||
media,
|
||||
root,
|
||||
onPick
|
||||
}: {
|
||||
gif: GifEntry
|
||||
media: (url: string) => string
|
||||
root: React.RefObject<HTMLDivElement | null>
|
||||
onPick: (gif: GifEntry) => void
|
||||
}): JSX.Element {
|
||||
const cell = useRef<HTMLButtonElement>(null)
|
||||
const [visible, setVisible] = useState(false)
|
||||
useEffect(() => {
|
||||
const el = cell.current
|
||||
if (!el) return
|
||||
const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting), { root: root.current, rootMargin: '80px' })
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [root])
|
||||
const ratio = gif.width && gif.height ? `${gif.width} / ${gif.height}` : '4 / 3'
|
||||
return (
|
||||
<button ref={cell} type="button" className="gif-cell" title={gif.title} style={{ aspectRatio: ratio }} onClick={() => onPick(gif)}>
|
||||
{visible ? (
|
||||
<video src={media(gif.src)} poster={gif.preview ? media(gif.preview) : undefined} autoPlay loop muted playsInline />
|
||||
) : gif.preview ? (
|
||||
<img src={media(gif.preview)} alt="" draggable={false} />
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Icon } from './Icon'
|
||||
import { embedColor } from '../lib/format'
|
||||
import { nickColor } from '../lib/util'
|
||||
import type { Embed } from '../../../shared/wire'
|
||||
|
||||
/** The embed kinds the daemon draws as these cards instead of as a link preview. */
|
||||
export const KICK_EVENT_KINDS = ['kicks', 'gift-sub', 'raid', 'redeem', 'subscribe', 'moderation', 'stream']
|
||||
|
||||
export function isKickEvent(embed: Embed): boolean {
|
||||
return !!embed.kind && KICK_EVENT_KINDS.includes(embed.kind)
|
||||
}
|
||||
|
||||
/** Kick's own mark: a K on a disc. Drawn rather than fetched - it is a few shapes. */
|
||||
function KicksMark({ size = 16 }: { size?: number }): JSX.Element {
|
||||
return (
|
||||
<span className="kicks-mark" style={{ width: size, height: size, fontSize: size * 0.68 }} aria-hidden="true">
|
||||
K
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What somebody did on a Kick stream, as the card Kick draws it - a bar down
|
||||
* the edge, a mark for what it was, who did it in bold, and the words - on this
|
||||
* client's own surfaces and tokens rather than Kick's page.
|
||||
*
|
||||
* Every kind of event a Kick channel has, all from the daemon
|
||||
* (nobilis/src/backend/kick/chat.rs): KICKs sent, a subscription and one gifted
|
||||
* to somebody, a raid, a channel reward redeemed - which keeps the colour the
|
||||
* streamer gave it - a ban, a timeout or a chat cleared, and the stream going
|
||||
* live or ending.
|
||||
*/
|
||||
export function KickEventCard({ embed }: { embed: Embed }): JSX.Element | null {
|
||||
const who = embed.author
|
||||
if (!who) return null
|
||||
const name = (colour?: string): JSX.Element => (
|
||||
<strong className="kick-event-name" style={{ color: colour ?? embed.authorColor ?? nickColor(who) }}>
|
||||
{who}
|
||||
</strong>
|
||||
)
|
||||
const accent = embedColor(embed.color) || undefined
|
||||
const tint = accent ? ({ '--kick-accent': accent } as React.CSSProperties) : undefined
|
||||
|
||||
if (embed.kind === 'kicks') {
|
||||
return (
|
||||
<div className="kick-event kicks selectable" style={tint}>
|
||||
<div className="kick-event-main">
|
||||
<div className="kick-event-line">
|
||||
{name()}
|
||||
<span className="kick-event-verb">sent</span>
|
||||
<KicksMark />
|
||||
<strong className="kick-event-amount">{embed.title}</strong>
|
||||
{embed.provider && <span className="kick-event-gift small muted">{embed.provider}</span>}
|
||||
</div>
|
||||
{embed.description && <div className="kick-event-message">{embed.description}</div>}
|
||||
</div>
|
||||
<span className="kick-event-badge">
|
||||
<KicksMark size={30} />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (embed.kind === 'raid') {
|
||||
return (
|
||||
<div className="kick-event raid selectable">
|
||||
<span className="kick-event-badge lead">
|
||||
<Icon name="groups" size={20} />
|
||||
</span>
|
||||
<div className="kick-event-main">
|
||||
<div className="kick-event-line">
|
||||
{name()}
|
||||
<span className="kick-event-verb">
|
||||
{embed.title ? 'has raided with' : 'has raided the channel'}
|
||||
</span>
|
||||
{embed.title && (
|
||||
<>
|
||||
<strong className="kick-event-amount">{embed.title}</strong>
|
||||
<span className="kick-event-verb">{embed.title === '1' ? 'viewer.' : 'viewers.'}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{embed.description && <div className="kick-event-message">{embed.description}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (embed.kind === 'redeem') {
|
||||
return (
|
||||
<div className="kick-event redeem selectable" style={tint}>
|
||||
<span className="kick-event-badge lead">
|
||||
<Icon name="stars" size={22} />
|
||||
</span>
|
||||
<div className="kick-event-main">
|
||||
{name(accent)}
|
||||
<div className="kick-event-line">
|
||||
<span className="kick-event-verb">has redeemed</span>
|
||||
<strong>{embed.title}</strong>
|
||||
</div>
|
||||
{embed.description && <div className="kick-event-message">{embed.description}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (embed.kind === 'subscribe') {
|
||||
return (
|
||||
<div className="kick-event subscribe selectable">
|
||||
<span className="kick-event-badge lead">
|
||||
<Icon name="workspace_premium" size={22} />
|
||||
</span>
|
||||
<div className="kick-event-line">
|
||||
{name()}
|
||||
<span className="kick-event-verb">subscribed</span>
|
||||
{embed.title && (
|
||||
<>
|
||||
<span className="kick-event-verb">-</span>
|
||||
<strong>{embed.title}</strong>
|
||||
<span className="kick-event-verb">months</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (embed.kind === 'moderation') {
|
||||
return (
|
||||
<div className="kick-event moderation selectable">
|
||||
<span className="kick-event-badge lead">
|
||||
<Icon name="gavel" size={20} />
|
||||
</span>
|
||||
<div className="kick-event-line">
|
||||
<strong className="kick-event-name">{who}</strong>
|
||||
<span className="kick-event-verb">{embed.title}</span>
|
||||
{embed.description && (
|
||||
<>
|
||||
<span className="kick-event-verb">by</span>
|
||||
<strong>{embed.description}</strong>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (embed.kind === 'stream') {
|
||||
const live = embed.icon === 'live'
|
||||
return (
|
||||
<div className={`kick-event stream selectable ${live ? 'live' : 'offline'}`}>
|
||||
<span className="kick-event-badge lead">
|
||||
<Icon name={live ? 'sensors' : 'sensors_off'} size={20} />
|
||||
</span>
|
||||
<div className="kick-event-line">
|
||||
<strong className="kick-event-name">{who}</strong>
|
||||
<span className="kick-event-verb">{embed.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const count = Number(embed.title) || 1
|
||||
const recipients = (embed.description ?? '').split('\n').filter(Boolean)
|
||||
return (
|
||||
<div className="kick-event gift selectable">
|
||||
<span className="kick-event-badge lead">
|
||||
<Icon name="redeem" size={22} />
|
||||
</span>
|
||||
<div className="kick-event-main">
|
||||
<div className="kick-event-line">
|
||||
{name()}
|
||||
<span className="kick-event-verb">gifted {count === 1 ? 'a sub' : `${count} subs`}</span>
|
||||
{recipients.length === 1 && (
|
||||
<>
|
||||
<span className="kick-event-verb">to</span>
|
||||
<strong style={{ color: nickColor(recipients[0]) }}>{recipients[0]}</strong>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{recipients.length > 1 && (
|
||||
<div className="kick-event-recipients">
|
||||
<span className="kick-event-verb">to</span>
|
||||
{recipients.map((r) => (
|
||||
<strong key={r} className="kick-event-recipient" style={{ color: nickColor(r) }}>
|
||||
{r}
|
||||
</strong>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -433,11 +433,22 @@ export function MediaEmbed({
|
||||
className="media-embed"
|
||||
style={ratio}
|
||||
src={media(fullSrc)}
|
||||
controls
|
||||
// A GIF is silent, runs on its own and goes round, and has no
|
||||
// player to operate: it plays when it is shown if autoplay is on,
|
||||
// and otherwise while the pointer is over it. Any other video is a
|
||||
// video, with its controls.
|
||||
{...(attachment?.gif
|
||||
? {
|
||||
muted: true,
|
||||
autoPlay: autoplay,
|
||||
loop: true,
|
||||
onMouseEnter: (e: React.MouseEvent<HTMLVideoElement>) => !autoplay && void e.currentTarget.play().catch(() => {}),
|
||||
onMouseLeave: (e: React.MouseEvent<HTMLVideoElement>) => !autoplay && e.currentTarget.pause()
|
||||
}
|
||||
: { controls: true, loop })}
|
||||
// Enough to draw the first frame and know how long it is, without
|
||||
// pulling megabytes for every video in a scrolled-past backlog.
|
||||
preload="metadata"
|
||||
loop={loop}
|
||||
playsInline
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
|
||||
@@ -52,6 +52,9 @@ const REANCHOR_THRESHOLD = 100
|
||||
*/
|
||||
const SCROLL_JITTER = 2
|
||||
|
||||
/** How far down from the top edge to look for the row being read, nearest first. */
|
||||
const PLACE_PROBES = [1, 24, 48, 80, 120, 180]
|
||||
|
||||
/**
|
||||
* How long after the reader last touched the list a scroll still counts as
|
||||
* theirs.
|
||||
@@ -162,6 +165,16 @@ const RENDER_WINDOW = 120
|
||||
const RENDER_STEP = 120
|
||||
/** Rows kept below a jumped-to message, so it has somewhere to sit. */
|
||||
const JUMP_MARGIN = 40
|
||||
/**
|
||||
* The most unread a conversation can have and still open at its first unread
|
||||
* message. Discord's rule, and for the same reason: its first screenful of
|
||||
* history is fifty messages, so an unread run that fits in it is somewhere to
|
||||
* start reading, and one that does not is somewhere nobody wants to be taken -
|
||||
* hundreds or thousands of messages up, with the conversation they came for
|
||||
* a long scroll away. Past it the log opens at its end, as any other does,
|
||||
* and the bar across the top says how much there is and goes back to it.
|
||||
*/
|
||||
const OPEN_AT_UNREAD_LIMIT = 50
|
||||
|
||||
export function MessageList(): JSX.Element {
|
||||
const store = useStore()
|
||||
@@ -363,17 +376,29 @@ export function MessageList(): JSX.Element {
|
||||
*
|
||||
* By hit test rather than by walking the rows: this runs on every scroll,
|
||||
* and a list of two thousand is not a thing to scan that often.
|
||||
*
|
||||
* Every element under the point, not the topmost one, and a few points down
|
||||
* the page rather than one. The "N new messages since" bar is laid over the
|
||||
* top of the log, so asking what was at the edge was answered with the bar
|
||||
* for as long as it was up - and the place stopped being kept, which the
|
||||
* next thing to change size then put the view back to. A date line or the
|
||||
* gap between two groups at the edge gave the same non-answer for a moment.
|
||||
*/
|
||||
const notePlace = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const box = el.getBoundingClientRect()
|
||||
const hit = document.elementFromPoint(box.left + 8, box.top + 1)
|
||||
const row = hit?.closest?.('[data-msg-id]') as HTMLElement | null
|
||||
if (!row) return
|
||||
placeRef.current = {
|
||||
id: row.dataset.msgId ?? '',
|
||||
offset: row.getBoundingClientRect().top - box.top
|
||||
for (const down of PLACE_PROBES) {
|
||||
for (const hit of document.elementsFromPoint(box.left + 8, box.top + down)) {
|
||||
if (!el.contains(hit)) continue
|
||||
const row = hit.closest?.('[data-msg-id]') as HTMLElement | null
|
||||
if (!row) continue
|
||||
placeRef.current = {
|
||||
id: row.dataset.msgId ?? '',
|
||||
offset: row.getBoundingClientRect().top - box.top
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -512,13 +537,21 @@ export function MessageList(): JSX.Element {
|
||||
// of switching, because what has been read from the daemon may not have
|
||||
// arrived yet - the line has to be in the drawn part of the log, which is a
|
||||
// tail, before it can be scrolled to.
|
||||
// The divider marks the first message newer than the snapshot taken when the
|
||||
// buffer was opened; it deliberately doesn't move as more arrive.
|
||||
const dividerIndex = dividerTs > 0 ? messages.findIndex((m) => m.ts > dividerTs) : -1
|
||||
// Where it is against what is drawn, for the callbacks below that have to
|
||||
// know whether the line exists on the page or only in the log.
|
||||
const dividerRef = useRef({ index: -1, start: 0, length: 0 })
|
||||
dividerRef.current = { index: dividerIndex, start, length: messages.length }
|
||||
|
||||
const openedAtRef = useRef('')
|
||||
useLayoutEffect(() => {
|
||||
const key = `${bufferId}|${dividerTs}`
|
||||
if (openedAtRef.current === key || messages.length === 0) return
|
||||
openedAtRef.current = key
|
||||
const first = dividerTs > 0 ? messages.findIndex((m) => m.ts > dividerTs) : -1
|
||||
if (first >= 0 && messages.length - first > 8) {
|
||||
if (first >= 0 && messages.length - first > 8 && messages.length - first <= OPEN_AT_UNREAD_LIMIT) {
|
||||
wantDividerRef.current = true
|
||||
setShown((n) => Math.max(n, messages.length - first + JUMP_MARGIN))
|
||||
}
|
||||
@@ -528,8 +561,13 @@ export function MessageList(): JSX.Element {
|
||||
const measureDivider = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
const line = el?.querySelector<HTMLElement>('.new-divider')
|
||||
if (!el || !line) {
|
||||
setDividerAbove(false)
|
||||
if (!el) return
|
||||
if (!line) {
|
||||
// Not drawn is not the same as not there. A conversation opened with a
|
||||
// long unread run is at its end, and its line is up in the log beyond
|
||||
// what is built - which is exactly when the bar is wanted.
|
||||
const { index, start } = dividerRef.current
|
||||
setDividerAbove(index >= 0 && index < start)
|
||||
return
|
||||
}
|
||||
// Clearly above, not merely just off the top: with the line a few pixels
|
||||
@@ -541,7 +579,18 @@ export function MessageList(): JSX.Element {
|
||||
const toDivider = useCallback((smooth: boolean) => {
|
||||
const el = scrollRef.current
|
||||
const line = el?.querySelector<HTMLElement>('.new-divider')
|
||||
if (!el || !line) return
|
||||
if (!el) return
|
||||
if (!line) {
|
||||
// In the log and not on the page: build the log back to it, and the
|
||||
// layout effect below scrolls to it once it is there.
|
||||
const { index, length } = dividerRef.current
|
||||
if (index < 0) return
|
||||
anchor(false)
|
||||
setMissedCount(0)
|
||||
wantDividerRef.current = true
|
||||
setShown((n) => Math.max(n, length - index + JUMP_MARGIN))
|
||||
return
|
||||
}
|
||||
anchor(false)
|
||||
// Going back to the line is not missing anything: what is below is what
|
||||
// was already there, not news.
|
||||
@@ -551,6 +600,13 @@ export function MessageList(): JSX.Element {
|
||||
el.scrollTo({ top: Math.max(0, target), behavior: smooth ? 'smooth' : 'auto' })
|
||||
}, [anchor, messages])
|
||||
|
||||
// Whether the line is out of sight, asked as the log changes as well as when
|
||||
// it is scrolled: a conversation opened at its end never scrolls, and is
|
||||
// the one with the most to say about what is above it.
|
||||
useLayoutEffect(() => {
|
||||
measureDivider()
|
||||
}, [bufferId, dividerTs, messages.length, shown, measureDivider])
|
||||
|
||||
// Once the line is drawn, start there.
|
||||
useLayoutEffect(() => {
|
||||
if (!wantDividerRef.current) return
|
||||
@@ -713,10 +769,6 @@ export function MessageList(): JSX.Element {
|
||||
return () => window.removeEventListener('moho:jump-to-present', go)
|
||||
}, [])
|
||||
|
||||
// The divider marks the first message newer than the snapshot taken when the
|
||||
// buffer was opened; it deliberately doesn't move as more arrive.
|
||||
const dividerIndex = dividerTs > 0 ? messages.findIndex((m) => m.ts > dividerTs) : -1
|
||||
|
||||
return (
|
||||
// Whose conversation this is, for everything drawn inside it: a strictly
|
||||
// routed account's media goes through its route (#257). The attribute is
|
||||
|
||||
@@ -7,6 +7,7 @@ import { EventSource } from './EventSource'
|
||||
import { ForwardPicker } from './ForwardPicker'
|
||||
import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu'
|
||||
import { MediaEmbed } from './MediaEmbed'
|
||||
import { KickEventCard, isKickEvent } from './KickEventCard'
|
||||
import { COMMON_EMOJI, EmojiPicker, readRecent } from './EmojiPicker'
|
||||
import { reactionLabel, reactorsSentence } from '../lib/reactions'
|
||||
import { UploadMeter } from './UploadMeter'
|
||||
@@ -17,6 +18,7 @@ import { useSniffedTypes, sniffUrl } from '../lib/sniff'
|
||||
import { useMediaUrl, useStrictRoute } from '../lib/route'
|
||||
import type { ChatMessage } from '../state/store'
|
||||
import type {
|
||||
Attachment,
|
||||
CustomEmoji,
|
||||
MessageBadge,
|
||||
MessageComponent,
|
||||
@@ -405,6 +407,9 @@ function MessageRowBody({
|
||||
// label on.
|
||||
const [highlightNotices] = usePref<boolean>('matrix.highlightNotices', true)
|
||||
const reward = isReward(message.kind)
|
||||
// KICKs sent, or subscriptions gifted: the daemon says which and to whom in
|
||||
// a card, and the line of text is what a copy or a search finds.
|
||||
const kickEvent = (message.embeds ?? []).find(isKickEvent)
|
||||
// Both of the modes that draw an avatar column. Bubbles is otherwise
|
||||
// nothing like comfy, but it wants the same picture beside the same first
|
||||
// line of a group.
|
||||
@@ -418,7 +423,24 @@ function MessageRowBody({
|
||||
*/
|
||||
const own = bubbles && !isSystem && !!message.isOwn
|
||||
|
||||
const attachments = message.attachments ?? []
|
||||
// A GIF that was linked is a picture to play, and goes where pictures go:
|
||||
// the same sizing, the same autoplay and loop settings, the same viewer.
|
||||
const gifs = useMemo(
|
||||
() =>
|
||||
(message.embeds ?? [])
|
||||
.filter((e) => e.kind === 'gif' && e.mediaUrl)
|
||||
.map((e): Attachment => ({
|
||||
// As the daemon said; by the ending only for one that did not.
|
||||
kind: e.mediaKind ?? (/\.(mp4|webm|mov|mkv|ogv)(\?|$)/i.test(e.mediaUrl!) ? 'video' : 'image'),
|
||||
gif: true,
|
||||
url: e.mediaUrl,
|
||||
width: e.width,
|
||||
height: e.height,
|
||||
filename: 'gif'
|
||||
})),
|
||||
[message.embeds]
|
||||
)
|
||||
const attachments = useMemo(() => [...(message.attachments ?? []), ...gifs], [message.attachments, gifs])
|
||||
// The ones that go in a grid, and the ones that stay in the row, each with
|
||||
// its place in the message so its key stays the same whichever it is.
|
||||
const indexed = attachments.map((att, i) => ({ att, i }))
|
||||
@@ -448,7 +470,12 @@ function MessageRowBody({
|
||||
const codeBlocks = extractCodeBlocks(normalized)
|
||||
const quoteBlocks = extractQuoteBlocks(stripCodeBlocks(normalized))
|
||||
|
||||
let body = stripEmbeddedUrls(normalized, media)
|
||||
// The page a GIF was linked from is not shown as well as the GIF: it is the
|
||||
// same thing said twice, and is how a posted GIF read as an address.
|
||||
const gifPages = (message.embeds ?? [])
|
||||
.filter((e) => e.kind === 'gif' && e.url)
|
||||
.map((e) => ({ url: e.url!, kind: 'image' as const }))
|
||||
let body = stripEmbeddedUrls(normalized, [...media, ...gifPages])
|
||||
body = stripCodeBlocks(body)
|
||||
body = stripQuoteBlocks(body)
|
||||
|
||||
@@ -763,6 +790,7 @@ function MessageRowBody({
|
||||
message.failed && 'failed',
|
||||
isSystem && 'system',
|
||||
reward && 'reward',
|
||||
kickEvent && 'has-kick-event',
|
||||
grouped && 'grouped',
|
||||
fresh && 'fresh',
|
||||
comfy && 'comfy',
|
||||
@@ -805,7 +833,7 @@ function MessageRowBody({
|
||||
{/* A join, a part, a topic change: not somebody speaking, so no face -
|
||||
but a small mark in the picture's own column, so the line's text
|
||||
starts where every other line's does instead of at the edge. */}
|
||||
{mode === 'comfy' && isSystem && (
|
||||
{mode === 'comfy' && isSystem && !kickEvent && (
|
||||
<span className="message-avatar system-mark" aria-hidden>
|
||||
<Icon name={SYSTEM_MARKS[message.kind] ?? 'info'} size={16} />
|
||||
</span>
|
||||
@@ -1035,6 +1063,8 @@ function MessageRowBody({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : kickEvent ? (
|
||||
<KickEventCard embed={kickEvent} />
|
||||
) : (
|
||||
parts.html && (
|
||||
<span className="message-body selectable">
|
||||
@@ -1077,7 +1107,8 @@ function MessageRowBody({
|
||||
</pre>
|
||||
))}
|
||||
|
||||
{(message.embeds || []).map((embed, i) => (
|
||||
{(message.embeds || []).map((embed, i) =>
|
||||
embed.kind === 'gif' || isKickEvent(embed) ? null : (
|
||||
<div
|
||||
key={`e${i}`}
|
||||
className={classes('rich-embed selectable', embed.kind === 'notice' && 'notice')}
|
||||
@@ -1170,7 +1201,8 @@ function MessageRowBody({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Pictures and videos that came together are laid out together:
|
||||
two side by side, three with the first tall, four in a square,
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Avatar } from './Avatar'
|
||||
import { Icon } from './Icon'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import { useChat, useIdSetPref, usePref, useStore } from '../state/hooks'
|
||||
import { isMutedBuffer, waitingCount } from '../lib/groups'
|
||||
import { rankBuffers, whereText } from '../lib/switcher'
|
||||
import { bufferKindGlyph, classes } from '../lib/util'
|
||||
|
||||
@@ -18,6 +19,8 @@ export function QuickSwitcher(): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const buffers = useChat((s) => s.buffers)
|
||||
const accounts = useChat((s) => s.accounts)
|
||||
const [muted] = useIdSetPref('mutedBuffers')
|
||||
const [mutedGroups] = usePref<string[]>('mutedGroups', [])
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [index, setIndex] = useState(0)
|
||||
@@ -103,6 +106,7 @@ export function QuickSwitcher(): JSX.Element | null {
|
||||
{hits.length === 0 && <div className="switcher-empty muted">Nothing by that name.</div>}
|
||||
{hits.map((hit, i) => {
|
||||
const b = hit.buffer
|
||||
const waiting = waitingCount(b, isMutedBuffer(b, muted, mutedGroups))
|
||||
return (
|
||||
<button
|
||||
key={b.id}
|
||||
@@ -122,9 +126,9 @@ export function QuickSwitcher(): JSX.Element | null {
|
||||
</span>
|
||||
<span className="switcher-title ellipsis">{hit.title}</span>
|
||||
<span className="switcher-where muted small ellipsis">{hit.where}</span>
|
||||
{b.unread > 0 && (
|
||||
{waiting > 0 && (
|
||||
<span className={classes('unread-badge', b.highlight && 'highlight')}>
|
||||
{b.unread > 99 ? '99+' : b.unread}
|
||||
{waiting > 99 ? '99+' : waiting}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -28,7 +28,8 @@ import {
|
||||
reorderFolder,
|
||||
FOLDER_DRAG_PREFIX,
|
||||
visibleGroups,
|
||||
countsTowardRail,
|
||||
countedFor,
|
||||
isMutedBuffer,
|
||||
dmGroup,
|
||||
DM_GROUP_ID,
|
||||
isDirectMessage,
|
||||
@@ -60,6 +61,8 @@ interface TileProps {
|
||||
active: boolean
|
||||
unread: number
|
||||
highlight: boolean
|
||||
/** How many unread messages mention this account: the number the tile shows. */
|
||||
mentions: number
|
||||
draggable: boolean
|
||||
dropTarget: boolean
|
||||
/** Being dragged right now: it leaves a gap where it was. */
|
||||
@@ -179,7 +182,7 @@ function isMerge(e: React.DragEvent): boolean {
|
||||
}
|
||||
|
||||
function RailTile(props: TileProps): JSX.Element {
|
||||
const { group, active, unread, highlight, draggable, dropTarget, customIcon, muted, lifted, mergeTarget } = props
|
||||
const { group, active, unread, highlight, mentions, draggable, dropTarget, customIcon, muted, lifted, mergeTarget } = props
|
||||
const { menu, open, close } = useContextMenu()
|
||||
const store = useStore()
|
||||
// What is waiting inside this server, for "Mark as read".
|
||||
@@ -299,15 +302,17 @@ function RailTile(props: TileProps): JSX.Element {
|
||||
onClose={close}
|
||||
/>
|
||||
)}
|
||||
{/* A mention waiting in here says how many are waiting, in the corner
|
||||
the service mark sits in - the mark gives way, since a count is what
|
||||
somebody scanning this column came to see. */}
|
||||
{highlight && unread > 0 && (
|
||||
<span className="rail-count highlight" title={`${unread} unread, with a mention`}>
|
||||
{unread > 99 ? '99+' : unread}
|
||||
{/* Mentions say how many are waiting, in the corner the service mark sits
|
||||
in - the mark gives way, since a count is what somebody scanning this
|
||||
column came to see. Only mentions: how many messages there are in a
|
||||
busy channel is not what this column is for, and the pill beside the
|
||||
tile already says there is something to read. */}
|
||||
{mentions > 0 && (
|
||||
<span className="rail-count highlight" title={`${mentions} mention${mentions === 1 ? '' : 's'}`}>
|
||||
{mentions > 99 ? '99+' : mentions}
|
||||
</span>
|
||||
)}
|
||||
{badge && !(highlight && unread > 0) && (
|
||||
{badge && mentions === 0 && (
|
||||
<span className={`rail-service${highlight ? ' highlight' : ''}`}>
|
||||
{badge.colour && badge.mark ? (
|
||||
<img src={badge.mark} alt="" draggable={false} />
|
||||
@@ -419,18 +424,24 @@ export function ServerRail(): JSX.Element | null {
|
||||
|
||||
// Unread rolls up from the buffers under each entry, so a guild whose
|
||||
// channels are all collapsed away still shows it has something waiting.
|
||||
const totals = new Map<string, { unread: number; highlight: boolean }>()
|
||||
const totals = new Map<string, { unread: number; highlight: boolean; mentions: number }>()
|
||||
// A direct message counts for every unread message in it - all of them are
|
||||
// for you - and anywhere else for its mentions (see countedFor).
|
||||
const bump = (key: string, b: BufferEntry): void => {
|
||||
const t = totals.get(key) || { unread: 0, highlight: false }
|
||||
t.unread += b.unread
|
||||
t.highlight = t.highlight || b.highlight
|
||||
const waiting = countedFor(b, muted, hidden, mutedGroups)
|
||||
// What a muted conversation says is only that your name was used in it:
|
||||
// its unread messages are not asking for anything.
|
||||
const quiet = isMutedBuffer(b, muted, mutedGroups)
|
||||
const t = totals.get(key) || { unread: 0, highlight: false, mentions: 0 }
|
||||
t.unread += quiet ? 0 : b.unread
|
||||
t.highlight = t.highlight || (quiet ? waiting > 0 : b.highlight)
|
||||
t.mentions += waiting
|
||||
totals.set(key, t)
|
||||
}
|
||||
const all = buffers as BufferEntry[]
|
||||
for (const b of all) {
|
||||
// Only what the pane below would show: a hidden buffer has no row to
|
||||
// reach, and a muted one is not asking for attention.
|
||||
if (!countsTowardRail(b, muted, hidden, mutedGroups)) continue
|
||||
// A hidden buffer has no row to reach.
|
||||
if (hidden.includes(b.id)) continue
|
||||
// A buffer can count on more than one tile: where it lives, and on any
|
||||
// aggregate page that also lists it. Both are places the user would look.
|
||||
if (b.groupId) bump(b.groupId, b)
|
||||
@@ -520,7 +531,7 @@ export function ServerRail(): JSX.Element | null {
|
||||
*/
|
||||
const unreadDms = all
|
||||
.filter(
|
||||
(b) => isDirectMessage(b) && b.unread > 0 && countsTowardRail(b, muted, hidden, mutedGroups)
|
||||
(b) => isDirectMessage(b) && countedFor(b, muted, hidden, mutedGroups) > 0
|
||||
)
|
||||
.sort((a, b) => (b.lastActivityTs || 0) - (a.lastActivityTs || 0))
|
||||
|
||||
@@ -552,6 +563,7 @@ export function ServerRail(): JSX.Element | null {
|
||||
onBrowse={g.kind === 'space' ? () => setBrowsing(g) : undefined}
|
||||
unread={t?.unread ?? 0}
|
||||
highlight={t?.highlight ?? false}
|
||||
mentions={t?.mentions ?? 0}
|
||||
// Direct messages and pinned lead the rail by definition, so
|
||||
// there is nowhere for them to be dragged to.
|
||||
draggable={!isFixedEntry(g)}
|
||||
@@ -599,6 +611,7 @@ export function ServerRail(): JSX.Element | null {
|
||||
open={open}
|
||||
unread={entry.members.reduce((n, m) => n + (totals.get(m.id)?.unread ?? 0), 0)}
|
||||
highlight={entry.members.some((m) => totals.get(m.id)?.highlight)}
|
||||
mentions={entry.members.reduce((n, m) => n + (totals.get(m.id)?.mentions ?? 0), 0)}
|
||||
onToggle={() => toggleFolder(entry.id)}
|
||||
lifted={dragging === `${FOLDER_DRAG_PREFIX}${entry.id}`}
|
||||
onDragStart={() => beginDrag(`${FOLDER_DRAG_PREFIX}${entry.id}`)}
|
||||
@@ -788,6 +801,7 @@ function FolderTile(props: {
|
||||
/** What is waiting in the servers inside, so a closed folder can say so. */
|
||||
unread: number
|
||||
highlight: boolean
|
||||
mentions: number
|
||||
onToggle: () => void
|
||||
onContextMenu: (e: React.MouseEvent) => void
|
||||
onDragStart: () => void
|
||||
@@ -795,7 +809,7 @@ function FolderTile(props: {
|
||||
onDrop: () => void
|
||||
onDragEnd: () => void
|
||||
}): JSX.Element {
|
||||
const { folder, members, customIcons, dropTarget, lifted, open, unread, highlight } = props
|
||||
const { folder, members, customIcons, dropTarget, lifted, open, unread, mentions } = props
|
||||
// Open, its servers show their own; closed, what is inside would be
|
||||
// invisible without this.
|
||||
const waiting = !open && unread > 0
|
||||
@@ -862,14 +876,14 @@ function FolderTile(props: {
|
||||
{/* Once the face is a grid of other people's icons, nothing says it is
|
||||
a folder any more - so it takes the same corner badge a guild uses
|
||||
to name its service. An empty folder is already unmistakably one. */}
|
||||
{members.length > 0 && !(waiting && highlight) && (
|
||||
{members.length > 0 && !(!open && mentions > 0) && (
|
||||
<span className="rail-service">
|
||||
<Icon name="folder" size={11} />
|
||||
</span>
|
||||
)}
|
||||
{waiting && highlight && (
|
||||
<span className="rail-count highlight" title={`${unread} unread, with a mention`}>
|
||||
{unread > 99 ? '99+' : unread}
|
||||
{!open && mentions > 0 && (
|
||||
<span className="rail-count highlight" title={`${mentions} mention${mentions === 1 ? '' : 's'}`}>
|
||||
{mentions > 99 ? '99+' : mentions}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useEscapeLayer } from '../lib/layers'
|
||||
import { presenceClass } from '../lib/presence'
|
||||
import { classes } from '../lib/util'
|
||||
import type { Account } from '../../../shared/wire'
|
||||
import { statusName, supportsStatus } from '../lib/status'
|
||||
import type { Status } from './UserFooter'
|
||||
|
||||
/** The statuses that can be chosen, with the dot each is drawn with. Signing out is separate, below them. */
|
||||
@@ -89,7 +90,7 @@ export function StatusPopout({
|
||||
aria-label="Set your status"
|
||||
style={pos ? { left: pos.left, bottom: pos.bottom } : { visibility: 'hidden' }}
|
||||
>
|
||||
{CHOICES.map((c) => (
|
||||
{CHOICES.filter((c) => supportsStatus(account.service, c.id)).map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
@@ -100,8 +101,8 @@ export function StatusPopout({
|
||||
>
|
||||
<span className={classes('status-dot', presenceClass(c.dot))} />
|
||||
<span className="status-choice-text">
|
||||
<span>{c.label}</span>
|
||||
{c.hint && <span className="small muted">{c.hint}</span>}
|
||||
<span>{statusName(account.service, c.id)}</span>
|
||||
{c.hint && <span className="small muted">{account.service === 'matrix' ? 'Your presence is set to offline' : c.hint}</span>}
|
||||
</span>
|
||||
{c.id === status && <Icon name="check" size={16} className="status-check" />}
|
||||
</button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Icon } from './Icon'
|
||||
import { Avatar } from './Avatar'
|
||||
import { useStore, useChat } from '../state/hooks'
|
||||
import { presenceLabel } from '../lib/presence'
|
||||
import { statusName } from '../lib/status'
|
||||
import { serviceLabel } from '../lib/util'
|
||||
import type { Account } from '../../../shared/wire'
|
||||
|
||||
@@ -34,8 +35,10 @@ export function effectiveStatus(account: Account): Status | 'connecting' {
|
||||
return (account.status as Status) || 'online'
|
||||
}
|
||||
|
||||
function label(status: Status | 'connecting'): string {
|
||||
return status === 'connecting' ? 'Connecting…' : presenceLabel(status)
|
||||
function label(status: Status | 'connecting', service: string, auto = false): string {
|
||||
if (status === 'connecting') return 'Connecting…'
|
||||
if (status === 'idle' && auto) return 'Idle (away)'
|
||||
return status === 'invisible' ? statusName(service, 'invisible') : presenceLabel(status)
|
||||
}
|
||||
|
||||
export function UserFooter({ account }: { account?: Account }): JSX.Element {
|
||||
@@ -71,12 +74,12 @@ export function UserFooter({ account }: { account?: Account }): JSX.Element {
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(!open)}
|
||||
title={detail ? `${name} — ${label(status)}\n${detail}` : `${name} — ${label(status)}`}
|
||||
title={detail ? `${name} — ${label(status, account.service, account.autoIdle)}\n${detail}` : `${name} — ${label(status, account.service, account.autoIdle)}`}
|
||||
>
|
||||
<Avatar name={name} url={account.avatarUrl} size={28} status={status} accountId={account.id} />
|
||||
<span className="user-identity">
|
||||
<span className="ellipsis user-name">{name}</span>
|
||||
<span className="ellipsis small muted">{watching ? 'Watching, signed out' : label(status)}</span>
|
||||
<span className="ellipsis small muted">{watching ? 'Watching, signed out' : label(status, account.service, account.autoIdle)}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -117,7 +120,7 @@ export function UserFooter({ account }: { account?: Account }): JSX.Element {
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<StatusPopout account={account} status={status} anchor={identity.current} onClose={() => setOpen(false)} />
|
||||
<StatusPopout account={account} status={account.autoIdle ? 'online' : status} anchor={identity.current} onClose={() => setOpen(false)} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -855,6 +855,27 @@ function GeneralSettings(): JSX.Element {
|
||||
<GlobalHighlightKeywords />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Presence">
|
||||
<ToggleSetting
|
||||
settingKey="presence.autoIdle"
|
||||
label="Go idle when I step away"
|
||||
description="Shows you as Idle on Discord and IRC after a while with nothing touched, and back to Online when you return. Only while your status is Online: Idle, Do not disturb and Invisible stay where you put them until you set Online again. Not while you are in a call. moho looks only at how long it has been since the keyboard or mouse was used - never at what you do. Where the system will not say (some Wayland desktops), it goes by your use of moho's own windows."
|
||||
defaultValue={true}
|
||||
/>
|
||||
<SelectionSetting
|
||||
settingKey="presence.autoIdleMinutes"
|
||||
label="Idle after"
|
||||
defaultValue="10"
|
||||
options={[
|
||||
{ label: '5 min', value: '5' },
|
||||
{ label: '10 min', value: '10' },
|
||||
{ label: '15 min', value: '15' },
|
||||
{ label: '30 min', value: '30' },
|
||||
{ label: '1 hour', value: '60' }
|
||||
]}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Updates">
|
||||
<ToggleSetting
|
||||
settingKey="updates.notify"
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferMenuEntries } from './buffermenu'
|
||||
|
||||
const noop = (): void => {}
|
||||
|
||||
function entries(over: Record<string, unknown>) {
|
||||
return bufferMenuEntries({
|
||||
buffer: { id: 'b', accountId: 'matrix:@me:x', name: '!r:x', kind: 'channel', serverMuted: true },
|
||||
account: { id: 'matrix:@me:x', service: 'matrix' },
|
||||
muted: false,
|
||||
pinned: false,
|
||||
filed: false,
|
||||
inCall: false,
|
||||
poppedOut: false,
|
||||
canCall: false,
|
||||
live: false,
|
||||
watched: false,
|
||||
onTogglePin: noop,
|
||||
onToggleMute: noop,
|
||||
onHide: noop,
|
||||
onClose: noop,
|
||||
onCall: noop,
|
||||
onHangUp: noop,
|
||||
onFile: noop,
|
||||
onPopOut: noop,
|
||||
onDock: noop,
|
||||
...over
|
||||
} as never) as { label?: string; disabled?: boolean; onClick?: () => void }[]
|
||||
}
|
||||
|
||||
describe('a mute held on the account', () => {
|
||||
it('can be taken off where the service lets it be, and that is what the entry does', () => {
|
||||
const unmute = vi.fn()
|
||||
const toggle = vi.fn()
|
||||
const entry = entries({ onUnmuteAccount: unmute, onToggleMute: toggle }).find((e) => e.label === 'Unmute')
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry?.disabled).toBeFalsy()
|
||||
entry?.onClick?.()
|
||||
expect(unmute).toHaveBeenCalled()
|
||||
// Not the window's own mute, which would only have added a second one.
|
||||
expect(toggle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('says where it is where it cannot be taken off from here', () => {
|
||||
const entry = entries({}).find((e) => (e.label ?? '').startsWith('Muted on'))
|
||||
expect(entry?.label).toBe('Muted on Matrix')
|
||||
expect(entry?.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,8 @@ export interface BufferMenuInput {
|
||||
spaces?: BufferGroup[]
|
||||
onTogglePin: () => void
|
||||
onToggleMute: () => void
|
||||
/** Takes off a mute the account itself holds, where the service lets it be taken off from here. */
|
||||
onUnmuteAccount?: () => void
|
||||
/**
|
||||
* Filing the conversation on the account rather than in this window -
|
||||
* Matrix's room tags. Absent for every service that has no such idea, which
|
||||
@@ -86,6 +88,7 @@ export function bufferMenuEntries({
|
||||
spaces,
|
||||
onTogglePin,
|
||||
onToggleMute,
|
||||
onUnmuteAccount,
|
||||
onTag,
|
||||
onToggleAutojoin,
|
||||
onSpace,
|
||||
@@ -204,16 +207,20 @@ export function bufferMenuEntries({
|
||||
}
|
||||
] as MenuEntry[])
|
||||
: []),
|
||||
// A mute made on the account itself is not this window's to undo, and an
|
||||
// "Unmute" that quietly did nothing would be worse than no entry at all -
|
||||
// so it says where the mute is, and where to go and take it off.
|
||||
// A mute made on the account itself - Matrix keeps it on the server, as a
|
||||
// push rule - is the account's to take off, and where the service lets it
|
||||
// be taken off from here that is what this does. Where it does not, the
|
||||
// entry says where the mute is rather than offering an "Unmute" that quietly
|
||||
// did nothing.
|
||||
buffer.serverMuted
|
||||
? {
|
||||
label: account ? `Muted on ${serviceLabel(account.service)}` : 'Muted on this account',
|
||||
icon: 'notifications_off',
|
||||
disabled: true,
|
||||
onClick: () => {}
|
||||
}
|
||||
? onUnmuteAccount
|
||||
? { label: 'Unmute', icon: 'notifications', onClick: onUnmuteAccount }
|
||||
: {
|
||||
label: account ? `Muted on ${serviceLabel(account.service)}` : 'Muted on this account',
|
||||
icon: 'notifications_off',
|
||||
disabled: true,
|
||||
onClick: () => {}
|
||||
}
|
||||
: { label: muted ? 'Unmute' : 'Mute', icon: muted ? 'notifications' : 'notifications_off', onClick: onToggleMute },
|
||||
{ separator: true },
|
||||
// Filing is a drag onto the heading now, not an entry per heading. That
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
extractMedia,
|
||||
extractQuoteBlocks,
|
||||
formatMessage,
|
||||
isForumUpload,
|
||||
isGifPage,
|
||||
hostnameOf,
|
||||
ircFormat,
|
||||
normalizeBBCode,
|
||||
@@ -253,3 +255,55 @@ describe('blocks', () => {
|
||||
expect(embedColor(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('links to the pages GIFs are shared from', () => {
|
||||
it('knows Tenor, Giphy and Klipy pages when it sees one', () => {
|
||||
for (const url of [
|
||||
'https://tenor.com/view/cat-gif-22954713',
|
||||
'https://tenor.com/de/view/cat-gif-22954713',
|
||||
'https://giphy.com/gifs/cat-Z7wFi5b2VEAr6',
|
||||
'https://giphy.com/embed/Z7wFi5b2VEAr6',
|
||||
'https://klipy.com/gifs/friends-joey-53',
|
||||
'https://klipy.com/clips/angry-8'
|
||||
]) {
|
||||
expect(isGifPage(url), url).toBe(true)
|
||||
}
|
||||
expect(isGifPage('https://tenor.com/')).toBe(false)
|
||||
expect(isGifPage('https://example.com/view/cat')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not ask a page what it is, which is a request to a third party for nothing', () => {
|
||||
const asked: string[] = []
|
||||
const media = extractMedia('look https://tenor.com/view/cat-gif-1 and https://klipy.com/gifs/x-1', {
|
||||
contentSniffing: true,
|
||||
onNeedSniff: (url) => asked.push(url)
|
||||
})
|
||||
expect(media).toEqual([])
|
||||
expect(asked).toEqual([])
|
||||
})
|
||||
|
||||
it('still draws a direct file from those hosts, like any other', () => {
|
||||
const media = extractMedia('https://media.tenor.com/abc/cat.mp4 https://media.giphy.com/media/Z7wFi5b2VEAr6/giphy.gif', {})
|
||||
expect(media.map((m) => m.kind)).toEqual(['video', 'image'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('the forum\'s own files', () => {
|
||||
const picture = 'https://uploads.kiwifarms.st/data/attachments/9671/9671535-a2e5ae0d55aadd338162770b3fa79f97-alt.avif?hash=-d_TarFo41'
|
||||
|
||||
it('are known by where they are', () => {
|
||||
expect(isForumUpload(picture)).toBe(true)
|
||||
expect(isForumUpload('https://kiwifarms.st/data/video/9667/9667943-abc.mp4?hash=x')).toBe(true)
|
||||
expect(isForumUpload('https://kiwifarms.st/threads/a-thread.123/')).toBe(false)
|
||||
expect(isForumUpload('https://example.com/data/attachments/1/2-a.avif')).toBe(false)
|
||||
})
|
||||
|
||||
it('are not loaded by the window, which would ask for them from the reader\'s own address', () => {
|
||||
expect(extractMedia(`[img]${picture}[/img]`, {})).toEqual([])
|
||||
expect(extractMedia(`look ${picture}`, { contentSniffing: true, onNeedSniff: () => { throw new Error('asked') } })).toEqual([])
|
||||
})
|
||||
|
||||
it('leave other pictures and an AVIF anywhere else as they were', () => {
|
||||
expect(extractMedia('https://i.ibb.co/JRkN8BFv/photo.avif', {}).map((m) => m.kind)).toEqual(['image'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -583,6 +583,43 @@ export const OPAQUE_IMAGE_HOSTS = ['i.ddos.lgbt']
|
||||
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|avif)(\?\S*)?$/i
|
||||
const VIDEO_EXT_RE = /\.(mp4|webm|mov|mkv|ogv)(\?\S*)?$/i
|
||||
|
||||
/**
|
||||
* A page of one of the sites GIFs are shared from - Tenor, Giphy, Klipy.
|
||||
*
|
||||
* Not a picture, whatever it shows: a page. What it shows reaches the message
|
||||
* as an embed, from Discord or from the daemon having read the page, so this
|
||||
* need not be asked what it is - which, for a probe, is a request to a third
|
||||
* party the moment a message arrives, and for Klipy one it answers with a
|
||||
* challenge.
|
||||
*/
|
||||
const GIF_PAGE_RE = /^https?:\/\/(?:www\.)?(?:tenor\.com\/(?:[a-z]{2}(?:-[A-Za-z]{2})?\/)?view\/|giphy\.com\/(?:gifs|embed)\/|klipy\.com\/(?:gifs?|clips?|stickers?|memes?)\/)/i
|
||||
|
||||
/**
|
||||
* One of the forum's own files: what its uploads host serves, a picture or a
|
||||
* video.
|
||||
*
|
||||
* Not loaded by the window. The daemon fetches these through the account's own
|
||||
* route and puts the file on the message, which is what draws; left to the
|
||||
* window they would be asked for from the reader's own address - which the host
|
||||
* turns away, and which for somebody on Tor is the leak routing is for. Between
|
||||
* the message arriving and the file being kept the link is simply a link, and if
|
||||
* it cannot be kept it stays one.
|
||||
*
|
||||
* The onion address is here as the clearnet one because the text has been
|
||||
* through `clearnetLinks` by the time it reaches this.
|
||||
*/
|
||||
const FORUM_UPLOAD_RE = /^https?:\/\/(?:uploads\.|www\.)?kiwifarms\.st\/data\/(?:attachments|video)\//i
|
||||
|
||||
/** Whether this is a link to one of the forum's own files. */
|
||||
export function isForumUpload(url: string): boolean {
|
||||
return FORUM_UPLOAD_RE.test(url)
|
||||
}
|
||||
|
||||
/** Whether this is a link to a GIF's page on one of those sites. */
|
||||
export function isGifPage(url: string): boolean {
|
||||
return GIF_PAGE_RE.test(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* What a thumbnail was linked to, told apart by what it is.
|
||||
*
|
||||
@@ -652,7 +689,7 @@ export function extractMedia(
|
||||
const url = raw.replace(/[),.;!?]+$/, '')
|
||||
const yt = youtubeId(url)
|
||||
const identity = yt ? `yt:${yt}` : url
|
||||
if (seen.has(identity) || targets.has(url)) continue
|
||||
if (seen.has(identity) || targets.has(url) || isForumUpload(url)) continue
|
||||
|
||||
if (yt) {
|
||||
seen.add(identity)
|
||||
@@ -666,6 +703,9 @@ export function extractMedia(
|
||||
} else if (OPAQUE_IMAGE_HOSTS.includes(hostnameOf(url))) {
|
||||
seen.add(identity)
|
||||
result.push({ url, kind: 'image', ...biggerCopy(url, opts.thumbnails) })
|
||||
} else if (isGifPage(url)) {
|
||||
// A page, not a file: see GIF_PAGE_RE. Left as the link it is.
|
||||
continue
|
||||
} else if (opts.contentSniffing && /^https?:\/\//i.test(url)) {
|
||||
// Nothing else could classify this. Not for file:// URLs: those are
|
||||
// always nobilis's own already-classified attachment paths.
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { BufferEntry } from '../state/store'
|
||||
import {
|
||||
compareInBand,
|
||||
countsTowardRail,
|
||||
countedFor,
|
||||
fileInFolder,
|
||||
foldTogether,
|
||||
orderedGroups,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
removeFromFolders,
|
||||
reorder,
|
||||
reorderFolder,
|
||||
waitingCount,
|
||||
type RailFolder,
|
||||
type RailGroup
|
||||
} from './groups'
|
||||
@@ -81,20 +82,23 @@ describe('folders', () => {
|
||||
|
||||
describe('what a muted place still says', () => {
|
||||
const buf = (extra: Partial<BufferEntry>): BufferEntry =>
|
||||
({ id: 'a|#x', accountId: 'a', kind: 'channel', name: '#x', lastActivityTs: 0, groupId: 'g', unread: 3, highlight: false, ...extra }) as BufferEntry
|
||||
({ id: 'a|#x', accountId: 'a', kind: 'channel', name: '#x', lastActivityTs: 0, groupId: 'g', unread: 3, highlight: false, mentions: 0, ...extra }) as BufferEntry
|
||||
|
||||
it('counts a quiet muted channel for nothing', () => {
|
||||
expect(countsTowardRail(buf({}), ['a|#x'], [])).toBe(false)
|
||||
expect(countsTowardRail(buf({}), [], [], ['g'])).toBe(false)
|
||||
it('counts a muted channel by its mentions, since muting is not hiding that you were asked for', () => {
|
||||
expect(countedFor(buf({ highlight: true, mentions: 2 }), ['a|#x'], [])).toBe(2)
|
||||
expect(countedFor(buf({ highlight: true, mentions: 2 }), [], [], ['g'])).toBe(2)
|
||||
expect(countedFor(buf({ highlight: true, mentions: 2, serverMuted: true }), [], [])).toBe(2)
|
||||
// Everything else said in it is not counted whether muted or not.
|
||||
expect(countedFor(buf({ unread: 97 }), ['a|#x'], [])).toBe(0)
|
||||
})
|
||||
|
||||
it('still counts it once somebody has used your name', () => {
|
||||
expect(countsTowardRail(buf({ highlight: true }), ['a|#x'], [])).toBe(true)
|
||||
expect(countsTowardRail(buf({ highlight: true }), [], [], ['g'])).toBe(true)
|
||||
it('counts a muted direct message for nothing - it has no mentions, only messages', () => {
|
||||
expect(countedFor(buf({ kind: 'dm', unread: 5 }), [], [])).toBe(5)
|
||||
expect(countedFor(buf({ kind: 'dm', unread: 5 }), ['a|#x'], [])).toBe(0)
|
||||
})
|
||||
|
||||
it('never counts what has no row to click through to', () => {
|
||||
expect(countsTowardRail(buf({ highlight: true }), [], ['a|#x'])).toBe(false)
|
||||
expect(countedFor(buf({ highlight: true, mentions: 3 }), [], ['a|#x'])).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -118,3 +122,15 @@ describe('compareInBand', () => {
|
||||
expect([dm('old', 1), dm('new', 9)].sort(compareInBand).map((c) => c.name)).toEqual(['new', 'old'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('waitingCount', () => {
|
||||
const buffer = (over: Partial<BufferEntry>): BufferEntry =>
|
||||
({ id: 'a|#x', kind: 'channel', unread: 97, mentions: 0, highlight: false, ...over }) as BufferEntry
|
||||
it('is the mentions in a channel, not everything said in it', () => {
|
||||
expect(waitingCount(buffer({}))).toBe(0)
|
||||
expect(waitingCount(buffer({ mentions: 2, highlight: true }))).toBe(2)
|
||||
})
|
||||
it('is each unread message in a direct message, which are all for you', () => {
|
||||
expect(waitingCount(buffer({ kind: 'dm', unread: 4 }))).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,6 +95,24 @@ export function isDirectMessage(buffer: BufferEntry): boolean {
|
||||
return buffer.kind === 'dm'
|
||||
}
|
||||
|
||||
/**
|
||||
* The number to show beside a conversation: what is addressed to you.
|
||||
*
|
||||
* Mentions, not everything said - a bold name already says there is activity,
|
||||
* and a count of it is the noise the rail and tray were rid of. A direct
|
||||
* message is the exception that is the same rule: every line in one is
|
||||
* addressed to you, so it is counted by each unread message - unless it is
|
||||
* muted, which is asking not to hear about it at all.
|
||||
*
|
||||
* A muted channel still counts the times your name was used in it. Muting is
|
||||
* about being interrupted - a popup, a sound, a phone buzzing - not about
|
||||
* hiding that you were asked for; Discord and Element both keep that badge.
|
||||
*/
|
||||
export function waitingCount(buffer: BufferEntry, muted = false): number {
|
||||
if (isDirectMessage(buffer)) return muted ? 0 : buffer.unread
|
||||
return buffer.mentions
|
||||
}
|
||||
|
||||
export function pinnedGroup(): RailGroup {
|
||||
return {
|
||||
id: PINNED_GROUP_ID,
|
||||
@@ -261,19 +279,24 @@ export function isMutedBuffer(
|
||||
}
|
||||
|
||||
/**
|
||||
* The buffers whose unread should reach a rail tile: what the pane below it
|
||||
* would actually list. A hidden buffer has no row to click through to, and a
|
||||
* muted one deliberately does not ask for attention - except to say that
|
||||
* somebody used its owner's name, which muting a place has never meant not
|
||||
* wanting to hear (the clients this one is measured against all keep that).
|
||||
* What a conversation adds to a rail tile or the tray: its waiting count, or
|
||||
* nothing at all if it is hidden, which has no row to click through to.
|
||||
*
|
||||
* Muting does not remove it. A muted place is not interrupting anybody - no
|
||||
* popup, no sound, no buzz on a phone - but the times it asked for you are
|
||||
* still true, and a tile that hid them would send you looking in the wrong
|
||||
* place. What a mute does take away is a direct message's count, since that
|
||||
* is everything said in it rather than anything that was meant for you in
|
||||
* particular.
|
||||
*/
|
||||
export function countsTowardRail(
|
||||
export function countedFor(
|
||||
buffer: BufferEntry,
|
||||
muted: string[],
|
||||
hidden: string[],
|
||||
mutedGroups: string[] = []
|
||||
): boolean {
|
||||
return !hidden.includes(buffer.id) && (buffer.highlight || !isMutedBuffer(buffer, muted, mutedGroups))
|
||||
): number {
|
||||
if (hidden.includes(buffer.id)) return 0
|
||||
return waitingCount(buffer, isMutedBuffer(buffer, muted, mutedGroups))
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { statusesFor, statusName, supportsStatus } from './status'
|
||||
|
||||
describe('the statuses a service has', () => {
|
||||
it('gives Discord all four', () => {
|
||||
expect(statusesFor('discord')).toEqual(['online', 'idle', 'dnd', 'invisible'])
|
||||
})
|
||||
|
||||
it('gives IRC away and back, and no invisible', () => {
|
||||
expect(statusesFor('irc')).toEqual(['online', 'idle', 'dnd'])
|
||||
})
|
||||
|
||||
it('gives Matrix offline presence as invisible and no idle', () => {
|
||||
expect(statusesFor('matrix')).toEqual(['online', 'dnd', 'invisible'])
|
||||
})
|
||||
|
||||
it('gives Kick and Sneedchat only the one that is moho\'s own', () => {
|
||||
expect(statusesFor('kick')).toEqual(['online', 'dnd'])
|
||||
expect(statusesFor('sneedchat')).toEqual(['online', 'dnd'])
|
||||
expect(supportsStatus('sneedchat', 'idle')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('what a status is called', () => {
|
||||
it('calls Matrix\'s invisible what it is, offline presence', () => {
|
||||
expect(statusName('matrix', 'invisible')).toBe('Offline')
|
||||
expect(statusName('discord', 'invisible')).toBe('Invisible')
|
||||
expect(statusName('matrix', 'dnd')).toBe('Do not disturb')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Which statuses a service has, kept in step with `status_supported` in the
|
||||
* daemon, which also refuses what a service has no state for.
|
||||
*
|
||||
* Offered by what each can actually express rather than by what could be faked
|
||||
* from something nearby: Discord has all four; IRC has away and back - idle and
|
||||
* online; Matrix has online and offline presence, so invisible is real and idle
|
||||
* is not; Kick and Sneedchat have none. Do not disturb is online with moho's own
|
||||
* desktop notifications silenced, which any service can have, so it is on all of
|
||||
* them - and only Discord has a real state for it, which also holds back its
|
||||
* phone notifications.
|
||||
*/
|
||||
export type Choosable = 'online' | 'idle' | 'dnd' | 'invisible'
|
||||
|
||||
export const ALL_STATUSES: Choosable[] = ['online', 'idle', 'dnd', 'invisible']
|
||||
|
||||
export function supportsStatus(service: string, status: Choosable): boolean {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
case 'dnd':
|
||||
return true
|
||||
case 'idle':
|
||||
return service === 'discord' || service === 'irc'
|
||||
case 'invisible':
|
||||
return service === 'discord' || service === 'matrix'
|
||||
}
|
||||
}
|
||||
|
||||
/** What an account of this service can be set to, in the order they are listed. */
|
||||
export function statusesFor(service: string): Choosable[] {
|
||||
return ALL_STATUSES.filter((s) => supportsStatus(service, s))
|
||||
}
|
||||
|
||||
/**
|
||||
* What a status is called on this service.
|
||||
*
|
||||
* Matrix has no invisible mode: what is sent for it is offline presence, which
|
||||
* is what it is, so that is what it says there. Everything else is called what
|
||||
* the others call it.
|
||||
*/
|
||||
export function statusName(service: string, status: Choosable): string {
|
||||
if (status === 'invisible' && service === 'matrix') return 'Offline'
|
||||
return { online: 'Online', idle: 'Idle', dnd: 'Do not disturb', invisible: 'Invisible' }[status]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { trayCount } from './traycount'
|
||||
import type { BufferEntry } from '../state/store'
|
||||
|
||||
const buf = (over: Partial<BufferEntry>): BufferEntry =>
|
||||
({ id: 'a|#x', accountId: 'a', kind: 'channel', name: '#x', lastActivityTs: 0, groupId: 'g', unread: 0, highlight: false, mentions: 0, ...over }) as BufferEntry
|
||||
|
||||
describe('the number on the tray', () => {
|
||||
it('is nothing when nothing is waiting', () => {
|
||||
expect(trayCount([buf({}), buf({ id: 'a|#y', unread: 40 })], [], [], [])).toEqual({ dms: 0, mentions: 0, latest: null })
|
||||
})
|
||||
|
||||
it('counts a channel by its mentions and not by its messages', () => {
|
||||
expect(trayCount([buf({ unread: 33, mentions: 2, highlight: true })], [], [], [])).toEqual({ dms: 0, mentions: 2, latest: 'a|#x' })
|
||||
})
|
||||
|
||||
it('counts a direct message by each unread message in it', () => {
|
||||
expect(trayCount([buf({ id: 'a|bob', kind: 'dm', unread: 3 })], [], [], [])).toEqual({ dms: 3, mentions: 0, latest: 'a|bob' })
|
||||
})
|
||||
|
||||
it('does not count what is hidden', () => {
|
||||
expect(trayCount([buf({ id: 'a|#h', mentions: 5 })], [], ['a|#h'], [])).toEqual({ dms: 0, mentions: 0, latest: null })
|
||||
})
|
||||
|
||||
it('counts the mentions in what is muted, and nothing else there', () => {
|
||||
const rows = [buf({ id: 'a|#m', mentions: 4, unread: 90 }), buf({ id: 'a|gm', mentions: 6, groupId: 'muted-group' })]
|
||||
expect(trayCount(rows, ['a|#m'], [], ['muted-group'])).toEqual({ dms: 0, mentions: 10, latest: 'a|#m' })
|
||||
expect(trayCount([buf({ mentions: 2, serverMuted: true })], [], [], [])).toMatchObject({ mentions: 2 })
|
||||
expect(trayCount([buf({ unread: 50, serverMuted: true })], [], [], [])).toMatchObject({ mentions: 0 })
|
||||
})
|
||||
|
||||
it('counts a muted direct message for nothing', () => {
|
||||
expect(trayCount([buf({ id: 'a|bob', kind: 'dm', unread: 3 })], ['a|bob'], [], [])).toEqual({ dms: 0, mentions: 0, latest: null })
|
||||
})
|
||||
|
||||
it('names the most recently active conversation that has something, for the tray to open', () => {
|
||||
const rows = [buf({ id: 'a|old', mentions: 1, lastActivityTs: 10 }), buf({ id: 'a|new', mentions: 1, lastActivityTs: 20 }), buf({ id: 'a|quiet', lastActivityTs: 99 })]
|
||||
expect(trayCount(rows, [], [], []).latest).toBe('a|new')
|
||||
})
|
||||
|
||||
it('adds the two kinds together and keeps them apart', () => {
|
||||
const rows = [buf({ id: 'a|bob', kind: 'dm', unread: 2 }), buf({ mentions: 3 })]
|
||||
expect(trayCount(rows, [], [], [])).toMatchObject({ dms: 2, mentions: 3 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { countedFor, isDirectMessage } from './groups'
|
||||
import type { BufferEntry } from '../state/store'
|
||||
|
||||
/** What the tray is told is waiting. */
|
||||
export interface TrayCount {
|
||||
/** Unread direct messages, counted by the message. */
|
||||
dms: number
|
||||
/** Mentions in everywhere that is not a direct message, counted by the mention. */
|
||||
mentions: number
|
||||
/** The conversation to open from the tray: the most recently active one with something counted. */
|
||||
latest: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The number on the tray, worked out the way the rail's is.
|
||||
*
|
||||
* The tray used to keep a count of its own in the main process, from the
|
||||
* notifications it was sent and a handful of "this was read" messages, and the
|
||||
* two drifted: a read on another device cleared the window and not the tray, a
|
||||
* mention in the conversation already open raised the tray and was never
|
||||
* counted by the window, and a room muted afterwards stayed in the tray's
|
||||
* number. It also counted conversations where the rail counts messages.
|
||||
*
|
||||
* So the window - which already knows every one of those things, because it is
|
||||
* what draws them - says what is waiting, by the same rule as the rail: a
|
||||
* muted or hidden conversation does not count, a direct message counts for each
|
||||
* unread message in it, and anywhere else counts for its mentions.
|
||||
*/
|
||||
export function trayCount(buffers: BufferEntry[], muted: string[], hidden: string[], mutedGroups: string[]): TrayCount {
|
||||
let dms = 0
|
||||
let mentions = 0
|
||||
let latest: BufferEntry | null = null
|
||||
for (const b of buffers) {
|
||||
const waiting = countedFor(b, muted, hidden, mutedGroups)
|
||||
if (waiting <= 0) continue
|
||||
if (isDirectMessage(b)) dms += waiting
|
||||
else mentions += waiting
|
||||
if (!latest || b.lastActivityTs > latest.lastActivityTs) latest = b
|
||||
}
|
||||
return { dms, mentions, latest: latest?.id ?? null }
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ChatStore } from './store'
|
||||
|
||||
/** The store, with a conversation already in its list, and the daemon's update for it applied. */
|
||||
function after(update: Record<string, unknown>, before: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
const store = new ChatStore() as unknown as {
|
||||
state: { buffers: unknown[] }
|
||||
handleBufferListChange: (data: unknown) => void
|
||||
}
|
||||
const base = { id: 'm|!r', accountId: 'm', kind: 'channel', name: '!r', lastActivityTs: 0, unread: 0, highlight: false, mentions: 0 }
|
||||
store.state = { ...store.state, buffers: [{ ...base, ...before }] }
|
||||
// What the daemon sends carries no unread tally: that is this window's own.
|
||||
store.handleBufferListChange({ id: base.id, accountId: base.accountId, kind: base.kind, name: base.name, lastActivityTs: 0, ...update })
|
||||
return store.state.buffers[0] as Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('an update to a conversation the window already has', () => {
|
||||
it('takes a mute off when the daemon leaves it out', () => {
|
||||
// The daemon omits a flag that is off, so absent has to mean cleared: a
|
||||
// room unmuted on the account stayed drawn as muted, with an Unmute that
|
||||
// had nothing left to do.
|
||||
expect(after({}, { serverMuted: true }).serverMuted).toBe(false)
|
||||
expect(after({ serverMuted: true }, {}).serverMuted).toBe(true)
|
||||
})
|
||||
|
||||
it('clears the other flags the same way, and keeps the unread count it holds', () => {
|
||||
const b = after({}, { favourite: true, lowPriority: true, readOnly: 'No permission', forum: true, unread: 4, mentions: 2, highlight: true })
|
||||
expect([b.favourite, b.lowPriority, b.readOnly, b.forum]).toEqual([false, false, undefined, false])
|
||||
expect([b.unread, b.mentions, b.highlight]).toEqual([4, 2, true])
|
||||
})
|
||||
})
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
VoiceSession
|
||||
} from '../../../shared/wire'
|
||||
import { addToast, type ToastAction, type ToastItem } from '../lib/toasts'
|
||||
import { ALL_STATUSES, statusesFor, supportsStatus } from '../lib/status'
|
||||
import { buildSmilieIndex, type SmilieEntry, type SmilieIndex } from '../lib/format'
|
||||
import { bufferDisplayName, isChatKind, isImageFile, resolveMediaUrl } from '../lib/util'
|
||||
import { runExport } from '../lib/exporter'
|
||||
@@ -71,6 +72,12 @@ export interface ForumPageCache {
|
||||
export interface BufferEntry extends WireBuffer {
|
||||
unread: number
|
||||
highlight: boolean
|
||||
/**
|
||||
* How many of the unread messages mention this account. The count the rail
|
||||
* shows: a tile that says 33 for thirty-three messages in a busy channel is
|
||||
* noise, where one that says 2 for two people asking for you is the point.
|
||||
*/
|
||||
mentions: number
|
||||
/**
|
||||
* Left unread on purpose, rather than merely not read yet.
|
||||
*
|
||||
@@ -1179,6 +1186,33 @@ export class ChatStore {
|
||||
private set(patch: Partial<ChatState>): void {
|
||||
this.state = { ...this.state, ...patch }
|
||||
for (const l of this.listeners) l()
|
||||
if (patch.accounts && !this.state.pinnedBufferId) this.reportTrayStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells main what status each connected account is at, and which statuses
|
||||
* any of them can be set to. The tray menu offers those and checks the one of
|
||||
* the account with the most to choose from - Discord's rather than a
|
||||
* service that has no idle to be at - and main stays quiet, with no popup and
|
||||
* no sound, for an account that is on Do not disturb.
|
||||
*/
|
||||
/** What main was last told, so an account list that changed in nothing that matters is not told again. */
|
||||
private lastTrayReport = ''
|
||||
|
||||
private reportTrayStatus(): void {
|
||||
const statuses: Record<string, string> = {}
|
||||
const connected = this.state.accounts.filter((a) => a.state === 'connected')
|
||||
// What was chosen, not what is shown: an account idle because its person is
|
||||
// away is still online as far as the menu and the notifier are concerned.
|
||||
for (const a of connected) if (a.status) statuses[a.id] = a.autoIdle ? 'online' : a.status
|
||||
const offered = ALL_STATUSES.filter((s) => connected.some((a) => supportsStatus(a.service, s)))
|
||||
const widest = [...connected].sort((a, b) => statusesFor(b.service).length - statusesFor(a.service).length)[0]
|
||||
// Invisible is what Discord calls it; where only Matrix has it, it is offline.
|
||||
const invisibleName = connected.some((a) => supportsStatus(a.service, 'invisible') && a.service !== 'matrix') ? 'Invisible' : 'Offline'
|
||||
const report = JSON.stringify([statuses, offered, (widest?.autoIdle ? 'online' : widest?.status) ?? null, invisibleName])
|
||||
if (report === this.lastTrayReport) return
|
||||
this.lastTrayReport = report
|
||||
window.moho.setAccountStatuses(statuses, offered, (widest?.autoIdle ? 'online' : widest?.status) ?? null, invisibleName)
|
||||
}
|
||||
|
||||
// --- lifecycle ------------------------------------------------------
|
||||
@@ -1244,8 +1278,14 @@ export class ChatStore {
|
||||
window.moho.onScreenPick(async (sources) => (await this.askForScreenSource(sources))?.id ?? null)
|
||||
|
||||
window.moho.onLinkChange((up) => {
|
||||
// Only the link coming up is news. Main says it again whenever the tray
|
||||
// is redrawn, and taking each of those for a reconnection refreshed
|
||||
// everything and put the pane back on the open conversation's server -
|
||||
// which is how a Discord account retrying every few seconds kept pulling
|
||||
// somebody off the IRC network they had just chosen.
|
||||
const wasUp = this.state.linkUp
|
||||
this.set({ linkUp: up })
|
||||
if (up) {
|
||||
if (up && !wasUp) {
|
||||
void this.refreshAll()
|
||||
void this.refreshNetSettings()
|
||||
}
|
||||
@@ -1256,6 +1296,11 @@ export class ChatStore {
|
||||
// window gets them instead; main routes them there.
|
||||
if (!this.state.pinnedBufferId) {
|
||||
window.moho.onActivateBuffer((id) => void this.selectBuffer(id))
|
||||
// The tray's menu: Settings, and a status for every account.
|
||||
window.moho.onTrayCommand((command, arg) => {
|
||||
if (command === 'settings') this.setActivePanel('settings')
|
||||
else if (arg === 'online' || arg === 'idle' || arg === 'dnd' || arg === 'invisible') void this.setStatusEverywhere(arg)
|
||||
})
|
||||
window.moho.onDeepLink((url) => this.followDeepLink(url))
|
||||
}
|
||||
|
||||
@@ -1344,7 +1389,10 @@ export class ChatStore {
|
||||
])
|
||||
// Not awaited: GitHub being slow is no reason to hold the window up.
|
||||
void this.checkForRelease()
|
||||
if (this.state.activeBufferId) await this.selectBuffer(this.state.activeBufferId)
|
||||
// The open conversation is read again, and subscribed to again, where it is.
|
||||
// Not followed to its server: a refresh is not somebody choosing it, and
|
||||
// the rail may have been moved to another since.
|
||||
if (this.state.activeBufferId) await this.selectBuffer(this.state.activeBufferId, false)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2119,7 +2167,9 @@ export class ChatStore {
|
||||
* left alone - choosing a status is not a request to connect it.
|
||||
*/
|
||||
async setStatusEverywhere(status: 'online' | 'idle' | 'dnd' | 'invisible', statusText?: string): Promise<void> {
|
||||
const connected = this.state.accounts.filter((a) => a.state === 'connected')
|
||||
// Only the accounts of a service that has this status: an idle for the
|
||||
// others is not asked of them, and they stay as they were.
|
||||
const connected = this.state.accounts.filter((a) => a.state === 'connected' && supportsStatus(a.service, status))
|
||||
await Promise.all(connected.map((a) => this.setAccountStatus(a.id, status, a.service === 'discord' || a.service === 'matrix' ? statusText : undefined)))
|
||||
}
|
||||
|
||||
@@ -2149,6 +2199,7 @@ export class ChatStore {
|
||||
const buffers = wire.map((b) => ({
|
||||
unread: existing.get(b.id)?.unread ?? 0,
|
||||
highlight: existing.get(b.id)?.highlight ?? false,
|
||||
mentions: existing.get(b.id)?.mentions ?? 0,
|
||||
...b
|
||||
}))
|
||||
this.set({ buffers })
|
||||
@@ -3196,7 +3247,7 @@ export class ChatStore {
|
||||
// Reading a room is how a deliberate mark is taken off, so the
|
||||
// flag clears with the count. Anything else would need a second
|
||||
// gesture to undo the first, and nobody would find it.
|
||||
b.id === data.bufferId ? { ...b, unread: 0, highlight: false, markedUnread: false } : b
|
||||
b.id === data.bufferId ? { ...b, unread: 0, highlight: false, mentions: 0, markedUnread: false } : b
|
||||
)
|
||||
})
|
||||
break
|
||||
@@ -3462,6 +3513,13 @@ export class ChatStore {
|
||||
break
|
||||
}
|
||||
|
||||
// Accounts shown as idle because their person stepped away, or back as
|
||||
// online because they returned: nothing they chose changed, so the list
|
||||
// is read again rather than patched.
|
||||
case 'accountStatus':
|
||||
void this.refreshAccounts()
|
||||
break
|
||||
|
||||
case 'presenceChange':
|
||||
this.set({
|
||||
presenceByBuffer: { ...this.state.presenceByBuffer, [data.bufferId]: data.members }
|
||||
@@ -3608,7 +3666,8 @@ export class ChatStore {
|
||||
remoteId: roomIdOrAlias,
|
||||
syncing: true,
|
||||
unread: 0,
|
||||
highlight: false
|
||||
highlight: false,
|
||||
mentions: 0
|
||||
}
|
||||
this.set({ buffers: [...this.state.buffers, stand_in] })
|
||||
try {
|
||||
@@ -3681,13 +3740,33 @@ export class ChatStore {
|
||||
// leaves an optional field out when it is not set, so a plain spread
|
||||
// kept the last value it ever had - a room that reconnected stayed
|
||||
// marked as interrupted, and the banner over it stayed up.
|
||||
//
|
||||
// The same for every flag the daemon leaves out when it is off - muted on
|
||||
// the account, favourite, low priority, read-only, a forum. A merge that
|
||||
// kept the last value it ever had left a room unmuted on the server still
|
||||
// drawn as muted here, with an Unmute that had nothing left to do.
|
||||
this.set({
|
||||
buffers: buffers.map((b) => (b.id === data.id ? { ...b, ...data, link: data.link, syncing: data.syncing, topic: data.topic } : b))
|
||||
buffers: buffers.map((b) =>
|
||||
b.id === data.id
|
||||
? {
|
||||
...b,
|
||||
...data,
|
||||
link: data.link,
|
||||
syncing: data.syncing,
|
||||
topic: data.topic,
|
||||
serverMuted: data.serverMuted ?? false,
|
||||
favourite: data.favourite ?? false,
|
||||
lowPriority: data.lowPriority ?? false,
|
||||
readOnly: data.readOnly,
|
||||
forum: data.forum ?? false
|
||||
}
|
||||
: b
|
||||
)
|
||||
})
|
||||
this.followPeekJoin(data)
|
||||
return
|
||||
}
|
||||
this.set({ buffers: [...buffers, { unread: 0, highlight: false, ...data }] })
|
||||
this.set({ buffers: [...buffers, { unread: 0, highlight: false, mentions: 0, ...data }] })
|
||||
// Filed under a rail entry this window has never been told of. An
|
||||
// account's own entry is not announced when the account is made - the
|
||||
// daemon lists one for every account when asked - so the first
|
||||
@@ -3836,7 +3915,7 @@ export class ChatStore {
|
||||
this.set({
|
||||
buffers: this.state.buffers.map((b) =>
|
||||
b.id === bufferId
|
||||
? { ...b, unread: b.unread + 1, highlight: b.highlight || !!msg.isHighlight }
|
||||
? { ...b, unread: b.unread + 1, highlight: b.highlight || !!msg.isHighlight, mentions: b.mentions + (msg.isHighlight ? 1 : 0) }
|
||||
: b
|
||||
)
|
||||
})
|
||||
@@ -3901,9 +3980,20 @@ export class ChatStore {
|
||||
replyingTo: null,
|
||||
editingId: '',
|
||||
buffers: this.state.buffers.map((b) =>
|
||||
b.id === bufferId ? { ...b, unread: 0, highlight: false } : b
|
||||
b.id === bufferId ? { ...b, unread: 0, highlight: false, mentions: 0 } : b
|
||||
)
|
||||
}
|
||||
// The line a conversation was last opened with is that visit's, and goes
|
||||
// when it is left. Kept, it was found again on every return: a buffer read
|
||||
// to its end, left, and come back to had the old line still in it with
|
||||
// everything said since below it - so it opened far up the history at a
|
||||
// place that was no longer where anything was unread, instead of at the
|
||||
// end. A conversation opened again works out its line afresh, from what
|
||||
// is unread now.
|
||||
if (leaving && leaving !== bufferId && this.state.dividerTsByBuffer[leaving] !== undefined) {
|
||||
const { [leaving]: _gone, ...rest } = this.state.dividerTsByBuffer
|
||||
patch.dividerTsByBuffer = rest
|
||||
}
|
||||
if (dividerTs === undefined && buffer.unread > 0) {
|
||||
const list = this.state.messagesByBuffer[bufferId] || []
|
||||
const firstUnread = list.length >= buffer.unread ? list[list.length - buffer.unread] : list[0]
|
||||
@@ -3913,7 +4003,7 @@ export class ChatStore {
|
||||
// time it was last marked read.
|
||||
const stoppedAt = list.length < buffer.unread ? this.state.lastReadTs?.[bufferId] : undefined
|
||||
patch.dividerTsByBuffer = {
|
||||
...this.state.dividerTsByBuffer,
|
||||
...(patch.dividerTsByBuffer ?? this.state.dividerTsByBuffer),
|
||||
[bufferId]: stoppedAt ?? (firstUnread ? firstUnread.ts - 1 : 0)
|
||||
}
|
||||
}
|
||||
@@ -4087,7 +4177,7 @@ export class ChatStore {
|
||||
// here too - the stored timestamp only decides what counts next time.
|
||||
this.set({
|
||||
buffers: this.state.buffers.map((b) =>
|
||||
bufferIds.includes(b.id) ? { ...b, unread: 0, highlight: false } : b
|
||||
bufferIds.includes(b.id) ? { ...b, unread: 0, highlight: false, mentions: 0 } : b
|
||||
)
|
||||
})
|
||||
} catch (e) {
|
||||
|
||||
@@ -52,7 +52,15 @@ export const IPC = {
|
||||
/** main -> window: a right click landed on somewhere text is typed. */
|
||||
editMenu: 'moho:editMenu',
|
||||
deepLink: 'moho:deepLink',
|
||||
popoutsChanged: 'moho:popout:changed'
|
||||
popoutsChanged: 'moho:popout:changed',
|
||||
/** main -> window: something from the tray menu that the window carries out. */
|
||||
trayCommand: 'moho:tray:command',
|
||||
/** window -> main: the status each connected account is at. */
|
||||
trayStatus: 'moho:tray:status',
|
||||
/** window -> main: what is waiting, for the number on the tray. */
|
||||
trayUnread: 'moho:tray:unread',
|
||||
/** window -> main: something was used in this window, so the person is here. */
|
||||
activity: 'moho:activity'
|
||||
} as const
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,11 @@ export interface Account {
|
||||
status?: string
|
||||
/** What it says beside the status, where the service has a place for that. */
|
||||
statusText?: string
|
||||
/**
|
||||
* Shown as idle because the person stepped away, not because they chose it:
|
||||
* `status` then reads idle while what they chose is online.
|
||||
*/
|
||||
autoIdle?: boolean
|
||||
state: string
|
||||
autojoin: string
|
||||
hasNickservPassword: boolean
|
||||
@@ -466,14 +471,29 @@ export interface Embed {
|
||||
provider?: string
|
||||
/** Who made it: a video's channel. */
|
||||
author?: string
|
||||
/** The colour the service draws that name in, as `#rrggbb`. */
|
||||
authorColor?: string
|
||||
/** The labelled lines under the description. */
|
||||
fields?: { name: string; value: string }[]
|
||||
/** The small line along the bottom. */
|
||||
footer?: string
|
||||
/** A button along the bottom of the card. With no address it is drawn and goes nowhere. */
|
||||
cta?: { label: string; url?: string }
|
||||
/** "notice" for the platform's own notices to an account. */
|
||||
/**
|
||||
* "notice" for the platform's own notices to an account; "gif" for a moving
|
||||
* picture that was linked, drawn as the picture and not as a card; and
|
||||
* "kicks" and "gift-sub" for what somebody did on a Kick stream - `author`
|
||||
* is who, `title` the amount, and for a gift `description` is who received
|
||||
* it, one name to a line - drawn as the card Kick draws it.
|
||||
*/
|
||||
kind?: string
|
||||
/** For a "gif": the video or image to play as it stands, which is not the page that was linked. */
|
||||
mediaUrl?: string
|
||||
/** Whether `mediaUrl` is a video or a picture, as the daemon knows it. */
|
||||
mediaKind?: 'video' | 'image'
|
||||
/** Its size, so the row is laid out before it has loaded. */
|
||||
width?: number
|
||||
height?: number
|
||||
/** A glyph for the heading. */
|
||||
icon?: string
|
||||
}
|
||||
@@ -511,6 +531,11 @@ export interface Attachment {
|
||||
* flag, not a guess from the mimetype.
|
||||
*/
|
||||
voice?: boolean
|
||||
/**
|
||||
* A GIF that was linked, to be played like one - silent, looping, without a
|
||||
* player's controls. Only ever set by the window itself, from an embed.
|
||||
*/
|
||||
gif?: boolean
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
|
||||