Compare commits
@@ -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,6 +17,29 @@ Each section has two parts:
|
||||
that made it, including dependency updates
|
||||
(`node scripts/changelog-deps.mjs <previous tag>` lists those).
|
||||
|
||||
## [1.1.0]
|
||||
|
||||
### Major features
|
||||
|
||||
- **Steam, fully ported:** friends and direct messages, group chats with their rooms, members, roles and permissions, voice rooms and one-to-one voice calls, all in the same window as everything else.
|
||||
- **Steam friends:** the friends list with presence and what each person is playing, friend requests and invitations, blocking and unblocking, nicknames, and profile cards; Steam's own friend folders are the headings on the Steam account's own page, made, renamed, deleted and filled on Steam, and what people were last called is kept between runs so a start shows names and not numbers.
|
||||
- **Steam messages:** emoticons, stickers and reactions in both directions, pictures and files (spoilers too) into friends and group rooms, mentions with `@name`, `@all` and `@online`, link cards, message history that stops asking once it reaches the start, and Steam's own wording for every refusal.
|
||||
- **Steam group chats:** joining by link, a friend's invitation answered from the Invitations tile, leaving, bans, kicks, role giving, notification levels, a group's picture, and the group's voice rooms with who is in them.
|
||||
- **Steam voice:** group voice rooms and one-to-one calls over the same relay the Steam client uses, with the speaking indicator, and a call that retries the way the client does when Steam fails it in passing.
|
||||
- **Sign in to Steam two ways:** a password with Steam Guard, or a QR code scanned by the Steam phone app; the token Steam hands back is kept, not the password.
|
||||
- **One Invitations tile** for everything that waits on a yes or a no, from every service: Matrix rooms, Steam friend requests and group invitations, Discord requests, and verification.
|
||||
- **Direct messages sorted under a heading per account** ("Steam - Goons", "Discord - name"), made once and yours to rename, move and delete after that.
|
||||
|
||||
### Fixes, patches and changes
|
||||
|
||||
- Kick chat moved from Pusher to Kick's realtime service ([moho `042f7b6`](https://github.com/Moho-Chat/moho/commit/042f7b6), [nobilis `c26f968`](https://github.com/Moho-Chat/nobilis/commit/c26f968))
|
||||
- Fixed a link at the end of a clause taking the clause's punctuation with it ([moho `0770478`](https://github.com/Moho-Chat/moho/commit/0770478))
|
||||
- Fixed Sneedchat members having no pictures ([nobilis `d1d0770`](https://github.com/Moho-Chat/nobilis/commit/d1d0770))
|
||||
- The profile card has a Status section ([moho `68684a4`](https://github.com/Moho-Chat/moho/commit/68684a4))
|
||||
- Comfy message lists follow Discord's layout on every service, and Classic stays names ([moho `82665ee`](https://github.com/Moho-Chat/moho/commit/82665ee))
|
||||
- Dropping a conversation on a category lights the whole category, not a line above it ([moho `0633a2c`](https://github.com/Moho-Chat/moho/commit/0633a2c))
|
||||
- Fixed Steam invite links not copying: the page's own clipboard is refused, so they go through the main process like every other copy
|
||||
|
||||
## [1.0.0]
|
||||
|
||||
### Major features
|
||||
@@ -27,9 +50,107 @@ Each section has two parts:
|
||||
- **Per-account Tor and SOCKS5 routing:** Clearnet, Service tunnel or Strict - Strict sends every picture, link check and embed through the account's route too. Or send everything through Tor at once.
|
||||
- **One mentions inbox across every service,** link previews, YouTube cards that play in place, voice messages, polls, and exporting a conversation to disk.
|
||||
- **A background daemon** (nobilis) that keeps every connection alive with the window closed.
|
||||
- **Discord forums:** forum and media channels appear in the channel list and open as a pane of posts - title, tags, the first message's words, replies, age, picture and top reaction - with search, sorting, older posts, and a New Post form
|
||||
- **A redesigned interface:** Discord-shaped messages with date separators and an unread bar, red mention badges, a Ctrl+K quick switcher, keyboard navigation, a rail with mention counts and folders, a member list with faces, and motion throughout
|
||||
- **Settings rebuilt:** one click from the gear, a grouped rail with a search over every setting, and new Appearance (layout, spacing, zoom, clock, reduce motion), Notifications and Keybinds pages with a hotkey recorder
|
||||
- **A one-box message composer:** a + menu, Send only when there is something to send, Shift+Enter for a new line on every service, an attachment tray with spoilers, and dropped files going into the tray
|
||||
- **Discord notices and permissions:** Discord's own safety notices drawn as the cards they are, and a banner in place of the message box wherever nothing can be written
|
||||
- **A rebuilt tray:** an icon drawn for the system's colour scheme with the number of conversations waiting on it, a tooltip that says what is waiting, and a menu of what is new, Show/Hide, Status, Settings, Restart moho and Quit - Restart moho brings back both the window and the daemon
|
||||
- **Status by service:** the plaque and the tray offer only the statuses each service has - Discord all four, IRC away and back, Matrix online and offline, Kick and Sneedchat none - and Do not disturb silences desktop notifications on every service, and is Discord's real mode there
|
||||
- **GIFs on every service:** a linked GIF is drawn as the picture - silent, looping, without the player - whichever service it came from, and a GIF picker with trending, categories and search sends one from the composer. Klipy, Tenor and Giphy pages are never probed for what they are.
|
||||
- **Idle detection:** moho notices when nothing has been used for a while, from the system where it can say and from its own windows where it cannot, and puts an account that has an idle on Idle and back. It has a setting for it and for how long, and the plaque says Idle (away) while keeping what you chose checked.
|
||||
- **Link cards on every service:** a YouTube link gets a titled card with its channel on IRC, Matrix, Kick and Sneedchat as it does on Discord - including through Tor, which YouTube refuses the usual way - and one added in an edit, read back from history, or posted before cards existed gets its card too.
|
||||
- **Kick events as cards:** KICKs sent to a streamer, gifted subscriptions with who received them, subscriptions, raids, redeemed rewards, bans and timeouts, and the stream going live or ending are each a card in moho's own theme, sitting where a person's message does in every layout.
|
||||
- **Conversations open where you stopped reading,** as Discord's do: at the first unread when the run is short, otherwise at the end with a bar to go back by, and a return to a conversation no longer lands hundreds of messages up.
|
||||
|
||||
### Fixes, patches and changes
|
||||
|
||||
- Fixed Kick's KICKs never being heard - they arrive on a subscription that was never opened - and drew KICKs, gifted subscriptions with their recipients, raids and redeemed rewards as cards ([moho `7bae0ea`](https://github.com/Moho-Chat/moho/commit/7bae0ea), [nobilis `95ee3b1`](https://github.com/Moho-Chat/nobilis/commit/95ee3b1))
|
||||
- Every other Kick event is a card too: subscriptions, bans, timeouts, unbans, a cleared chat, and the stream going live or ending ([moho `f118395`](https://github.com/Moho-Chat/moho/commit/f118395), [nobilis `23489f8`](https://github.com/Moho-Chat/nobilis/commit/23489f8))
|
||||
- Kick cards start at the picture's edge with the name where a name is in the log, in comfy and bubbles, with the same air above as a message ([moho `64ade92`](https://github.com/Moho-Chat/moho/commit/64ade92))
|
||||
- A conversation opens as Discord's does, and the line from a visit is dropped when the conversation is left ([moho `39dcba5`](https://github.com/Moho-Chat/moho/commit/39dcba5))
|
||||
- Fixed an unpinned Kick message never leaving the screen ([nobilis `81a33a1`](https://github.com/Moho-Chat/nobilis/commit/81a33a1))
|
||||
- Fixed cancelling an outgoing IRC file offer that nobody had accepted doing nothing for the three minutes the offer lasts ([nobilis `b3e92b5`](https://github.com/Moho-Chat/nobilis/commit/b3e92b5))
|
||||
- Fixed the IRC capability list being cut short when a server sends it over several lines with the continuation mark in the other field ([nobilis `b3e92b5`](https://github.com/Moho-Chat/nobilis/commit/b3e92b5))
|
||||
- Fixed a removed account leaving six lists behind in memory, among them its silenced channels and what its network said about MONITOR ([nobilis `b3e92b5`](https://github.com/Moho-Chat/nobilis/commit/b3e92b5))
|
||||
- Fixed a raid of one viewer reading "1 viewers" ([nobilis `b3e92b5`](https://github.com/Moho-Chat/nobilis/commit/b3e92b5))
|
||||
- Every test was read; those that could not fail now can, tests that checked a copy of the code now check the code, and the untested cases mutation testing found are covered ([moho `8a7e3e7`](https://github.com/Moho-Chat/moho/commit/8a7e3e7), [moho `b0e6775`](https://github.com/Moho-Chat/moho/commit/b0e6775), [moho `47653c5`](https://github.com/Moho-Chat/moho/commit/47653c5), [moho `09c060d`](https://github.com/Moho-Chat/moho/commit/09c060d), [nobilis `2477514`](https://github.com/Moho-Chat/nobilis/commit/2477514), [nobilis `b522fe6`](https://github.com/Moho-Chat/nobilis/commit/b522fe6), [nobilis `b3e92b5`](https://github.com/Moho-Chat/nobilis/commit/b3e92b5))
|
||||
- Nothing in the code or documentation refers to the maintainer's own server any more ([moho `97611c8`](https://github.com/Moho-Chat/moho/commit/97611c8), [nobilis `1c70d32`](https://github.com/Moho-Chat/nobilis/commit/1c70d32))
|
||||
- Documentation brought up to date with the settings pages, how a conversation opens, presence, link cards, Kick events and the daemon as it is ([moho `d99ad2b`](https://github.com/Moho-Chat/moho/commit/d99ad2b), [nobilis `6a1bb8e`](https://github.com/Moho-Chat/nobilis/commit/6a1bb8e))
|
||||
- Added idle detection with a setting for it and for how long ([moho `23c87dd`](https://github.com/Moho-Chat/moho/commit/23c87dd), [nobilis `35ea0b7`](https://github.com/Moho-Chat/nobilis/commit/35ea0b7))
|
||||
- Added GIFs drawn as pictures on every service, and a GIF picker ([moho `55248eb`](https://github.com/Moho-Chat/moho/commit/55248eb), [nobilis `046fbb4`](https://github.com/Moho-Chat/nobilis/commit/046fbb4))
|
||||
- Fixed a Sneedchat picture from the forum's uploads host - an AVIF, or an MP4 - not drawing: its domain is taken off and it is fetched through the account's route, clearnet or Tor ([moho `07f4fc2`](https://github.com/Moho-Chat/moho/commit/07f4fc2), [nobilis `ca29a63`](https://github.com/Moho-Chat/nobilis/commit/ca29a63))
|
||||
- Fixed the tray's number drifting from the client's: it is now counted by the window, by the rail's rule, and told to the tray whenever it changes ([moho `1e314f8`](https://github.com/Moho-Chat/moho/commit/1e314f8))
|
||||
- Fixed YouTube cards never appearing for an account on Tor: YouTube answers every Tor exit with a 403 on its card lookup, so the video's own page is read when that happens ([moho `8064e79`](https://github.com/Moho-Chat/moho/commit/8064e79), [nobilis `340342b`](https://github.com/Moho-Chat/nobilis/commit/340342b))
|
||||
- Fixed link cards being lost when a message was edited, and missing from Matrix history, Discord catch-up and older messages ([moho `638b02e`](https://github.com/Moho-Chat/moho/commit/638b02e), [nobilis `dab394f`](https://github.com/Moho-Chat/nobilis/commit/dab394f))
|
||||
- Fixed scrolling through a long run of unread messages snapping back to where the "new messages" bar appeared ([moho `336335a`](https://github.com/Moho-Chat/moho/commit/336335a))
|
||||
- The number beside a conversation is what is addressed to you - mentions in a channel, each unread message in a direct message - and the bold name is the sign of activity ([moho `987f31c`](https://github.com/Moho-Chat/moho/commit/987f31c))
|
||||
- A muted channel keeps its mention count in the list, the rail, the switcher and the tray, and stops the popups; muting a Discord channel or direct message is now set on the account, so a phone is quiet too ([moho `7803cc3`](https://github.com/Moho-Chat/moho/commit/7803cc3), [nobilis `b0fa250`](https://github.com/Moho-Chat/nobilis/commit/b0fa250))
|
||||
- Reworked the tray icon and menu, and dropped Restart daemon and Stop daemon from it ([moho `24106e5`](https://github.com/Moho-Chat/moho/commit/24106e5))
|
||||
- Do not disturb now silences an account's desktop notifications and the taskbar flash; the tray no longer has a switch of its own for it ([moho `53fb0f3`](https://github.com/Moho-Chat/moho/commit/53fb0f3))
|
||||
- Idle from the tray reaches Discord and IRC and leaves Matrix, Kick and Sneedchat as they were; a service is no longer asked for a status it does not have ([moho `c1bcb72`](https://github.com/Moho-Chat/moho/commit/c1bcb72), [nobilis `a8441ab`](https://github.com/Moho-Chat/nobilis/commit/a8441ab))
|
||||
- Fixed Matrix's rate limit on changing status showing as M_LIMIT_EXCEEDED: a limited request now waits the time the server names, and a longer wait is applied when it is over ([moho `0dfff0d`](https://github.com/Moho-Chat/moho/commit/0dfff0d), [nobilis `43135c1`](https://github.com/Moho-Chat/nobilis/commit/43135c1))
|
||||
- Fixed the window being pulled back to the open conversation's server whenever you chose another: the daemon link coming up is acted on once, a refresh no longer follows the open conversation, and the tray is told only what changed ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a))
|
||||
- Matrix's invisible is called Offline in the plaque, its popout and the tray, which is what it sets ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a))
|
||||
- A room muted on a Matrix account can now be unmuted from the menu, including a mute set from Element ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a), [nobilis `59db82c`](https://github.com/Moho-Chat/nobilis/commit/59db82c))
|
||||
- Server rail tiles now count the mentions waiting in them rather than the messages, and a muted room counts for nothing, mentions included ([moho `0dc048c`](https://github.com/Moho-Chat/moho/commit/0dc048c))
|
||||
- Fixed a room unmuted on a Matrix account still being drawn as muted; a mute or unmute the server did not take is now reported with its reason, and Matrix requests to a server that does not answer give up ([moho `95f1784`](https://github.com/Moho-Chat/moho/commit/95f1784), [nobilis `d1bbcaf`](https://github.com/Moho-Chat/nobilis/commit/d1bbcaf))
|
||||
- Discord requests now give up on a connection that has gone quiet, a history catch-up always releases its place, and the gateway logs what it receives and why a message is dropped ([moho `b151ef8`](https://github.com/Moho-Chat/moho/commit/b151ef8), [nobilis `a34821e`](https://github.com/Moho-Chat/nobilis/commit/a34821e))
|
||||
- Fixed Discord reconnecting every few seconds after being told it is rate limited: it now waits out the minute Discord asks for ([nobilis `59db82c`](https://github.com/Moho-Chat/nobilis/commit/59db82c))
|
||||
- Fixed Sneedchat pictures posted back to back by a script merging into one unusable link ([moho `6edae7a`](https://github.com/Moho-Chat/moho/commit/6edae7a))
|
||||
- Discord files staged together now go as one message, with an upload ring that fills as it uploads ([moho `9888c80`](https://github.com/Moho-Chat/moho/commit/9888c80), [nobilis `9690905`](https://github.com/Moho-Chat/nobilis/commit/9690905))
|
||||
- Fixed the release mirror script printing a traceback while the release did not exist yet ([moho `a8d9f41`](https://github.com/Moho-Chat/moho/commit/a8d9f41))
|
||||
- Defined the design tokens the stylesheet used but never had, and applied one set across it ([moho `9db25c8`](https://github.com/Moho-Chat/moho/commit/9db25c8), [moho `3cdfe37`](https://github.com/Moho-Chat/moho/commit/3cdfe37))
|
||||
- Added a screenshot harness for before-and-after pictures of the interface ([moho `88375ba`](https://github.com/Moho-Chat/moho/commit/88375ba))
|
||||
- Unread channels now read as unread, mentions are red, and every badge is one style ([moho `4ca37dd`](https://github.com/Moho-Chat/moho/commit/4ca37dd))
|
||||
- Added date separators to the message list ([moho `b2464e4`](https://github.com/Moho-Chat/moho/commit/b2464e4))
|
||||
- Added Mark as read on channels, categories and servers, and Escape to read what you are in ([moho `2745a2f`](https://github.com/Moho-Chat/moho/commit/2745a2f))
|
||||
- Added a Ctrl+K quick switcher and keyboard navigation between channels ([moho `1eef37e`](https://github.com/Moho-Chat/moho/commit/1eef37e))
|
||||
- Voice panel: a horizontal microphone bar, the connection's real latency, and a share button ([moho `ad0f0c0`](https://github.com/Moho-Chat/moho/commit/ad0f0c0), [nobilis `e0f6169`](https://github.com/Moho-Chat/nobilis/commit/e0f6169))
|
||||
- Replies jump to the message they answer, and names and pictures open the profile ([moho `bf45147`](https://github.com/Moho-Chat/moho/commit/bf45147))
|
||||
- Added Copy and Copy text on every message, and Copy message link on Discord ([moho `0033955`](https://github.com/Moho-Chat/moho/commit/0033955))
|
||||
- The hover toolbar fades in, can be reached by keyboard, and offers Edit, Copy and your recent reactions ([moho `57e1856`](https://github.com/Moho-Chat/moho/commit/57e1856))
|
||||
- Fixed six interface bugs: a nested voice button, folded categories hiding unread, muted channels hiding mentions, overlapping corners, the typing strip moving the log, and pinned rows missing their service mark ([moho `6598043`](https://github.com/Moho-Chat/moho/commit/6598043))
|
||||
- Pending sends dim only their text, failed ones can be deleted, and Up in an empty box edits your last message in a multi-line editor ([moho `6a82c32`](https://github.com/Moho-Chat/moho/commit/6a82c32))
|
||||
- Server rail: mention counts, folders that say what is waiting and stay open, an instant name flyout and a red close button ([moho `8072b24`](https://github.com/Moho-Chat/moho/commit/8072b24))
|
||||
- Member list: faces, presence dots, and a click for the profile; Discord's member pictures now come with the list ([moho `c912ed3`](https://github.com/Moho-Chat/moho/commit/c912ed3), [nobilis `4099305`](https://github.com/Moho-Chat/nobilis/commit/4099305))
|
||||
- Voice channel list: faces, muted and deafened marks, a speaking ring and a collapsible heading ([moho `843288d`](https://github.com/Moho-Chat/moho/commit/843288d))
|
||||
- Comfy messages in Discord's shape, amber mention rows, one code and quote style, and marked system lines ([moho `3a7677e`](https://github.com/Moho-Chat/moho/commit/3a7677e))
|
||||
- Buttons are primary, subtle or the one danger style, and the restarts ask first ([moho `2c8609c`](https://github.com/Moho-Chat/moho/commit/2c8609c))
|
||||
- Escape closes only the top layer, and full-page panels close on Escape ([moho `c5e7619`](https://github.com/Moho-Chat/moho/commit/c5e7619))
|
||||
- Thread panel reads the display settings, groups replies and follows the newest ([moho `33129f9`](https://github.com/Moho-Chat/moho/commit/33129f9))
|
||||
- Toasts slide in and out, come in three kinds, pause on hover and fold repeats ([moho `2d1b8be`](https://github.com/Moho-Chat/moho/commit/2d1b8be))
|
||||
- Context menu: keyboard navigation, checked items, shortcut hints, and placement that holds while it animates ([moho `4065963`](https://github.com/Moho-Chat/moho/commit/4065963), [moho `fcb7df5`](https://github.com/Moho-Chat/moho/commit/fcb7df5))
|
||||
- A conversation opens at its first unread, with a bar saying how many are above ([moho `55fd192`](https://github.com/Moho-Chat/moho/commit/55fd192))
|
||||
- Added hover fades, turning fold arrows, arriving messages, a reaction pop and growing overlays ([moho `6d8a050`](https://github.com/Moho-Chat/moho/commit/6d8a050))
|
||||
- Every dialog is built from one shared frame with a close button, kept focus and a scrim below the title bar ([moho `5cb30ce`](https://github.com/Moho-Chat/moho/commit/5cb30ce), [moho `420a2f7`](https://github.com/Moho-Chat/moho/commit/420a2f7), [moho `6a329c6`](https://github.com/Moho-Chat/moho/commit/6a329c6))
|
||||
- Shift+Enter makes a new line on every service and pasted line breaks stay; IRC sends them as a batch or separate lines, Sneedchat as [br], Kick as one line ([moho `9ba630c`](https://github.com/Moho-Chat/moho/commit/9ba630c), [nobilis `ccc8de6`](https://github.com/Moho-Chat/nobilis/commit/ccc8de6))
|
||||
- Reply bar: its own surface, the author's face, Esc to cancel, and an @ switch to answer without pinging on Discord ([moho `f48185e`](https://github.com/Moho-Chat/moho/commit/f48185e), [nobilis `918fcfa`](https://github.com/Moho-Chat/nobilis/commit/918fcfa))
|
||||
- One style for the name, command and emoji lists, with member pictures and :shortcode emoji completion ([moho `28d8b3c`](https://github.com/Moho-Chat/moho/commit/28d8b3c))
|
||||
- Lightbox: step through a conversation's pictures, wheel and pinch zoom with panning, and Copy picture ([moho `817cb1e`](https://github.com/Moho-Chat/moho/commit/817cb1e))
|
||||
- Emoji picker: walk it with the arrow keys, Enter picks the top result, and skin tones are remembered ([moho `99fd0d3`](https://github.com/Moho-Chat/moho/commit/99fd0d3))
|
||||
- Attachment tray cards with name, size and a Discord spoiler toggle; dropped files go into the tray instead of being sent at once ([moho `15e7061`](https://github.com/Moho-Chat/moho/commit/15e7061), [nobilis `0f5669b`](https://github.com/Moho-Chat/nobilis/commit/0f5669b))
|
||||
- Composer: one box with the + on the left and emoji, stickers, voice and Send on the right ([moho `a59c1ab`](https://github.com/Moho-Chat/moho/commit/a59c1ab))
|
||||
- Several pictures in one message lay out as a grid, and SPOILER_ files are blurred until clicked ([moho `87ea3b8`](https://github.com/Moho-Chat/moho/commit/87ea3b8))
|
||||
- Pinned messages show as a banner over the log for Discord and Matrix, with a count on the header ([moho `3a74cf0`](https://github.com/Moho-Chat/moho/commit/3a74cf0))
|
||||
- Reactions say what they are and who gave them on hover ([moho `506140d`](https://github.com/Moho-Chat/moho/commit/506140d), [nobilis `1da3ad8`](https://github.com/Moho-Chat/nobilis/commit/1da3ad8))
|
||||
- User footer: muted and deafened are red, voice buttons stay on every service, and the status is a popout with a custom status for Discord and Matrix and Apply to all accounts ([moho `c026609`](https://github.com/Moho-Chat/moho/commit/c026609), [nobilis `f280563`](https://github.com/Moho-Chat/nobilis/commit/f280563))
|
||||
- The footer's join button is gone; the rail's + is the one way in, pinned under a rule above the settings cog while the server list scrolls ([moho `b4c1ced`](https://github.com/Moho-Chat/moho/commit/b4c1ced), [moho `dad5543`](https://github.com/Moho-Chat/moho/commit/dad5543))
|
||||
- Header: one 48px row across every column, filled toggles, and the channel topic from IRC, Matrix and Discord ([moho `2ef9e35`](https://github.com/Moho-Chat/moho/commit/2ef9e35), [nobilis `44d1611`](https://github.com/Moho-Chat/nobilis/commit/44d1611))
|
||||
- "(edited)" says when on hover, for edits seen live ([moho `7f4c356`](https://github.com/Moho-Chat/moho/commit/7f4c356), [nobilis `e51f139`](https://github.com/Moho-Chat/nobilis/commit/e51f139))
|
||||
- Incoming Discord and Matrix calls share one card style and stack ([moho `59d4893`](https://github.com/Moho-Chat/moho/commit/59d4893))
|
||||
- Settings: the gear opens it directly, with Accounts and Downloads as pages, a search, one switch for every boolean and a saved tick ([moho `66a0c0f`](https://github.com/Moho-Chat/moho/commit/66a0c0f), [moho `cc4c9f6`](https://github.com/Moho-Chat/moho/commit/cc4c9f6))
|
||||
- Settings: added Appearance, Notifications and Keybinds pages, the last with a recorder for the global hotkey ([moho `fe6ad97`](https://github.com/Moho-Chat/moho/commit/fe6ad97), [moho `adca84d`](https://github.com/Moho-Chat/moho/commit/adca84d), [moho `82a0bdf`](https://github.com/Moho-Chat/moho/commit/82a0bdf))
|
||||
- Accounts open by default with few accounts, Remove sits in a danger zone, and Join picks its account and says Join ([moho `f8dcccd`](https://github.com/Moho-Chat/moho/commit/f8dcccd))
|
||||
- Mentions page: service and unread filters, day groups, two-line bodies and put-away ([moho `078fe41`](https://github.com/Moho-Chat/moho/commit/078fe41))
|
||||
- Room settings is a tabbed dialog instead of a popover ([moho `f8936c6`](https://github.com/Moho-Chat/moho/commit/f8936c6))
|
||||
- Quiet buttons such as Cancel have their own fill, so they no longer vanish into a dialog ([moho `50aaa97`](https://github.com/Moho-Chat/moho/commit/50aaa97))
|
||||
- Fixed browsing an IRC network's channels freezing or blanking the window: the daemon holds the list and sends a page, it times out after a minute, and a refusal is reported ([moho `2b41c5e`](https://github.com/Moho-Chat/moho/commit/2b41c5e), [nobilis `152fdf1`](https://github.com/Moho-Chat/nobilis/commit/152fdf1))
|
||||
- Channels in the list no longer jump to the top as messages arrive ([moho `9f03d6b`](https://github.com/Moho-Chat/moho/commit/9f03d6b))
|
||||
- Fixed Discord's notices showing only a stand-in title: they are read as cards, and a banner replaces the message box in them and in channels this account cannot write in ([moho `ff7d313`](https://github.com/Moho-Chat/moho/commit/ff7d313), [moho `d7c4215`](https://github.com/Moho-Chat/moho/commit/d7c4215), [moho `2a4f797`](https://github.com/Moho-Chat/moho/commit/2a4f797), [nobilis `1b270be`](https://github.com/Moho-Chat/nobilis/commit/1b270be), [nobilis `97ac6bc`](https://github.com/Moho-Chat/nobilis/commit/97ac6bc), [nobilis `7561b88`](https://github.com/Moho-Chat/nobilis/commit/7561b88))
|
||||
- A window whose page dies is reloaded and the reason logged, instead of being left empty ([moho `6899694`](https://github.com/Moho-Chat/moho/commit/6899694), [moho `06bb812`](https://github.com/Moho-Chat/moho/commit/06bb812))
|
||||
- Discord forums, with live and archived posts read together ([moho `dd0c25e`](https://github.com/Moho-Chat/moho/commit/dd0c25e), [nobilis `d02bba0`](https://github.com/Moho-Chat/nobilis/commit/d02bba0), [nobilis `8363003`](https://github.com/Moho-Chat/nobilis/commit/8363003), [nobilis `3cf1a5b`](https://github.com/Moho-Chat/nobilis/commit/3cf1a5b), [nobilis `6abcb8e`](https://github.com/Moho-Chat/nobilis/commit/6abcb8e))
|
||||
- Added Rust crate tokio-util 0.7.19 ([nobilis `9690905`](https://github.com/Moho-Chat/nobilis/commit/9690905))
|
||||
- Channel rows in the sidebar now show a single `#` instead of two ([moho `ccdf090`](https://github.com/Moho-Chat/moho/commit/ccdf090))
|
||||
- Typing anywhere in the window now goes into the message box, without clicking it first ([moho `b5f920f`](https://github.com/Moho-Chat/moho/commit/b5f920f))
|
||||
- Added a formatting strip over selected text in the message box, offering the formats each service can carry ([moho `9ca477c`](https://github.com/Moho-Chat/moho/commit/9ca477c))
|
||||
@@ -108,3 +229,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,7 +1,7 @@
|
||||
pkgname=moho-git
|
||||
pkgver=r1.268751e
|
||||
pkgrel=1
|
||||
pkgdesc="Unified desktop chat client for IRC, Discord, Sneedchat and Matrix"
|
||||
pkgdesc="Unified desktop chat client for IRC, Discord, Matrix, Kick, Sneedchat and Steam"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/Moho-Chat/moho"
|
||||
license=('GPL3')
|
||||
|
||||
@@ -1,45 +1,43 @@
|
||||
# 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, Sneedchat, Steam 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, Sneedchat, Steam 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.** Voice, camera and screen sharing on Discord and on Matrix, voice rooms and calls on Steam, including the group
|
||||
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.
|
||||
|
||||
## Supported protocols
|
||||
|
||||
| Protocol | State | What you get |
|
||||
| --- | --- | --- |
|
||||
| **IRC** | Supported | Channels and queries on any number of networks, with SASL, TLS, DCC file transfers and the IRCv3 capabilities servers offer |
|
||||
| **Matrix** | Supported | Rooms, spaces and direct messages, end-to-end encryption with device verification and key backup, Element Call, and signing in by QR code |
|
||||
| **Discord** | Supported | Servers, direct messages, forums and threads, voice, video, screen sharing and Go Live with end-to-end encryption, stages and the soundboard |
|
||||
| **Kick** | Supported | A streamer's chat beside the stream, with polls, predictions and its events drawn as cards |
|
||||
| **Sneedchat** | Supported | The forum's chat rooms, over clearnet or Tor |
|
||||
| **Steam** | Supported, fully ported | Friends and direct messages, group chats with rooms, roles and invitations, voice rooms and calls, emoticons, stickers, reactions, pictures and files, friend folders, and signing in by password or by QR code |
|
||||
|
||||
Every protocol gets the same window, mentions inbox, search and per-account Tor or SOCKS5 routing.
|
||||
How much of each service's own feature set a protocol has is as far as its service lets a
|
||||
third-party client go; more protocols are planned.
|
||||
|
||||
## Get it
|
||||
|
||||
Prebuilt Linux and Windows downloads are on the
|
||||
@@ -47,10 +45,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 +79,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 +106,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.2",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "moho",
|
||||
"version": "1.0.0-rc.2",
|
||||
"version": "1.1.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.2",
|
||||
"version": "1.1.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,12 +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"
|
||||
},
|
||||
@@ -73,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)
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* What the scripts that drive the real app share: a small fake IRC server to
|
||||
* give it something to show, the app launched against a throwaway home, and a
|
||||
* line to its window over the DevTools protocol.
|
||||
*
|
||||
* The app never touches a real account or the daemon of a moho already
|
||||
* running: HOME, the XDG directories and - because the daemon's socket lives
|
||||
* there - XDG_RUNTIME_DIR are all its own.
|
||||
*/
|
||||
|
||||
import { spawn, execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import net from 'node:net'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import electronPath from 'electron'
|
||||
|
||||
export const root = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))))
|
||||
|
||||
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
// --- a fake IRC server ---------------------------------------------------
|
||||
|
||||
/**
|
||||
* A server that registers anybody and, when they join a channel, says
|
||||
* whatever `onJoin` says there.
|
||||
*
|
||||
* `caps` are offered and granted whole. `server-time` among them lets a line
|
||||
* carry when it was said (`at(date)` builds the tag), which is how history
|
||||
* from other days reaches the client.
|
||||
*/
|
||||
export function fakeIrc({ people = [], caps = [], onJoin, onLine } = {}) {
|
||||
const server = net.createServer((socket) => {
|
||||
let nick = 'checker'
|
||||
let negotiating = false
|
||||
let registered = false
|
||||
const send = (line) => socket.write(line + '\r\n')
|
||||
const welcome = () => {
|
||||
if (registered) return
|
||||
registered = true
|
||||
send(`:fake 001 ${nick} :Welcome`)
|
||||
send(`:fake 376 ${nick} :End of MOTD`)
|
||||
}
|
||||
let buffered = ''
|
||||
socket.on('error', () => {})
|
||||
socket.on('data', (chunk) => {
|
||||
buffered += chunk.toString()
|
||||
let at
|
||||
while ((at = buffered.indexOf('\n')) >= 0) {
|
||||
const line = buffered.slice(0, at).replace(/\r$/, '')
|
||||
buffered = buffered.slice(at + 1)
|
||||
onLine?.(line, send)
|
||||
const [command, ...args] = line.split(' ')
|
||||
switch (command.toUpperCase()) {
|
||||
case 'CAP':
|
||||
if (args[0] === 'LS') {
|
||||
negotiating = true
|
||||
send(`:fake CAP * LS :${caps.join(' ')}`)
|
||||
} else if (args[0] === 'REQ') {
|
||||
send(`:fake CAP * ACK ${args.slice(1).join(' ')}`)
|
||||
} else if (args[0] === 'END') {
|
||||
negotiating = false
|
||||
welcome()
|
||||
}
|
||||
break
|
||||
case 'NICK':
|
||||
nick = args[0]
|
||||
break
|
||||
case 'USER':
|
||||
if (!negotiating) welcome()
|
||||
break
|
||||
case 'PING':
|
||||
send(`:fake PONG fake ${args.join(' ')}`)
|
||||
break
|
||||
case 'JOIN':
|
||||
for (const channel of args[0].split(',')) {
|
||||
send(`:${nick}!u@h JOIN ${channel}`)
|
||||
send(`:fake 353 ${nick} = ${channel} :${[nick, ...people].join(' ')}`)
|
||||
send(`:fake 366 ${nick} ${channel} :End of NAMES`)
|
||||
onJoin?.(channel, nick, send)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server)))
|
||||
}
|
||||
|
||||
/** The server-time tag for a moment. */
|
||||
export const at = (date) => `@time=${date.toISOString()} `
|
||||
|
||||
// --- the app, and a line to it --------------------------------------------
|
||||
|
||||
export async function freePort() {
|
||||
const probe = net.createServer()
|
||||
await new Promise((r) => probe.listen(0, '127.0.0.1', r))
|
||||
const { port } = probe.address()
|
||||
await new Promise((r) => probe.close(r))
|
||||
return port
|
||||
}
|
||||
|
||||
async function pageTarget(port) {
|
||||
for (let i = 0; i < 120; i++) {
|
||||
try {
|
||||
const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
|
||||
const page = list.find((t) => t.type === 'page' && !t.url.startsWith('devtools:'))
|
||||
if (page) return page
|
||||
} catch {
|
||||
/* not up yet */
|
||||
}
|
||||
await sleep(500)
|
||||
}
|
||||
throw new Error('the app never opened a window')
|
||||
}
|
||||
|
||||
export function cdp(url) {
|
||||
const socket = new WebSocket(url)
|
||||
let id = 0
|
||||
const waiting = new Map()
|
||||
const listeners = new Map()
|
||||
socket.addEventListener('message', (e) => {
|
||||
const msg = JSON.parse(e.data)
|
||||
if (msg.id && waiting.has(msg.id)) {
|
||||
const { resolve, reject } = waiting.get(msg.id)
|
||||
waiting.delete(msg.id)
|
||||
if (msg.error) reject(new Error(msg.error.message))
|
||||
else resolve(msg.result)
|
||||
} else if (msg.method) {
|
||||
for (const fn of listeners.get(msg.method) ?? []) fn(msg.params)
|
||||
}
|
||||
})
|
||||
const ready = new Promise((r) => socket.addEventListener('open', r))
|
||||
const call = async (method, params = {}) => {
|
||||
await ready
|
||||
const n = ++id
|
||||
socket.send(JSON.stringify({ id: n, method, params }))
|
||||
return new Promise((resolve, reject) => waiting.set(n, { resolve, reject }))
|
||||
}
|
||||
const evaluate = async (expression) => {
|
||||
const r = await call('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text)
|
||||
return r.result.value
|
||||
}
|
||||
const on = (method, fn) => {
|
||||
if (!listeners.has(method)) listeners.set(method, new Set())
|
||||
listeners.get(method).add(fn)
|
||||
return () => listeners.get(method).delete(fn)
|
||||
}
|
||||
return { call, evaluate, on, close: () => socket.close() }
|
||||
}
|
||||
|
||||
/** Waits until an expression in the page is truthy. */
|
||||
export async function until(page, expression, what, ms = 30000) {
|
||||
const end = Date.now() + ms
|
||||
while (Date.now() < end) {
|
||||
if (await page.evaluate(`!!(${expression})`).catch(() => false)) return
|
||||
await sleep(250)
|
||||
}
|
||||
throw new Error(`timed out waiting for ${what}`)
|
||||
}
|
||||
|
||||
/** Clicks the first element matching `selector` whose text or title holds `text`. */
|
||||
export const click = (selector, text) => `(() => {
|
||||
const el = [...document.querySelectorAll(${JSON.stringify(selector)})]
|
||||
.find((e) => ${JSON.stringify(text ?? '')} === '' || (e.innerText + ' ' + (e.title || '')).includes(${JSON.stringify(text ?? '')}))
|
||||
if (!el) return false
|
||||
el.click()
|
||||
return true
|
||||
})()`
|
||||
|
||||
/**
|
||||
* On Hyprland, out of the way: a window the harness opens should not land on
|
||||
* top of what the person running it is doing. Anywhere else, left alone.
|
||||
*/
|
||||
function moveAside(pid) {
|
||||
if (!process.env.HYPRLAND_INSTANCE_SIGNATURE) return
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j'], { encoding: 'utf8' }))
|
||||
const descendants = new Set([pid])
|
||||
// The window belongs to Electron's browser process, a child of the one
|
||||
// spawned; walking /proc is enough to find it.
|
||||
for (const entry of fs.readdirSync('/proc')) {
|
||||
if (!/^\d+$/.test(entry)) continue
|
||||
try {
|
||||
const stat = fs.readFileSync(`/proc/${entry}/stat`, 'utf8')
|
||||
const ppid = Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1])
|
||||
if (descendants.has(ppid)) descendants.add(Number(entry))
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
}
|
||||
for (const c of clients) {
|
||||
if (!descendants.has(c.pid)) continue
|
||||
execFileSync('hyprctl', [
|
||||
'dispatch',
|
||||
`hl.dsp.window.move({ window = "address:${c.address}", workspace = "${process.env.MOHO_HARNESS_WORKSPACE ?? '2'}", silent = true })`
|
||||
])
|
||||
}
|
||||
} catch {
|
||||
/* not this compositor's dialect; leave it */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = {}, args = [], seed } = {}) {
|
||||
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'moho-ui-'))
|
||||
const run = path.join(scratch, 'run')
|
||||
fs.mkdirSync(run, { mode: 0o700 })
|
||||
// Files the app should find on its first start, written into its own home.
|
||||
seed?.(scratch)
|
||||
const port = await freePort()
|
||||
|
||||
// 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'] : []), ...args]
|
||||
const app = spawn(electronPath, [root, ...flags], {
|
||||
cwd: root,
|
||||
detached: true,
|
||||
stdio: process.env.MOHO_UI_VERBOSE ? 'inherit' : 'ignore',
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
HOME: scratch,
|
||||
XDG_CONFIG_HOME: path.join(scratch, 'config'),
|
||||
XDG_CACHE_HOME: path.join(scratch, 'cache'),
|
||||
XDG_DATA_HOME: path.join(scratch, 'data'),
|
||||
XDG_STATE_HOME: path.join(scratch, 'state'),
|
||||
XDG_RUNTIME_DIR: run,
|
||||
// The runtime directory is moved so the daemon's socket is this run's
|
||||
// own - which also moves where a Wayland client looks for the
|
||||
// compositor. An absolute path keeps it pointing at the real one.
|
||||
...(process.env.WAYLAND_DISPLAY && process.env.XDG_RUNTIME_DIR && !path.isAbsolute(process.env.WAYLAND_DISPLAY)
|
||||
? { WAYLAND_DISPLAY: path.join(process.env.XDG_RUNTIME_DIR, process.env.WAYLAND_DISPLAY) }
|
||||
: {})
|
||||
}
|
||||
})
|
||||
|
||||
const stop = async () => {
|
||||
page?.close()
|
||||
try {
|
||||
process.kill(-app.pid, 'SIGTERM')
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
await sleep(1500)
|
||||
try {
|
||||
process.kill(-app.pid, 'SIGKILL')
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
// Whatever a crash left behind that is still pointed at this run's
|
||||
// directory: Chromium's helpers can outlive the group they were started
|
||||
// in, and a harness that leaves windows and daemons running is worse than
|
||||
// one that fails.
|
||||
for (const entry of fs.readdirSync('/proc')) {
|
||||
if (!/^\d+$/.test(entry)) continue
|
||||
try {
|
||||
if (Number(entry) === process.pid) continue
|
||||
const cmd = fs.readFileSync(`/proc/${entry}/cmdline`, 'utf8')
|
||||
// The daemon is started by the app with only the environment to say
|
||||
// whose it is.
|
||||
const env = cmd.includes(scratch) ? '' : fs.readFileSync(`/proc/${entry}/environ`, 'utf8')
|
||||
if (cmd.includes(scratch) || env.includes(`XDG_RUNTIME_DIR=${scratch}`)) process.kill(Number(entry), 'SIGKILL')
|
||||
} catch {
|
||||
/* gone, or not ours to read */
|
||||
}
|
||||
}
|
||||
fs.rmSync(scratch, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
let page
|
||||
try {
|
||||
page = cdp((await pageTarget(port)).webSocketDebuggerUrl)
|
||||
moveAside(app.pid)
|
||||
await until(page, 'window.moho && document.querySelector("#root > *")', 'the window to load')
|
||||
} catch (e) {
|
||||
await stop()
|
||||
throw e
|
||||
}
|
||||
return { page, stop, port, scratch }
|
||||
}
|
||||
|
||||
/**
|
||||
* An IRC account on the fake server, its channels joined, and the window
|
||||
* showing the first of them.
|
||||
*/
|
||||
export async function openIrcChannel(page, { port, nick, channels, waitFor }) {
|
||||
await until(page, 'window.moho.rpc("listAccounts", {}).then(() => true)', 'the daemon')
|
||||
await page.evaluate(
|
||||
`window.moho.rpc("addAccount", { nick: ${JSON.stringify(nick)}, host: "localhost", port: ${port}, ssl: false, autojoin: ${JSON.stringify(channels.join(','))} })`
|
||||
)
|
||||
await until(
|
||||
page,
|
||||
`window.moho.rpc("listBuffers", {}).then((b) => ${JSON.stringify(channels)}.every((c) => b.some((x) => x.id.endsWith("|" + c))))`,
|
||||
'the channels'
|
||||
)
|
||||
await page.call('Page.reload')
|
||||
await sleep(1000)
|
||||
await until(page, 'document.querySelector("nav[aria-label=Servers] [aria-label*=\\"localhost\\"]")', 'the account in the rail')
|
||||
await page.evaluate(click('nav[aria-label=Servers] [aria-label*="localhost"]'))
|
||||
await until(page, `[...document.querySelectorAll(".buffer-row")].some((r) => (r.title || r.innerText).includes(${JSON.stringify(channels[0])}))`, 'the channel row')
|
||||
await page.evaluate(click('.buffer-row', channels[0]))
|
||||
await until(page, `[...document.querySelectorAll(".message-row")].some((r) => r.innerText.includes(${JSON.stringify(waitFor)}))`, 'the messages')
|
||||
}
|
||||
@@ -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"])')"; 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,93 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* A one-to-one Steam call with somebody on the official client: a scratch app signed in as
|
||||
* the throwaway account (never the real one) with a fake microphone joins the group's voice room
|
||||
* and reports what it hears every ten seconds, until ~/.local/share/moho-dev/stop exists.
|
||||
*
|
||||
* MODE=call node scripts/probe-steam-call.mjs (or MODE=wait, which answers the first ring)
|
||||
*
|
||||
* npx electron-vite build && node scripts/probe-steam-voice.mjs
|
||||
*
|
||||
* Needs the throwaway account's block in ~/.config/nobilis/accounts.toml. The app is the
|
||||
* one built in out/, with its own home and runtime directory, and signs the account in
|
||||
* for as long as this runs - so nothing else may be signed in as it.
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const live = fs.readFileSync(path.join(os.homedir(), '.config/nobilis/accounts.toml'), 'utf8')
|
||||
const block = live.split(/\n(?=\[)/).find((b) => b.startsWith('[[steam_account]]') && /username\s*=\s*"mohotester"/.test(b))
|
||||
if (!block) throw new Error('the throwaway account is not in accounts.toml')
|
||||
|
||||
const { page, stop, scratch } = await launchApp({
|
||||
env: { MOHO_UI_SHOTS: '1' },
|
||||
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', `--use-file-for-fake-audio-capture=${os.homedir()}/.local/share/moho-dev/speech.wav`],
|
||||
seed: (home) => {
|
||||
fs.mkdirSync(path.join(home, 'config/nobilis'), { recursive: true })
|
||||
fs.writeFileSync(path.join(home, 'config/nobilis/accounts.toml'), block + '\n')
|
||||
}
|
||||
})
|
||||
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
|
||||
const ev = (code) => page.evaluate(code)
|
||||
try {
|
||||
// The window onto workspace 2, where the user does not look.
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
let account = ''
|
||||
for (let i = 0; i < 90 && !account; i++) {
|
||||
account = await ev(`(() => { const a = window.__mohoShots.state().accounts.find((a) => a.id.startsWith('steam:') && a.state === 'connected'); return a ? a.id : '' })()`)
|
||||
if (!account) await sleep(1000)
|
||||
}
|
||||
say('account', account || 'NOT CONNECTED')
|
||||
if (!account) throw new Error('not connected')
|
||||
const rpc = (m, p) => page.evaluate(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
|
||||
page.on('console', (m) => say('console', m.type(), m.text().slice(0, 300)))
|
||||
const mode = process.env.MODE || 'call'
|
||||
const stopFile = path.join(os.homedir(), '.local/share/moho-dev/stop')
|
||||
const state = (expr) => ev(`(() => { const s = window.__mohoShots.state(); return ${expr} })()`)
|
||||
if (mode === 'call') {
|
||||
let buffer = ''
|
||||
for (let i = 0; i < 60 && !buffer; i++) {
|
||||
buffer = await state(`(s.buffers.find((b) => b.accountId === ${JSON.stringify(account)} && b.kind === 'dm' && b.name === 'Salastil') || {}).id || ''`)
|
||||
if (!buffer) await sleep(1000)
|
||||
}
|
||||
say('calling', buffer)
|
||||
await ev(`window.__mohoShots.store.callBuffer(${JSON.stringify(buffer)})`)
|
||||
} else {
|
||||
say('waiting for a ring')
|
||||
}
|
||||
let answered = false
|
||||
let phase = ''
|
||||
for (let t = 0; t < 1200 && !fs.existsSync(stopFile); t += 3) {
|
||||
if (mode === 'wait' && !answered) {
|
||||
const ring = await state(`s.steamRings && s.steamRings.length ? JSON.stringify(s.steamRings[0]) : ''`)
|
||||
if (ring) {
|
||||
say('ringing', ring)
|
||||
await ev(`window.__mohoShots.store.answerSteamCall(window.__mohoShots.state().steamRings[0])`)
|
||||
answered = true
|
||||
}
|
||||
}
|
||||
const now = await state(`JSON.stringify({ phase: s.steamVoiceNow ? s.steamVoiceNow.phase : 'idle', toasts: s.toasts.map((t) => t.message || t.text || JSON.stringify(t)) })`)
|
||||
if (now !== phase) { phase = now; say('state', now) }
|
||||
const report = await ev(`(async () => {
|
||||
const v = window.__mohoShots.store.steamVoiceLive
|
||||
if (!v || !v.pc) return { none: true }
|
||||
const stats = []
|
||||
const rep = await v.pc.getStats()
|
||||
rep.forEach((s) => { if (['outbound-rtp', 'inbound-rtp', 'media-source'].includes(s.type)) stats.push({ type: s.type, packetsSent: s.packetsSent, packetsReceived: s.packetsReceived, audioLevel: s.audioLevel }) })
|
||||
return { phase: v.phase, rtt: v.rttMs, ice: v.pc.iceConnectionState, stats }
|
||||
})().catch((e) => ({ error: String(e) }))`)
|
||||
say('report', JSON.stringify(report))
|
||||
await sleep(3000)
|
||||
}
|
||||
await ev(`window.__mohoShots.store.leaveVoice(${JSON.stringify(account)})`)
|
||||
await sleep(2000)
|
||||
say('left the call')
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The groups Steam says an account is in, after a fresh sign-in: a scratch app signed in as
|
||||
* the throwaway account (never the real one) with a fake microphone joins the group's voice room
|
||||
* and reports what it hears every ten seconds, until ~/.local/share/moho-dev/stop exists.
|
||||
*
|
||||
* NAME='Biggest Newest Moho Test' WAY=rail node scripts/probe-steam-leave.mjs (WAY=rail or settings)
|
||||
*
|
||||
* npx electron-vite build && node scripts/probe-steam-voice.mjs
|
||||
*
|
||||
* Needs the throwaway account's block in ~/.config/nobilis/accounts.toml. The app is the
|
||||
* one built in out/, with its own home and runtime directory, and signs the account in
|
||||
* for as long as this runs - so nothing else may be signed in as it.
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const live = fs.readFileSync(path.join(os.homedir(), '.config/nobilis/accounts.toml'), 'utf8')
|
||||
const block = live.split(/\n(?=\[)/).find((b) => b.startsWith('[[steam_account]]') && /username\s*=\s*"mohotester"/.test(b))
|
||||
if (!block) throw new Error('the throwaway account is not in accounts.toml')
|
||||
|
||||
const { page, stop, scratch } = await launchApp({
|
||||
env: { MOHO_UI_SHOTS: '1' },
|
||||
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', `--use-file-for-fake-audio-capture=${os.homedir()}/.local/share/moho-dev/speech.wav`],
|
||||
seed: (home) => {
|
||||
fs.mkdirSync(path.join(home, 'config/nobilis'), { recursive: true })
|
||||
fs.writeFileSync(path.join(home, 'config/nobilis/accounts.toml'), block + '\n')
|
||||
}
|
||||
})
|
||||
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
|
||||
const ev = (code) => page.evaluate(code)
|
||||
try {
|
||||
// The window onto workspace 2, where the user does not look.
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
let account = ''
|
||||
for (let i = 0; i < 90 && !account; i++) {
|
||||
account = await ev(`(() => { const a = window.__mohoShots.state().accounts.find((a) => a.id.startsWith('steam:') && a.state === 'connected'); return a ? a.id : '' })()`)
|
||||
if (!account) await sleep(1000)
|
||||
}
|
||||
say('account', account || 'NOT CONNECTED')
|
||||
if (!account) throw new Error('not connected')
|
||||
const rpc = (m, p) => page.evaluate(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
|
||||
page.on('console', (m) => say('console', m.type(), m.text().slice(0, 300)))
|
||||
await sleep(5000)
|
||||
say('window groups', await ev(`JSON.stringify(window.__mohoShots.state().groups.filter((g) => g.accountId === ${JSON.stringify(account)}).map((g) => g.name))`))
|
||||
say('daemon groups', JSON.stringify(((await rpc('steamChatGroups', { accountId: account })) ?? []).map((g) => g.name)))
|
||||
say('steam channels', await ev(`JSON.stringify(window.__mohoShots.state().buffers.filter((b) => b.accountId === ${JSON.stringify(account)} && b.kind === 'channel').map((b) => b.name))`))
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Leaving a Steam group through the window, the way a person does: a scratch app signed in as
|
||||
* the throwaway account (never the real one) with a fake microphone joins the group's voice room
|
||||
* and reports what it hears every ten seconds, until ~/.local/share/moho-dev/stop exists.
|
||||
*
|
||||
* NAME='Biggest Newest Moho Test' WAY=rail node scripts/probe-steam-leave.mjs (WAY=rail or settings)
|
||||
*
|
||||
* npx electron-vite build && node scripts/probe-steam-voice.mjs
|
||||
*
|
||||
* Needs the throwaway account's block in ~/.config/nobilis/accounts.toml. The app is the
|
||||
* one built in out/, with its own home and runtime directory, and signs the account in
|
||||
* for as long as this runs - so nothing else may be signed in as it.
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const live = fs.readFileSync(path.join(os.homedir(), '.config/nobilis/accounts.toml'), 'utf8')
|
||||
const block = live.split(/\n(?=\[)/).find((b) => b.startsWith('[[steam_account]]') && /username\s*=\s*"mohotester"/.test(b))
|
||||
if (!block) throw new Error('the throwaway account is not in accounts.toml')
|
||||
|
||||
const { page, stop, scratch } = await launchApp({
|
||||
env: { MOHO_UI_SHOTS: '1' },
|
||||
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', `--use-file-for-fake-audio-capture=${os.homedir()}/.local/share/moho-dev/speech.wav`],
|
||||
seed: (home) => {
|
||||
fs.mkdirSync(path.join(home, 'config/nobilis'), { recursive: true })
|
||||
fs.writeFileSync(path.join(home, 'config/nobilis/accounts.toml'), block + '\n')
|
||||
}
|
||||
})
|
||||
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
|
||||
const ev = (code) => page.evaluate(code)
|
||||
try {
|
||||
// The window onto workspace 2, where the user does not look.
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
let account = ''
|
||||
for (let i = 0; i < 90 && !account; i++) {
|
||||
account = await ev(`(() => { const a = window.__mohoShots.state().accounts.find((a) => a.id.startsWith('steam:') && a.state === 'connected'); return a ? a.id : '' })()`)
|
||||
if (!account) await sleep(1000)
|
||||
}
|
||||
say('account', account || 'NOT CONNECTED')
|
||||
if (!account) throw new Error('not connected')
|
||||
const rpc = (m, p) => page.evaluate(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
|
||||
page.on('console', (m) => say('console', m.type(), m.text().slice(0, 300)))
|
||||
const name = process.env.NAME ?? ''
|
||||
const way = process.env.WAY ?? 'rail'
|
||||
const state = (expr) => ev(`(() => { const s = window.__mohoShots.state(); return ${expr} })()`)
|
||||
if (process.env.CREATE) say('created', JSON.stringify(await rpc('steamChatGroupAction', { accountId: account, action: 'create', name })))
|
||||
let group = null
|
||||
for (let i = 0; i < 60 && !group; i++) {
|
||||
group = await state(`(s.groups.find((g) => g.accountId === ${JSON.stringify(account)} && g.name === ${JSON.stringify(name)}) || null)`)
|
||||
if (!group) await sleep(1000)
|
||||
}
|
||||
say('group', JSON.stringify(group))
|
||||
if (process.env.VOICE && group) {
|
||||
// In the group's voice room when it is left.
|
||||
const number = group.id.split('steamgroup:')[1]
|
||||
say('voice room', JSON.stringify(await rpc('steamChatGroupAction', { accountId: account, action: 'createRoom', groupId: number, name: 'talk', voice: true })))
|
||||
let rooms = []
|
||||
for (let i = 0; i < 20 && !rooms.length; i++) {
|
||||
rooms = await rpc('steamVoiceRooms', { accountId: account, groupId: number })
|
||||
if (!rooms.length) await sleep(500)
|
||||
}
|
||||
await ev(`window.__mohoShots.store.joinSteamVoice(${JSON.stringify(account)}, ${JSON.stringify(number)}, ${JSON.stringify(rooms[0].roomId)})`)
|
||||
for (let i = 0; i < 30; i++) {
|
||||
if ((await state(`s.steamVoiceNow ? s.steamVoiceNow.phase : 'idle'`)) === 'connected') break
|
||||
await sleep(500)
|
||||
}
|
||||
say('in voice', await state(`s.steamVoiceNow ? s.steamVoiceNow.phase : 'idle'`))
|
||||
await sleep(3000)
|
||||
}
|
||||
if (!group) throw new Error('the group is not in the window')
|
||||
await sleep(4000)
|
||||
const click = (text) => ev(`(() => { const e = [...document.querySelectorAll('button, [role=menuitem]')].find((b) => b.textContent.trim().endsWith(${JSON.stringify(text)})); if (!e) return false; e.click(); return true })()`)
|
||||
if (way === 'room') {
|
||||
// A room's own menu: what it offers is leaving the group.
|
||||
await ev(`window.__mohoShots.store.selectGroup(${JSON.stringify(group.id)})`)
|
||||
let room = null
|
||||
for (let i = 0; i < 30 && !room; i++) {
|
||||
room = await state(`(s.buffers.find((b) => b.groupId === ${JSON.stringify(group.id)} && b.kind === 'channel') || null)`)
|
||||
if (!room) await sleep(500)
|
||||
}
|
||||
say('room', JSON.stringify(room && room.name))
|
||||
await sleep(800)
|
||||
say('menu opened', await ev(`(() => { const row = [...document.querySelectorAll('.buffer-row')][0]; if (!row) return false; const r = row.getBoundingClientRect(); row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: r.x + 20, clientY: r.y + 10 })); return true })()`))
|
||||
await sleep(400)
|
||||
say('menu', JSON.stringify(await ev(`[...document.querySelectorAll('.context-menu button, .context-menu [role=menuitem]')].map((e) => e.textContent.trim())`)))
|
||||
say('clicked', await click('Leave group…'))
|
||||
} else if (way === 'rail') {
|
||||
say('menu opened', await ev(`(() => { const tile = [...document.querySelectorAll('.rail-tile')].find((t) => t.getAttribute('aria-label') === ${JSON.stringify(name)}); if (!tile) return false; const r = tile.getBoundingClientRect(); tile.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: r.x + 10, clientY: r.y + 10 })); return true })()`))
|
||||
await sleep(400)
|
||||
say('menu', JSON.stringify(await ev(`[...document.querySelectorAll('.context-menu button, .context-menu [role=menuitem]')].map((e) => e.textContent.trim())`)))
|
||||
say('clicked Leave server', await click('Leave server'))
|
||||
} else {
|
||||
say('menu opened', await ev(`(() => { const tile = [...document.querySelectorAll('.rail-tile')].find((t) => t.getAttribute('aria-label') === ${JSON.stringify(name)}); if (!tile) return false; const r = tile.getBoundingClientRect(); tile.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: r.x + 10, clientY: r.y + 10 })); return true })()`))
|
||||
await sleep(400)
|
||||
say('clicked Group settings', await click('Group settings'))
|
||||
await sleep(800)
|
||||
say('clicked Leave group', await click('Leave group'))
|
||||
}
|
||||
await sleep(600)
|
||||
say('buttons now', JSON.stringify(await ev(`[...document.querySelectorAll('button')].map((b) => b.textContent.trim()).filter((t) => /leave/i.test(t))`)))
|
||||
// The confirmation counts down before it will leave ("Leave in 10"): waited out, as a person has to.
|
||||
for (let i = 0; i < 40; i++) {
|
||||
if (await ev(`[...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Leave' && !b.disabled)`)) break
|
||||
await sleep(500)
|
||||
}
|
||||
// The dialog's own Leave (or the inline confirm's).
|
||||
say('confirmed', await ev(`(() => { const all = [...document.querySelectorAll('button')].filter((b) => b.textContent.trim() === 'Leave'); const e = all[all.length - 1]; if (!e) return false; e.click(); return true })()`))
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const still = await state(`s.groups.some((g) => g.id === ${JSON.stringify(group.id)})`)
|
||||
if (!still) break
|
||||
await sleep(500)
|
||||
}
|
||||
say('still in the window', await state(`s.groups.some((g) => g.id === ${JSON.stringify(group.id)})`))
|
||||
say('toasts', await state(`JSON.stringify(s.toasts.map((t) => t.message || t.text || JSON.stringify(t)))`))
|
||||
say('voice after', await state(`s.steamVoiceNow ? s.steamVoiceNow.phase : 'idle'`))
|
||||
const held = await rpc('steamChatGroups', { accountId: account })
|
||||
say('daemon still holds', JSON.stringify((held ?? []).map((g) => g.name)))
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Signing in to Steam by scanning a QR code with the phone app, through the window as a person
|
||||
* does it: a scratch app with no accounts, Accounts -> Steam -> "Sign in with a QR code". The code is
|
||||
* photographed to <output directory>/qr-N.png each time Steam replaces it, for somebody to scan,
|
||||
* and the run ends when the account is signed in or when STOP exists. The scratch home is thrown
|
||||
* away, so the token Steam hands back is not kept.
|
||||
*
|
||||
* node scripts/probe-steam-qr.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const stopFile = path.join(os.homedir(), '.local/share/moho-dev/stop')
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
|
||||
const ev = (code) => page.evaluate(code)
|
||||
const shot = async (name) => {
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
}
|
||||
try {
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
await sleep(2500)
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 900, height: 900, deviceScaleFactor: 1, mobile: false })
|
||||
// Everything the daemon says about the sign-in, kept as it comes.
|
||||
await ev(`window.__events = []; window.moho.onEvent((e) => { if (String(e.event).startsWith('steamLogin') || e.event === 'accountsChanged') window.__events.push({ at: Date.now(), event: e.event, data: JSON.stringify(e.data).slice(0, 300) }) })`)
|
||||
await ev(`window.__mohoShots.store.setActivePanel('accounts')`)
|
||||
await sleep(800)
|
||||
const click = (text) => ev(`(() => { const e = [...document.querySelectorAll('button')].find((b) => b.textContent.trim().includes(${JSON.stringify(text)})); if (!e) return false; e.click(); return true })()`)
|
||||
say('add form', JSON.stringify(await ev(`[...document.querySelectorAll('button')].map((b) => b.textContent.trim()).filter(Boolean).slice(0, 30)`)))
|
||||
say('clicked Steam', await click('Steam'))
|
||||
await sleep(600)
|
||||
say('clicked QR', await click('Sign in with a QR code'))
|
||||
let last = ''
|
||||
let n = 0
|
||||
for (let t = 0; t < 600 && !fs.existsSync(stopFile); t += 2) {
|
||||
const src = await ev(`document.querySelector('img.qr-image')?.src ?? ''`)
|
||||
if (src && src !== last) {
|
||||
last = src
|
||||
n++
|
||||
await sleep(300)
|
||||
await shot(`qr-${n}.png`)
|
||||
say('QR', n, 'drawn', src.slice(-40))
|
||||
}
|
||||
const seen = await ev(`window.__events.splice(0)`).catch(() => [])
|
||||
for (const e of seen) say('event', e.event, e.data)
|
||||
const accounts = await ev(`JSON.stringify(window.__mohoShots.state().accounts.filter((a) => a.service === 'steam').map((a) => [a.id, a.state]))`)
|
||||
if (accounts !== '[]') {
|
||||
say('signed in', accounts)
|
||||
break
|
||||
}
|
||||
const toasts = await ev(`JSON.stringify(window.__mohoShots.state().toasts.map((x) => x.message || x.text || JSON.stringify(x)))`)
|
||||
if (toasts !== '[]' && toasts !== (globalThis.__t ?? '')) { globalThis.__t = toasts; say('toasts', toasts) }
|
||||
await sleep(2000)
|
||||
}
|
||||
await sleep(4000)
|
||||
for (const e of (await ev(`window.__events.splice(0)`).catch(() => []))) say('event', e.event, e.data)
|
||||
say('accounts after', await ev(`JSON.stringify(window.__mohoShots.state().accounts.filter((a) => a.service === 'steam').map((a) => [a.id, a.state, a.displayName]))`))
|
||||
await shot('qr-after.png')
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Steam voice in a group that already exists, with somebody on the official client in the room too: a scratch app signed in as
|
||||
* the throwaway account (never the real one) with a fake microphone joins the group's voice room
|
||||
* and reports what it hears every ten seconds, until ~/.local/share/moho-dev/stop exists.
|
||||
*
|
||||
* GROUP=78995476 ROOM=316282066 node scripts/probe-steam-voice-join.mjs
|
||||
*
|
||||
* npx electron-vite build && node scripts/probe-steam-voice.mjs
|
||||
*
|
||||
* Needs the throwaway account's block in ~/.config/nobilis/accounts.toml. The app is the
|
||||
* one built in out/, with its own home and runtime directory, and signs the account in
|
||||
* for as long as this runs - so nothing else may be signed in as it.
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const live = fs.readFileSync(path.join(os.homedir(), '.config/nobilis/accounts.toml'), 'utf8')
|
||||
const block = live.split(/\n(?=\[)/).find((b) => b.startsWith('[[steam_account]]') && /username\s*=\s*"mohotester"/.test(b))
|
||||
if (!block) throw new Error('the throwaway account is not in accounts.toml')
|
||||
|
||||
const { page, stop, scratch } = await launchApp({
|
||||
env: { MOHO_UI_SHOTS: '1' },
|
||||
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', `--use-file-for-fake-audio-capture=${os.homedir()}/.local/share/moho-dev/speech.wav`],
|
||||
seed: (home) => {
|
||||
fs.mkdirSync(path.join(home, 'config/nobilis'), { recursive: true })
|
||||
fs.writeFileSync(path.join(home, 'config/nobilis/accounts.toml'), block + '\n')
|
||||
}
|
||||
})
|
||||
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
|
||||
const ev = (code) => page.evaluate(code)
|
||||
try {
|
||||
// The window onto workspace 2, where the user does not look.
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
let account = ''
|
||||
for (let i = 0; i < 90 && !account; i++) {
|
||||
account = await ev(`(() => { const a = window.__mohoShots.state().accounts.find((a) => a.id.startsWith('steam:') && a.state === 'connected'); return a ? a.id : '' })()`)
|
||||
if (!account) await sleep(1000)
|
||||
}
|
||||
say('account', account || 'NOT CONNECTED')
|
||||
if (!account) throw new Error('not connected')
|
||||
const rpc = (m, p) => page.evaluate(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
|
||||
page.on('console', (m) => say('console', m.type(), m.text().slice(0, 300)))
|
||||
const group = process.env.GROUP
|
||||
const roomId = process.env.ROOM
|
||||
const stopFile = path.join(os.homedir(), '.local/share/moho-dev/stop')
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const rooms = await rpc('steamVoiceRooms', { accountId: account, groupId: group })
|
||||
if (Array.isArray(rooms) && rooms.length) break
|
||||
await sleep(500)
|
||||
}
|
||||
await ev(`window.__mohoShots.store.joinSteamVoice(${JSON.stringify(account)}, ${JSON.stringify(group)}, ${JSON.stringify(roomId)})`)
|
||||
let phase = ''
|
||||
let lastToast = ''
|
||||
for (let i = 0; i < 80; i++) {
|
||||
const now = await ev(`(() => { const s = window.__mohoShots.state(); return JSON.stringify({ phase: s.steamVoiceNow ? s.steamVoiceNow.phase : 'idle', toasts: s.toasts.map((t) => t.message || t.text || JSON.stringify(t)) }) })()`)
|
||||
if (now !== lastToast) { lastToast = now; say('fast', now) }
|
||||
await sleep(250)
|
||||
}
|
||||
for (let t = 0; t < 1200 && !fs.existsSync(stopFile); t += 10) {
|
||||
const now = await ev(`(() => { const s = window.__mohoShots.state(); return s.steamVoiceNow ? s.steamVoiceNow.phase : 'idle' })()`)
|
||||
if (now !== phase) { phase = now; say('phase', now) }
|
||||
const report = await ev(`(async () => {
|
||||
const v = window.__mohoShots.store.steamVoiceLive
|
||||
if (!v) return { none: true }
|
||||
const stats = []
|
||||
const rep = await v.pc.getStats()
|
||||
rep.forEach((s) => { if (['outbound-rtp', 'inbound-rtp', 'media-source', 'remote-inbound-rtp'].includes(s.type)) stats.push({ type: s.type, packetsSent: s.packetsSent, bytesSent: s.bytesSent, packetsReceived: s.packetsReceived, packetsLost: s.packetsLost, ssrc: s.ssrc, audioLevel: s.audioLevel, energy: s.totalAudioEnergy, rtt: s.roundTripTime }) })
|
||||
return { statuses: v.statuses ? [...v.statuses.entries()] : null, session: v.session, owners: v.owners ? [...v.owners.entries()] : null, track: v.mic ? v.mic.getAudioTracks().map((t) => [t.enabled, t.muted, t.readyState]) : null, phase: v.phase, rtt: v.rttMs, ice: v.pc.iceConnectionState, speakers: window.__mohoShots.state().steamVoiceNow?.speakers, stats }
|
||||
})().catch((e) => ({ error: String(e) }))`)
|
||||
say('report', JSON.stringify(report))
|
||||
say('toasts', await ev(`JSON.stringify(window.__mohoShots.state().toasts.map((t) => t.message || t.text || t))`))
|
||||
say('rooms', JSON.stringify(await rpc('steamVoiceRooms', { accountId: account, groupId: group })))
|
||||
await sleep(3000)
|
||||
}
|
||||
await ev(`window.__mohoShots.store.leaveVoice(${JSON.stringify(account)})`)
|
||||
await sleep(2000)
|
||||
say('left voice; the group is left in place')
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Steam voice against real Steam, through the real window: a scratch app signed in as
|
||||
* the throwaway account (never the real one) with a fake microphone makes a group with a
|
||||
* voice room, joins its voice, reports how it went, leaves, and leaves the group.
|
||||
*
|
||||
* npx electron-vite build && node scripts/probe-steam-voice.mjs
|
||||
*
|
||||
* Needs the throwaway account's block in ~/.config/nobilis/accounts.toml. The app is the
|
||||
* one built in out/, with its own home and runtime directory, and signs the account in
|
||||
* for as long as this runs - so nothing else may be signed in as it.
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const live = fs.readFileSync(path.join(os.homedir(), '.config/nobilis/accounts.toml'), 'utf8')
|
||||
const block = live.split(/\n(?=\[)/).find((b) => b.startsWith('[[steam_account]]') && /username\s*=\s*"mohotester"/.test(b))
|
||||
if (!block) throw new Error('the throwaway account is not in accounts.toml')
|
||||
|
||||
const { page, stop, scratch } = await launchApp({
|
||||
env: { MOHO_UI_SHOTS: '1' },
|
||||
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'],
|
||||
seed: (home) => {
|
||||
fs.mkdirSync(path.join(home, 'config/nobilis'), { recursive: true })
|
||||
fs.writeFileSync(path.join(home, 'config/nobilis/accounts.toml'), block + '\n')
|
||||
}
|
||||
})
|
||||
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
|
||||
const ev = (code) => page.evaluate(code)
|
||||
try {
|
||||
// The window onto workspace 2, where the user does not look.
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
let account = ''
|
||||
for (let i = 0; i < 90 && !account; i++) {
|
||||
account = await ev(`(() => { const a = window.__mohoShots.state().accounts.find((a) => a.id.startsWith('steam:') && a.state === 'connected'); return a ? a.id : '' })()`)
|
||||
if (!account) await sleep(1000)
|
||||
}
|
||||
say('account', account || 'NOT CONNECTED')
|
||||
if (!account) throw new Error('not connected')
|
||||
const rpc = (m, p) => page.evaluate(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
|
||||
const made = await rpc('steamChatGroupAction', { accountId: account, action: 'create', name: 'voice probe' })
|
||||
say('created', JSON.stringify(made))
|
||||
const group = String(made.groupId).split('steamgroup:')[1]
|
||||
say('room', JSON.stringify(await rpc('steamChatGroupAction', { accountId: account, action: 'createRoom', groupId: group, name: 'talk', voice: true })))
|
||||
let rooms = []
|
||||
for (let i = 0; i < 20 && !rooms.length; i++) {
|
||||
rooms = await rpc('steamVoiceRooms', { accountId: account, groupId: group })
|
||||
if (!rooms.length) await sleep(500)
|
||||
}
|
||||
say('voice rooms', JSON.stringify(rooms))
|
||||
const roomId = rooms[0]?.roomId
|
||||
await sleep(1500)
|
||||
await ev(`window.__mohoShots.store.joinSteamVoice(${JSON.stringify(account)}, ${JSON.stringify(group)}, ${JSON.stringify(roomId)})`)
|
||||
let phase = ''
|
||||
const seen = []
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const now = await ev(`(() => { const s = window.__mohoShots.state(); return s.steamVoiceNow ? s.steamVoiceNow.phase : 'idle' })()`)
|
||||
if (now !== phase) { phase = now; seen.push(now); say('phase', now) }
|
||||
if (now === 'connected') break
|
||||
await sleep(500)
|
||||
}
|
||||
await sleep(4000)
|
||||
const report = await ev(`(async () => {
|
||||
const v = window.__mohoShots.store.steamVoiceLive
|
||||
const stats = []
|
||||
const rep = await v.pc.getStats()
|
||||
rep.forEach((s) => { if (['outbound-rtp', 'inbound-rtp', 'candidate-pair'].includes(s.type) && (s.type !== 'candidate-pair' || s.nominated)) stats.push({ type: s.type, kind: s.kind, packetsSent: s.packetsSent, bytesSent: s.bytesSent, packetsReceived: s.packetsReceived, rtt: s.currentRoundTripTime, state: s.state, ssrc: s.ssrc }) })
|
||||
return { phase: v.phase, level: v.level, rtt: v.rttMs, ice: v.pc.iceConnectionState, voiceChatId: v.voiceChatId, session: v.session, stats, mic: !!v.mic, sessions: window.__mohoShots.state().voiceSessions }
|
||||
})().catch((e) => ({ error: String(e) }))`)
|
||||
say('report', JSON.stringify(report, null, 1))
|
||||
say('rooms while in', JSON.stringify(await rpc('steamVoiceRooms', { accountId: account, groupId: group })))
|
||||
await ev(`window.__mohoShots.store.leaveVoice(${JSON.stringify(account)})`)
|
||||
await sleep(2000)
|
||||
say('rooms after leaving', JSON.stringify(await rpc('steamVoiceRooms', { accountId: account, groupId: group })))
|
||||
say('now', await ev(`JSON.stringify(window.__mohoShots.state().steamVoiceNow)`))
|
||||
say('left group', JSON.stringify(await rpc('leaveGroup', { groupId: made.groupId })))
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -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,90 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Photographs a Steam chat group as the window shows it: a server on the rail
|
||||
* with its rooms as channels and its members listed, then the group's settings
|
||||
* dialog on each of its tabs. Made-up state on a fake IRC server, in a scratch
|
||||
* home; the daemon's answers for the group are stood in for here.
|
||||
*
|
||||
* node scripts/shot-steam-chatgroups.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const shot = async (name) => {
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
}
|
||||
try {
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
await page.evaluate(`(() => {
|
||||
const group = {
|
||||
id: '7', railId: 'steam:me|steamgroup:7', name: 'Raiders', tagline: 'Friday nights, 8pm', owner: '76561197960265729', myRank: 50, myRankWord: 'Owner',
|
||||
mayModerate: true, mayManage: true, mayInvite: true, mayChangeRoles: true, muted: false, notify: 2,
|
||||
permissions: [{ key: 'chat', label: 'Write messages' }, { key: 'kick', label: 'Remove people' }, { key: 'ban', label: 'Ban people' }, { key: 'invite', label: 'Invite people and make links' }],
|
||||
rooms: [{ id: '70', name: '', voice: false }, { id: '71', name: 'loot', voice: false }],
|
||||
roles: [{ id: '90', name: 'Member' }, { id: '91', name: 'Raid leaders' }],
|
||||
members: [
|
||||
{ steamid: '76561197960265729', name: 'Gabe', rank: 50, rankWord: 'Owner', roles: ['90'] },
|
||||
{ steamid: '76561197960265730', name: 'Alyx', rank: 30, rankWord: 'Moderator', roles: ['90', '91'] },
|
||||
{ steamid: '76561197960265731', name: 'Eli', rank: 20, rankWord: 'Member', roles: ['90'] }
|
||||
],
|
||||
kicked: []
|
||||
}
|
||||
window.__rpcFixture = {
|
||||
steamChatGroups: () => [group],
|
||||
steamChatGroupAction: (params) => {
|
||||
if (params.action === 'links') return { links: [{ code: 'Ga3kODnC', link: 'https://s.team/chat/Ga3kODnC', expires: 1800000000 }] }
|
||||
if (params.action === 'roleActions') return { chat: true, kick: params.roleId === '91', ban: false, invite: true }
|
||||
if (params.action === 'bans') return { bans: [{ steamid: '76561197960265800', name: 'Spammer' }] }
|
||||
return {}
|
||||
}
|
||||
}
|
||||
const s = window.__mohoShots
|
||||
const st = s.state()
|
||||
const irc = st.accounts[0]
|
||||
const account = { ...irc, id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
|
||||
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
|
||||
const room = (name, id, position) => ({ ...channel, id: 'steam:me|' + name, accountId: 'steam:me', name, kind: 'channel', remoteId: '7:' + id, groupId: 'steam:me|steamgroup:7', position, unread: 0, highlight: false, mentions: 0 })
|
||||
const general = room('Raiders/#General', '70', 1)
|
||||
const loot = { ...room('Raiders/#loot', '71', 2), unread: 3 }
|
||||
s.patch({
|
||||
accounts: [...st.accounts, account],
|
||||
groups: [...st.groups, { id: 'steam:me|steamgroup:7', accountId: 'steam:me', service: 'steam', kind: 'guild', name: 'Raiders', position: 3 }],
|
||||
buffers: [...st.buffers, general, loot],
|
||||
activeBufferId: general.id, activeGroupId: 'steam:me|steamgroup:7',
|
||||
presenceByBuffer: { ...st.presenceByBuffer, [general.id]: [
|
||||
{ nick: 'Gabe', status: 'online', userId: '76561197960265729', prefix: '👑', roles: ['Owner'], powerLevel: 50 },
|
||||
{ nick: 'Alyx', status: 'online', userId: '76561197960265730', prefix: '🛡', roles: ['Moderator', 'Raid leaders'], powerLevel: 30, activity: { kind: 'game', text: 'Half-Life: Alyx' } },
|
||||
{ nick: 'Eli', status: 'offline', away: true, userId: '76561197960265731', powerLevel: 20 }
|
||||
] }
|
||||
})
|
||||
})()`)
|
||||
await sleep(1500)
|
||||
await shot('steam-chatgroup.png')
|
||||
console.log('channels:', JSON.stringify(await page.evaluate(`[...document.querySelectorAll('.sidebar')].map((s) => s.innerText).join('|')`)))
|
||||
// The rail entry's menu has the settings.
|
||||
await page.evaluate(`(() => { const tile = [...document.querySelectorAll('.rail-tile')].find((t) => t.getAttribute('aria-label') === 'Raiders' || t.textContent.includes('Ra')); tile.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 40, clientY: 200 })) })()`)
|
||||
await sleep(400)
|
||||
const entries = await page.evaluate(`[...document.querySelectorAll('.context-menu button, .context-menu [role=menuitem]')].map((e) => e.textContent.trim())`)
|
||||
console.log('menu:', JSON.stringify(entries))
|
||||
await page.evaluate(`(() => { const item = [...document.querySelectorAll('.context-menu button, .context-menu [role=menuitem]')].find((e) => e.textContent.includes('Group settings')); item && item.click() })()`)
|
||||
await sleep(600)
|
||||
for (const tab of ['General', 'Rooms', 'People', 'Roles', 'Invites']) {
|
||||
await page.evaluate(`(() => { const t = [...document.querySelectorAll('.steam-group .room-settings-tab')].find((e) => e.textContent.trim() === ${JSON.stringify(tab)}); t && t.click() })()`)
|
||||
await sleep(500)
|
||||
if (tab === 'Roles') await page.evaluate(`(() => { const b = [...document.querySelectorAll('.steam-group button')].find((e) => e.textContent.trim() === 'Edit' ); b && b.click() })()`)
|
||||
await sleep(300)
|
||||
await shot(`steam-chatgroup-${tab.toLowerCase()}.png`)
|
||||
const box = await page.evaluate(`(() => { const r = document.querySelector('.steam-group')?.getBoundingClientRect(); return r ? [Math.round(r.left), Math.round(r.top), Math.round(r.right), Math.round(r.bottom)] : null })()`)
|
||||
console.log(tab, 'dialog box', JSON.stringify(box))
|
||||
}
|
||||
} finally {
|
||||
await stop()
|
||||
irc.close()
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Photographs a Steam account's own page with its friends under the account's folders, then moves a
|
||||
* friend to another folder by dragging and says which changes the window asked Steam for. Made-up
|
||||
* state on a fake IRC server, in a scratch home; the daemon's answers are stood in for here.
|
||||
*
|
||||
* node scripts/shot-steam-folders.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const shot = async (name) => {
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
}
|
||||
try {
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
await page.evaluate(`(() => {
|
||||
window.__calls = []
|
||||
const folders = [
|
||||
{ id: 1, name: 'Steam - ISRAD', members: ['76561197960265704'] },
|
||||
{ id: 3, name: 'Steam - Friends', members: ['76561197960265701', '76561197960265702'] },
|
||||
{ id: 5, name: 'Steam - PL', members: ['76561197960265705'] },
|
||||
{ id: 7, name: 'Steam - Goons', members: ['76561197960265703'] }
|
||||
]
|
||||
window.__folders = folders
|
||||
window.__rpcFixture = { steamFriendGroups: () => folders, steamGroupChange: (p) => { window.__calls.push(p); return {} } }
|
||||
const s = window.__mohoShots
|
||||
const st = s.state()
|
||||
const irc = st.accounts[0]
|
||||
const ircGroup = st.groups.find((g) => g.kind === 'account')
|
||||
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
|
||||
const account = { ...irc, id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
|
||||
const group = { ...ircGroup, id: 'account:steam:me', accountId: 'steam:me', service: 'steam', name: 'Steam - me' }
|
||||
const friend = (name, id, category) => ({ ...channel, id: 'steam:me|' + name, accountId: 'steam:me', name, kind: 'dm', remoteId: id, groupId: group.id, category: category ?? null, position: 0, unread: 0, highlight: false, mentions: 0 })
|
||||
const buffers = [
|
||||
friend('Dandalf', '76561197960265701', 'Friends'), friend('GetCreped', '76561197960265702', 'Friends'),
|
||||
friend('Shaod', '76561197960265703', 'Goons'), friend('WigglyQuimble', '76561197960265704', 'ISRAD'),
|
||||
friend('Hujoppi', '76561197960265705', 'PL'), friend('Stranger', '76561197960265706', null)
|
||||
]
|
||||
s.patch({
|
||||
accounts: [...st.accounts, account],
|
||||
groups: [...st.groups, group],
|
||||
buffers: [...st.buffers, ...buffers],
|
||||
steamGroups: { 'steam:me': folders },
|
||||
activeGroupId: group.id, activeBufferId: buffers[0].id
|
||||
})
|
||||
})()`)
|
||||
await sleep(1500)
|
||||
// The daemon answered the window's own question about the made-up account with nothing; say it again.
|
||||
await page.evaluate(`window.__mohoShots.patch({ steamGroups: { 'steam:me': window.__folders } })`)
|
||||
await sleep(500)
|
||||
await shot('steam-folders.png')
|
||||
console.log('classes:', JSON.stringify(await page.evaluate(`[...document.querySelectorAll('.bufferlist [class*=category]')].filter((e) => e.textContent.trim() === 'expand_morePL').map((e) => e.className + ' ' + e.tagName + ' ' + e.draggable)`)))
|
||||
await page.evaluate(`(() => { const st = window.__mohoShots.store; window.__ranAll = []; const orig = st.moveSteamFriend.bind(st); st.moveSteamFriend = (...a) => { window.__moved = a; return orig(...a) }; const run = st.runSteamGroupCalls.bind(st); st.runSteamGroupCalls = (c) => { window.__ran = c; window.__ranAll.push(...c); return run(c) } })()`)
|
||||
// Drag Shaod (Goons) onto the PL heading.
|
||||
const moved = await page.evaluate(`(async () => {
|
||||
const row = [...document.querySelectorAll('.bufferlist [draggable=true]')].find((e) => e.textContent.trim().endsWith('Shaod') && e.textContent.trim().length < 12)
|
||||
const head = [...document.querySelectorAll('.bufferlist [class*=category]')].find((e) => e.textContent.trim() === 'expand_morePL')
|
||||
if (!row || !head) return { row: !!row, head: !!head }
|
||||
const dt = new DataTransfer()
|
||||
const wait = () => new Promise((r) => setTimeout(r, 150))
|
||||
row.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer: dt }))
|
||||
await wait()
|
||||
head.dispatchEvent(new DragEvent('dragenter', { bubbles: true, cancelable: true, dataTransfer: dt }))
|
||||
head.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt }))
|
||||
await wait()
|
||||
window.__finish = () => { head.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt })) }
|
||||
return { dragged: true }
|
||||
})()`)
|
||||
console.log('drag:', JSON.stringify(moved))
|
||||
await shot('steam-folders-drag.png')
|
||||
await page.evaluate(`window.__finish && window.__finish()`)
|
||||
await sleep(500)
|
||||
console.log('state:', JSON.stringify(await page.evaluate(`(() => { const st = window.__mohoShots.state(); const b = st.buffers.find((x) => x.id === 'steam:me|Shaod'); return { b: b && [b.remoteId, b.category, b.accountId], g: st.steamGroups } })()`)))
|
||||
console.log('moved:', JSON.stringify(await page.evaluate(`[window.__moved, window.__ran]`)))
|
||||
console.log('calls:', JSON.stringify(await page.evaluate(`window.__calls`)))
|
||||
// A new folder: named, and made on Steam.
|
||||
await page.evaluate(`(() => { const t = [...document.querySelectorAll('.bufferlist button')].find((b) => b.textContent.includes('Steam - me')); t && t.click() })()`)
|
||||
await sleep(300)
|
||||
await page.evaluate(`(() => { const i = [...document.querySelectorAll('.context-menu button, .context-menu [role=menuitem]')].find((e) => e.textContent.includes('New category')); i && i.click() })()`)
|
||||
await sleep(400)
|
||||
await shot('steam-folders-naming.png')
|
||||
await page.evaluate(`(() => { const inp = document.querySelector('.reason-prompt-box'); if (!inp) return; const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; set.call(inp, 'Raiders'); inp.dispatchEvent(new Event('input', { bubbles: true })) })()`)
|
||||
await sleep(300)
|
||||
await page.evaluate(`(() => { const inp = document.querySelector('.reason-prompt-box'); inp && inp.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) })()`)
|
||||
await sleep(500)
|
||||
console.log('all calls:', JSON.stringify(await page.evaluate(`window.__ranAll`)))
|
||||
} finally {
|
||||
await stop()
|
||||
irc.close()
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Photographs the direct-messages page when a Steam account has friend groups:
|
||||
* each group a heading, its friends' conversations under it. Then changes one
|
||||
* as Steam would, and reads where the conversation went. Made-up state on a fake
|
||||
* IRC server, in a scratch home.
|
||||
*
|
||||
* node scripts/shot-steam-groups.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
try {
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1000, height: 700, deviceScaleFactor: 1, mobile: false })
|
||||
await page.evaluate(`(() => {
|
||||
const s = window.__mohoShots
|
||||
const st = s.state()
|
||||
const irc = st.accounts[0]
|
||||
const account = { ...irc, id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
|
||||
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
|
||||
const dm = (name, remoteId) => ({ ...channel, id: 'steam:me|' + name, accountId: 'steam:me', name, kind: 'dm', remoteId, unread: 0, highlight: false, mentions: 0 })
|
||||
s.patch({
|
||||
accounts: [...st.accounts, account],
|
||||
buffers: [...st.buffers, dm('Gabe', '76561197960265729'), dm('Alyx', '76561197960287930'), dm('Eli', '76561197960265730')],
|
||||
activeBufferId: 'steam:me|Gabe', activeGroupId: '~dms',
|
||||
steamGroups: { 'steam:me': [{ id: 7, name: 'Raiders', members: ['76561197960265729', '76561197960265730'] }, { id: 8, name: 'Work', members: [] }] }
|
||||
})
|
||||
})()`)
|
||||
await sleep(1500)
|
||||
const headings = () => page.evaluate(`[...document.querySelectorAll('.sidebar')].map((s) => s.innerText).join('|')`)
|
||||
console.log('page:', JSON.stringify(await headings()))
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, 'steam-groups.png'), Buffer.from(data, 'base64'))
|
||||
// Steam moves Alyx into Work.
|
||||
await page.evaluate(`(() => { const s = window.__mohoShots; s.patch({ steamGroups: { 'steam:me': [{ id: 7, name: 'Raiders', members: ['76561197960265729', '76561197960265730'] }, { id: 8, name: 'Work', members: ['76561197960287930'] }] } }) })()`)
|
||||
await sleep(1200)
|
||||
console.log('after Steam moved Alyx into Work:', JSON.stringify(await headings()))
|
||||
} finally {
|
||||
await stop()
|
||||
irc.close()
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Photographs the Invitations tile with a Steam group invitation on it, as the window shows it.
|
||||
* Made-up state on a fake IRC server, in a scratch home.
|
||||
*
|
||||
* node scripts/shot-steam-invite.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
try {
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
await page.evaluate(`(() => {
|
||||
const s = window.__mohoShots
|
||||
const st = s.state()
|
||||
const account = { ...st.accounts[0], id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
|
||||
s.patch({
|
||||
accounts: [...st.accounts, account],
|
||||
matrixInvites: { 'steam:me': [{ roomId: '79003375', name: 'Biggest Newest Moho Test', inviter: 'Salastil', avatarUrl: null, isDirect: false }] },
|
||||
activeGroupId: '~invitations'
|
||||
})
|
||||
})()`)
|
||||
await sleep(1200)
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, 'steam-invite.png'), Buffer.from(data, 'base64'))
|
||||
console.log(await page.evaluate(`document.querySelector('.invite-panel')?.innerText`))
|
||||
} finally {
|
||||
await stop()
|
||||
irc.close()
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Photographs a Steam conversation Steam has warned about: the red banner over
|
||||
* it and the red line in the log, then the banner dismissed. Made-up state on a
|
||||
* fake IRC server, in a scratch home.
|
||||
*
|
||||
* node scripts/shot-steam-notice.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
try {
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1000, height: 620, deviceScaleFactor: 1, mobile: false })
|
||||
await page.evaluate(`(() => {
|
||||
const s = window.__mohoShots
|
||||
const st = s.state()
|
||||
const irc = st.accounts[0]
|
||||
const account = { ...irc, id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
|
||||
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
|
||||
const notice = { kind: 'suspicious', text: 'Steam warns that this person may not be who they appear to be. Be careful with links, offers and requests for anything - and do not give out your account details.' }
|
||||
const dm = { ...channel, id: 'steam:me|Gabe', accountId: 'steam:me', name: 'Gabe', kind: 'dm', remoteId: '76561197960265829', unread: 0, highlight: false, mentions: 0, notice }
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const m = (id, from, body, kind = 'chat') => ({ id, bufferId: dm.id, from, body, ts: now - 600 + Number(id.split(':')[1] || 0), isAction: false, isHighlight: false, kind })
|
||||
s.patch({
|
||||
accounts: [...st.accounts, account], buffers: [...st.buffers, dm], activeBufferId: dm.id, activeGroupId: '~dms',
|
||||
messagesByBuffer: { ...st.messagesByBuffer, [dm.id]: [
|
||||
m('1:1', 'Gabe', 'hey, long time'),
|
||||
m('2:2', 'Steam', notice.text, 'warning'),
|
||||
m('3:3', 'Gabe', 'can you check this link for me')
|
||||
] }
|
||||
})
|
||||
})()`)
|
||||
await sleep(1000)
|
||||
const shot = async (name) => {
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
console.log('wrote', name)
|
||||
}
|
||||
await shot('steam-notice-before.png')
|
||||
console.log('banner:', await page.evaluate(`!!document.querySelector('.notice-banner')`), '| warning lines:', await page.evaluate(`document.querySelectorAll('.message-row.warning').length`))
|
||||
await page.evaluate(`document.querySelector('.notice-banner-dismiss').click()`)
|
||||
await sleep(600)
|
||||
console.log('banner after dismiss:', await page.evaluate(`!!document.querySelector('.notice-banner')`), '| warning lines:', await page.evaluate(`document.querySelectorAll('.message-row.warning').length`))
|
||||
await shot('steam-notice-after.png')
|
||||
} finally {
|
||||
await stop()
|
||||
irc.close()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Photographs the message box of a Steam conversation with its emoji picker
|
||||
* open: no GIF button, and a picker with no Unicode emoji. Made-up state, on a
|
||||
* fake IRC server, in a scratch home.
|
||||
*
|
||||
* node scripts/shot-steam-picker.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
try {
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1000, height: 700, deviceScaleFactor: 1, mobile: false })
|
||||
const shot = async (name) => {
|
||||
await sleep(600)
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
console.log('wrote', name)
|
||||
}
|
||||
// A Steam account and a conversation with a friend, selected.
|
||||
await page.evaluate(`(() => {
|
||||
const s = window.__mohoShots
|
||||
const st = s.state()
|
||||
const irc = st.accounts[0]
|
||||
const account = { ...irc, id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
|
||||
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
|
||||
const dm = { ...channel, id: 'steam:me|Gabe', accountId: 'steam:me', name: 'Gabe', kind: 'dm', remoteId: '76561197960265829', unread: 0, highlight: false, mentions: 0 }
|
||||
s.patch({ accounts: [...st.accounts, account], buffers: [...st.buffers, dm], activeBufferId: dm.id, activeGroupId: '~dms' })
|
||||
})()`)
|
||||
await shot('steam-composer.png')
|
||||
await page.evaluate(`document.querySelector('button[title="Emoji"]').click()`)
|
||||
await shot('steam-picker.png')
|
||||
const gif = await page.evaluate(`!!document.querySelector('button[title="GIFs"]')`)
|
||||
const sticker = await page.evaluate(`!!document.querySelector('button[title="Stickers"]')`)
|
||||
console.log('GIF button:', gif, '| Stickers button:', sticker)
|
||||
} finally {
|
||||
await stop()
|
||||
irc.close()
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Photographs the column about a Steam friend, beside their conversation, and
|
||||
* checks the conversation's right-click menu offers it. Made-up state on a fake
|
||||
* IRC server, in a scratch home.
|
||||
*
|
||||
* node scripts/shot-steam-profile.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
try {
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1100, height: 700, deviceScaleFactor: 1, mobile: false })
|
||||
await page.evaluate(`(() => {
|
||||
const s = window.__mohoShots
|
||||
const st = s.state()
|
||||
const irc = st.accounts[0]
|
||||
const account = { ...irc, id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
|
||||
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
|
||||
const dm = { ...channel, id: 'steam:me|Gabe', accountId: 'steam:me', name: 'Gabe', kind: 'dm', remoteId: '76561197960265829', unread: 0, highlight: false, mentions: 0 }
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const m = (id, body) => ({ id, bufferId: dm.id, from: 'Gabe', body, ts: now - 600 + Number(id.split(':')[1]), isAction: false, isHighlight: false, kind: 'chat' })
|
||||
s.patch({
|
||||
accounts: [...st.accounts, account], buffers: [...st.buffers, dm], activeBufferId: dm.id, activeGroupId: '~dms',
|
||||
messagesByBuffer: { ...st.messagesByBuffer, [dm.id]: [m('1:1', 'want to play later?')] },
|
||||
presenceByBuffer: { ...st.presenceByBuffer, [dm.id]: [{ nick: 'Gabe', status: 'online', userId: '76561197960265829', activity: { kind: 'game', text: 'Counter-Strike 2' }, client: 'desktop', rich: 'Competitive - Dust II' }] }
|
||||
})
|
||||
})()`)
|
||||
await sleep(600)
|
||||
// The right-click menu of the row, and what it offers.
|
||||
const rect = await page.evaluate(`(() => { const r = [...document.querySelectorAll('.buffer-row, .buffer')].find((e) => e.textContent.includes('Gabe')); if (!r) return null; const b = r.getBoundingClientRect(); return { x: b.x + 20, y: b.y + 10 } })()`)
|
||||
console.log('row at', JSON.stringify(rect))
|
||||
await page.call('Input.dispatchMouseEvent', { type: 'mousePressed', x: rect.x, y: rect.y, button: 'right', clickCount: 1 })
|
||||
await page.call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: rect.x, y: rect.y, button: 'right', clickCount: 1 })
|
||||
await sleep(400)
|
||||
console.log('menu:', JSON.stringify(await page.evaluate(`[...document.querySelectorAll('[role=menuitem], .context-menu button')].map((e) => e.textContent.trim()).slice(0, 6)`)))
|
||||
// Chosen from the menu, as a person would.
|
||||
console.log('clicked Profile:', await page.evaluate(`(() => { const e = [...document.querySelectorAll('[role=menuitem], .context-menu button')].find((x) => x.textContent.trim() === 'Profile'); if (!e) return false; e.click(); return true })()`))
|
||||
await sleep(500)
|
||||
// What Steam said, as the daemon would send it: the card every service opens.
|
||||
await page.evaluate(`window.__mohoShots.patch({ profile: {
|
||||
service: 'steam', accountId: 'steam:me', id: '76561197960265729', name: 'Gabe', createdTs: 1300000000,
|
||||
status: 'online', activity: { kind: 'game', text: 'Counter-Strike 2' },
|
||||
nameHistory: [{ name: 'Gabe Newell', when: '3 Mar, 2019 @ 1:00am' }, { name: 'Valve Gabe', when: '12 Jun, 2012 @ 6:40pm' }],
|
||||
extra: [{ label: 'Steam level', value: '42' }, { label: 'Real name', value: 'Gabe N' }, { label: 'Location', value: 'Bellevue, United States' }, { label: 'On', value: 'desktop' }, { label: 'Doing', value: 'Competitive - Dust II' }, { label: 'Headline', value: 'Hello' }]
|
||||
} })`)
|
||||
await sleep(500)
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, 'steam-profile-card.png'), Buffer.from(data, 'base64'))
|
||||
console.log('wrote steam-profile-card.png; card:', await page.evaluate(`!!document.querySelector('.profile-card')`), '| previous names:', await page.evaluate(`document.querySelectorAll('.profile-name-history li').length`))
|
||||
// Offline: last seen instead of playing.
|
||||
await page.evaluate(`(() => { const s = window.__mohoShots; const st = s.state(); s.patch({ profile: { ...st.profile, status: 'offline', activity: null, lastActiveTs: Math.floor(Date.now() / 1000) - 7200 } }) })()`)
|
||||
await sleep(400)
|
||||
console.log('offline card:', JSON.stringify(await page.evaluate(`document.querySelector('.profile-card')?.innerText.replace(/\\n+/g, ' | ').slice(0, 300)`)))
|
||||
} finally {
|
||||
await stop()
|
||||
irc.close()
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Photographs a Steam conversation holding a picture and a video, each plain
|
||||
* and each sent as a spoiler. Made-up state on a fake IRC server in a scratch
|
||||
* home; Steam's addresses are answered from a generated picture and clip.
|
||||
*
|
||||
* node scripts/shot-steam-spoiler.mjs <output directory> <picture.png> <clip.webm>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
|
||||
|
||||
const [out = '.', picture, clip] = process.argv.slice(2)
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
try {
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1000, height: 900, deviceScaleFactor: 1, mobile: false })
|
||||
// Steam's addresses answered here: the picture and the clip, whatever the path.
|
||||
await page.call('Fetch.enable', { patterns: [{ urlPattern: '*steamusercontent.com*' }, { urlPattern: '*steamstatic.com*' }, { urlPattern: '*example.com*' }] })
|
||||
page.on('Fetch.requestPaused', async (e) => {
|
||||
const video = e.request.url.includes('/cdn-video/') || e.request.url.includes('cdn.steamusercontent.com')
|
||||
const body = fs.readFileSync(video ? clip : picture).toString('base64')
|
||||
await page.call('Fetch.fulfillRequest', {
|
||||
requestId: e.requestId,
|
||||
responseCode: 200,
|
||||
responseHeaders: [{ name: 'Content-Type', value: video ? 'video/webm' : 'image/png' }, { name: 'Access-Control-Allow-Origin', value: '*' }],
|
||||
body
|
||||
})
|
||||
})
|
||||
const img = (n) => `https://images.steamusercontent.com/ugc/${n}/AAAA/`
|
||||
const vid = (n) => `https://cdn.steamusercontent.com/ugc/${n}/BBBB/`
|
||||
await page.evaluate(`(() => {
|
||||
const s = window.__mohoShots
|
||||
const st = s.state()
|
||||
const irc = st.accounts[0]
|
||||
const account = { ...irc, id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
|
||||
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
|
||||
const dm = { ...channel, id: 'steam:me|Gabe', accountId: 'steam:me', name: 'Gabe', kind: 'dm', remoteId: '76561197960265829', unread: 0, highlight: false, mentions: 0 }
|
||||
const m = (id, body) => ({ id, bufferId: dm.id, from: 'Gabe', body, ts: Math.floor(Date.now() / 1000) - 600 + Number(id), isAction: false, isHighlight: false, kind: 'chat' })
|
||||
s.patch({
|
||||
accounts: [...st.accounts, account], buffers: [...st.buffers, dm], activeBufferId: dm.id, activeGroupId: '~dms',
|
||||
messagesByBuffer: { ...st.messagesByBuffer, [dm.id]: [
|
||||
m('1', 'a plain picture'), m('2', '\\n[steam:image:${img(1)}]\\n'),
|
||||
m('3', 'a picture sent as a spoiler'), m('4', '||\\n[steam:image:${img(2)}]\\n||'),
|
||||
m('5', 'a plain video'), m('6', '\\n[steam:video:${vid(3)}]\\n'),
|
||||
m('7', 'a video sent as a spoiler'), m('8', '||\\n[steam:video:${vid(4)}]\\n||'),
|
||||
m('9', 'an emoticon [steam:emote:steamhappy] in a line, then a sticker'), m('10', '[steam:sticker:Nyan GLaDOS]'),
|
||||
m('11', 'a link with words: [look at this](https://links.invalid/a?x=1&y=2) and a bare one https://links.invalid/b, then a picture https://example.com/p.png'),
|
||||
{ ...m('12', '[x.salastil.com](http://x.salastil.com)'), embeds: [{ url: 'http://x.salastil.com', title: 'Home', description: 'A privacy-focused Twitter/X frontend', provider: 'x.salastil.com', imageUrl: 'https://community.steamstatic.com/chat/image/abc/share_image' }] }
|
||||
] }
|
||||
})
|
||||
})()`)
|
||||
await sleep(1500)
|
||||
const shot = async (name) => {
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
console.log('wrote', name)
|
||||
}
|
||||
await shot('steam-spoiler-before.png')
|
||||
console.log('links:', JSON.stringify(await page.evaluate(`[...document.querySelectorAll('.message-body a, .message-text a, .message-row a')].map((a) => [a.textContent, a.getAttribute('href')]).filter((l) => l[1] && !l[1].startsWith('spoiler'))`)))
|
||||
console.log('covers:', await page.evaluate(`document.querySelectorAll('.spoiler-cover').length`), '| embeds:', await page.evaluate(`document.querySelectorAll('.media-embed-wrap').length`))
|
||||
await page.evaluate(`document.querySelector('.spoiler-cover')?.click()`)
|
||||
await sleep(500)
|
||||
console.log('covers after one click:', await page.evaluate(`document.querySelectorAll('.spoiler-cover').length`))
|
||||
await shot('steam-spoiler-after.png')
|
||||
} finally {
|
||||
await stop()
|
||||
irc.close()
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Photographs Steam voice as the window shows it: a group's voice room with people in
|
||||
* it, the panel for the call you are in, and a call ringing. Made-up state on a fake IRC
|
||||
* server, in a scratch home.
|
||||
*
|
||||
* node scripts/shot-steam-voice.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
try {
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
await page.evaluate(`(() => {
|
||||
const s = window.__mohoShots
|
||||
const st = s.state()
|
||||
const account = { ...st.accounts[0], id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
|
||||
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
|
||||
const room = (name, id, position) => ({ ...channel, id: 'steam:me|' + name, accountId: 'steam:me', name, kind: 'channel', remoteId: '7:' + id, groupId: 'steam:me|steamgroup:7', position, unread: 0, highlight: false, mentions: 0 })
|
||||
const dm = { ...channel, id: 'steam:me|Gabe', accountId: 'steam:me', name: 'Gabe', kind: 'dm', remoteId: '76561197960265729', unread: 0, highlight: false, mentions: 0 }
|
||||
const general = room('Raiders/#General', '70', 1)
|
||||
const talk = room('Raiders/#Lounge', '71', 2)
|
||||
const live = s.store.steamVoiceLive
|
||||
live.speakers = new Set(['76561197960265730'])
|
||||
live.statuses = new Map([['76561197960265731', { micMuted: true, outputMuted: false, noMic: false }]])
|
||||
live.level = 0.4
|
||||
live.rttMs = 23
|
||||
s.patch({
|
||||
accounts: [...st.accounts, account],
|
||||
groups: [...st.groups, { id: 'steam:me|steamgroup:7', accountId: 'steam:me', service: 'steam', kind: 'guild', name: 'Raiders', position: 3 }],
|
||||
buffers: [...st.buffers, general, talk, dm],
|
||||
activeBufferId: general.id, activeGroupId: 'steam:me|steamgroup:7',
|
||||
steamVoiceRooms: { 'steam:me|7': [{ roomId: '71', bufferId: talk.id, name: 'Lounge', members: [
|
||||
{ userId: '76561197960265729', nick: 'Gabe' },
|
||||
{ userId: '76561197960265730', nick: 'Alyx' },
|
||||
{ userId: '76561197960265731', nick: 'Eli' },
|
||||
{ userId: '76561197960287930', nick: 'me', isSelf: true }
|
||||
] }] },
|
||||
steamVoiceNow: { accountId: 'steam:me', phase: 'connected', target: { kind: 'room', groupId: '7', roomId: '71', bufferId: talk.id, name: 'Lounge', groupName: 'Raiders', railId: 'steam:me|steamgroup:7' } },
|
||||
voiceSessions: [{ accountId: 'steam:me', channelId: '71', channelName: 'Lounge', isDirect: false, bufferId: talk.id, railId: 'steam:me|steamgroup:7' }],
|
||||
steamRings: [{ accountId: 'steam:me', steamid: '76561197960265729', voiceChatId: '99' }]
|
||||
})
|
||||
})()`)
|
||||
// The window asks the daemon who is in the voice rooms when the group opens, and the
|
||||
// daemon knows no such group: the made-up answer goes in after that.
|
||||
await sleep(1200)
|
||||
await page.evaluate(`(() => { const s = window.__mohoShots; s.patch({ steamVoiceRooms: { 'steam:me|7': [{ roomId: '71', bufferId: 'steam:me|Raiders/#Lounge', name: 'Lounge', members: [{ userId: '76561197960265729', nick: 'Gabe' }, { userId: '76561197960265730', nick: 'Alyx' }, { userId: '76561197960265731', nick: 'Eli' }, { userId: '76561197960287930', nick: 'me', isSelf: true }] }] } }) })()`)
|
||||
await sleep(800)
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, 'steam-voice.png'), Buffer.from(data, 'base64'))
|
||||
console.log('panel:', JSON.stringify(await page.evaluate(`document.querySelector('.voice-panel')?.innerText`)))
|
||||
console.log('ring:', JSON.stringify(await page.evaluate(`document.querySelector('.incoming-call')?.innerText`)))
|
||||
} finally {
|
||||
await stop()
|
||||
irc.close()
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Photographs the member list and the direct-message rows in comfy and in
|
||||
* classic, with made-up presence on them (a game, a track, words somebody
|
||||
* wrote), so the two looks can be compared by eye.
|
||||
*
|
||||
* node scripts/shot-userlist.mjs <output directory>
|
||||
*
|
||||
* Runs the built app against a scratch home with the screenshot hook on, and
|
||||
* a fake IRC server for something to be in.
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
|
||||
const PEOPLE = ['Crujix', 'Currington', 'dakota', 'Gremlin_magos', 'onkell', 'Salastil', 'Taylaigh']
|
||||
|
||||
const MEMBERS = [
|
||||
{ nick: 'Crujix', status: 'idle', activity: { kind: 'game', text: 'Songs of Syx Demo' } },
|
||||
{ nick: 'Currington', status: 'online', activity: { kind: 'game', text: 'Philippians 2' } },
|
||||
{ nick: 'dakota', status: 'online' },
|
||||
{ nick: 'Gremlin_magos', status: 'dnd', activity: { kind: 'custom', text: 'ask questions' } },
|
||||
{ nick: 'onkell', status: 'online', activity: { kind: 'game', text: 'Down to Dungeon' } },
|
||||
{ nick: 'Salastil', status: 'online', activity: { kind: 'music', text: 'Ramones' } },
|
||||
{ nick: 'Taylaigh', status: 'offline', away: true }
|
||||
]
|
||||
|
||||
const irc = await fakeIrc({
|
||||
people: PEOPLE,
|
||||
onJoin: (channel, _nick, send) => send(`:Crujix!u@h PRIVMSG ${channel} :hello there`)
|
||||
})
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
try {
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1100, height: 760, deviceScaleFactor: 1, mobile: false })
|
||||
const shot = async (name) => {
|
||||
await sleep(600)
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
console.log('wrote', name)
|
||||
}
|
||||
|
||||
const dress = async () => {
|
||||
await page.evaluate(`(() => {
|
||||
const s = window.__mohoShots
|
||||
const st = s.state()
|
||||
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
|
||||
const dm = (name, extra = {}) => ({ ...channel, id: 'irc:x|' + name, name, kind: 'dm', unread: 0, highlight: false, mentions: 0, ...extra })
|
||||
const gamer = dm('k. mardam bae')
|
||||
const quiet = dm('Alexcellence')
|
||||
s.patch({
|
||||
buffers: [...st.buffers, gamer, quiet],
|
||||
presenceByBuffer: {
|
||||
...st.presenceByBuffer,
|
||||
[channel.id]: ${JSON.stringify(MEMBERS)},
|
||||
[gamer.id]: [{ nick: 'k. mardam bae', status: 'idle', activity: { kind: 'game', text: "Assassin's Creed Valhalla" } }],
|
||||
[quiet.id]: [{ nick: 'Alexcellence', status: 'online' }]
|
||||
}
|
||||
})
|
||||
})()`)
|
||||
}
|
||||
await dress()
|
||||
await page.evaluate(`window.moho.prefs.set('display.messageMode', 'comfy')`)
|
||||
await shot('comfy-channel.png')
|
||||
await page.evaluate(`(() => {
|
||||
const s = window.__mohoShots
|
||||
const channel = s.state().buffers.find((b) => b.id.endsWith('|#ui'))
|
||||
s.patch({ profile: { service: 'irc', accountId: channel.accountId, name: 'onkell', handle: 'onkell', status: 'online', extra: [{ label: 'Client', value: 'moho' }] } })
|
||||
})()`)
|
||||
await shot('profile-card.png')
|
||||
await page.evaluate(`(() => { window.__mohoShots.patch({ profile: null }) })()`)
|
||||
await page.evaluate(`(() => { const s = window.__mohoShots; s.store.selectGroup('~dms') })()`)
|
||||
await shot('comfy-dms.png')
|
||||
// A window is not told about its own preference change, so the new mode is
|
||||
// set, the page reloaded to read it, and the made-up state put back.
|
||||
await page.evaluate(`window.moho.prefs.set('display.messageMode', 'classic')`)
|
||||
await sleep(300)
|
||||
await page.call('Page.reload', {})
|
||||
await sleep(2500)
|
||||
await dress()
|
||||
await page.evaluate(`(() => { const s = window.__mohoShots; s.store.selectGroup('~dms') })()`)
|
||||
await shot('classic-dms.png')
|
||||
await page.evaluate(`(() => { const s = window.__mohoShots; const b = s.state().buffers.find((b) => b.id.endsWith('|#ui')); s.store.selectBuffer(b.id) })()`)
|
||||
await shot('classic-channel.png')
|
||||
} finally {
|
||||
await stop()
|
||||
irc.close()
|
||||
}
|
||||
@@ -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)
|
||||
@@ -23,15 +23,7 @@
|
||||
* On a machine with no display, run it under xvfb-run.
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import net from 'node:net'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import electronPath from 'electron'
|
||||
|
||||
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
|
||||
import { click, fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
|
||||
|
||||
/** When each popup is measured after it is opened, in milliseconds. */
|
||||
const SAMPLES = [80, 400, 1500, 3500]
|
||||
@@ -45,119 +37,8 @@ const SIZES = [
|
||||
/** Everything that opens on top of the UI. */
|
||||
const POPUPS = '.context-menu, .emoji-picker, .header-popover, .profile-card, [role="dialog"], [role="menu"]'
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
// --- a fake IRC server ---------------------------------------------------
|
||||
|
||||
const PEOPLE = ['alice', 'bob', 'carol', 'dave_the_longer_named', 'eve']
|
||||
|
||||
function fakeIrc() {
|
||||
const server = net.createServer((socket) => {
|
||||
let nick = 'checker'
|
||||
const send = (line) => socket.write(line + '\r\n')
|
||||
let buffered = ''
|
||||
socket.on('error', () => {})
|
||||
socket.on('data', (chunk) => {
|
||||
buffered += chunk.toString()
|
||||
let at
|
||||
while ((at = buffered.indexOf('\n')) >= 0) {
|
||||
const line = buffered.slice(0, at).replace(/\r$/, '')
|
||||
buffered = buffered.slice(at + 1)
|
||||
const [command, ...args] = line.split(' ')
|
||||
switch (command.toUpperCase()) {
|
||||
case 'CAP':
|
||||
if (args[0] === 'LS') send(':fake CAP * LS :')
|
||||
break
|
||||
case 'NICK':
|
||||
nick = args[0]
|
||||
break
|
||||
case 'USER':
|
||||
send(`:fake 001 ${nick} :Welcome`)
|
||||
send(`:fake 376 ${nick} :End of MOTD`)
|
||||
break
|
||||
case 'PING':
|
||||
send(`:fake PONG fake ${args.join(' ')}`)
|
||||
break
|
||||
case 'JOIN': {
|
||||
const channel = args[0].split(',')[0]
|
||||
send(`:${nick}!u@h JOIN ${channel}`)
|
||||
send(`:fake 353 ${nick} = ${channel} :${[nick, ...PEOPLE].join(' ')}`)
|
||||
send(`:fake 366 ${nick} ${channel} :End of NAMES`)
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const who = PEOPLE[i % PEOPLE.length]
|
||||
send(`:${who}!u@h PRIVMSG ${channel} :line ${i + 1} from ${who}, long enough to wrap on a narrow window`)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server)))
|
||||
}
|
||||
|
||||
// --- the app, and a line to it --------------------------------------------
|
||||
|
||||
async function freePort() {
|
||||
const probe = net.createServer()
|
||||
await new Promise((r) => probe.listen(0, '127.0.0.1', r))
|
||||
const { port } = probe.address()
|
||||
await new Promise((r) => probe.close(r))
|
||||
return port
|
||||
}
|
||||
|
||||
async function pageTarget(port) {
|
||||
for (let i = 0; i < 120; i++) {
|
||||
try {
|
||||
const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
|
||||
const page = list.find((t) => t.type === 'page' && !t.url.startsWith('devtools:'))
|
||||
if (page) return page
|
||||
} catch {
|
||||
/* not up yet */
|
||||
}
|
||||
await sleep(500)
|
||||
}
|
||||
throw new Error('the app never opened a window')
|
||||
}
|
||||
|
||||
function cdp(url) {
|
||||
const socket = new WebSocket(url)
|
||||
let id = 0
|
||||
const waiting = new Map()
|
||||
socket.addEventListener('message', (e) => {
|
||||
const msg = JSON.parse(e.data)
|
||||
if (msg.id && waiting.has(msg.id)) {
|
||||
const { resolve, reject } = waiting.get(msg.id)
|
||||
waiting.delete(msg.id)
|
||||
if (msg.error) reject(new Error(msg.error.message))
|
||||
else resolve(msg.result)
|
||||
}
|
||||
})
|
||||
const ready = new Promise((r) => socket.addEventListener('open', r))
|
||||
const call = async (method, params = {}) => {
|
||||
await ready
|
||||
const n = ++id
|
||||
socket.send(JSON.stringify({ id: n, method, params }))
|
||||
return new Promise((resolve, reject) => waiting.set(n, { resolve, reject }))
|
||||
}
|
||||
const evaluate = async (expression) => {
|
||||
const r = await call('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text)
|
||||
return r.result.value
|
||||
}
|
||||
return { call, evaluate, close: () => socket.close() }
|
||||
}
|
||||
|
||||
/** Waits until an expression in the page is truthy. */
|
||||
async function until(page, expression, what, ms = 30000) {
|
||||
const end = Date.now() + ms
|
||||
while (Date.now() < end) {
|
||||
if (await page.evaluate(`!!(${expression})`).catch(() => false)) return
|
||||
await sleep(250)
|
||||
}
|
||||
throw new Error(`timed out waiting for ${what}`)
|
||||
}
|
||||
|
||||
// --- the checks -------------------------------------------------------------
|
||||
|
||||
/** Where every open popup is, against the window. */
|
||||
@@ -186,20 +67,13 @@ const rightClickInCorner = (selector, text) => `(() => {
|
||||
return true
|
||||
})()`
|
||||
|
||||
const click = (selector, text) => `(() => {
|
||||
const el = [...document.querySelectorAll(${JSON.stringify(selector)})]
|
||||
.find((e) => ${JSON.stringify(text ?? '')} === '' || (e.innerText + ' ' + (e.title || '')).includes(${JSON.stringify(text ?? '')}))
|
||||
if (!el) return false
|
||||
el.click()
|
||||
return true
|
||||
})()`
|
||||
|
||||
const SCENARIOS = [
|
||||
{ name: 'emoji picker', open: click('button[title="Emoji"]') },
|
||||
{ name: 'channel list menu, opened in the corner', open: rightClickInCorner('.buffer-row', '#ui') },
|
||||
{ name: 'message menu, opened in the corner', open: rightClickInCorner('.message-row', 'line 12') },
|
||||
{ name: 'member menu, opened in the corner', open: rightClickInCorner('.nick-row', 'alice') },
|
||||
{ name: 'mentions inbox', open: click('button[title="Mentions"]') }
|
||||
{ name: 'mentions inbox', open: click('button[title="Mentions"]') },
|
||||
{ name: 'status popout', open: click('.user-identity-button') }
|
||||
]
|
||||
|
||||
async function closePopups(page) {
|
||||
@@ -217,58 +91,21 @@ async function closePopups(page) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const irc = await fakeIrc()
|
||||
const ircPort = irc.address().port
|
||||
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'moho-ui-'))
|
||||
const run = path.join(scratch, 'run')
|
||||
fs.mkdirSync(run, { mode: 0o700 })
|
||||
const port = await freePort()
|
||||
|
||||
// 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 app = spawn(electronPath, [root, ...flags], {
|
||||
cwd: root,
|
||||
detached: true,
|
||||
stdio: process.env.MOHO_UI_VERBOSE ? 'inherit' : 'ignore',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: scratch,
|
||||
XDG_CONFIG_HOME: path.join(scratch, 'config'),
|
||||
XDG_CACHE_HOME: path.join(scratch, 'cache'),
|
||||
XDG_DATA_HOME: path.join(scratch, 'data'),
|
||||
XDG_STATE_HOME: path.join(scratch, 'state'),
|
||||
XDG_RUNTIME_DIR: run,
|
||||
// The runtime directory is moved so the daemon's socket is this run's
|
||||
// own - which also moves where a Wayland client looks for the
|
||||
// compositor. An absolute path keeps it pointing at the real one.
|
||||
...(process.env.WAYLAND_DISPLAY && process.env.XDG_RUNTIME_DIR && !path.isAbsolute(process.env.WAYLAND_DISPLAY)
|
||||
? { WAYLAND_DISPLAY: path.join(process.env.XDG_RUNTIME_DIR, process.env.WAYLAND_DISPLAY) }
|
||||
: {})
|
||||
const irc = await fakeIrc({
|
||||
people: PEOPLE,
|
||||
onJoin: (channel, _nick, send) => {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const who = PEOPLE[i % PEOPLE.length]
|
||||
send(`:${who}!u@h PRIVMSG ${channel} :line ${i + 1} from ${who}, long enough to wrap on a narrow window`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const failures = []
|
||||
let checked = 0
|
||||
let page
|
||||
const { page, stop } = await launchApp()
|
||||
try {
|
||||
page = cdp((await pageTarget(port)).webSocketDebuggerUrl)
|
||||
await until(page, 'window.moho && document.querySelector("#root > *")', 'the window to load')
|
||||
|
||||
// An account on the fake server, with a channel to open popups over.
|
||||
await until(page, 'window.moho.rpc("listAccounts", {}).then(() => true)', 'the daemon')
|
||||
await page.evaluate(
|
||||
`window.moho.rpc("addAccount", { nick: "checker", host: "localhost", port: ${ircPort}, ssl: false, autojoin: "#ui" })`
|
||||
)
|
||||
await until(page, 'window.moho.rpc("listBuffers", {}).then((b) => b.some((x) => x.id.endsWith("|#ui")))', 'the channel')
|
||||
await page.call('Page.reload')
|
||||
await sleep(1000)
|
||||
await until(page, 'document.querySelector("[title*=\\"localhost\\"]")', 'the account in the rail')
|
||||
await page.evaluate(click('[title*="localhost"]'))
|
||||
await until(page, `[...document.querySelectorAll(".buffer-row")].some((r) => (r.title || r.innerText).includes("#ui"))`, 'the channel row')
|
||||
await page.evaluate(click('.buffer-row', '#ui'))
|
||||
await until(page, `[...document.querySelectorAll(".message-row")].some((r) => r.innerText.includes("line 12"))`, 'the messages')
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'line 12' })
|
||||
|
||||
for (const size of SIZES) {
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: false })
|
||||
@@ -302,20 +139,8 @@ async function main() {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
page?.close()
|
||||
try {
|
||||
process.kill(-app.pid, 'SIGTERM')
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
await sleep(1500)
|
||||
try {
|
||||
process.kill(-app.pid, 'SIGKILL')
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
await stop()
|
||||
irc.close()
|
||||
fs.rmSync(scratch, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
|
||||
@@ -99,6 +99,23 @@ export function localPath(source: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The bytes of one piece of media, wherever it is: a file nobilis cached, or a
|
||||
* link (including the app's own moho-media one). For putting a picture on the
|
||||
* clipboard, where saving it first would leave a file nobody asked for.
|
||||
*/
|
||||
export async function readMedia(
|
||||
source: string,
|
||||
fetchRemote: (url: string) => Promise<{ ok: boolean; status: number; bytes: () => Promise<Uint8Array> }>
|
||||
): Promise<Uint8Array> {
|
||||
const local = localPath(source)
|
||||
if (local) return fsp.readFile(local)
|
||||
if (!/^(https?|moho-media):\/\//i.test(source)) throw new Error('unsupported link')
|
||||
const res = await fetchRemote(source)
|
||||
if (!res.ok) throw new Error(`server returned ${res.status}`)
|
||||
return res.bytes()
|
||||
}
|
||||
|
||||
export interface SaveResult {
|
||||
path?: string
|
||||
error?: string
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -6,30 +6,36 @@ import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
clipboard,
|
||||
ClipboardItem,
|
||||
dialog,
|
||||
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, type EditAction, type EditMenuRequest, type PopoutState } from '../shared/ipc'
|
||||
import { EDIT_ACTIONS, IPC, POPOUT_FLAG, UI_SHOTS_FLAG, type EditAction, type EditMenuRequest, type PopoutState } from '../shared/ipc'
|
||||
import { clearnetLinks } from '../shared/clearnet'
|
||||
import { readCapped, pictureNamedIn } from './imagepage'
|
||||
import { DEEP_LINK_SCHEMES, isDeepLink } from '../shared/deeplink'
|
||||
import { allowPickedFile, allowRoot, installMediaHandler, registerMediaScheme } from './media-protocol'
|
||||
import { installScreenShare } from './screenshare'
|
||||
import { defaultDownloadDir, saveMedia } from './downloads'
|
||||
import { defaultDownloadDir, readMedia, saveMedia } from './downloads'
|
||||
import type { Buffer as ChatBuffer } from '../shared/wire'
|
||||
import { log } from './log'
|
||||
import { applyPolicy, applyTunnel } from './tunnel'
|
||||
@@ -130,6 +136,7 @@ let prefs: Prefs
|
||||
let nobilis: NobilisProcess
|
||||
let client: NobilisClient
|
||||
let notifier: Notifier
|
||||
let idleWatcher: IdleWatcher
|
||||
let registeredHotkey: string | null = null
|
||||
|
||||
/**
|
||||
@@ -189,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())
|
||||
@@ -225,6 +278,47 @@ function callerWindow(event: Electron.IpcMainInvokeEvent): BrowserWindow | null
|
||||
* The gap between them is a fraction of a second of the background colour,
|
||||
* which is a far better failure than no window at all.
|
||||
*/
|
||||
/**
|
||||
* What to do when a window's page dies under it.
|
||||
*
|
||||
* Nothing used to be done, and nothing said: the renderer process would be
|
||||
* gone - out of memory, killed, crashed - and the window stayed on screen as an
|
||||
* empty shell with no page in it, which is what "the window just went
|
||||
* invisible" was. Now the reason is written to the log, where it outlives the
|
||||
* process, and the page is loaded again so the window comes back with the
|
||||
* conversation list and everything else intact: the daemon is a separate
|
||||
* process and kept running all along.
|
||||
*
|
||||
* Not when the app is quitting, and not more than a few times a minute: a page that dies as it loads would otherwise be reloaded for
|
||||
* ever.
|
||||
*/
|
||||
function recoverFromCrash(win: BrowserWindow, name: string): void {
|
||||
const recent: number[] = []
|
||||
win.webContents.on('render-process-gone', (_e, details) => {
|
||||
log.error(
|
||||
`[window] ${name}: the page process is gone - ${details.reason} (exit code ${details.exitCode}) at ${win.isDestroyed() ? 'a destroyed window' : win.webContents.getURL()}`
|
||||
)
|
||||
// A page that exits cleanly while its window is still there is no window
|
||||
// being closed - that would have taken the window with it - and left the
|
||||
// window empty just the same. Only the app quitting is let go.
|
||||
if (win.isDestroyed() || quitting) return
|
||||
const now = Date.now()
|
||||
while (recent.length > 0 && now - recent[0] > 60_000) recent.shift()
|
||||
recent.push(now)
|
||||
if (recent.length > 3) {
|
||||
log.error(`[window] ${name}: it keeps dying; leaving it`)
|
||||
return
|
||||
}
|
||||
setTimeout(() => {
|
||||
// Loaded afresh rather than reloaded: a page that had navigated away
|
||||
// would otherwise reload wherever it had got to.
|
||||
if (!win.isDestroyed()) loadRenderer(win)
|
||||
}, 500)
|
||||
})
|
||||
win.webContents.on('unresponsive', () => log.warn(`[window] ${name}: the page has stopped responding`))
|
||||
win.webContents.on('responsive', () => log.warn(`[window] ${name}: the page is responding again`))
|
||||
}
|
||||
|
||||
function revealOnce(win: BrowserWindow, focus = false): void {
|
||||
let done = false
|
||||
const reveal = (): void => {
|
||||
@@ -265,6 +359,9 @@ function wireEditMenu(win: BrowserWindow): void {
|
||||
})
|
||||
}
|
||||
|
||||
/** The screenshot harness's flag, passed on to every window when it asked for it. */
|
||||
const shotsArguments = process.env.MOHO_UI_SHOTS === '1' ? [UI_SHOTS_FLAG] : []
|
||||
|
||||
function createWindow(): void {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1100,
|
||||
@@ -290,11 +387,13 @@ function createWindow(): void {
|
||||
// is allowed, and reads its popout flag from process.argv, which stays
|
||||
// readable. See the note in whenReady for the hosts that cannot honour
|
||||
// this.
|
||||
sandbox: true
|
||||
sandbox: true,
|
||||
additionalArguments: shotsArguments
|
||||
}
|
||||
})
|
||||
|
||||
revealOnce(mainWindow)
|
||||
recoverFromCrash(mainWindow, 'main')
|
||||
wireEditMenu(mainWindow)
|
||||
|
||||
// A link that arrived before this window existed - from the click that
|
||||
@@ -306,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', () => {
|
||||
@@ -459,7 +561,7 @@ function openPopout(bufferId: string, title?: string): void {
|
||||
nodeIntegration: false,
|
||||
// As the main window - a popout draws the same conversations.
|
||||
sandbox: true,
|
||||
additionalArguments: [`${POPOUT_FLAG}${bufferId}`]
|
||||
additionalArguments: [`${POPOUT_FLAG}${bufferId}`, ...shotsArguments]
|
||||
}
|
||||
})
|
||||
popouts.set(bufferId, win)
|
||||
@@ -467,6 +569,7 @@ function openPopout(bufferId: string, title?: string): void {
|
||||
// Focused as well as shown: unlike the main window at startup, this one was
|
||||
// asked for just now.
|
||||
revealOnce(win, true)
|
||||
recoverFromCrash(win, `popout ${bufferId}`)
|
||||
wireEditMenu(win)
|
||||
|
||||
// Sent to this window rather than broadcast: every window draws its own
|
||||
@@ -514,46 +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()
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Quit',
|
||||
click: () => {
|
||||
app.quit()
|
||||
}
|
||||
}
|
||||
])
|
||||
)
|
||||
tray.on('click', toggleWindow)
|
||||
/** 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)
|
||||
}
|
||||
|
||||
function updateTray(unreadCount: number, hasAlert: boolean): void {
|
||||
/**
|
||||
* 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
|
||||
tray.setImage(trayIcon(hasAlert))
|
||||
tray.setToolTip(unreadCount > 0 ? `moho - ${unreadCount} unread` : 'moho')
|
||||
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)
|
||||
},
|
||||
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 lastTray: TrayState = NOTHING_WAITING
|
||||
|
||||
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 ? 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 && state.unread > 0 && trayStatus !== 'dnd' && !mainWindow.isFocused())
|
||||
}
|
||||
refreshTrayMenu()
|
||||
if (!tray) return
|
||||
tray.setImage(trayIcon(badge ? state.unread : 0))
|
||||
lastTrayTooltip = trayTooltip(state, badge)
|
||||
tray.setToolTip(lastTrayTooltip)
|
||||
send(IPC.link, client.linkUp)
|
||||
}
|
||||
|
||||
@@ -585,6 +754,12 @@ function applyHotkey(accelerator: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
app.on('child-process-gone', (_e, details) => {
|
||||
// The graphics process or a utility: a window that stops painting with its
|
||||
// page still alive is this.
|
||||
log.error(`[process] ${details.type} process gone - ${details.reason} (exit code ${details.exitCode})`)
|
||||
})
|
||||
|
||||
function wireIpc(): void {
|
||||
ipcMain.handle(IPC.rpc, async (_e, method: string, params: Record<string, unknown>) => {
|
||||
try {
|
||||
@@ -598,19 +773,27 @@ function wireIpc(): void {
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.prefsGetAll, () => prefs.all())
|
||||
ipcMain.handle(IPC.prefsSet, (e, key: string, value: unknown) => {
|
||||
prefs.set(key, value)
|
||||
// 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))
|
||||
@@ -719,10 +902,19 @@ function wireIpc(): void {
|
||||
return res.canceled || !res.filePath ? null : res.filePath
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.pickFile, async (e) => {
|
||||
ipcMain.handle(IPC.pickFile, async (e, extensions?: string[]) => {
|
||||
const parent = callerWindow(e) ?? mainWindow
|
||||
if (!parent) return null
|
||||
const res = await dialog.showOpenDialog(parent, { properties: ['openFile'] })
|
||||
// Where a service takes only some kinds of file, the dialog shows only
|
||||
// those and has no "all files" to widen it: a file that would be refused
|
||||
// is not offered. Extensions only, as the dialog wants them - no dots.
|
||||
const taken = Array.isArray(extensions)
|
||||
? extensions.filter((x): x is string => typeof x === 'string' && /^[A-Za-z0-9]{1,8}$/.test(x))
|
||||
: []
|
||||
const res = await dialog.showOpenDialog(parent, {
|
||||
properties: ['openFile'],
|
||||
...(taken.length ? { filters: [{ name: 'Pictures and videos', extensions: taken }] } : {})
|
||||
})
|
||||
if (res.canceled || !res.filePaths[0]) return null
|
||||
// The renderer draws a thumbnail of what was staged, and that goes back
|
||||
// through the guarded media scheme like every other local file. Where
|
||||
@@ -849,6 +1041,40 @@ function wireIpc(): void {
|
||||
if (typeof text === 'string' && text.length > 0) clipboard.writeText(text)
|
||||
})
|
||||
|
||||
// A picture on the clipboard, for pasting into another app. Taken from the
|
||||
// page's own pixels when it could read them (that also covers formats the
|
||||
// clipboard does not take as they are, such as WebP), and otherwise from
|
||||
// wherever the picture lives.
|
||||
ipcMain.handle(IPC.copyImage, async (_e, source: string, dataUrl?: string) => {
|
||||
try {
|
||||
const img =
|
||||
typeof dataUrl === 'string' && dataUrl.startsWith('data:image/')
|
||||
? nativeImage.createFromDataURL(dataUrl)
|
||||
: nativeImage.createFromBuffer(
|
||||
Buffer.from(
|
||||
await readMedia(String(source), async (url) => {
|
||||
const res = await net.fetch(url)
|
||||
return { ok: res.ok, status: res.status, bytes: async () => new Uint8Array(await res.arrayBuffer()) }
|
||||
})
|
||||
)
|
||||
)
|
||||
if (img.isEmpty()) return { error: "this kind of picture can't be copied" }
|
||||
await clipboard.write([new ClipboardItem({ 'image/png': new Blob([img.toPNG()], { type: 'image/png' }) })])
|
||||
return {}
|
||||
} catch (e) {
|
||||
return { error: (e as Error).message }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.fileSize, async (_e, file: string) => {
|
||||
try {
|
||||
const st = await fsp.stat(String(file))
|
||||
return st.isFile() ? st.size : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.restartDaemon, () => nobilis.restart())
|
||||
ipcMain.handle(IPC.daemonStatus, () => ({
|
||||
binaryPath: nobilis.binaryPath,
|
||||
@@ -1032,32 +1258,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))
|
||||
@@ -1099,6 +1331,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()
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const shown: { silent?: boolean }[] = []
|
||||
vi.mock('electron', () => {
|
||||
class Notification {
|
||||
static isSupported(): boolean {
|
||||
return true
|
||||
}
|
||||
opts: { silent?: boolean }
|
||||
constructor(opts: { silent?: boolean }) {
|
||||
this.opts = opts
|
||||
}
|
||||
on(): void {}
|
||||
show(): void {
|
||||
shown.push(this.opts)
|
||||
}
|
||||
}
|
||||
return { Notification, nativeImage: {}, app: { getPath: () => '/tmp' } }
|
||||
})
|
||||
|
||||
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>, 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, (state) => alerts.push(state), () => {}, () => null, () => false, (account) => dnd.includes(account))
|
||||
return { n, alerts }
|
||||
}
|
||||
|
||||
describe('notification settings', () => {
|
||||
beforeEach(() => {
|
||||
shown.length = 0
|
||||
})
|
||||
|
||||
it('shows a notification, with the system sound, by default', async () => {
|
||||
await notifier({}).n.handle(payload)
|
||||
expect(shown).toHaveLength(1)
|
||||
expect(shown[0].silent).toBe(false)
|
||||
})
|
||||
|
||||
it('shows none when desktop notifications are off, but still counts the conversation as unread', async () => {
|
||||
const { n, alerts } = notifier({ 'notifications.desktop': false })
|
||||
await n.handle(payload)
|
||||
expect(shown).toHaveLength(0)
|
||||
expect(alerts.at(-1)?.unread).toBe(1)
|
||||
})
|
||||
|
||||
it('makes them silent when the sound is off', async () => {
|
||||
await notifier({ 'notifications.sound': false }).n.handle(payload)
|
||||
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,12 +113,19 @@ 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
|
||||
if (!Notification.isSupported()) return
|
||||
|
||||
const icon = await this.notificationIcon(payload.avatarUrl)
|
||||
const notification = new Notification({
|
||||
title: payload.title || 'moho',
|
||||
body: payload.body || '',
|
||||
// The system's own sound for it, unless asked to be quiet.
|
||||
silent: !this.prefs.get<boolean>('notifications.sound', true),
|
||||
...(icon ? { icon } : {})
|
||||
})
|
||||
notification.on('click', () => this.onActivate(payload.bufferId))
|
||||
@@ -283,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 }
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { contextBridge, ipcRenderer, webUtils } from 'electron'
|
||||
import { IPC, POPOUT_FLAG, type EditAction, type EditMenuRequest, type PopoutState, type ScreenSource } from '../shared/ipc'
|
||||
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron'
|
||||
import { IPC, POPOUT_FLAG, UI_SHOTS_FLAG, type EditAction, type EditMenuRequest, type PopoutState, type ScreenSource } from '../shared/ipc'
|
||||
import type { NobilisEvent } from '../shared/wire'
|
||||
|
||||
/**
|
||||
@@ -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)
|
||||
@@ -121,6 +142,8 @@ const api = {
|
||||
ipcRenderer.invoke(IPC.resolveImagePage, url),
|
||||
|
||||
/** Puts a line of text on the system clipboard. */
|
||||
/** How large everything is drawn: 1 is as designed. */
|
||||
setZoom: (factor: number): void => webFrame.setZoomFactor(factor),
|
||||
copyText: (text: string): Promise<void> => ipcRenderer.invoke(IPC.writeClipboardText, text),
|
||||
/**
|
||||
* Where a dropped file actually lives.
|
||||
@@ -133,7 +156,7 @@ const api = {
|
||||
*/
|
||||
pathForFile: (file: File): string => webUtils.getPathForFile(file),
|
||||
|
||||
pickFile: (): Promise<string | null> => ipcRenderer.invoke(IPC.pickFile),
|
||||
pickFile: (extensions?: string[]): Promise<string | null> => ipcRenderer.invoke(IPC.pickFile, extensions),
|
||||
/**
|
||||
* Answers the main process when it needs a screen or window chosen.
|
||||
*
|
||||
@@ -156,6 +179,14 @@ const api = {
|
||||
importGroupIcon: (groupId: string): Promise<{ path?: string; error?: string }> =>
|
||||
ipcRenderer.invoke(IPC.importGroupIcon, groupId),
|
||||
defaultDownloadDir: (): Promise<string> => ipcRenderer.invoke(IPC.defaultDownloadDir),
|
||||
/**
|
||||
* Puts a picture on the system clipboard: from its pixels where the page
|
||||
* could read them, otherwise from where it lives.
|
||||
*/
|
||||
/** How big a file is, for the tray it is staged in; null for what cannot be read. */
|
||||
fileSize: (path: string): Promise<number | null> => ipcRenderer.invoke(IPC.fileSize, path),
|
||||
copyImage: (source: string, dataUrl?: string): Promise<{ error?: string }> =>
|
||||
ipcRenderer.invoke(IPC.copyImage, source, dataUrl),
|
||||
downloadMedia: (source: string, filename?: string): Promise<{ path?: string; error?: string }> =>
|
||||
ipcRenderer.invoke(IPC.downloadMedia, source, filename),
|
||||
readClipboardImage: (): Promise<string | null> => ipcRenderer.invoke(IPC.readClipboardImage),
|
||||
@@ -182,6 +213,9 @@ const api = {
|
||||
ipcRenderer.invoke(IPC.markBufferRead, bufferId),
|
||||
|
||||
/** Conversations in windows of their own. */
|
||||
/** Whether the screenshot harness started this window - see UI_SHOTS_FLAG. */
|
||||
uiShots: process.argv.includes(UI_SHOTS_FLAG),
|
||||
|
||||
popout: {
|
||||
/** Set only in a popped-out window, naming the conversation it shows. */
|
||||
bufferId: popoutBufferId,
|
||||
@@ -199,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
|
||||
|
||||
@@ -3,26 +3,28 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { TitleBar } from './components/TitleBar'
|
||||
import { BufferList } from './components/BufferList'
|
||||
import { ServerRail } from './components/ServerRail'
|
||||
import { ForumPane } from './components/ForumPane'
|
||||
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 { INVITATIONS_GROUP_ID, 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'
|
||||
import { ThreadPanel } from './components/ThreadPanel'
|
||||
import { InvitePanel } from './components/InvitePanel'
|
||||
import { SteamFriendDialogs } from './components/SteamFriendDialogs'
|
||||
import { VerificationDialog } from './components/VerificationDialog'
|
||||
import { ProfileCard } from './components/ProfileCard'
|
||||
import { ConversationTools } from './components/ConversationTools'
|
||||
import { AccountsPanel } from './components/AccountsPanel'
|
||||
import { SettingsPanel } from './components/settings/SettingsPanel'
|
||||
import { DownloadsPanel } from './components/DownloadsPanel'
|
||||
import { JoinPanel } from './components/JoinPanel'
|
||||
import { PeekBar, PeekView } from './components/PeekView'
|
||||
import { Toasts } from './components/Toasts'
|
||||
import { IncomingCallPanel } from './components/IncomingCallPanel'
|
||||
import { CallAudio, CallStage, IncomingMatrixCall, ScreenPicker } from './components/CallStage'
|
||||
import { CallAudio, CallStage, ScreenPicker } from './components/CallStage'
|
||||
import { CallWindowHost } from './components/stage/CallWindowHost'
|
||||
import { StreamStage, StreamWindowHost } from './components/StreamStage'
|
||||
import { EventCreatePanel, EventsPane } from './components/DiscordEvents'
|
||||
@@ -33,14 +35,28 @@ import { Icon, IconButton } from './components/Icon'
|
||||
import { ConversationMenu } from './components/ConversationMenu'
|
||||
import { watchDelta, watchedKickBuffers } from './lib/kickwatch'
|
||||
import { useActiveBuffer, useChat, usePref, usePrefsReady, useStore } from './state/hooks'
|
||||
import { bufferDisplayName } from './lib/util'
|
||||
import { useDmCategories } from './state/useDmCategories'
|
||||
import { bufferDisplayName, setHourFormat } from './lib/util'
|
||||
import type { BufferEntry } from './state/store'
|
||||
import { loadLocalEmotes, recoverMissingEmotes } from './lib/emotecache'
|
||||
import { restoreMissingMedia } from './lib/mediarestore'
|
||||
import { useShortcuts } from './lib/shortcuts'
|
||||
import { useEscapeLayer } from './lib/layers'
|
||||
import { installDialogFocus } from './lib/dialogs'
|
||||
import { QuickSwitcher } from './components/QuickSwitcher'
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
useShortcuts()
|
||||
useEffect(() => installDialogFocus(), [])
|
||||
// The full-page panels - Settings, Accounts, Downloads, Join - go away on
|
||||
// Escape like everything else that opens over the window, but underneath
|
||||
// whatever opened on top of them.
|
||||
const panelOpen = useChat((s) => s.activePanel !== '')
|
||||
useEscapeLayer(() => store.setActivePanel(''), panelOpen)
|
||||
const store = useStore()
|
||||
const prefsReady = usePrefsReady()
|
||||
// Every account's direct messages under a heading of its own.
|
||||
useDmCategories()
|
||||
const [booted, setBooted] = useState(false)
|
||||
|
||||
const accounts = useChat((s) => s.accounts)
|
||||
@@ -64,7 +80,36 @@ export default function App(): JSX.Element {
|
||||
const streamElsewhere = !!watching && (watching.bufferId !== activeBufferId || activePanel !== '')
|
||||
|
||||
const [sidebarFolded, setSidebarFolded] = usePref<boolean>('ui.sidebarFolded', false)
|
||||
// How the window looks, from the appearance settings: how large, how close
|
||||
// together, whether things move, how the clock is written.
|
||||
const [zoom] = usePref<number>('appearance.zoom', 1)
|
||||
const [density] = usePref<string>('appearance.density', 'cozy')
|
||||
const [reduceMotion] = usePref<boolean>('appearance.reduceMotion', false)
|
||||
const [hourFormat] = usePref<string>('display.hourFormat', '24')
|
||||
setHourFormat(hourFormat)
|
||||
useEffect(() => window.moho.setZoom(zoom), [zoom])
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.density = density
|
||||
if (reduceMotion) document.documentElement.dataset.reduceMotion = ''
|
||||
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', '')
|
||||
|
||||
@@ -140,12 +185,8 @@ export default function App(): JSX.Element {
|
||||
const headerTitle =
|
||||
peeking
|
||||
? `Looking · ${peek?.name || peek?.alias || ''}`
|
||||
: activePanel === 'accounts'
|
||||
? 'Accounts'
|
||||
: activePanel === 'settings'
|
||||
: activePanel === 'accounts' || activePanel === 'settings' || activePanel === 'downloads'
|
||||
? 'Settings'
|
||||
: activePanel === 'downloads'
|
||||
? 'Downloads'
|
||||
: activePanel === 'join'
|
||||
? `Join · ${joinAccount?.displayName ?? ''}`
|
||||
: onMentionsPage
|
||||
@@ -210,8 +251,9 @@ export default function App(): JSX.Element {
|
||||
|
||||
{activePanel === '' && !onMentionsPage && !peeking && buffer?.kind === 'channel' && (
|
||||
<IconButton
|
||||
name={userListFolded ? 'group' : 'group_off'}
|
||||
name="group"
|
||||
title={userListFolded ? 'Show members' : 'Hide members'}
|
||||
className={userListFolded ? undefined : 'active'}
|
||||
onClick={() => setUserListFolded(!userListFolded)}
|
||||
/>
|
||||
)}
|
||||
@@ -248,7 +290,8 @@ export default function App(): JSX.Element {
|
||||
<>
|
||||
{/* Above the box, saying why it will not work here yet. */}
|
||||
{buffer && <MembershipGate buffer={buffer} />}
|
||||
<Composer />
|
||||
{/* A forum has no box: what is said in it is said in a post. */}
|
||||
{!buffer?.forum && <Composer />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -281,12 +324,12 @@ export default function App(): JSX.Element {
|
||||
room you share - and a dialog only the account panel can show is a
|
||||
request nobody can answer. */}
|
||||
<VerificationDialog />
|
||||
<SteamFriendDialogs />
|
||||
|
||||
{/* Over everything: a call arrives while you are looking elsewhere. */}
|
||||
<IncomingCallPanel />
|
||||
{/* A Matrix call rings here too, but is answered by this window rather
|
||||
than by the daemon - the media is the window's. */}
|
||||
<IncomingMatrixCall />
|
||||
<ScreenPicker />
|
||||
{/* A form a Discord bot asked for. Over everything, because it is the
|
||||
answer to something just pressed and it expires. */}
|
||||
@@ -314,6 +357,7 @@ export default function App(): JSX.Element {
|
||||
<TransferPanel />
|
||||
<FileDrop />
|
||||
<Toasts />
|
||||
<QuickSwitcher />
|
||||
<EditMenu />
|
||||
</div>
|
||||
)
|
||||
@@ -353,13 +397,16 @@ function Body({
|
||||
activeGroupId: string
|
||||
peeking: boolean
|
||||
}): JSX.Element {
|
||||
if (activePanel === 'settings') return <SettingsPanel />
|
||||
// Above the no-accounts case below: a finished download is still worth
|
||||
// looking at on a machine whose accounts have since been removed.
|
||||
if (activePanel === 'downloads') return <DownloadsPanel />
|
||||
const activeBuffer = useActiveBuffer()
|
||||
// Settings, with Accounts and Downloads as pages of it. Above the
|
||||
// no-accounts case below: a finished download is still worth looking at on
|
||||
// a machine whose accounts have since been removed.
|
||||
if (activePanel === 'settings' || activePanel === 'accounts' || activePanel === 'downloads') {
|
||||
return <SettingsPanel page={activePanel} />
|
||||
}
|
||||
// With no accounts at all, the accounts panel is the only useful thing to
|
||||
// show - there is nothing to chat in yet.
|
||||
if (activePanel === 'accounts' || !hasAccounts) return <AccountsPanel />
|
||||
if (!hasAccounts) return <AccountsPanel />
|
||||
if (activePanel === 'join') return <JoinPanel />
|
||||
// A room being looked into: the pane's own view, with nothing to type into.
|
||||
if (peeking) return <PeekView />
|
||||
@@ -368,7 +415,9 @@ function Body({
|
||||
if (activeGroupId === MENTIONS_GROUP_ID) return <MentionsPage />
|
||||
// An invitation stands where the conversation would, because it is the
|
||||
// conversation being offered.
|
||||
if (activeGroupId.startsWith(INVITE_PREFIX)) return <InvitePanel groupId={activeGroupId} />
|
||||
if (activeGroupId === INVITATIONS_GROUP_ID) return <InvitePanel />
|
||||
// A forum is a list of posts, not a log.
|
||||
if (hasBuffer && activeBuffer?.forum) return <ForumPane buffer={activeBuffer} />
|
||||
if (hasBuffer) return <MessageList />
|
||||
return <Placeholder icon="forum" text="Select or join a channel" />
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import { ConversationTools } from './components/ConversationTools'
|
||||
import { BufferFace } from './components/BufferFace'
|
||||
import { FileDrop } from './components/FileDrop'
|
||||
import { Toasts } from './components/Toasts'
|
||||
import { CallStage, IncomingMatrixCall, ScreenPicker } from './components/CallStage'
|
||||
import { CallStage, ScreenPicker } from './components/CallStage'
|
||||
import { IncomingCallPanel } from './components/IncomingCallPanel'
|
||||
import { StreamStage } from './components/StreamStage'
|
||||
import { Icon, IconButton } from './components/Icon'
|
||||
import { useActiveBuffer, useChat, usePref, usePrefsReady, useStore } from './state/hooks'
|
||||
@@ -137,7 +138,8 @@ export function Popout({ bufferId }: { bufferId: string }): JSX.Element {
|
||||
{/* A call rings wherever the client is, including here: this window has
|
||||
its own connection to the daemon and its own media, so a call
|
||||
answered here is answered here. */}
|
||||
<IncomingMatrixCall />
|
||||
{/* Silent: the main window is the one that rings. */}
|
||||
<IncomingCallPanel silent />
|
||||
<ScreenPicker />
|
||||
{/* A popped-out conversation is one conversation, so a call that is not
|
||||
this one is in the corner and this one is above the log. */}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<!-- Steam's mark, drawn as a plain silhouette: a disc, the small gear on its
|
||||
arm, and the cut-out ring for the larger one. -->
|
||||
<path fill="#fff" fill-rule="evenodd" d="M12 1.5A10.5 10.5 0 0 0 1.55 11.1l5.6 2.3a3 3 0 0 1 1.7-.5h.2l2.5-3.6v-.05a4 4 0 1 1 4 4h-.1l-3.55 2.55a3 3 0 0 1-5.95.5L1.8 14.9A10.5 10.5 0 1 0 12 1.5zm-4 17.1-1.3-.55a2.25 2.25 0 1 0 1.25-3.1l1.35.55a1.65 1.65 0 1 1-1.3 3.1zm7.05-6.2a2.65 2.65 0 1 0-2.65-2.65 2.65 2.65 0 0 0 2.65 2.65zm0-.95a1.7 1.7 0 1 1 1.7-1.7 1.7 1.7 0 0 1-1.7 1.7z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 615 B |
@@ -1,7 +1,9 @@
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { SwitchRow } from './Switch'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Icon, IconButton, ServiceMark } from './Icon'
|
||||
import { MatrixAccountTools } from './MatrixAccountTools'
|
||||
import { SteamAccountTools } from './SteamAccountTools'
|
||||
import { IgnoreList } from './IgnoreList'
|
||||
import { TunnelAllSwitch } from './settings/Tunnel'
|
||||
import { useChat, useConnectionDetail, useMapPref, usePref, useStore } from '../state/hooks'
|
||||
@@ -22,7 +24,7 @@ import type { Account } from '../../../shared/wire'
|
||||
* that gains a backend appears here as soon as it gains a form, and one the
|
||||
* daemon stops offering disappears without this file being touched.
|
||||
*/
|
||||
const FORMS = ['irc', 'discord', 'sneedchat', 'matrix', 'kick'] as const
|
||||
const FORMS = ['irc', 'discord', 'sneedchat', 'matrix', 'kick', 'steam'] as const
|
||||
type AddableService = (typeof FORMS)[number]
|
||||
|
||||
/** What `listProtocols` answers with. */
|
||||
@@ -117,7 +119,9 @@ export function AccountsPanel(): JSX.Element {
|
||||
key={service}
|
||||
type="button"
|
||||
className={`service-chip${adding === service ? ' active' : ''}`}
|
||||
onClick={() => setAdding(adding === service ? null : service)}
|
||||
// Choosing the one already open leaves it open: the chip is a
|
||||
// choice among services, and the way out is the Cancel below.
|
||||
onClick={() => setAdding(service)}
|
||||
>
|
||||
<ServiceMark icon={icon} size={16} />
|
||||
{nameFor(service)}
|
||||
@@ -131,6 +135,12 @@ export function AccountsPanel(): JSX.Element {
|
||||
{adding === 'sneedchat' && <SneedChatForm />}
|
||||
{adding === 'matrix' && <MatrixForm />}
|
||||
{adding === 'kick' && <KickForm onDone={() => setAdding(null)} />}
|
||||
{adding === 'steam' && <SteamForm onDone={() => setAdding(null)} />}
|
||||
{adding && (
|
||||
<button type="button" className="button subtle small" onClick={() => setAdding(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="panel-section">
|
||||
@@ -153,7 +163,10 @@ export function AccountsPanel(): JSX.Element {
|
||||
// A service worth looking at even unasked: something in it is not
|
||||
// working, or is on its way to working.
|
||||
const troubled = mine.some((a) => TROUBLE_STATES.includes(a.state))
|
||||
const shut = !(openGroups[service] ?? troubled)
|
||||
// Open when there are few enough to see at once: a page that opens
|
||||
// on a closed list makes the common case - one or two accounts -
|
||||
// take clicks to reach what is there.
|
||||
const shut = !(openGroups[service] ?? (troubled || accounts.length <= 4))
|
||||
return (
|
||||
<div key={service} className="account-group">
|
||||
<button
|
||||
@@ -445,6 +458,8 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
|
||||
|
||||
{account.service === 'matrix' && <MatrixAccountTools account={account} />}
|
||||
|
||||
{account.service === 'steam' && <SteamAccountTools account={account} />}
|
||||
|
||||
{account.service === 'sneedchat' && (
|
||||
<>
|
||||
<RouteLevel account={account} onChange={(level) => call('setAccountRoute', { accountId: account.id, level })} />
|
||||
@@ -463,6 +478,8 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
|
||||
{/* Asked first, in place. Removing takes the saved sign-in, the
|
||||
history kept here and - for Matrix - this device's keys, and
|
||||
none of it comes back; it used to happen on the first click. */}
|
||||
<div className="danger-zone">
|
||||
<div className="danger-zone-title small">Danger zone</div>
|
||||
{confirmingRemove ? (
|
||||
<div className="remove-confirm" role="alertdialog" aria-label="Remove this account?">
|
||||
<span className="small">
|
||||
@@ -488,7 +505,7 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
|
||||
>
|
||||
<Icon name="delete" size={16} /> Remove
|
||||
</button>
|
||||
<button type="button" className="button" autoFocus onClick={() => setConfirmingRemove(false)}>
|
||||
<button type="button" className="button subtle" autoFocus onClick={() => setConfirmingRemove(false)}>
|
||||
Keep it
|
||||
</button>
|
||||
</div>
|
||||
@@ -498,6 +515,7 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
|
||||
<Icon name="delete" size={16} /> Remove account
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -548,20 +566,12 @@ function IrcSasl({
|
||||
|
||||
return (
|
||||
<div className="sasl-block">
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={account.saslEnabled}
|
||||
onChange={(e) => apply({ enabled: e.target.checked })}
|
||||
/>
|
||||
<span>
|
||||
Authenticate with SASL
|
||||
<span className="small muted">
|
||||
{' '}
|
||||
— identifies during connection, before any channel is joined
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<SwitchRow
|
||||
title="Authenticate with SASL"
|
||||
description="Identifies during connection, before any channel is joined"
|
||||
checked={account.saslEnabled}
|
||||
onChange={(on) => apply({ enabled: on })}
|
||||
/>
|
||||
|
||||
{account.saslEnabled && (
|
||||
<>
|
||||
@@ -634,47 +644,26 @@ function IrcSasl({
|
||||
allowance decides nothing, and offering it there would be a
|
||||
switch that appears to weaken something and does not. */}
|
||||
{!account.ssl && (
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={account.allowPlaintextSasl}
|
||||
onChange={(e) => {
|
||||
setAllowPlaintext(e.target.checked)
|
||||
apply({ allowPlaintextSasl: e.target.checked })
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
Send SASL credentials over this unencrypted connection
|
||||
<span className="small muted">
|
||||
{' '}
|
||||
— SASL PLAIN is the password with base64 round it, not encryption. Without
|
||||
this, connecting is refused rather than done quietly in the clear.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<SwitchRow
|
||||
title="Send SASL credentials over this unencrypted connection"
|
||||
description="SASL PLAIN is the password with base64 round it, not encryption. Without this, connecting is refused rather than done quietly in the clear."
|
||||
checked={account.allowPlaintextSasl}
|
||||
onChange={(on) => {
|
||||
setAllowPlaintext(on)
|
||||
apply({ allowPlaintextSasl: on })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* The live login is tried first and needs no setting: this is only
|
||||
the last resort, for a server that refuses it, and it costs a
|
||||
moment out of every channel - hence off unless asked for. */}
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={account.reconnectForSasl}
|
||||
onChange={(e) =>
|
||||
call('setIrcReconnectForSasl', { accountId: account.id, enabled: e.target.checked })
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
Reconnect to log in when services come back
|
||||
<span className="small muted">
|
||||
{' '}
|
||||
— if the network's services were down when moho connected, it logs in as soon as
|
||||
they return. A few servers only accept that login while connecting; this lets moho
|
||||
reconnect for them, which briefly drops you from your channels.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<SwitchRow
|
||||
title="Reconnect to log in when services come back"
|
||||
description="If the network's services were down when moho connected, it logs in as soon as they return. A few servers only accept that login while connecting; this lets moho reconnect for them, which briefly drops you from your channels."
|
||||
checked={account.reconnectForSasl}
|
||||
onChange={(on) => call('setIrcReconnectForSasl', { accountId: account.id, enabled: on })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -859,7 +848,7 @@ function IrcForm({ onDone }: { onDone: () => void }): JSX.Element {
|
||||
for both would be asking somebody to type their password twice. */}
|
||||
<div className="field">
|
||||
<span className="small muted">Sign in</span>
|
||||
<div className="field-row">
|
||||
<div className="setting-segmented" role="radiogroup" aria-label="Sign in">
|
||||
{(
|
||||
[
|
||||
['none', 'No account'],
|
||||
@@ -867,15 +856,16 @@ function IrcForm({ onDone }: { onDone: () => void }): JSX.Element {
|
||||
['nickserv', 'NickServ']
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<label key={value} className="checkbox-row">
|
||||
<input
|
||||
type="radio"
|
||||
name="irc-auth"
|
||||
checked={auth === value}
|
||||
onChange={() => setAuth(value)}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={auth === value}
|
||||
className={auth === value ? 'active' : undefined}
|
||||
onClick={() => setAuth(value)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{auth !== 'none' && (
|
||||
@@ -900,10 +890,7 @@ function IrcForm({ onDone }: { onDone: () => void }): JSX.Element {
|
||||
</div>
|
||||
|
||||
{!chosen && (
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={ssl} onChange={(e) => setSsl(e.target.checked)} />
|
||||
<span>Use TLS</span>
|
||||
</label>
|
||||
<SwitchRow title="Use TLS" checked={ssl} onChange={setSsl} />
|
||||
)}
|
||||
<TorSwitch service="irc" on={useTor} onChange={setUseTor} />
|
||||
<button type="button" className="button" disabled={busy || !nick || !host} onClick={() => void submit()}>
|
||||
@@ -1209,6 +1196,282 @@ function KickForm({ onDone }: { onDone: () => void }): JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
/** What Steam can ask for to let a sign-in through; any one of them will do. */
|
||||
interface SteamOption {
|
||||
kind: 'deviceCode' | 'emailCode' | 'approval' | 'emailApproval'
|
||||
hint?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Signing in to Steam.
|
||||
*
|
||||
* A conversation rather than a request, because Steam may want something from
|
||||
* the person's phone or inbox: the daemon answers with a login id and the rest
|
||||
* arrives as events (see nobilis backend/steam/login.rs). So the form has
|
||||
* three shapes - the credentials, then whatever Steam is waiting for, then
|
||||
* nothing, because the account exists and the form closes itself.
|
||||
*
|
||||
* The password is typed here, goes to the daemon once, and is forgotten when
|
||||
* this form is: the account keeps the token Steam hands back, never the
|
||||
* password.
|
||||
*/
|
||||
function SteamForm({ onDone }: { onDone: () => void }): JSX.Element {
|
||||
const store = useStore()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [useTor, setUseTor] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [loginId, setLoginId] = useState('')
|
||||
const [detail, setDetail] = useState('')
|
||||
const [needs, setNeeds] = useState<{ options: SteamOption[]; error?: string } | null>(null)
|
||||
const [code, setCode] = useState('')
|
||||
// The picture of the code a phone scans, while a sign-in by scanning waits.
|
||||
const [qr, setQr] = useState<{ path: string; at: number } | null>(null)
|
||||
|
||||
// The id lives in a ref as well as state, because the first event can be
|
||||
// handled before the render that follows the answer to the request that
|
||||
// started the sign-in. Nothing else is signing in from this form, so an
|
||||
// event arriving before the id is known can only be this one's.
|
||||
const current = useRef('')
|
||||
const setId = (id: string): void => {
|
||||
current.current = id
|
||||
setLoginId(id)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const off = window.moho.onEvent((e) => {
|
||||
const d = e.data as {
|
||||
loginId?: string
|
||||
detail?: string
|
||||
options?: SteamOption[]
|
||||
error?: string
|
||||
success?: boolean
|
||||
path?: string
|
||||
}
|
||||
if (!e.event.startsWith('steamLogin')) return
|
||||
if (current.current && d.loginId !== current.current) return
|
||||
if (e.event === 'steamLoginQr' && d.path) {
|
||||
// The same file is written again when the code is replaced, so the
|
||||
// moment is part of what is drawn.
|
||||
setQr({ path: d.path, at: Date.now() })
|
||||
} else if (e.event === 'steamLoginStatus') {
|
||||
setDetail(d.detail || '')
|
||||
} else if (e.event === 'steamLoginNeeds') {
|
||||
setNeeds({ options: d.options ?? [], error: d.error })
|
||||
setCode('')
|
||||
} else if (e.event === 'steamLoginResult') {
|
||||
setNeeds(null)
|
||||
setQr(null)
|
||||
setId('')
|
||||
setBusy(false)
|
||||
setDetail('')
|
||||
if (d.success) {
|
||||
setPassword('')
|
||||
void store.refreshAccounts()
|
||||
onDone()
|
||||
} else {
|
||||
store.toast('error', d.error || 'Steam sign-in failed')
|
||||
}
|
||||
}
|
||||
})
|
||||
return off
|
||||
}, [onDone, store])
|
||||
|
||||
// Closing the form on a sign-in that is still waiting tells the daemon
|
||||
// nobody is, so it stops asking Steam and frees what it was holding.
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (current.current) void window.moho.rpc('cancelSteamLogin', { loginId: current.current }).catch(() => {})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const begin = (): void => {
|
||||
setBusy(true)
|
||||
setDetail('')
|
||||
setNeeds(null)
|
||||
current.current = ''
|
||||
void window.moho
|
||||
.rpc<{ loginId: string }>('addSteamAccount', { username, password, useTor })
|
||||
.then((r) => setId(r.loginId))
|
||||
.catch((e: Error) => {
|
||||
setBusy(false)
|
||||
store.toast('error', e.message)
|
||||
})
|
||||
}
|
||||
|
||||
// Signing in by scanning a code with the Steam app: nothing is typed.
|
||||
const beginQr = (): void => {
|
||||
setBusy(true)
|
||||
setDetail('')
|
||||
setNeeds(null)
|
||||
setQr(null)
|
||||
current.current = ''
|
||||
void window.moho
|
||||
.rpc<{ loginId: string }>('addSteamAccountQr', { useTor })
|
||||
.then((r) => setId(r.loginId))
|
||||
.catch((e: Error) => {
|
||||
setBusy(false)
|
||||
store.toast('error', e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const send = (): void => {
|
||||
const typed = code.trim()
|
||||
if (!typed || !loginId) return
|
||||
void window.moho
|
||||
.rpc('submitSteamGuardCode', { loginId, code: typed })
|
||||
.then(() => setDetail('checking the code'))
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
|
||||
const cancel = (): void => {
|
||||
if (current.current) void window.moho.rpc('cancelSteamLogin', { loginId: current.current }).catch(() => {})
|
||||
setId('')
|
||||
setQr(null)
|
||||
setNeeds(null)
|
||||
setDetail('')
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
if (qr) {
|
||||
return (
|
||||
<div className="add-form">
|
||||
<p>Scan this with the Steam app on your phone.</p>
|
||||
<p className="small muted">
|
||||
In the app, open Steam Guard and choose the scan option (the QR icon), point the phone
|
||||
at the code, and approve the sign-in. moho keeps only the token Steam hands back.
|
||||
</p>
|
||||
<img className="qr-image" src={resolveMediaUrl(`file://${qr.path}#${qr.at}`)} alt="Steam sign-in QR code" />
|
||||
{detail && <p className="small muted">{detail}…</p>}
|
||||
<div className="field-row">
|
||||
<button type="button" className="button" onClick={cancel}>
|
||||
Cancel sign-in
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const takesCode = !!needs?.options.some((o) => o.kind === 'deviceCode' || o.kind === 'emailCode')
|
||||
const approvals = needs?.options.filter((o) => o.kind === 'approval' || o.kind === 'emailApproval') ?? []
|
||||
|
||||
if (needs) {
|
||||
return (
|
||||
<div className="add-form">
|
||||
<p>Steam wants to check that this is you.</p>
|
||||
{approvals.map((o) => (
|
||||
<p key={o.kind} className="small muted">
|
||||
{o.kind === 'approval'
|
||||
? `Approve the sign-in in the Steam app on your phone${o.hint ? ` (${o.hint})` : ''}.`
|
||||
: `Approve the sign-in from the email Steam sent${o.hint ? ` to ${o.hint}` : ''}.`}
|
||||
</p>
|
||||
))}
|
||||
{takesCode && (
|
||||
<label className="field">
|
||||
<span className="small muted">
|
||||
{needs.options.some((o) => o.kind === 'deviceCode')
|
||||
? 'Code from your Steam Guard authenticator'
|
||||
: 'Code Steam emailed you'}
|
||||
</span>
|
||||
<input
|
||||
className="text-field"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') send()
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{needs.error && (
|
||||
<div className="warning-note">
|
||||
<Icon name="warning" size={16} />
|
||||
<span>{needs.error}</span>
|
||||
</div>
|
||||
)}
|
||||
{detail && <p className="small muted">{detail}…</p>}
|
||||
<div className="field-row">
|
||||
{takesCode && (
|
||||
<button type="button" className="button primary" disabled={!code.trim()} onClick={send}>
|
||||
Submit code
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="button" onClick={cancel}>
|
||||
Cancel sign-in
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="add-form">
|
||||
<p className="small muted">
|
||||
Sign in with your Steam account name — the one you type at the Steam login, not the
|
||||
display name your friends see. moho signs in as the Steam mobile app's chat, so it runs
|
||||
beside the Steam launcher without signing it out. Your password goes to Steam and is not
|
||||
kept; moho keeps only the token Steam hands back.
|
||||
</p>
|
||||
<TorSwitch service="steam" on={useTor} onChange={setUseTor} />
|
||||
<div className="field-row">
|
||||
<label className="field">
|
||||
<span className="small muted">Account name</span>
|
||||
<input
|
||||
className="text-field"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={username}
|
||||
disabled={busy}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="small muted">Password</span>
|
||||
<input
|
||||
className="text-field"
|
||||
type="password"
|
||||
value={password}
|
||||
disabled={busy}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && username.trim() && password && !busy) begin()
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<button
|
||||
type="button"
|
||||
className="button primary"
|
||||
disabled={busy || !username.trim() || !password}
|
||||
onClick={begin}
|
||||
>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button subtle"
|
||||
disabled={busy}
|
||||
title="Sign in by scanning a code with the Steam app on your phone, with no password typed here"
|
||||
onClick={beginQr}
|
||||
>
|
||||
Sign in with a QR code
|
||||
</button>
|
||||
{busy && (
|
||||
<button type="button" className="button" onClick={cancel}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{busy && detail && <p className="small muted">{detail}…</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an account's connections go through Tor or a SOCKS5 proxy.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Icon } from './Icon'
|
||||
import { activityIcon } from '../lib/activity'
|
||||
import type { Activity } from '../../../shared/wire'
|
||||
|
||||
/**
|
||||
* What somebody is doing, as the small line under their name: a gamepad and a
|
||||
* game, a note and a track, or - with no picture at all - words they wrote
|
||||
* themselves, which is how Discord draws a custom status.
|
||||
*/
|
||||
export function ActivityLine({ activity }: { activity: Activity }): JSX.Element {
|
||||
const icon = activityIcon(activity)
|
||||
return (
|
||||
<span className="activity-line small muted">
|
||||
{icon && <Icon name={icon} size={13} />}
|
||||
<span className="ellipsis">{activity.text}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useChat, useStore } from '../state/hooks'
|
||||
import { classes } from '../lib/util'
|
||||
import type { BufferEntry } from '../state/store'
|
||||
import type { DiscordFriend } from '../../../shared/wire'
|
||||
import { useEscapeLayer } from '../lib/layers'
|
||||
|
||||
/** Ten people including you, which is Discord's own ceiling. */
|
||||
const GROUP_DM_MAX_OTHERS = 9
|
||||
@@ -49,19 +50,16 @@ export function AddToConversation({ buffer }: { buffer: BufferEntry }): JSX.Elem
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
}, [open, accountId, store])
|
||||
|
||||
useEscapeLayer(() => setOpen(false), open)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const away = (e: MouseEvent): void => {
|
||||
if (!box.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const key = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', away)
|
||||
window.addEventListener('keydown', key)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', away)
|
||||
window.removeEventListener('keydown', key)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
@@ -158,7 +156,7 @@ export function AddToConversation({ buffer }: { buffer: BufferEntry }): JSX.Elem
|
||||
</span>
|
||||
)}
|
||||
<span className="ellipsis">{f.globalName || f.username}</span>
|
||||
<span className={`presence-dot ${f.status || 'offline'}`} />
|
||||
<span className={`status-dot ${f.status || 'offline'}`} />
|
||||
</button>
|
||||
))}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { nickColor } from '../lib/util'
|
||||
import { presenceColor, presenceLabel } from '../lib/presence'
|
||||
import { presenceClass, presenceLabel } from '../lib/presence'
|
||||
|
||||
/**
|
||||
* Somebody's face, with a fallback for the many people who have no picture.
|
||||
@@ -61,11 +61,7 @@ export function Avatar({
|
||||
</span>
|
||||
)}
|
||||
{status && (
|
||||
<span
|
||||
className="presence-dot"
|
||||
style={{ background: presenceColor(status) }}
|
||||
title={presenceLabel(status)}
|
||||
/>
|
||||
<span className={`presence-dot ${presenceClass(status)}`} title={presenceLabel(status)} />
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -2,21 +2,29 @@ import { useMediaUrl } from '../lib/route'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { bufferLink } from '../lib/bufferlink'
|
||||
import { Icon, IconButton, ServiceMark } from './Icon'
|
||||
import { ContextMenu, useContextMenu } from './ContextMenu'
|
||||
import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu'
|
||||
import { bufferMenuEntries } from '../lib/buffermenu'
|
||||
import { UserFooter } from './UserFooter'
|
||||
import { VoiceChannels } from './VoiceChannels'
|
||||
import { SteamVoiceRooms } from './SteamVoiceRooms'
|
||||
import { VoicePanel } from './VoicePanel'
|
||||
import { useChat, useIdSetPref, useMapPref, usePref, useStore } from '../state/hooks'
|
||||
import { presenceClass, presenceLabel } from '../lib/presence'
|
||||
import { activityLabel, dmActivity } from '../lib/activity'
|
||||
import { ActivityLine } from './ActivityLine'
|
||||
import { steamCategoryId, parseSteamCategory } from '../lib/steamgroups'
|
||||
import { NamePrompt } from './NamePrompt'
|
||||
import { useChat, useComfy, useIdSetPref, useInvitations, useMapPref, usePref, useStore } from '../state/hooks'
|
||||
import {
|
||||
compareInBand,
|
||||
dmGroup,
|
||||
DM_GROUP_ID,
|
||||
isDirectMessage,
|
||||
isMutedBuffer,
|
||||
INVITE_PREFIX,
|
||||
INVITATIONS_GROUP_ID,
|
||||
MENTIONS_GROUP_ID,
|
||||
PINNED_GROUP_ID,
|
||||
pinnedGroup,
|
||||
waitingCount,
|
||||
visibleGroups,
|
||||
type RailGroup
|
||||
} from '../lib/groups'
|
||||
@@ -31,6 +39,7 @@ import {
|
||||
} from '../lib/util'
|
||||
import type { Account, Member } from '../../../shared/wire'
|
||||
import { Avatar } from './Avatar'
|
||||
import { currentInvitation } from '../lib/invitations'
|
||||
import {
|
||||
categoryKey,
|
||||
reorderCategories,
|
||||
@@ -69,6 +78,7 @@ export function BufferList(): JSX.Element {
|
||||
const voiceSessions = useChat((s) => s.voiceSessions)
|
||||
const presence = useChat((s) => s.presenceByBuffer)
|
||||
const popouts = useChat((s) => s.popouts)
|
||||
const activeInvitationKey = useChat((s) => s.activeInvitationKey)
|
||||
|
||||
const [pinned, togglePin, isPinned] = useIdSetPref('pinnedBuffers')
|
||||
const [muted, toggleMute] = useIdSetPref('mutedBuffers')
|
||||
@@ -126,12 +136,15 @@ export function BufferList(): JSX.Element {
|
||||
return isDirectMessage(open) ? DM_GROUP_ID : open.groupId
|
||||
}, [buffers, activeBufferId])
|
||||
|
||||
// An invitation is selected the same way the mentions page is: a rail entry
|
||||
// with no group behind it. Without this the lookup below fails to find it,
|
||||
// falls back to some real group, and the effect writes that back - so
|
||||
// choosing an invitation landed on whichever server happened to be first,
|
||||
// which is exactly what it looked like.
|
||||
const onInvite = activeGroupId.startsWith(INVITE_PREFIX)
|
||||
// The invitations page is selected the same way the mentions page is: a rail
|
||||
// entry with no group behind it. Without this the lookup below fails to find
|
||||
// it, falls back to some real group, and the effect writes that back - so
|
||||
// choosing it landed on whichever server happened to be first. Only while
|
||||
// something is waiting: with nothing left the page has nothing to say, and
|
||||
// falling back is the way off it.
|
||||
const invitations = useInvitations()
|
||||
const onInvite = activeGroupId === INVITATIONS_GROUP_ID && invitations.length > 0
|
||||
const shownInvitation = currentInvitation(invitations, activeInvitationKey)
|
||||
|
||||
const activeGroup =
|
||||
onMentionsPage || onInvite
|
||||
@@ -155,6 +168,8 @@ export function BufferList(): JSX.Element {
|
||||
|
||||
const isPinnedPage = activeGroup?.id === PINNED_GROUP_ID
|
||||
const isDmPage = activeGroup?.id === DM_GROUP_ID
|
||||
// A Steam account's own page: its friends sit under the folders Steam keeps for the account.
|
||||
const steamPage = activeGroup?.kind === 'account' && accounts.find((a) => a.id === activeGroup.accountId)?.service === 'steam'
|
||||
/**
|
||||
* Whose identity the plaque shows.
|
||||
*
|
||||
@@ -175,12 +190,14 @@ export function BufferList(): JSX.Element {
|
||||
onMentionsPage && voiceSessions.length === 0
|
||||
? undefined
|
||||
: (accounts.find((a) => a.id === activeGroup?.accountId) ??
|
||||
(onInvite ? accounts.find((a) => a.id === shownInvitation?.accountId) : undefined) ??
|
||||
accounts.find((a) => a.id === activeBuffer?.accountId) ??
|
||||
accounts[0])
|
||||
|
||||
/**
|
||||
* What the pane lists, in reading order: pinned first, then direct messages,
|
||||
* then channels, each band by most recent activity.
|
||||
* then channels. Direct messages go by most recent activity; channels hold
|
||||
* their place (see `compareInBand`).
|
||||
*
|
||||
* The pinned page is the same list unfiltered by group - a pin is a
|
||||
* cross-service shortcut, so its page is the one place they all appear
|
||||
@@ -210,9 +227,7 @@ export function BufferList(): JSX.Element {
|
||||
if (b.kind === 'dm') return 2
|
||||
return 3
|
||||
}
|
||||
return [...inScope].sort(
|
||||
(a, b) => band(a) - band(b) || (b.lastActivityTs || 0) - (a.lastActivityTs || 0)
|
||||
)
|
||||
return [...inScope].sort((a, b) => band(a) - band(b) || compareInBand(a, b))
|
||||
}, [visible, activeGroup, isPinnedPage, isDmPage, pinned])
|
||||
|
||||
/**
|
||||
@@ -227,13 +242,50 @@ export function BufferList(): JSX.Element {
|
||||
*
|
||||
* Ordinary channels sort by the service's own position within a heading,
|
||||
* since that is the order the server arranged them in and the reason it
|
||||
* supplies one. The gathered pages have no such order to respect and keep
|
||||
* the recency they were already sorted by.
|
||||
* supplies one. The gathered pages keep the order they were sorted into.
|
||||
*/
|
||||
/**
|
||||
* The headings this page draws under its own, and where each conversation is filed.
|
||||
*
|
||||
* A Steam account's own page has Steam's folders for the account, as its own client lists them
|
||||
* (without the "Steam - " some carry), and each friend is under the one Steam has them in:
|
||||
* what Steam says wins over anything filed here, and a change made here is made on Steam. Every
|
||||
* other page has the headings somebody made.
|
||||
*/
|
||||
const [naming, setNaming] = useState<{ id: string; name: string } | null>(null)
|
||||
const [makingFolder, setMakingFolder] = useState(false)
|
||||
const steamGroups = useChat((s) => s.steamGroups)
|
||||
const steamFolders = useMemo<CustomCategory[]>(
|
||||
() =>
|
||||
steamPage && activeGroup
|
||||
? (steamGroups[activeGroup.accountId] ?? [])
|
||||
.map((g) => ({ id: steamCategoryId(activeGroup.accountId, g.id), name: g.name.replace(/^Steam - /, '').trim() }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
: [],
|
||||
[steamPage, activeGroup, steamGroups]
|
||||
)
|
||||
const myCategories = steamPage ? steamFolders : activeGroup ? (customCats[activeGroup.id] ?? []) : []
|
||||
const viewAssignment = useMemo(() => {
|
||||
if (!steamPage || !activeGroup) return assignment
|
||||
const folders = [...(steamGroups[activeGroup.accountId] ?? [])].sort((a, b) => a.id - b.id)
|
||||
const next = { ...assignment }
|
||||
for (const b of groupBuffers) {
|
||||
if (b.kind !== 'dm' || !b.remoteId) continue
|
||||
const home = folders.find((g) => g.members.includes(b.remoteId!))
|
||||
if (home) next[b.id] = steamCategoryId(activeGroup.accountId, home.id)
|
||||
else delete next[b.id]
|
||||
}
|
||||
return next
|
||||
}, [steamPage, activeGroup, steamGroups, assignment, groupBuffers])
|
||||
/** Files a conversation under a heading: on Steam for a Steam page, in the preferences for the rest. */
|
||||
const fileBuffer = (bufferId: string, categoryId: string): void => {
|
||||
if (steamPage) store.moveSteamFriend(bufferId, parseSteamCategory(categoryId)?.groupId ?? null)
|
||||
else setAssignment(bufferId, categoryId)
|
||||
}
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
if (!activeGroup) return null
|
||||
const mine = customCats[activeGroup.id] ?? []
|
||||
const list = sections(groupBuffers, mine, assignment, categoryOrder[activeGroup.id] ?? [])
|
||||
const list = sections(groupBuffers, myCategories, viewAssignment, categoryOrder[activeGroup.id] ?? [])
|
||||
if (!isPinnedPage && !isDmPage) {
|
||||
for (const s of list) {
|
||||
s.buffers.sort((a, b) => (a.position || 0) - (b.position || 0))
|
||||
@@ -243,19 +295,22 @@ export function BufferList(): JSX.Element {
|
||||
// they just made it, and a heading that vanishes until something is put
|
||||
// in it cannot have anything put in it.
|
||||
return list.filter((s) => s.custom || s.buffers.length > 0)
|
||||
}, [activeGroup, isPinnedPage, isDmPage, groupBuffers, customCats, assignment, categoryOrder])
|
||||
}, [activeGroup, isPinnedPage, isDmPage, steamPage, groupBuffers, myCategories, viewAssignment, categoryOrder])
|
||||
|
||||
// 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' : service === 'steam' ? 'steamSetFriendMuted' : null
|
||||
}
|
||||
const isEffectivelyMuted = (buffer: BufferEntry): boolean =>
|
||||
isMutedBuffer(buffer, muted, mutedGroups)
|
||||
|
||||
const [naming, setNaming] = useState<{ id: string; name: string } | null>(null)
|
||||
const { menu: groupMenu, open: openGroupMenuAt, close: closeGroupMenu } = useContextMenu()
|
||||
const [catMenu, setCatMenu] = useState<{ x: number; y: number; section: CategorySection } | null>(null)
|
||||
|
||||
const myCategories = activeGroup ? (customCats[activeGroup.id] ?? []) : []
|
||||
const writeCategories = (next: CustomCategory[]): void => {
|
||||
if (activeGroup) setCustomCats(activeGroup.id, next)
|
||||
}
|
||||
@@ -279,9 +334,9 @@ export function BufferList(): JSX.Element {
|
||||
}, [store, eventGuild?.accountId, eventGuild?.guildId])
|
||||
const openCategoryMenu = (e: React.MouseEvent, section: CategorySection): void => {
|
||||
e.preventDefault()
|
||||
// Only a heading you made is yours to rename or remove; a server's
|
||||
// category is theirs, and offering to rename it would be a lie.
|
||||
if (!section.custom) return
|
||||
// Every heading can be read through; only one you made is yours to rename
|
||||
// or remove - a server's category is theirs, and offering to rename it
|
||||
// would be a lie.
|
||||
setCatMenu({ x: e.clientX, y: e.clientY, section })
|
||||
}
|
||||
|
||||
@@ -293,6 +348,10 @@ export function BufferList(): JSX.Element {
|
||||
muted={isEffectivelyMuted(b)}
|
||||
pinned={isPinned(b.id)}
|
||||
accounts={accounts}
|
||||
// The pinned page gathers rooms from every service, so each says which
|
||||
// one it is from - `#general` on Discord and on IRC look the same
|
||||
// otherwise. A conversation with a person keeps their face instead.
|
||||
showServiceIcon={isPinnedPage && b.kind !== 'dm'}
|
||||
// Already on the page being shown; keep the rail where it is. A room
|
||||
// still being joined has nothing behind it to select - it exists in
|
||||
// this window only - so the row is there to be seen rather than opened.
|
||||
@@ -300,15 +359,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))
|
||||
@@ -336,7 +403,7 @@ export function BufferList(): JSX.Element {
|
||||
status={dmStatus(b, presence, buffers)}
|
||||
onCall={() => void store.callBuffer(b.id)}
|
||||
onHangUp={() => void store.leaveVoice(b.accountId)}
|
||||
onFile={(categoryId) => setAssignment(b.id, categoryId)}
|
||||
onFile={(categoryId) => fileBuffer(b.id, categoryId)}
|
||||
poppedOut={popouts.open.includes(b.id)}
|
||||
onPopOut={() => store.popOut(b.id)}
|
||||
onDock={() => store.dock(b.id)}
|
||||
@@ -400,7 +467,7 @@ export function BufferList(): JSX.Element {
|
||||
return
|
||||
}
|
||||
if (!section.custom) return
|
||||
setAssignment(held.id, section.key)
|
||||
fileBuffer(held.id, section.key)
|
||||
}
|
||||
|
||||
const hideBuffer = (id: string): void => {
|
||||
@@ -419,10 +486,31 @@ export function BufferList(): JSX.Element {
|
||||
{/* Not on the mentions page, which is deliberately server-less: the
|
||||
pane is empty there because there is nothing to list, not because
|
||||
something still needs picking. */}
|
||||
{accounts.length > 0 && !activeGroup && !onMentionsPage && (
|
||||
{accounts.length > 0 && !activeGroup && !onMentionsPage && !onInvite && (
|
||||
<div className="bufferlist-empty muted small">Select a server on the left.</div>
|
||||
)}
|
||||
|
||||
{onInvite && (
|
||||
<>
|
||||
<div className="group-title">
|
||||
<span className="ellipsis group-title-name">Invitations</span>
|
||||
</div>
|
||||
{invitations.map((invitation) => (
|
||||
<button
|
||||
key={invitation.key}
|
||||
type="button"
|
||||
className={classes('buffer-row', invitation.key === shownInvitation?.key && 'active', 'unread')}
|
||||
title={`${invitation.name} — ${invitation.detail}`}
|
||||
onClick={() => store.selectInvitation(invitation.key)}
|
||||
>
|
||||
<Avatar name={invitation.name} url={invitation.avatarUrl ?? undefined} size={22} accountId={invitation.accountId} />
|
||||
<span className="ellipsis buffer-name">{invitation.name}</span>
|
||||
<ServiceMark icon={serviceIcon(invitation.service)} size={15} />
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeGroup && (
|
||||
<>
|
||||
<div className="group-title">
|
||||
@@ -515,14 +603,23 @@ export function BufferList(): JSX.Element {
|
||||
const key = categoryKey(activeGroup.id, section)
|
||||
const folded = isCollapsed(key)
|
||||
return (
|
||||
<div key={section.key} className="category">
|
||||
<div
|
||||
key={section.key}
|
||||
// A conversation held over a heading that will take it lights the whole
|
||||
// category, so what is about to happen is the heading and its people
|
||||
// being added to - not a line that could be read as "put it above".
|
||||
className={classes('category', dragging?.kind === 'buffer' && overCategory === section.key && 'drop-into')}
|
||||
>
|
||||
{naming?.id === section.key ? (
|
||||
<CategoryNameField
|
||||
value={naming.name}
|
||||
onChange={(name) => setNaming({ id: naming.id, name })}
|
||||
onCommit={() => {
|
||||
const name = naming.name.trim()
|
||||
if (name) {
|
||||
if (name && steamPage && activeGroup) {
|
||||
const group = parseSteamCategory(naming.id)?.groupId
|
||||
if (group !== undefined) store.renameSteamFolder(activeGroup.accountId, group, name)
|
||||
} else if (name) {
|
||||
writeCategories(
|
||||
myCategories.map((c) => (c.id === naming.id ? { ...c, name } : c))
|
||||
)
|
||||
@@ -539,7 +636,7 @@ export function BufferList(): JSX.Element {
|
||||
'category-head',
|
||||
'small',
|
||||
dragging?.kind === 'category' && dragging.id === section.key && 'lifted',
|
||||
overCategory === section.key && 'drop-target'
|
||||
overCategory === section.key && dragging?.kind !== 'buffer' && 'drop-target'
|
||||
)}
|
||||
onClick={() => toggleCollapsed(key)}
|
||||
onContextMenu={(e) => openCategoryMenu(e, section)}
|
||||
@@ -572,23 +669,44 @@ export function BufferList(): JSX.Element {
|
||||
title={
|
||||
section.custom
|
||||
? 'Your heading — drop channels here, drag to reorder, right-click to rename or remove'
|
||||
: `${section.name} — drag to reorder`
|
||||
: `${section.name} — drag to reorder, right-click to mark as read`
|
||||
}
|
||||
>
|
||||
<Icon name={folded ? 'chevron_right' : 'expand_more'} size={14} />
|
||||
<Icon name="expand_more" size={14} className={classes('fold-chevron', folded && 'folded')} />
|
||||
<span className="ellipsis">{section.name}</span>
|
||||
{folded && section.buffers.length > 0 && (
|
||||
<span className="muted category-count">{section.buffers.length}</span>
|
||||
)}
|
||||
{folded && section.buffers.length > 0 && (() => {
|
||||
// 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) => 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 === 1 ? 'mention' : 'mentions'} in ${waiting.length} ${waiting.length === 1 ? 'channel' : 'channels'}`}
|
||||
>
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</span>
|
||||
) : (
|
||||
<span className="muted category-count">{section.buffers.length}</span>
|
||||
)
|
||||
})()}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* A collapsed heading still shows the channel you are
|
||||
reading, or selecting it from elsewhere would appear
|
||||
to do nothing. */}
|
||||
to do nothing - and the ones with something waiting,
|
||||
or folding a category would hide exactly what the
|
||||
list is for. */}
|
||||
{section.buffers
|
||||
.filter((b) => !folded || b.id === activeBufferId)
|
||||
.filter(
|
||||
(b) =>
|
||||
!folded ||
|
||||
b.id === activeBufferId ||
|
||||
(b.unread > 0 && (!isEffectivelyMuted(b) || b.highlight))
|
||||
)
|
||||
.map((b) => renderRow(b))}
|
||||
{section.custom && section.buffers.length === 0 && !folded && (
|
||||
<div className="category-empty small muted">Drag a channel onto this heading</div>
|
||||
@@ -601,6 +719,7 @@ export function BufferList(): JSX.Element {
|
||||
|
||||
{/* Below the text channels, as everywhere else that has both. */}
|
||||
<VoiceChannels group={activeGroup} />
|
||||
<SteamVoiceRooms group={activeGroup} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -626,6 +745,11 @@ export function BufferList(): JSX.Element {
|
||||
label: 'New category',
|
||||
icon: 'create_new_folder',
|
||||
onClick: () => {
|
||||
// On Steam it is made on Steam, once it has a name.
|
||||
if (steamPage) {
|
||||
setMakingFolder(true)
|
||||
return
|
||||
}
|
||||
const id = `cat-${Date.now().toString(36)}`
|
||||
// At the top, not the end. The command was given from the
|
||||
// group's own title at the top of this list, and a heading
|
||||
@@ -654,24 +778,53 @@ export function BufferList(): JSX.Element {
|
||||
y={catMenu.y}
|
||||
entries={[
|
||||
{
|
||||
label: 'Rename',
|
||||
icon: 'edit',
|
||||
onClick: () =>
|
||||
setNaming({ id: catMenu.section.key, name: catMenu.section.name ?? '' })
|
||||
label: 'Mark as read',
|
||||
icon: 'mark_chat_read',
|
||||
disabled: !catMenu.section.buffers.some((b) => b.unread > 0 || b.highlight),
|
||||
onClick: () => void store.markBuffersRead(catMenu.section.buffers.map((b) => b.id))
|
||||
},
|
||||
{
|
||||
label: 'Remove',
|
||||
icon: 'delete',
|
||||
danger: true,
|
||||
// The channels stay; only the heading goes, and they fall back
|
||||
// to wherever the service filed them.
|
||||
onClick: () => writeCategories(myCategories.filter((c) => c.id !== catMenu.section.key))
|
||||
}
|
||||
...(catMenu.section.custom
|
||||
? ([
|
||||
{ separator: true },
|
||||
{
|
||||
label: 'Rename',
|
||||
icon: 'edit',
|
||||
onClick: () =>
|
||||
setNaming({ id: catMenu.section.key, name: catMenu.section.name ?? '' })
|
||||
},
|
||||
{
|
||||
label: 'Remove',
|
||||
icon: 'delete',
|
||||
danger: true,
|
||||
// The channels stay; only the heading goes, and they fall back
|
||||
// to wherever the service filed them.
|
||||
onClick: () => {
|
||||
const group = parseSteamCategory(catMenu.section.key)
|
||||
if (steamPage && activeGroup && group) store.deleteSteamFolder(activeGroup.accountId, group.groupId)
|
||||
else writeCategories(myCategories.filter((c) => c.id !== catMenu.section.key))
|
||||
}
|
||||
}
|
||||
] as MenuEntry[])
|
||||
: [])
|
||||
]}
|
||||
onClose={() => setCatMenu(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{makingFolder && activeGroup && (
|
||||
<NamePrompt
|
||||
title="New category"
|
||||
detail="Made on Steam, so it is on this account in every Steam client. Steam's own client only reads its folders when it signs in, so it shows a folder or a move made here after it is restarted."
|
||||
placeholder="Name"
|
||||
confirmLabel="Create"
|
||||
onCancel={() => setMakingFolder(false)}
|
||||
onConfirm={(name) => {
|
||||
setMakingFolder(false)
|
||||
store.createSteamFolder(activeGroup.accountId, name)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Outside the group above, so a call stays visible and hangable-up
|
||||
wherever you navigate. */}
|
||||
<VoicePanel />
|
||||
@@ -776,13 +929,9 @@ export function dmStatus(
|
||||
* is nothing to leave and this only hides it.
|
||||
*/
|
||||
function ConnectionDot({ state }: { state: string }): JSX.Element {
|
||||
const color =
|
||||
state === 'connected'
|
||||
? 'var(--success)'
|
||||
: state === 'connecting'
|
||||
? 'var(--warning)'
|
||||
: 'var(--outline)'
|
||||
return <span className="connection-dot" style={{ background: color }} title={state} />
|
||||
// The same dot as a person's, because it says the same kind of thing: here,
|
||||
// being reached, or not.
|
||||
return <span className={`connection-dot ${presenceClass(state)}`} title={presenceLabel(state)} />
|
||||
}
|
||||
|
||||
interface BufferRowProps {
|
||||
@@ -797,6 +946,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. */
|
||||
@@ -846,6 +997,7 @@ function BufferRow({
|
||||
onSelect,
|
||||
onTogglePin,
|
||||
onToggleMute,
|
||||
onUnmuteAccount,
|
||||
onToggleAutojoin,
|
||||
autojoins,
|
||||
onOpenInBrowser,
|
||||
@@ -872,6 +1024,7 @@ function BufferRow({
|
||||
onDragEnd
|
||||
}: BufferRowProps): JSX.Element {
|
||||
const { menu, open, close } = useContextMenu()
|
||||
const store = useStore()
|
||||
const account = accounts.find((a) => a.id === buffer.accountId)
|
||||
// A DM's picture comes off the service's CDN; through the route for a
|
||||
// strictly routed account (#257).
|
||||
@@ -887,6 +1040,12 @@ function BufferRow({
|
||||
// Whether this channel's stream is the one in the window. Selected down to
|
||||
// a boolean for the same reason as `live` above.
|
||||
const watched = useChat((s) => s.watching?.bufferId === buffer.id)
|
||||
// What the person is doing: under their name in comfy, and in the tooltip
|
||||
// either way. Selected as the one object rather than a copy of it, so the row
|
||||
// redraws when it changes and not on every presence update in the window.
|
||||
const activity = useChat((s) => dmActivity(buffer, s.presenceByBuffer))
|
||||
const comfy = useComfy()
|
||||
const doing = activity ? activityLabel(activity) : undefined
|
||||
|
||||
// Under an account header the row's own kind is what's worth showing (a
|
||||
// channel vs a DM vs the server buffer). A pinned row has no header above
|
||||
@@ -908,6 +1067,9 @@ function BufferRow({
|
||||
// that it is not one of the others - a notice about a quota is easy to
|
||||
// scroll past when it arrives in what looks like a room a stranger made.
|
||||
<Icon name={serviceRoomGlyph()} size={15} />
|
||||
) : buffer.forum ? (
|
||||
// A forum is posts, not a place to talk, and says so in the glyph.
|
||||
<Icon name="forum" size={15} />
|
||||
) : (
|
||||
<Icon name={bufferKindGlyph(buffer.kind)} size={15} />
|
||||
)
|
||||
@@ -927,7 +1089,7 @@ function BufferRow({
|
||||
// Calling from the row it belongs to, as well as from the header of the
|
||||
// conversation once it is open - the list is where you look for somebody
|
||||
// you want to reach, so it is where reaching them should be offered.
|
||||
const canCall = account?.service === 'discord' && buffer.kind === 'dm'
|
||||
const canCall = (account?.service === 'discord' || account?.service === 'steam') && buffer.kind === 'dm'
|
||||
|
||||
// The same entries the header's nameplate opens - see lib/buffermenu.ts for
|
||||
// why they live there rather than here.
|
||||
@@ -946,6 +1108,7 @@ function BufferRow({
|
||||
spaces,
|
||||
onTogglePin,
|
||||
onToggleMute,
|
||||
onUnmuteAccount,
|
||||
onToggleAutojoin,
|
||||
onSpace,
|
||||
onWatch,
|
||||
@@ -958,9 +1121,12 @@ function BufferRow({
|
||||
onFile,
|
||||
onPopOut,
|
||||
onDock,
|
||||
onMarkRead: () => void store.markBuffersRead([buffer.id]),
|
||||
onMarkUnread: account?.service === 'matrix' ? onMarkUnread : undefined,
|
||||
onTag: account?.service === 'matrix' ? onTag : undefined,
|
||||
markedUnread: buffer.markedUnread
|
||||
markedUnread: buffer.markedUnread,
|
||||
onSteamFriend: account?.service === 'steam' && buffer.kind === 'dm' && buffer.remoteId ? (what) => store.askSteamFriend(what, buffer.id) : undefined,
|
||||
onProfile: account?.service === 'steam' && buffer.kind === 'dm' && buffer.remoteId ? () => store.showProfile(buffer.id, buffer.name, buffer.remoteId) : undefined
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -971,6 +1137,10 @@ function BufferRow({
|
||||
'buffer-row',
|
||||
active && 'active',
|
||||
buffer.highlight && 'highlight',
|
||||
// Said by the name, not only by a number beside it: bold, and a bar
|
||||
// at the edge. Not for a muted room, which asked not to be told.
|
||||
((buffer.unread > 0 && (!muted || buffer.highlight)) || (buffer.markedUnread && !muted)) && 'unread',
|
||||
muted && 'muted-row',
|
||||
lifted && 'lifted',
|
||||
link && 'unlinked'
|
||||
)}
|
||||
@@ -984,10 +1154,17 @@ function BufferRow({
|
||||
onDragStart()
|
||||
}}
|
||||
onDragEnd={onDragEnd}
|
||||
title={link ? `${buffer.name} — ${link.detail}` : draggable ? `${buffer.name} — drag onto a heading to file it` : buffer.name}
|
||||
title={link ? `${buffer.name} — ${link.detail}` : draggable ? `${buffer.name} — drag onto a heading to file it` : doing ? `${buffer.name} — ${doing}` : buffer.name}
|
||||
>
|
||||
{leading}
|
||||
<span className="ellipsis buffer-name">{rowName}</span>
|
||||
{comfy && activity ? (
|
||||
<span className="buffer-text">
|
||||
<span className="ellipsis buffer-name">{rowName}</span>
|
||||
<ActivityLine activity={activity} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="ellipsis buffer-name">{rowName}</span>
|
||||
)}
|
||||
{/* So a conversation with no unread count and no traffic in it is
|
||||
explained rather than merely quiet: it is being read elsewhere. */}
|
||||
{/* Still filling in - a Matrix room between joining it and the server
|
||||
@@ -1009,9 +1186,9 @@ function BufferRow({
|
||||
arrive is worse than either on its own. */}
|
||||
{live ? (
|
||||
<span className="live-badge">LIVE</span>
|
||||
) : buffer.unread > 0 && !muted ? (
|
||||
<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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Icon, IconButton } from './Icon'
|
||||
import { Modal } from './Modal'
|
||||
import { VideoStage } from './VideoStage'
|
||||
import { Stage, fitTiles, type StageButton, type StageTile } from './stage/Stage'
|
||||
import { Avatar } from './Avatar'
|
||||
@@ -367,12 +368,7 @@ export function ScreenPicker(): JSX.Element | null {
|
||||
if (!sources) return null
|
||||
|
||||
return (
|
||||
<div className="lightbox-backdrop" onClick={() => store.chooseScreenSource(null)}>
|
||||
<div className="screen-picker" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
|
||||
<div className="screen-picker-head">
|
||||
<span>Share a screen or window</span>
|
||||
<IconButton name="close" size={16} title="Cancel" onClick={() => store.chooseScreenSource(null)} />
|
||||
</div>
|
||||
<Modal title="Share a screen or window" icon="screen_share" className="screen-picker" onClose={() => store.chooseScreenSource(null)}>
|
||||
<div className="screen-picker-grid">
|
||||
{sources.map((source) => (
|
||||
<button
|
||||
@@ -386,47 +382,7 @@ export function ScreenPicker(): JSX.Element | null {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Somebody ringing, and the two answers.
|
||||
*
|
||||
* Its own panel rather than a line in the log, because a call is answered in
|
||||
* the seconds it is offered and a message that scrolls is not an offer. Two
|
||||
* ways in, because an offer with a camera and one without are different
|
||||
* things to accept - and answering a video call with audio only is a thing
|
||||
* people do on purpose.
|
||||
*/
|
||||
export function IncomingMatrixCall(): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const ringing = useChat((s) => s.ringingCall)
|
||||
const buffers = useChat((s) => s.buffers)
|
||||
if (!ringing) return null
|
||||
|
||||
const where = buffers.find((b) => b.id === ringing.bufferId)?.name ?? ''
|
||||
|
||||
return (
|
||||
<div className="incoming-calls">
|
||||
<div className="incoming-call">
|
||||
<Icon name={ringing.video ? 'videocam' : 'call'} size={18} />
|
||||
<span className="ellipsis">
|
||||
{ringing.from} is calling{where ? ` in ${where}` : ''}
|
||||
</span>
|
||||
<button type="button" className="button" onClick={() => void store.answerMatrixCall(false)}>
|
||||
Answer
|
||||
</button>
|
||||
{ringing.video && (
|
||||
<button type="button" className="button" onClick={() => void store.answerMatrixCall(true)}>
|
||||
With video
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="button danger" onClick={() => store.declineMatrixCall()}>
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cameraKey, streamKey } from '../lib/framefeed'
|
||||
import type { VideoQuality } from '../lib/discordscreen'
|
||||
import { usePref } from '../state/hooks'
|
||||
import type { VoiceMember, VoiceSession } from '../../../shared/wire'
|
||||
import { useEscapeLayer } from '../lib/layers'
|
||||
|
||||
/** How often to ask who is talking. */
|
||||
const POLL_MS = 300
|
||||
@@ -416,10 +417,9 @@ function GoLiveChooser({
|
||||
.catch(() => setLimits({ maxHeight: 720, maxFramerate: 30, source: false }))
|
||||
}, [accountId])
|
||||
|
||||
useEscapeLayer(onClose)
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
// A press anywhere else is a change of mind. Except on the button that
|
||||
// opened this, which closes it by itself and would otherwise reopen it.
|
||||
const onDown = (e: MouseEvent): void => {
|
||||
@@ -427,12 +427,8 @@ function GoLiveChooser({
|
||||
if (box.current?.contains(target) || target.closest('.stage-button')) return
|
||||
onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
window.addEventListener('mousedown', onDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('mousedown', onDown)
|
||||
}
|
||||
return () => window.removeEventListener('mousedown', onDown)
|
||||
}, [onClose])
|
||||
|
||||
const heights = HEIGHTS.filter((h) => (h === 'source' ? !!limits?.source : h <= (limits?.maxHeight ?? 720)))
|
||||
@@ -496,21 +492,15 @@ function volumeBadge(volume: number | undefined): string | undefined {
|
||||
*/
|
||||
function StagePanel({ onClose, label, children }: { onClose: () => void; label: string; children: React.ReactNode }): JSX.Element {
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
useEscapeLayer(onClose)
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
const onDown = (e: MouseEvent): void => {
|
||||
const target = e.target as HTMLElement
|
||||
if (box.current?.contains(target) || target.closest('.stage-button')) return
|
||||
onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
window.addEventListener('mousedown', onDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('mousedown', onDown)
|
||||
}
|
||||
return () => window.removeEventListener('mousedown', onDown)
|
||||
}, [onClose])
|
||||
return (
|
||||
<div className="stage-panel" ref={box} role="dialog" aria-label={label}>
|
||||
|
||||
@@ -4,15 +4,17 @@ import { applyFormat } from '../lib/composeFormatDom'
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Icon, IconButton, MaskIcon } from './Icon'
|
||||
import { ContextMenu, type MenuEntry } from './ContextMenu'
|
||||
import torMark from '../assets/tor.svg'
|
||||
import { PollComposer } from './PollComposer'
|
||||
import { Avatar } from './Avatar'
|
||||
import { EmojiPicker, type StickerEntry } from './EmojiPicker'
|
||||
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'
|
||||
import { emojiPreview } from '../lib/format'
|
||||
import { bufferDisplayName, classes, fileNameOf, isImageFile, resolveMediaUrl } from '../lib/util'
|
||||
import { bufferDisplayName, classes, fileNameOf, humanSize, isImageFile, resolveMediaUrl } from '../lib/util'
|
||||
import { completeNick, cyclePrefix, type Completion } from '../lib/completion'
|
||||
import {
|
||||
mentionInsert,
|
||||
@@ -21,12 +23,23 @@ import {
|
||||
rankMentions,
|
||||
type MentionTarget
|
||||
} from '../lib/mentions'
|
||||
import { STAGE_FILES, type StagedFile } from '../lib/staging'
|
||||
import { withTone } from '../lib/skintone'
|
||||
import { STEAM_FILE_TYPES, steamFileRefusal } from '../lib/steamfiles'
|
||||
import { usePref } from '../state/hooks'
|
||||
import { rankShortcodes, shortcodeQuery, unicodeTargets, type ShortcodeTarget } from '../lib/shortcodes'
|
||||
|
||||
interface StagedAttachment {
|
||||
id: number
|
||||
path: string
|
||||
name: string
|
||||
isImage: boolean
|
||||
/** A picture of it where the path cannot be shown: made from the dropped file. */
|
||||
preview?: string
|
||||
/** In bytes, once it has been asked. */
|
||||
size?: number | null
|
||||
/** Sent hidden behind a spoiler: Discord and Steam. */
|
||||
spoiler: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,11 +109,23 @@ interface TypedCommand {
|
||||
command?: { options?: { name: string; type?: number; required?: boolean }[] }
|
||||
}
|
||||
|
||||
export function Composer(): JSX.Element | null {
|
||||
/**
|
||||
* The message box. In a thread panel it is the same box answering the thread
|
||||
* rather than the room: it takes the open thread's conversation, sends through
|
||||
* the thread, and leaves alone what belongs to the room's own box - the
|
||||
* keyboard listeners for typing anywhere, files dropped on the window, a reply
|
||||
* in progress and the things that are a message of their own kind.
|
||||
*/
|
||||
export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const buffer = useActiveBuffer()
|
||||
const replyingTo = useChat((s) => s.replyingTo)
|
||||
const whisperingTo = useChat((s) => s.whisperingTo)
|
||||
const activeBuffer = useActiveBuffer()
|
||||
const threadBufferId = useChat((s) => s.openThread?.bufferId)
|
||||
const allBuffers = useChat((s) => s.buffers)
|
||||
const buffer = thread ? (allBuffers.find((b) => b.id === threadBufferId) ?? null) : activeBuffer
|
||||
const storedReply = useChat((s) => s.replyingTo)
|
||||
const storedWhisper = useChat((s) => s.whisperingTo)
|
||||
const replyingTo = thread ? null : storedReply
|
||||
const whisperingTo = thread ? null : storedWhisper
|
||||
const accounts = useChat((s) => s.accounts)
|
||||
const [text, setText] = useState('')
|
||||
const [staged, setStaged] = useState<StagedAttachment[]>([])
|
||||
@@ -110,6 +135,34 @@ export function Composer(): JSX.Element | null {
|
||||
/** 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
|
||||
* spot. Set further down, where the box's own staging is.
|
||||
*/
|
||||
const takeFiles = useRef<(files: StagedFile[]) => void>(() => {})
|
||||
useEffect(() => {
|
||||
// Files dropped on the window go to the room's box, not to this one.
|
||||
if (thread) return
|
||||
const onStage = (e: Event): void => takeFiles.current((e as CustomEvent<StagedFile[]>).detail)
|
||||
window.addEventListener(STAGE_FILES, onStage)
|
||||
return () => window.removeEventListener(STAGE_FILES, onStage)
|
||||
}, [thread])
|
||||
// The pictures made for the tray are held for as long as it is, and let go
|
||||
// of when a card leaves it or the box is gone.
|
||||
const previews = useRef(new Set<string>())
|
||||
useEffect(() => {
|
||||
const kept = new Set(staged.map((a) => a.preview).filter((p): p is string => !!p))
|
||||
for (const url of staged.map((a) => a.preview)) if (url) previews.current.add(url)
|
||||
for (const url of [...previews.current]) {
|
||||
if (!kept.has(url)) {
|
||||
URL.revokeObjectURL(url)
|
||||
previews.current.delete(url)
|
||||
}
|
||||
}
|
||||
}, [staged])
|
||||
useEffect(() => () => previews.current.forEach((url) => URL.revokeObjectURL(url)), [])
|
||||
/** The little form for sending a place, which is a field rather than a map. */
|
||||
const [placeOpen, setPlaceOpen] = useState(false)
|
||||
const seqRef = useRef(0)
|
||||
@@ -144,6 +197,8 @@ export function Composer(): JSX.Element | null {
|
||||
const cycle = useRef<{ last: Completion; attempt: number } | null>(null)
|
||||
/** The "@..." being typed, and which suggestion is selected. */
|
||||
const [mention, setMention] = useState<{ query: string; index: number } | null>(null)
|
||||
/** The ":..." being typed, the same way. */
|
||||
const [shortcode, setShortcode] = useState<{ query: string; index: number } | null>(null)
|
||||
/**
|
||||
* The slash commands this channel offers, while one is being typed.
|
||||
*
|
||||
@@ -166,18 +221,26 @@ export function Composer(): JSX.Element | null {
|
||||
const [roles, setRoles] = useState<{ id: string; name: string; colour?: string }[]>([])
|
||||
const emojiButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const stickerButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const placeButtonRef = 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)
|
||||
|
||||
const smilies = useChat((s) => s.smilies)
|
||||
const bufferEmojiByBuffer = useChat((s) => s.bufferEmoji)
|
||||
|
||||
const replyPing = useChat((s) => s.replyPing)
|
||||
// The message being answered, if it is on screen: for its author's face.
|
||||
const repliedTo = useChat((s) =>
|
||||
s.replyingTo?.id && buffer ? s.messagesByBuffer[buffer.id]?.find((m) => m.id === s.replyingTo?.id) : undefined
|
||||
)
|
||||
const account = buffer && accounts.find((a) => a.id === buffer.accountId)
|
||||
const service = account?.service
|
||||
const tunnelAll = useChat((s) => s.tunnelAll)
|
||||
const route = useChat((s) => s.route)
|
||||
const routed = tunnelAll || !!account?.useTor
|
||||
const media = useMediaUrl(account?.id)
|
||||
const hasStickers = service === 'matrix' || service === 'discord'
|
||||
const hasStickers = service === 'matrix' || service === 'discord' || service === 'steam'
|
||||
const formats = formatsFor(service)
|
||||
/**
|
||||
* The account's stickers, asked for when the picker opens - from either
|
||||
@@ -187,7 +250,7 @@ export function Composer(): JSX.Element | null {
|
||||
const loadStickers = (): void => {
|
||||
if (!hasStickers || !buffer) return
|
||||
void window.moho
|
||||
.rpc<StickerEntry[]>(service === 'discord' ? 'listDiscordStickers' : 'listMatrixStickers', { bufferId: buffer.id })
|
||||
.rpc<StickerEntry[]>(service === 'discord' ? 'listDiscordStickers' : service === 'steam' ? 'listSteamStickers' : 'listMatrixStickers', { bufferId: buffer.id })
|
||||
.then(setStickers)
|
||||
.catch(() => setStickers([]))
|
||||
}
|
||||
@@ -195,14 +258,27 @@ export function Composer(): JSX.Element | null {
|
||||
// of an attachment at all, so a file there is uploaded and the link sent -
|
||||
// which is what people do by hand on IRC anyway, and is why it is offered
|
||||
// here rather than left as the one service where the button is dead.
|
||||
// Steam takes a picture or a short video, and says which and how big.
|
||||
const supportsAttachments =
|
||||
service === 'discord' || service === 'sneedchat' || service === 'matrix' || service === 'irc'
|
||||
service === 'discord' || service === 'sneedchat' || service === 'matrix' || service === 'irc' || service === 'steam'
|
||||
|
||||
// Starting a reply puts the cursor in the box: the next thing anybody does
|
||||
// is write the answer.
|
||||
const replyingId = useChat((s) => s.replyingTo?.id ?? '')
|
||||
useEffect(() => {
|
||||
if (replyingId) inputRef.current?.focus()
|
||||
}, [replyingId])
|
||||
|
||||
// Refocus on buffer switch so typing works immediately after clicking a
|
||||
// channel, without a second click into the field.
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [buffer?.id])
|
||||
if (!thread) inputRef.current?.focus()
|
||||
}, [buffer?.id, thread])
|
||||
// And a thread opened is a thread to answer.
|
||||
const threadRoot = useChat((s) => s.openThread?.rootId)
|
||||
useEffect(() => {
|
||||
if (thread && threadRoot) inputRef.current?.focus()
|
||||
}, [thread, threadRoot])
|
||||
|
||||
// Typing anywhere in an active window goes into the box, as it does in
|
||||
// every other chat client: no click first. Only when nothing else is being
|
||||
@@ -210,6 +286,8 @@ export function Composer(): JSX.Element | null {
|
||||
// a picture being looked at - and never with a shortcut key held, which is
|
||||
// the app's own to answer.
|
||||
useEffect(() => {
|
||||
// The room's box alone listens: two would both take the same key.
|
||||
if (thread) return
|
||||
const free = (): boolean => {
|
||||
const active = document.activeElement
|
||||
if (active instanceof HTMLElement) {
|
||||
@@ -254,7 +332,7 @@ export function Composer(): JSX.Element | null {
|
||||
document.removeEventListener('paste', onPaste)
|
||||
window.removeEventListener('focus', onWindowFocus)
|
||||
}
|
||||
}, [])
|
||||
}, [thread])
|
||||
|
||||
/**
|
||||
* Everything that can be tagged here: the people in the conversation, the
|
||||
@@ -263,9 +341,9 @@ export function Composer(): JSX.Element | null {
|
||||
const mentionTargets = useMemo((): MentionTarget[] => {
|
||||
const members: MentionTarget[] = (roster ?? []).map((m) => ({
|
||||
name: m.nick,
|
||||
detail: m.userId && m.userId !== m.nick ? undefined : undefined,
|
||||
kind: 'member' as const,
|
||||
userId: m.userId
|
||||
userId: m.userId,
|
||||
avatarUrl: m.avatarUrl
|
||||
}))
|
||||
const roleTargets: MentionTarget[] = roles.map((r) => ({
|
||||
name: r.name,
|
||||
@@ -286,6 +364,31 @@ export function Composer(): JSX.Element | null {
|
||||
[mention, mentionTargets]
|
||||
)
|
||||
|
||||
/**
|
||||
* Everything a ":word" can become here: the common Unicode set, this
|
||||
* channel's own emoji (a server's, or a Kick channel's) and, on Sneedchat,
|
||||
* its smilies - the same places the picker draws from.
|
||||
*/
|
||||
const [tone] = usePref<number>('emoji.skinTone', 0)
|
||||
const bufferEmoji = bufferEmojiByBuffer[buffer?.id ?? ''] || []
|
||||
const shortcodeTargets = useMemo((): ShortcodeTarget[] => {
|
||||
const own: ShortcodeTarget[] = bufferEmoji.map((e) => ({ name: e.name, aliases: [], token: emojiToken(e) }))
|
||||
const sneed: ShortcodeTarget[] =
|
||||
service === 'sneedchat'
|
||||
? smilies.flatMap((s) => {
|
||||
const [first, ...rest] = s.aliases ?? []
|
||||
return first ? [{ name: first.replace(/^:|:$/g, ''), aliases: rest.map((a) => a.replace(/^:|:$/g, '')), token: first }] : []
|
||||
})
|
||||
: []
|
||||
// The hands come in the tone chosen in the picker.
|
||||
const common = unicodeTargets(COMMON_EMOJI.map((e) => ({ ...e, emoji: withTone(e.emoji, tone) })))
|
||||
return [...own, ...sneed, ...common]
|
||||
}, [bufferEmoji, smilies, service, tone])
|
||||
const shortcodes = useMemo(
|
||||
() => (shortcode ? rankShortcodes(shortcodeTargets, shortcode.query) : []),
|
||||
[shortcode, shortcodeTargets]
|
||||
)
|
||||
|
||||
// What has been typed as a command, if anything: a slash at the very start
|
||||
// and the word after it. Not mid-message - "and/or" is not a command.
|
||||
const typedCommand = /^\/\S*$/.test(text) ? text.slice(1) : null
|
||||
@@ -333,10 +436,76 @@ export function Composer(): JSX.Element | null {
|
||||
void window.moho.rpc('sendTyping', { bufferId: buffer.id }).catch(() => {})
|
||||
}
|
||||
|
||||
const stage = (path: string): void => {
|
||||
const stage = (path: string, file?: File): void => {
|
||||
const name = fileNameOf(path)
|
||||
setStaged((s) => [...s, { id: ++seqRef.current, path, name, isImage: isImageFile(name) }])
|
||||
// Steam refuses most files, and what it takes is known: said now, at the
|
||||
// box, rather than once the message has gone and failed.
|
||||
if (service === 'steam') {
|
||||
const why = steamFileRefusal(name)
|
||||
if (why) {
|
||||
store.toast('error', why)
|
||||
return
|
||||
}
|
||||
}
|
||||
const id = ++seqRef.current
|
||||
const isImage = isImageFile(name)
|
||||
const preview = file && isImage ? URL.createObjectURL(file) : undefined
|
||||
setStaged((s) => [...s, { id, path, name, isImage, spoiler: false, preview }])
|
||||
// Its size is the main process's to read; the card says it when it comes.
|
||||
void window.moho.fileSize(path).then((size) => {
|
||||
const tooBig = service === 'steam' ? steamFileRefusal(name, size ?? undefined) : null
|
||||
if (tooBig) {
|
||||
store.toast('error', tooBig)
|
||||
setStaged((s) => s.filter((a) => a.id !== id))
|
||||
return
|
||||
}
|
||||
setStaged((s) => s.map((a) => (a.id === id ? { ...a, size } : a)))
|
||||
})
|
||||
}
|
||||
takeFiles.current = (files) => {
|
||||
if (!supportsAttachments) {
|
||||
store.toast('info', "Files can't be sent in this conversation")
|
||||
return
|
||||
}
|
||||
files.forEach((f) => stage(f.path, f.file))
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
// Steam takes a short list of kinds of file, so the dialog offers those and
|
||||
// nothing else.
|
||||
const pickAndStage = (): void =>
|
||||
void window.moho.pickFile(service === 'steam' ? STEAM_FILE_TYPES : undefined).then((p) => p && stage(p))
|
||||
/**
|
||||
* What the "+" holds. A file everywhere it can go; and on Matrix the things
|
||||
* that are a message of their own kind rather than text - a place, a poll.
|
||||
* With nothing but the file, the button is that and skips the menu.
|
||||
*/
|
||||
const plusEntries: MenuEntry[] = [
|
||||
{
|
||||
label: supportsAttachments ? 'Upload a file' : "Upload a file (not available here)",
|
||||
icon: 'upload_file',
|
||||
disabled: !supportsAttachments,
|
||||
onClick: pickAndStage
|
||||
},
|
||||
...(service === 'matrix' && !thread
|
||||
? ([
|
||||
{
|
||||
label: 'Send a location',
|
||||
icon: 'location_on',
|
||||
onClick: () => {
|
||||
setPickerOpen(false)
|
||||
setStickerPicker(false)
|
||||
setPlaceOpen(true)
|
||||
}
|
||||
},
|
||||
{ label: 'Start a poll', icon: 'ballot', onClick: () => setPolling(true) }
|
||||
] as MenuEntry[])
|
||||
: [])
|
||||
]
|
||||
|
||||
/** Sends to where this box answers: the room, or the thread it is the panel's. */
|
||||
const post = (body: string, attachment?: string | string[], spoilers?: string[]): Promise<void> =>
|
||||
thread ? store.sendToThread(body, attachment, spoilers) : store.sendMessage(buffer.id, body, attachment, spoilers)
|
||||
|
||||
const submit = (): void => {
|
||||
// The text as this service will read it: formatting in the box becomes
|
||||
@@ -368,12 +537,35 @@ export function Composer(): JSX.Element | null {
|
||||
if (staged.length > 0) {
|
||||
// Each attachment is its own send; the typed text rides along as the
|
||||
// caption on the first one, matching how Discord treats a caption.
|
||||
staged.forEach((att, i) => {
|
||||
void store.sendMessage(buffer.id, i === 0 ? body : '', att.path)
|
||||
})
|
||||
if (service === 'discord') {
|
||||
// Discord carries up to ten files in one message, which is what its
|
||||
// own client makes of several chosen at once: one post, one caption,
|
||||
// one upload to watch. Past ten, the next ten make the next message.
|
||||
for (let i = 0; i < staged.length; i += 10) {
|
||||
const batch = staged.slice(i, i + 10)
|
||||
void post(
|
||||
i === 0 ? body : '',
|
||||
batch.map((att) => att.path),
|
||||
batch.filter((att) => att.spoiler).map((att) => att.path)
|
||||
)
|
||||
}
|
||||
} else if (service === 'steam') {
|
||||
// Steam sends the words and then each file as messages of their own,
|
||||
// all of them from one request, so one upload to watch and a caption
|
||||
// that always comes first.
|
||||
void post(
|
||||
body,
|
||||
staged.map((att) => att.path),
|
||||
staged.filter((att) => att.spoiler).map((att) => att.path)
|
||||
)
|
||||
} else {
|
||||
staged.forEach((att, i) => {
|
||||
void post(i === 0 ? body : '', att.path)
|
||||
})
|
||||
}
|
||||
setStaged([])
|
||||
} else {
|
||||
void store.sendMessage(buffer.id, body)
|
||||
void post(body)
|
||||
}
|
||||
if (inputRef.current) inputRef.current.replaceChildren()
|
||||
setText('')
|
||||
@@ -422,9 +614,10 @@ export function Composer(): JSX.Element | null {
|
||||
|
||||
// Pasting into an editable div would otherwise bring the clipboard's own
|
||||
// markup with it - fonts, colours, whole tables. Only the text is wanted.
|
||||
// Newlines flattened for the same reason Enter does not make one, and
|
||||
// because an input silently did this to a multi-line paste anyway.
|
||||
const pasted = e.clipboardData.getData('text/plain').replace(/\s*\n\s*/g, ' ')
|
||||
// Line breaks kept: they are the person's, and what becomes of them is the
|
||||
// service's - a newline on Discord and Matrix, a batch or separate lines on
|
||||
// IRC, a space on Kick. Only the carriage returns a paste brings are tidied.
|
||||
const pasted = e.clipboardData.getData('text/plain').replace(/\r\n?/g, '\n')
|
||||
if (pasted) {
|
||||
// Deprecated, and still the only way to insert at the caret while
|
||||
// keeping the box's own undo history intact. Chromium is the only
|
||||
@@ -520,11 +713,14 @@ export function Composer(): JSX.Element | null {
|
||||
const node = selection?.anchorNode
|
||||
if (!selection || !node || node.nodeType !== Node.TEXT_NODE) {
|
||||
setMention(null)
|
||||
setShortcode(null)
|
||||
return
|
||||
}
|
||||
const before = (node.textContent ?? '').slice(0, selection.anchorOffset)
|
||||
const query = mentionQuery(before)
|
||||
setMention(query === null ? null : { query, index: 0 })
|
||||
const code = shortcodeQuery(before)
|
||||
setShortcode(code === null ? null : { query: code, index: 0 })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -564,6 +760,45 @@ export function Composer(): JSX.Element | null {
|
||||
if (inputRef.current) setText(composerText(inputRef.current))
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a chosen emoji in the box in place of the ":word" that was typed,
|
||||
* as its picture where there is one - as the picker's own choice would be.
|
||||
*/
|
||||
const takeShortcode = (target: ShortcodeTarget): void => {
|
||||
const selection = window.getSelection()
|
||||
const node = selection?.anchorNode
|
||||
if (!selection || !node || node.nodeType !== Node.TEXT_NODE || !shortcode) return
|
||||
const offset = selection.anchorOffset
|
||||
const start = offset - shortcode.query.length - 1
|
||||
if (start < 0) return
|
||||
|
||||
const preview = emojiPreview(target.token, service === 'sneedchat' ? smilies : [])
|
||||
let inserted: Node
|
||||
if (preview) {
|
||||
const img = document.createElement('img')
|
||||
img.className = 'composer-emoji'
|
||||
img.src = media(preview.src)
|
||||
img.alt = preview.label
|
||||
img.title = preview.label
|
||||
img.dataset.token = target.token
|
||||
inserted = img
|
||||
} else {
|
||||
inserted = document.createTextNode(target.token)
|
||||
}
|
||||
const range = document.createRange()
|
||||
range.setStart(node, start)
|
||||
range.setEnd(node, offset)
|
||||
range.deleteContents()
|
||||
range.insertNode(inserted)
|
||||
range.setStartAfter(inserted)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
setShortcode(null)
|
||||
if (inputRef.current) setText(composerText(inputRef.current))
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the word before the caret with a roster name.
|
||||
*
|
||||
@@ -606,6 +841,27 @@ export function Composer(): JSX.Element | null {
|
||||
if (inputRef.current) setText(composerText(inputRef.current))
|
||||
}
|
||||
|
||||
// Somewhere that can be read and not written in: a channel this account has
|
||||
// no permission to send to, or Discord's own notices. A box that every send
|
||||
// is refused from is a worse answer than saying so where it would be.
|
||||
if (buffer.readOnly) {
|
||||
const official = buffer.readOnly.includes('official Discord')
|
||||
return (
|
||||
<div className="composer">
|
||||
<div className="divider-h" />
|
||||
<div className="composer-readonly" role="status">
|
||||
<Icon name={official ? 'verified_user' : 'lock'} size={22} />
|
||||
<div className="composer-readonly-text">
|
||||
<span className="composer-readonly-title">{buffer.readOnly}</span>
|
||||
{official && (
|
||||
<span className="small">Discord will never ask you for your password or account token.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// A Kick account with no sign-in watches; it cannot talk. Offering the
|
||||
// box anyway meant typing a message the service was always going to refuse.
|
||||
if (account && account.service === 'kick' && !account.hasPassword) {
|
||||
@@ -640,7 +896,7 @@ export function Composer(): JSX.Element | null {
|
||||
as a bug until the line above says what was searched for. */}
|
||||
{commands.length > 0 && (
|
||||
<div className="command-picker" role="listbox" aria-label="Run a command">
|
||||
<div className="command-picker-head small muted">
|
||||
<div className="picker-head small muted">
|
||||
Commands matching /{typedCommand}
|
||||
</div>
|
||||
{commands.map((c, i) => (
|
||||
@@ -674,6 +930,7 @@ export function Composer(): JSX.Element | null {
|
||||
|
||||
{mention && suggestions.length > 0 && (
|
||||
<div className="mention-picker" role="listbox" aria-label="Tag somebody">
|
||||
<div className="picker-head small muted">Members matching @{mention.query}</div>
|
||||
{suggestions.map((target, i) => (
|
||||
<button
|
||||
key={`${target.kind}:${target.name}`}
|
||||
@@ -690,7 +947,7 @@ export function Composer(): JSX.Element | null {
|
||||
onMouseEnter={() => setMention({ ...mention, index: i })}
|
||||
>
|
||||
{target.kind === 'member' ? (
|
||||
<Avatar name={target.name} size={20} />
|
||||
<Avatar name={target.name} url={target.avatarUrl} size={24} accountId={account?.id} />
|
||||
) : (
|
||||
<span className="mention-glyph" style={target.colour ? { color: target.colour } : undefined}>
|
||||
<Icon name={target.kind === 'role' ? 'group' : 'campaign'} size={16} />
|
||||
@@ -705,6 +962,34 @@ export function Composer(): JSX.Element | null {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shortcode && shortcodes.length > 0 && (
|
||||
<div className="shortcode-picker" role="listbox" aria-label="Pick an emoji">
|
||||
<div className="picker-head small muted">Emoji matching :{shortcode.query}</div>
|
||||
{shortcodes.map((target, i) => {
|
||||
const preview = emojiPreview(target.token, service === 'sneedchat' ? smilies : [])
|
||||
return (
|
||||
<button
|
||||
key={`${target.token}:${target.name}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === shortcode.index}
|
||||
className={classes('shortcode-option', i === shortcode.index && 'active')}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
takeShortcode(target)
|
||||
}}
|
||||
onMouseEnter={() => setShortcode({ ...shortcode, index: i })}
|
||||
>
|
||||
<span className="shortcode-glyph">
|
||||
{preview ? <img src={media(preview.src)} alt="" /> : target.glyph}
|
||||
</span>
|
||||
<span className="ellipsis">:{target.name}:</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TypingLine bufferId={buffer.id} />
|
||||
|
||||
{whisperingTo && (
|
||||
@@ -720,40 +1005,98 @@ export function Composer(): JSX.Element | null {
|
||||
{replyingTo && (
|
||||
<div className="composer-reply small">
|
||||
<Icon name="reply" size={14} />
|
||||
<span className="ellipsis muted">
|
||||
Replying to {replyingTo.from}
|
||||
{replyingTo.body ? `: ${replyingTo.body}` : ''}
|
||||
{repliedTo?.avatarUrl && <img className="reply-avatar" src={media(repliedTo.avatarUrl)} alt="" />}
|
||||
<span className="ellipsis">
|
||||
Replying to <strong>{replyingTo.from}</strong>
|
||||
{replyingTo.body ? <span className="muted">{`: ${replyingTo.body}`}</span> : ''}
|
||||
</span>
|
||||
<IconButton name="close" size={14} title="Cancel reply" onClick={() => store.cancelReply()} />
|
||||
{/* Whether the one answered is told, as Discord's own bar has it.
|
||||
Only there: nothing else here can say it. */}
|
||||
{service === 'discord' && replyingTo.id && (
|
||||
<button
|
||||
type="button"
|
||||
className={classes('reply-ping', replyPing && 'on')}
|
||||
title={replyPing ? 'The author will be pinged. Click to answer without pinging them.' : 'The author will not be pinged. Click to ping them.'}
|
||||
aria-pressed={replyPing}
|
||||
onClick={() => store.toggleReplyPing()}
|
||||
>
|
||||
@ {replyPing ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
)}
|
||||
<IconButton name="close" size={14} title="Cancel reply (Esc)" onClick={() => store.cancelReply()} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{staged.length > 0 && (
|
||||
<div className="composer-attachments">
|
||||
{staged.map((att) => (
|
||||
<div key={att.id} className="staged-thumb" title={att.name}>
|
||||
{att.isImage ? (
|
||||
<img src={resolveMediaUrl(att.path)} alt={att.name} />
|
||||
) : (
|
||||
<div className="staged-file">
|
||||
<Icon name="description" size={22} />
|
||||
<span className="small ellipsis">{att.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="staged-remove"
|
||||
title="Remove"
|
||||
onClick={() => setStaged((s) => s.filter((x) => x.id !== att.id))}
|
||||
>
|
||||
<Icon name="close" size={13} color="var(--error)" />
|
||||
</button>
|
||||
<div key={att.id} className={classes('staged-card', att.spoiler && 'is-spoiler')}>
|
||||
<div className="staged-preview">
|
||||
{att.isImage ? (
|
||||
<img src={att.preview ?? resolveMediaUrl(att.path)} alt="" />
|
||||
) : (
|
||||
<Icon name="description" size={32} />
|
||||
)}
|
||||
{att.spoiler && <span className="staged-spoiler-pill">SPOILER</span>}
|
||||
</div>
|
||||
<div className="staged-meta">
|
||||
<span className="staged-name ellipsis" title={att.name}>
|
||||
{att.name}
|
||||
</span>
|
||||
{typeof att.size === 'number' && <span className="small muted">{humanSize(att.size)}</span>}
|
||||
</div>
|
||||
{/* On the card's corner, and there only while it is pointed at
|
||||
or focused - they would be a dozen buttons otherwise. */}
|
||||
<div className="staged-actions">
|
||||
{(service === 'discord' || service === 'steam') && (
|
||||
<button
|
||||
type="button"
|
||||
className={classes('staged-action', att.spoiler && 'on')}
|
||||
title={att.spoiler ? 'Not a spoiler' : 'Mark as spoiler'}
|
||||
aria-pressed={att.spoiler}
|
||||
onClick={() => setStaged((s) => s.map((x) => (x.id === att.id ? { ...x, spoiler: !x.spoiler } : x)))}
|
||||
>
|
||||
<Icon name={att.spoiler ? 'visibility_off' : 'visibility'} size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="staged-action danger"
|
||||
title="Remove"
|
||||
onClick={() => setStaged((s) => s.filter((x) => x.id !== att.id))}
|
||||
>
|
||||
<Icon name="delete" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="composer-row">
|
||||
<div className="composer-box">
|
||||
{/* Everything that is not typed goes through here, inside the box on
|
||||
the left as in Discord. Where there is only the one thing - a
|
||||
file - it is that thing, without a menu in between. */}
|
||||
<button
|
||||
ref={plusButtonRef}
|
||||
type="button"
|
||||
className="icon-button composer-plus"
|
||||
title={plusEntries.length > 1 ? 'Add to the message' : supportsAttachments ? 'Attach a file' : "Attachments aren't supported for this service"}
|
||||
aria-haspopup={plusEntries.length > 1 ? 'menu' : undefined}
|
||||
disabled={plusEntries.length <= 1 && !supportsAttachments}
|
||||
onClick={(e) => {
|
||||
if (plusEntries.length > 1) {
|
||||
const r = e.currentTarget.getBoundingClientRect()
|
||||
setPlusMenu({ x: r.left, y: r.top - 6 })
|
||||
} else {
|
||||
pickAndStage()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon name="add_circle" size={22} fill />
|
||||
</button>
|
||||
|
||||
{/* Where this conversation's traffic goes: through Tor, the account
|
||||
being routed or everything being. Beside the lock rather than
|
||||
instead of it - a Matrix room can be both encrypted and routed,
|
||||
@@ -772,12 +1115,13 @@ export function Composer(): JSX.Element | null {
|
||||
</span>
|
||||
))}
|
||||
{service === 'matrix' && (
|
||||
<Icon
|
||||
name={buffer.encrypted ? 'lock' : 'lock_open'}
|
||||
size={16}
|
||||
color={buffer.encrypted ? 'var(--primary)' : 'var(--surface-variant-text)'}
|
||||
style={{ margin: '0 2px' }}
|
||||
/>
|
||||
<span className="composer-lock" title={buffer.encrypted ? 'Encrypted' : 'Not encrypted'}>
|
||||
<Icon
|
||||
name={buffer.encrypted ? 'lock' : 'lock_open'}
|
||||
size={16}
|
||||
color={buffer.encrypted ? 'var(--primary)' : 'var(--surface-variant-text)'}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="composer-input-wrap">
|
||||
@@ -791,18 +1135,18 @@ export function Composer(): JSX.Element | null {
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={`Message ${bufferDisplayName(buffer.name)}`}
|
||||
aria-label={thread ? 'Reply in thread' : `Message ${bufferDisplayName(buffer.name)}`}
|
||||
onInput={(e) => {
|
||||
setText(composerText(e.currentTarget))
|
||||
noteTyping()
|
||||
noteMention()
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Enter sends and Shift+Enter does nothing, which is what the
|
||||
// input this replaced did. An editable div would happily take a
|
||||
// second line, but a newline reaching IRC is a malformed
|
||||
// PRIVMSG - nothing between here and the socket splits one - so
|
||||
// multi-line is a separate change with its own thinking to do.
|
||||
// Enter sends and Shift+Enter makes a new line. What a line break
|
||||
// becomes on the way out is the daemon's to decide per service -
|
||||
// IRC cannot carry one in a PRIVMSG, so it goes as a multiline
|
||||
// batch where the network has them and as separate lines where
|
||||
// it does not.
|
||||
// While the mention list is up it owns the keys somebody is
|
||||
// already using to drive it - a list you steer with the arrows
|
||||
// and take with Enter is one nobody has to be taught.
|
||||
@@ -827,6 +1171,24 @@ export function Composer(): JSX.Element | null {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (shortcode && shortcodes.length > 0) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
const step = e.key === 'ArrowDown' ? 1 : shortcodes.length - 1
|
||||
setShortcode({ ...shortcode, index: (shortcode.index + step) % shortcodes.length })
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
takeShortcode(shortcodes[shortcode.index])
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setShortcode(null)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (mention && suggestions.length > 0) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
@@ -848,8 +1210,37 @@ export function Composer(): JSX.Element | null {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (!e.shiftKey) submit()
|
||||
// Shift+Enter is a new line, on every service: what becomes of
|
||||
// it on the way out is the daemon's business per protocol.
|
||||
else document.execCommand('insertLineBreak')
|
||||
return
|
||||
}
|
||||
// Escape puts a reply down - after the lists above, which took
|
||||
// theirs first.
|
||||
if (e.key === 'Escape' && replyingTo) {
|
||||
e.preventDefault()
|
||||
store.cancelReply()
|
||||
return
|
||||
}
|
||||
// Up in an empty box opens your last message for editing, as in
|
||||
// Discord and Element: the quickest way to fix a typo.
|
||||
if (
|
||||
!thread &&
|
||||
e.key === 'ArrowUp' &&
|
||||
!e.shiftKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
!text &&
|
||||
(service === 'discord' || service === 'sneedchat' || service === 'matrix')
|
||||
) {
|
||||
const last = store.lastEditableOwn(buffer.id)
|
||||
if (last) {
|
||||
e.preventDefault()
|
||||
store.startEdit(last)
|
||||
return
|
||||
}
|
||||
}
|
||||
// The browser would make its own bold and italic here, in markup
|
||||
// this service may not have. Taken over: the same keys, applied
|
||||
// as the formats this service carries, and nothing where it has
|
||||
@@ -890,7 +1281,7 @@ export function Composer(): JSX.Element | null {
|
||||
/>
|
||||
{!text.trim() && (
|
||||
<span className="composer-placeholder muted">
|
||||
Message {bufferDisplayName(buffer.name)}
|
||||
{thread ? 'Reply in thread' : `Message ${bufferDisplayName(buffer.name)}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -902,6 +1293,7 @@ export function Composer(): JSX.Element | null {
|
||||
title="Emoji"
|
||||
onClick={() => {
|
||||
setStickerPicker(false)
|
||||
setGifPicker(false)
|
||||
if (!pickerOpen) loadStickers()
|
||||
setPickerOpen(!pickerOpen)
|
||||
}}
|
||||
@@ -909,11 +1301,30 @@ export function Composer(): JSX.Element | null {
|
||||
<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 && service !== 'steam' && 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
|
||||
pack supplies them, and Discord, where each guild has its own. */}
|
||||
{(service === 'matrix' || service === 'discord') && (
|
||||
{!thread && (service === 'matrix' || service === 'discord' || service === 'steam') && (
|
||||
<button
|
||||
ref={stickerButtonRef}
|
||||
type="button"
|
||||
@@ -922,6 +1333,7 @@ export function Composer(): JSX.Element | null {
|
||||
onClick={() => {
|
||||
const opening = !stickerPicker
|
||||
setPickerOpen(false)
|
||||
setGifPicker(false)
|
||||
setStickerPicker(opening)
|
||||
if (opening) loadStickers()
|
||||
}}
|
||||
@@ -930,53 +1342,29 @@ export function Composer(): JSX.Element | null {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Beside the sticker button for the same reason it is beside the
|
||||
emoji one: a location is the message rather than something typed
|
||||
into it. Matrix only, which is the one service here that carries
|
||||
a place as its own kind of message. */}
|
||||
{service === 'matrix' && (
|
||||
<button
|
||||
ref={placeButtonRef}
|
||||
type="button"
|
||||
className="icon-button"
|
||||
title="Send a location"
|
||||
onClick={() => {
|
||||
setPickerOpen(false)
|
||||
setStickerPicker(false)
|
||||
setPlaceOpen(!placeOpen)
|
||||
}}
|
||||
>
|
||||
<Icon name="location_on" size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Saying it rather than typing it. The two services that have voice
|
||||
messages at all: Discord's flag and base64 waveform, and Matrix's
|
||||
MSC3245 marker with integers. The daemon speaks both from one
|
||||
recording, so this is the same button either way. */}
|
||||
{(service === 'discord' || service === 'matrix') && <VoiceRecorder bufferId={buffer.id} />}
|
||||
{!thread && (service === 'discord' || service === 'matrix') && <VoiceRecorder bufferId={buffer.id} />}
|
||||
|
||||
{/* Only Matrix, and only because it is the only service whose polls
|
||||
this client can start: Kick's are the streamer's to make and
|
||||
Discord's are read here. */}
|
||||
{service === 'matrix' && (
|
||||
<IconButton name="ballot" title="Start a poll" onClick={() => setPolling(true)} />
|
||||
{/* Sending is the one thing the box does that its own Enter does not
|
||||
say, so it is only drawn when there is something to send. */}
|
||||
{(text.trim() || staged.length > 0) && (
|
||||
<button type="button" className="icon-button composer-send" title="Send (Enter)" onClick={submit}>
|
||||
<Icon name="send" size={20} fill />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
name="add"
|
||||
title={
|
||||
supportsAttachments ? 'Attach a file' : "Attachments aren't supported for this service"
|
||||
}
|
||||
disabled={!supportsAttachments}
|
||||
onClick={() => void window.moho.pickFile().then((p) => p && stage(p))}
|
||||
/>
|
||||
<IconButton name="send" title="Send" onClick={submit} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{plusMenu && (
|
||||
<ContextMenu x={plusMenu.x} y={plusMenu.y} above entries={plusEntries} onClose={() => setPlusMenu(null)} />
|
||||
)}
|
||||
|
||||
{placeOpen && (
|
||||
<PlaceField
|
||||
anchor={placeButtonRef.current}
|
||||
anchor={plusButtonRef.current}
|
||||
onClose={() => setPlaceOpen(false)}
|
||||
onSend={(place, label) => {
|
||||
setPlaceOpen(false)
|
||||
@@ -990,6 +1378,16 @@ export function Composer(): JSX.Element | null {
|
||||
{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
|
||||
@@ -1000,14 +1398,19 @@ export function Composer(): JSX.Element | null {
|
||||
// Only where the service has stickers, which is what decides
|
||||
// whether there is a Stickers tab at all.
|
||||
stickers={hasStickers ? stickers : undefined}
|
||||
// Steam gives an account a short set of its own and takes nothing else.
|
||||
fixedSet={service === 'steam'}
|
||||
onSticker={
|
||||
hasStickers
|
||||
? (sticker) => {
|
||||
setStickerPicker(false)
|
||||
setPickerOpen(false)
|
||||
const sent = sticker.id
|
||||
? window.moho.rpc('sendDiscordSticker', { bufferId: buffer.id, stickerId: sticker.id })
|
||||
: window.moho.rpc('sendMatrixSticker', { bufferId: buffer.id, mxc: sticker.mxc, body: sticker.body })
|
||||
const sent =
|
||||
service === 'steam'
|
||||
? window.moho.rpc('sendSteamSticker', { bufferId: buffer.id, name: sticker.name })
|
||||
: sticker.id
|
||||
? window.moho.rpc('sendDiscordSticker', { bufferId: buffer.id, stickerId: sticker.id })
|
||||
: window.moho.rpc('sendMatrixSticker', { bufferId: buffer.id, mxc: sticker.mxc, body: sticker.body })
|
||||
void sent.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
: undefined
|
||||
@@ -1047,7 +1450,7 @@ export function Composer(): JSX.Element | null {
|
||||
* stopped, and a Matrix client that closes mid-sentence never sends its
|
||||
* cancel. Without this the line would sit there indefinitely.
|
||||
*/
|
||||
function TypingLine({ bufferId }: { bufferId: string }): JSX.Element | null {
|
||||
function TypingLine({ bufferId }: { bufferId: string }): JSX.Element {
|
||||
const entry = useChat((s) => s.typingByBuffer[bufferId])
|
||||
const [, tick] = useState(0)
|
||||
|
||||
@@ -1059,7 +1462,12 @@ function TypingLine({ bufferId }: { bufferId: string }): JSX.Element | null {
|
||||
return () => clearTimeout(t)
|
||||
}, [entry])
|
||||
|
||||
if (!entry || entry.until <= Date.now() || entry.nicks.length === 0) return null
|
||||
// Always drawn, empty or not: the strip keeps its height whether anybody is
|
||||
// writing or not, so the log above it is not shoved up and down as people
|
||||
// start and stop.
|
||||
if (!entry || entry.until <= Date.now() || entry.nicks.length === 0) {
|
||||
return <div className="composer-typing small muted" aria-live="polite" />
|
||||
}
|
||||
|
||||
const names = entry.nicks
|
||||
const who =
|
||||
@@ -1070,8 +1478,13 @@ function TypingLine({ bufferId }: { bufferId: string }): JSX.Element | null {
|
||||
: `${names.length} people are typing`
|
||||
|
||||
return (
|
||||
<div className="composer-typing small muted ellipsis" aria-live="polite">
|
||||
{who}…
|
||||
<div className="composer-typing small muted" aria-live="polite">
|
||||
<span className="typing-dots" aria-hidden>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
<span className="ellipsis">{who}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* A button for something that interrupts - restarting Tor, restarting the
|
||||
* daemon - that asks once more before doing it, in place.
|
||||
*
|
||||
* Not a dialog: the question is about the thing in front of you and answers
|
||||
* itself in a click, and it takes itself back after a few seconds so a
|
||||
* forgotten question is not left lying about as a loaded button.
|
||||
*/
|
||||
export function ConfirmButton({
|
||||
label,
|
||||
question,
|
||||
confirmLabel,
|
||||
onConfirm,
|
||||
danger
|
||||
}: {
|
||||
label: string
|
||||
/** What is being asked, in a few words: "Restart Tor and lose its circuits?" */
|
||||
question: string
|
||||
confirmLabel: string
|
||||
onConfirm: () => void
|
||||
danger?: boolean
|
||||
}): JSX.Element {
|
||||
const [asking, setAsking] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!asking) return
|
||||
const t = setTimeout(() => setAsking(false), 6000)
|
||||
return () => clearTimeout(t)
|
||||
}, [asking])
|
||||
|
||||
if (!asking) {
|
||||
return (
|
||||
<button type="button" className="button subtle" onClick={() => setAsking(true)}>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="confirm-button" role="alertdialog" aria-label={question}>
|
||||
<span className="small">{question}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={danger ? 'button danger' : 'button'}
|
||||
onClick={() => {
|
||||
setAsking(false)
|
||||
onConfirm()
|
||||
}}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
<button type="button" className="button subtle" autoFocus onClick={() => setAsking(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Icon } from './Icon'
|
||||
import { useEscapeLayer } from '../lib/layers'
|
||||
|
||||
export interface MenuItem {
|
||||
label: string
|
||||
@@ -8,6 +9,10 @@ export interface MenuItem {
|
||||
danger?: boolean
|
||||
disabled?: boolean
|
||||
separator?: false
|
||||
/** On or chosen: drawn with a tick, and read as such by a screen reader. */
|
||||
checked?: boolean
|
||||
/** The key that does this without the menu, said beside it: "Ctrl+K". */
|
||||
shortcut?: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
@@ -38,6 +43,8 @@ interface Props {
|
||||
x: number
|
||||
y: number
|
||||
entries: MenuEntry[]
|
||||
/** `y` is the menu's bottom edge rather than its top: for a button at the foot of the window. */
|
||||
above?: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
@@ -46,19 +53,60 @@ interface Props {
|
||||
* or clipping ancestor, then nudged back inside the viewport once its real
|
||||
* size is known.
|
||||
*/
|
||||
export function ContextMenu({ x, y, entries, onClose }: Props): JSX.Element {
|
||||
export function ContextMenu({ x, y, entries, above, onClose }: Props): JSX.Element {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState({ x, y })
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
// The layout size, not getBoundingClientRect: that is the size as drawn,
|
||||
// and while the entry animation is scaling the menu it is a few pixels
|
||||
// smaller than it will be - which parked the menu that far off the edge.
|
||||
const width = el.offsetWidth
|
||||
const height = el.offsetHeight
|
||||
const top = above ? y - height : y
|
||||
setPos({
|
||||
x: Math.max(4, Math.min(x, window.innerWidth - rect.width - 4)),
|
||||
y: Math.max(4, Math.min(y, window.innerHeight - rect.height - 4))
|
||||
x: Math.max(4, Math.min(x, window.innerWidth - width - 4)),
|
||||
y: Math.max(4, Math.min(top, window.innerHeight - height - 4))
|
||||
})
|
||||
}, [x, y, entries.length])
|
||||
}, [x, y, above, entries.length])
|
||||
|
||||
useEscapeLayer(onClose)
|
||||
|
||||
// Opened from the keyboard or the mouse, it is the thing with focus, so the
|
||||
// arrows work at once and closing it gives the focus back to where it was.
|
||||
useEffect(() => {
|
||||
const before = document.activeElement
|
||||
ref.current?.focus({ preventScroll: true })
|
||||
return () => {
|
||||
if (before instanceof HTMLElement && document.contains(before)) before.focus({ preventScroll: true })
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** The entries that can be moved onto, in order. */
|
||||
const items = (): HTMLButtonElement[] =>
|
||||
[...(ref.current?.querySelectorAll<HTMLButtonElement>('button.context-menu-item:not([disabled])') ?? [])]
|
||||
const move = (to: 'next' | 'previous' | 'first' | 'last'): void => {
|
||||
const list = items()
|
||||
if (list.length === 0) return
|
||||
const here = list.indexOf(document.activeElement as HTMLButtonElement)
|
||||
const index =
|
||||
to === 'first' ? 0 : to === 'last' ? list.length - 1 : to === 'next' ? (here + 1) % list.length : (here <= 0 ? list.length : here) - 1
|
||||
list[index].focus()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// A menu belongs to the place it was opened over: once that scrolls away,
|
||||
// or the window is left, it has nothing to point at.
|
||||
const away = (): void => onClose()
|
||||
window.addEventListener('scroll', away, true)
|
||||
window.addEventListener('blur', away)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', away, true)
|
||||
window.removeEventListener('blur', away)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
// Presses inside the menu must not dismiss it. This listener runs in the
|
||||
@@ -73,24 +121,37 @@ export function ContextMenu({ x, y, entries, onClose }: Props): JSX.Element {
|
||||
onClose()
|
||||
}
|
||||
const onResize = (): void => onClose()
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('mousedown', onMouseDown, true)
|
||||
window.addEventListener('resize', onResize)
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', onMouseDown, true)
|
||||
window.removeEventListener('resize', onResize)
|
||||
window.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
className="context-menu"
|
||||
className={above ? 'context-menu above' : 'context-menu'}
|
||||
role="menu"
|
||||
tabIndex={-1}
|
||||
style={{ left: pos.x, top: pos.y }}
|
||||
onKeyDown={(e) => {
|
||||
const keys: Record<string, 'next' | 'previous' | 'first' | 'last'> = {
|
||||
ArrowDown: 'next',
|
||||
ArrowUp: 'previous',
|
||||
Home: 'first',
|
||||
End: 'last'
|
||||
}
|
||||
if (e.key in keys) {
|
||||
e.preventDefault()
|
||||
move(keys[e.key])
|
||||
} else if (e.key === 'Tab') {
|
||||
// Leaving the menu by Tab is closing it.
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{entries.map((entry, i) =>
|
||||
entry.separator ? (
|
||||
@@ -115,6 +176,8 @@ export function ContextMenu({ x, y, entries, onClose }: Props): JSX.Element {
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
role={entry.checked !== undefined ? 'menuitemcheckbox' : 'menuitem'}
|
||||
aria-checked={entry.checked}
|
||||
className={`context-menu-item${entry.danger ? ' danger' : ''}`}
|
||||
disabled={entry.disabled}
|
||||
onClick={() => {
|
||||
@@ -123,7 +186,9 @@ export function ContextMenu({ x, y, entries, onClose }: Props): JSX.Element {
|
||||
}}
|
||||
>
|
||||
{entry.icon && <Icon name={entry.icon} size={16} />}
|
||||
<span>{entry.label}</span>
|
||||
<span className="context-menu-label">{entry.label}</span>
|
||||
{entry.shortcut && <kbd className="context-menu-shortcut">{entry.shortcut}</kbd>}
|
||||
{entry.checked && <Icon name="check" size={16} className="context-menu-check" />}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
@@ -136,6 +201,8 @@ export function ContextMenu({ x, y, entries, onClose }: Props): JSX.Element {
|
||||
export function useContextMenu(): {
|
||||
menu: { x: number; y: number } | null
|
||||
open: (e: React.MouseEvent) => void
|
||||
/** Opens it under an element rather than at the pointer: for a button that is a menu. */
|
||||
openFrom: (el: HTMLElement) => void
|
||||
close: () => void
|
||||
} {
|
||||
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
|
||||
@@ -146,6 +213,13 @@ export function useContextMenu(): {
|
||||
e.stopPropagation()
|
||||
setMenu({ x: e.clientX, y: e.clientY })
|
||||
},
|
||||
openFrom: (el: HTMLElement) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
// To the side of a button in a column, below one in a row; the menu
|
||||
// nudges itself back inside the window either way.
|
||||
const side = r.width < 80 && r.left < 120
|
||||
setMenu(side ? { x: r.right + 6, y: r.top } : { x: r.left, y: r.bottom + 4 })
|
||||
},
|
||||
close: () => setMenu(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import { HeaderTopic } from './HeaderTopic'
|
||||
import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu'
|
||||
import { ExportDialog } from './ExportDialog'
|
||||
import { ReasonPrompt } from './ReasonPrompt'
|
||||
import { Avatar } from './Avatar'
|
||||
import { bufferMenuEntries } from '../lib/buffermenu'
|
||||
import { dmStatus } from './BufferList'
|
||||
import { activityLabel, dmActivity } from '../lib/activity'
|
||||
import { bufferDisplayName, classes } from '../lib/util'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import { useIdSetPref, useMapPref, usePref } from '../state/hooks'
|
||||
@@ -50,6 +52,8 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
|
||||
|
||||
const account = accounts.find((a) => a.id === buffer.accountId)
|
||||
const name = bufferDisplayName(buffer.name)
|
||||
const activity = dmActivity(buffer, presence)
|
||||
const statusText = activity ? activityLabel(activity) : undefined
|
||||
|
||||
const entries: MenuEntry[] = [
|
||||
...bufferMenuEntries({
|
||||
@@ -60,7 +64,7 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
|
||||
filed: !!assignment[buffer.id],
|
||||
inCall: voiceSessions.some((s) => s.bufferId === buffer.id),
|
||||
poppedOut: popouts.open.includes(buffer.id),
|
||||
canCall: account?.service === 'discord' && buffer.kind === 'dm',
|
||||
canCall: (account?.service === 'discord' || account?.service === 'steam') && buffer.kind === 'dm',
|
||||
live,
|
||||
watched,
|
||||
autojoins: store.autojoins(buffer.accountId, name),
|
||||
@@ -68,12 +72,19 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
|
||||
onTogglePin: () => togglePin(buffer.id),
|
||||
onToggleMute: () => {
|
||||
toggleMute(buffer.id)
|
||||
if (account?.service === 'matrix') {
|
||||
if (account?.service === 'matrix' || account?.service === 'steam') {
|
||||
void window.moho
|
||||
.rpc('setMatrixRoomMuted', { bufferId: buffer.id, muted: !isMuted(buffer.id) })
|
||||
.rpc(account.service === 'steam' ? 'steamSetFriendMuted' : 'setMatrixRoomMuted', { bufferId: buffer.id, muted: !isMuted(buffer.id) })
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
},
|
||||
onUnmuteAccount:
|
||||
account?.service === 'matrix' || account?.service === 'steam'
|
||||
? () =>
|
||||
void window.moho
|
||||
.rpc(account.service === 'steam' ? 'steamSetFriendMuted' : '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)
|
||||
@@ -94,12 +105,15 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
|
||||
onFile: (categoryId) => setAssignment(buffer.id, categoryId),
|
||||
onPopOut: () => store.popOut(buffer.id),
|
||||
onDock: () => store.dock(buffer.id),
|
||||
onMarkRead: () => void store.markBuffersRead([buffer.id]),
|
||||
onMarkUnread: account?.service === 'matrix' ? () => void store.markUnread(buffer.id) : undefined,
|
||||
onTag:
|
||||
account?.service === 'matrix'
|
||||
? (tag, on) => void store.setRoomTag(buffer.id, tag, on)
|
||||
: undefined,
|
||||
markedUnread: buffer.markedUnread
|
||||
markedUnread: buffer.markedUnread,
|
||||
onSteamFriend: account?.service === 'steam' && buffer.kind === 'dm' && buffer.remoteId ? (what) => store.askSteamFriend(what, buffer.id) : undefined,
|
||||
onProfile: account?.service === 'steam' && buffer.kind === 'dm' && buffer.remoteId ? () => store.showProfile(buffer.id, buffer.name, buffer.remoteId) : undefined
|
||||
}),
|
||||
{ separator: true },
|
||||
// Last, and on its own. It is the one entry here that starts something
|
||||
@@ -142,7 +156,7 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={classes('header-nameplate', menu && 'open')}
|
||||
className={classes('header-nameplate', menu && 'open', buffer.topic && 'with-topic')}
|
||||
title={`${name} — options`}
|
||||
aria-haspopup="menu"
|
||||
onClick={(e) => open(e)}
|
||||
@@ -157,8 +171,13 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
|
||||
/>
|
||||
) : null}
|
||||
<span className="main-header-title ellipsis">{name}</span>
|
||||
{/* What they are doing, beside the name: the game, or a state the dot's
|
||||
colour does not tell apart. */}
|
||||
{statusText && <span className="header-status small muted ellipsis">{statusText}</span>}
|
||||
</button>
|
||||
|
||||
{buffer.topic && <HeaderTopic topic={buffer.topic} />}
|
||||
|
||||
{menu && <ContextMenu x={menu.x} y={menu.y} entries={entries} onClose={close} />}
|
||||
|
||||
{reporting && (
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Icon } from './Icon'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
|
||||
/**
|
||||
* A warning the service has put on a conversation, over the top of it, in red,
|
||||
* until the person dismisses it.
|
||||
*
|
||||
* Steam's "this person may not be who they appear to be" is the one there is.
|
||||
* Said in the log as well, where it stays in its place; this is what stops it
|
||||
* being missed by somebody who opens the conversation scrolled to the bottom.
|
||||
* Dismissing tells the service, which does not raise it again - and if it does
|
||||
* raise one, the banner is back, because that is a new warning.
|
||||
*/
|
||||
export function ConversationNotice({ bufferId }: { bufferId: string }): JSX.Element | null {
|
||||
const notice = useChat((s) => s.buffers.find((b) => b.id === bufferId)?.notice)
|
||||
const store = useStore()
|
||||
if (!notice) return null
|
||||
return (
|
||||
<div className="notice-banner" role="alert">
|
||||
<Icon name="warning" size={18} />
|
||||
<span className="notice-banner-text">{notice.text}</span>
|
||||
<button type="button" className="notice-banner-dismiss" onClick={() => void store.dismissNotice(bufferId)}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -467,9 +467,9 @@ function PinnedMessages({ buffer }: { buffer: BufferEntry }): JSX.Element | null
|
||||
}
|
||||
setOpen(true)
|
||||
setRows(null)
|
||||
void window.moho
|
||||
.rpc<{ pinned: Message[] }>('listPinned', { bufferId: buffer.id })
|
||||
.then((answer) => setRows(answer.pinned))
|
||||
void store
|
||||
.loadPins(buffer.id)
|
||||
.then((pinned) => setRows(pinned))
|
||||
.catch((e: Error) => {
|
||||
store.toast('error', e.message)
|
||||
setRows([])
|
||||
@@ -494,6 +494,7 @@ function PinnedMessages({ buffer }: { buffer: BufferEntry }): JSX.Element | null
|
||||
className={open ? 'active' : undefined}
|
||||
onClick={show}
|
||||
/>
|
||||
{count > 0 && <span className="header-count">{count > 99 ? '99+' : count}</span>}
|
||||
</span>
|
||||
{open && (
|
||||
<HeaderPopover anchor={button.current} width={400} onClose={() => setOpen(false)}>
|
||||
@@ -614,6 +615,7 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
|
||||
// to a name half written.
|
||||
const filterMenu = suggestions(query)
|
||||
const isMatrix = buffer.accountId.startsWith('matrix:')
|
||||
const isSteam = buffer.accountId.startsWith('steam:')
|
||||
const stream = useChat((s) => s.kickStreams)[buffer.id]
|
||||
// Whether this window is the one playing this channel. Per window rather
|
||||
// than per channel: a popped-out chat is its own window with its own
|
||||
@@ -948,7 +950,7 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
|
||||
/>
|
||||
)}
|
||||
|
||||
{isDiscord && isDm && (
|
||||
{(isDiscord || isSteam) && isDm && (
|
||||
<IconButton
|
||||
name={inCall ? 'call_end' : 'call'}
|
||||
title={inCall ? 'Hang up' : `Call ${buffer.name}`}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { Modal } from './Modal'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Icon, IconButton } from './Icon'
|
||||
import { Avatar } from './Avatar'
|
||||
import { ContextMenu, type MenuEntry } from './ContextMenu'
|
||||
@@ -89,15 +89,6 @@ export function EventsPane(): JSX.Element | null {
|
||||
if (count !== undefined) load()
|
||||
}, [count, load])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pane) return
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape' && !menu && !sharing) store.closeEvents()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [pane, menu, sharing, store])
|
||||
|
||||
if (!pane) return null
|
||||
|
||||
const act = (event: DiscordEvent, method: string, params: Record<string, unknown>, failed: string): void => {
|
||||
@@ -133,9 +124,8 @@ export function EventsPane(): JSX.Element | null {
|
||||
]
|
||||
|
||||
const n = events?.length ?? 0
|
||||
return createPortal(
|
||||
<div className="lightbox-backdrop" onClick={() => store.closeEvents()}>
|
||||
<div className="events-pane" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Events">
|
||||
return (
|
||||
<Modal title="Events" className="events-pane" onClose={() => store.closeEvents()} header={
|
||||
<div className="events-pane-head">
|
||||
<Icon name="calendar_month" size={22} />
|
||||
<span className="events-pane-count">
|
||||
@@ -148,6 +138,7 @@ export function EventsPane(): JSX.Element | null {
|
||||
<span className="events-pane-spacer" />
|
||||
<IconButton name="close" title="Close" onClick={() => store.closeEvents()} />
|
||||
</div>
|
||||
}>
|
||||
|
||||
<div className="events-pane-list">
|
||||
{events === null && <p className="small muted">Loading…</p>}
|
||||
@@ -210,11 +201,9 @@ export function EventsPane(): JSX.Element | null {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{menu && <ContextMenu x={menu.x} y={menu.y} entries={menuFor(menu.event)} onClose={() => setMenu(null)} />}
|
||||
{sharing && <ShareEvent accountId={pane.accountId} event={sharing} onClose={() => setSharing(null)} />}
|
||||
</div>,
|
||||
document.body
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -276,15 +265,6 @@ export function EventCreatePanel(): JSX.Element | null {
|
||||
.catch(() => setChannels([]))
|
||||
}, [target])
|
||||
|
||||
useEffect(() => {
|
||||
if (!target) return
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') store.closeEventCreate()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [target, store])
|
||||
|
||||
if (!target) return null
|
||||
|
||||
const offered = channels.filter((c) => (kind === 'stage' ? c.stage : !c.stage))
|
||||
@@ -349,15 +329,15 @@ export function EventCreatePanel(): JSX.Element | null {
|
||||
</label>
|
||||
)
|
||||
|
||||
return createPortal(
|
||||
<div className="lightbox-backdrop" onClick={() => store.closeEventCreate()}>
|
||||
<div className="events-pane event-create" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Create an event">
|
||||
return (
|
||||
<Modal title="Create an event" className="events-pane event-create" onClose={() => store.closeEventCreate()} header={
|
||||
<div className="events-pane-head">
|
||||
<Icon name="calendar_add_on" size={22} />
|
||||
<span className="events-pane-count">Create an event</span>
|
||||
<span className="events-pane-spacer" />
|
||||
<IconButton name="close" title="Close" onClick={() => store.closeEventCreate()} />
|
||||
</div>
|
||||
}>
|
||||
|
||||
<div className="event-create-body">
|
||||
<div className="event-create-section">
|
||||
@@ -442,9 +422,7 @@ export function EventCreatePanel(): JSX.Element | null {
|
||||
{sending ? 'Creating…' : 'Create Event'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -489,13 +467,6 @@ function ShareEvent({
|
||||
.catch(() => setTargets([]))
|
||||
}, [accountId, event.guildId, event.id])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
const shown = (targets ?? []).filter(
|
||||
@@ -551,8 +522,7 @@ function ShareEvent({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lightbox-backdrop share-event-backdrop" onClick={onClose}>
|
||||
<div className="events-pane share-event" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Invite friends to event">
|
||||
<Modal title="Invite friends to event" className="events-pane share-event" onClose={onClose} header={
|
||||
<div className="share-event-head">
|
||||
<div>
|
||||
<div className="events-pane-count">Invite friends to event</div>
|
||||
@@ -565,6 +535,7 @@ function ShareEvent({
|
||||
<span className="events-pane-spacer" />
|
||||
<IconButton name="close" title="Close" onClick={onClose} />
|
||||
</div>
|
||||
}>
|
||||
<div className="emoji-search share-event-search">
|
||||
<Icon name="search" size={16} />
|
||||
<input autoFocus placeholder="Search for friends" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
@@ -595,7 +566,6 @@ function ShareEvent({
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Icon } from './Icon'
|
||||
import { Modal } from './Modal'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
|
||||
/**
|
||||
@@ -34,14 +33,6 @@ export function DiscordModal(): JSX.Element | null {
|
||||
first.current?.focus()
|
||||
}, [modal])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') store.closeDiscordModal()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [store])
|
||||
|
||||
if (!modal) return null
|
||||
|
||||
// What the bot said it needs. Checked here so the button says so before it
|
||||
@@ -60,13 +51,8 @@ export function DiscordModal(): JSX.Element | null {
|
||||
void store.submitDiscordModal(values)
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="lightbox-backdrop" onClick={() => store.closeDiscordModal()}>
|
||||
<div className="discord-modal" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
|
||||
<div className="discord-modal-head">
|
||||
<Icon name="edit_note" size={18} />
|
||||
<span>{modal.title}</span>
|
||||
</div>
|
||||
return (
|
||||
<Modal title={modal.title} icon="edit_note" className="discord-modal" onClose={() => store.closeDiscordModal()}>
|
||||
|
||||
{modal.fields.map((field, i) => {
|
||||
const value = values[field.customId] ?? ''
|
||||
@@ -114,8 +100,6 @@ export function DiscordModal(): JSX.Element | null {
|
||||
{sending ? 'Sending…' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@ import React, { createContext, useContext, useEffect, useLayoutEffect, useMemo,
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { Icon } from './Icon'
|
||||
import { discordEmojiUrl, emojiPreview, type SmilieEntry } from '../lib/format'
|
||||
import { discordEmojiUrl, emojiPreview, sourcePick, type SmilieEntry } from '../lib/format'
|
||||
import { drawableEmoteUrl, onLocalEmotes } from '../lib/emotecache'
|
||||
import { LottieSticker } from './LottieSticker'
|
||||
import type { CustomEmoji } from '../../../shared/wire'
|
||||
import { useEscapeLayer } from '../lib/layers'
|
||||
import { TONE_NAMES, TONES, withTone } from '../lib/skintone'
|
||||
import { usePref } from '../state/hooks'
|
||||
|
||||
/**
|
||||
* Emoji insertion popup: a search box, then a small curated set of common
|
||||
@@ -17,7 +20,7 @@ import type { CustomEmoji } from '../../../shared/wire'
|
||||
* the categories that come up constantly in chat. Each entry carries search
|
||||
* keywords, since "fire" only finds 🔥 if something says so.
|
||||
*/
|
||||
const COMMON_EMOJI: { emoji: string; name: string }[] = [
|
||||
export const COMMON_EMOJI: { emoji: string; name: string }[] = [
|
||||
{ emoji: '😀', name: 'grinning happy' },
|
||||
{ emoji: '😂', name: 'joy laughing tears' },
|
||||
{ emoji: '😅', name: 'sweat smile nervous' },
|
||||
@@ -35,6 +38,10 @@ const COMMON_EMOJI: { emoji: string; name: string }[] = [
|
||||
{ emoji: '👍', name: 'thumbs up yes approve' },
|
||||
{ emoji: '👎', name: 'thumbs down no' },
|
||||
{ emoji: '👏', name: 'clap applause' },
|
||||
{ emoji: '👋', name: 'wave hello bye' },
|
||||
{ emoji: '🙌', name: 'raised hands praise' },
|
||||
{ emoji: '👌', name: 'ok perfect' },
|
||||
{ emoji: '✌️', name: 'peace victory' },
|
||||
{ emoji: '🙏', name: 'pray thanks please' },
|
||||
{ emoji: '🤝', name: 'handshake deal' },
|
||||
{ emoji: '💪', name: 'muscle strong' },
|
||||
@@ -64,7 +71,9 @@ const COMMON_EMOJI: { emoji: string; name: string }[] = [
|
||||
* draw the picture still shows something readable; Discord's is the other way
|
||||
* round and flags animation. Neither is negotiable - the server parses it.
|
||||
*/
|
||||
function emojiToken(e: CustomEmoji): string {
|
||||
export function emojiToken(e: CustomEmoji): string {
|
||||
// Steam's, named rather than numbered: the id carries the service.
|
||||
if (e.id.startsWith('steam:')) return `[steam:emote:${e.id.slice(6)}]`
|
||||
if (e.url) return `[emote:${e.id}:${e.name}]`
|
||||
return `<${e.animated ? 'a' : ''}:${e.name}:${e.id}>`
|
||||
}
|
||||
@@ -111,7 +120,7 @@ function stickerKey(s: StickerEntry): string {
|
||||
return s.id ?? s.mxc ?? s.name
|
||||
}
|
||||
|
||||
function readRecent(accountId?: string): string[] {
|
||||
export function readRecent(accountId?: string): string[] {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(recentKey(accountId)) || '[]')
|
||||
return Array.isArray(stored) ? stored : []
|
||||
@@ -198,6 +207,12 @@ interface Props {
|
||||
accountId?: string
|
||||
onSelect: (text: string) => void
|
||||
onClose: () => void
|
||||
/**
|
||||
* The service has a short set of its own and nothing else is accepted - Steam
|
||||
* - so there is no Unicode emoji to offer, only what the account was given.
|
||||
* Recent picks that are not from that set are left out too.
|
||||
*/
|
||||
fixedSet?: boolean
|
||||
}
|
||||
|
||||
export function EmojiPicker({
|
||||
@@ -210,7 +225,8 @@ export function EmojiPicker({
|
||||
accountId,
|
||||
onSelect,
|
||||
onSticker,
|
||||
onClose
|
||||
onClose,
|
||||
fixedSet = false
|
||||
}: Props): JSX.Element {
|
||||
// The account's emoji and stickers through its route when it is strictly
|
||||
// routed (#257) - Discord's come straight off its CDN otherwise.
|
||||
@@ -221,11 +237,27 @@ export function EmojiPicker({
|
||||
const [recentStickers, setRecentStickers] = useState<string[]>(() => readRecentStickers(accountId))
|
||||
/** What is under the pointer, for the line along the bottom. */
|
||||
const [hovered, setHovered] = useState<{ name: string; from?: string; src?: string; text?: string } | null>(null)
|
||||
/** The line along the bottom, said for one cell: under the pointer or under the arrow keys. */
|
||||
const describe = (cell: HTMLElement): void => {
|
||||
const img = cell.querySelector('img')
|
||||
const heading = cell.closest('.emoji-grid-window')?.previousElementSibling?.textContent ?? undefined
|
||||
setHovered({
|
||||
name: cell.dataset.name || cell.title,
|
||||
from: heading,
|
||||
src: img?.getAttribute('src') ?? undefined,
|
||||
text: img || cell.querySelector('.lottie-sticker') ? undefined : cell.textContent ?? undefined
|
||||
})
|
||||
}
|
||||
/** The section the list is scrolled to, lit in the rail. */
|
||||
const [current, setCurrent] = useState<string>('')
|
||||
const [query, setQuery] = useState('')
|
||||
// Which skin tone the hands are drawn in, kept across windows and sessions.
|
||||
const [tone, setTone] = usePref<number>('emoji.skinTone', 0)
|
||||
const [toneMenu, setToneMenu] = useState(false)
|
||||
/** The cell the arrow keys are on: the search box keeps the focus. */
|
||||
const keyed = useRef<HTMLElement | null>(null)
|
||||
const [pos, setPos] = useState({ left: 0, top: 0 })
|
||||
const [recent, setRecent] = useState<string[]>(() => readRecent(accountId))
|
||||
const [recent, setRecent] = useState<string[]>(() => readRecent(accountId).filter((r) => !fixedSet || r.startsWith('[steam:')))
|
||||
// Everything this account can send, wherever it came from. Asked of the
|
||||
// daemon rather than assembled here, because the answer depends on things
|
||||
// only it knows - a Nitro subscription, a Kick channel's standing.
|
||||
@@ -306,10 +338,9 @@ export function EmojiPicker({
|
||||
}
|
||||
}, [anchor])
|
||||
|
||||
useEscapeLayer(onClose)
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
// Same capture-phase hazard as ContextMenu: dismissing on any mousedown
|
||||
// would unmount the cell before its click could land, so a press inside
|
||||
// the picker never selected anything. The anchor is excluded too, so the
|
||||
@@ -321,19 +352,15 @@ export function EmojiPicker({
|
||||
if (anchor?.contains(target)) return
|
||||
onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
window.addEventListener('mousedown', onMouseDown, true)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('mousedown', onMouseDown, true)
|
||||
}
|
||||
return () => window.removeEventListener('mousedown', onMouseDown, true)
|
||||
}, [onClose, anchor])
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
|
||||
const unicode = useMemo(
|
||||
() => (q ? COMMON_EMOJI.filter((e) => e.name.includes(q) || e.emoji === q) : COMMON_EMOJI),
|
||||
[q]
|
||||
() => (fixedSet ? [] : q ? COMMON_EMOJI.filter((e) => e.name.includes(q) || e.emoji === q) : COMMON_EMOJI),
|
||||
[q, fixedSet]
|
||||
)
|
||||
const custom = useMemo(
|
||||
() => (q ? customEmoji.filter((e) => e.name.toLowerCase().includes(q)) : customEmoji),
|
||||
@@ -535,6 +562,82 @@ export function EmojiPicker({
|
||||
}
|
||||
useEffect(followScroll, [tab, q, rail.length])
|
||||
|
||||
const clearKeyed = (): void => {
|
||||
keyed.current?.classList.remove('kbd')
|
||||
keyed.current = null
|
||||
}
|
||||
// A new search is a new list: the cell that was keyed is not in it.
|
||||
useEffect(clearKeyed, [q, tab])
|
||||
|
||||
/** Every cell that can be chosen, in reading order. */
|
||||
const choosable = (): HTMLElement[] =>
|
||||
[...(scroll.current?.querySelectorAll<HTMLElement>('.emoji-cell:not(:disabled)') ?? [])]
|
||||
const key = (cell: HTMLElement): void => {
|
||||
clearKeyed()
|
||||
cell.classList.add('kbd')
|
||||
keyed.current = cell
|
||||
cell.scrollIntoView({ block: 'nearest' })
|
||||
describe(cell)
|
||||
}
|
||||
/**
|
||||
* Moves the arrow keys' place through the grid. Left and right go cell to
|
||||
* cell; up and down go to the nearest cell in the row above or below, which
|
||||
* is how a grid of unequal sections has to be walked - there is no common
|
||||
* column count. Only the rows built are walked, so at the end of those it
|
||||
* scrolls a little and tries once more, which builds the next.
|
||||
*/
|
||||
const move = (dir: 'ArrowDown' | 'ArrowUp' | 'ArrowLeft' | 'ArrowRight', retry = true): void => {
|
||||
const list = choosable()
|
||||
if (list.length === 0) return
|
||||
const here = keyed.current?.isConnected ? keyed.current : null
|
||||
if (!here) {
|
||||
if (dir === 'ArrowDown' || dir === 'ArrowRight') key(list[0])
|
||||
return
|
||||
}
|
||||
const i = list.indexOf(here)
|
||||
if (dir === 'ArrowRight' || dir === 'ArrowLeft') {
|
||||
const next = list[i + (dir === 'ArrowRight' ? 1 : -1)]
|
||||
if (next) key(next)
|
||||
else if (dir === 'ArrowRight' && retry) {
|
||||
scroll.current?.scrollBy({ top: 120 })
|
||||
requestAnimationFrame(() => move(dir, false))
|
||||
}
|
||||
return
|
||||
}
|
||||
const r = here.getBoundingClientRect()
|
||||
const cx = r.left + r.width / 2
|
||||
const rects = list.map((el) => ({ el, r: el.getBoundingClientRect() }))
|
||||
const beyond = rects.filter((x) => (dir === 'ArrowDown' ? x.r.top > r.top + 4 : x.r.top < r.top - 4))
|
||||
if (beyond.length === 0) {
|
||||
if (dir === 'ArrowUp') clearKeyed()
|
||||
else if (retry) {
|
||||
scroll.current?.scrollBy({ top: 120 })
|
||||
requestAnimationFrame(() => move(dir, false))
|
||||
}
|
||||
return
|
||||
}
|
||||
// The nearest row in that direction, then the cell in it nearest across.
|
||||
const rowTop = dir === 'ArrowDown' ? Math.min(...beyond.map((x) => x.r.top)) : Math.max(...beyond.map((x) => x.r.top))
|
||||
const row = beyond.filter((x) => Math.abs(x.r.top - rowTop) < 4)
|
||||
row.sort((a, b) => Math.abs(a.r.left + a.r.width / 2 - cx) - Math.abs(b.r.left + b.r.width / 2 - cx))
|
||||
key(row[0].el)
|
||||
}
|
||||
const onSearchKey = (e: React.KeyboardEvent): void => {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
move(e.key)
|
||||
} else if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && keyed.current?.isConnected) {
|
||||
// Only once a cell is keyed: before that they are the caret's.
|
||||
e.preventDefault()
|
||||
move(e.key)
|
||||
} else if (e.key === 'Enter') {
|
||||
// The one keyed, or the top result when none is.
|
||||
e.preventDefault()
|
||||
const cell = keyed.current?.isConnected ? keyed.current : choosable()[0]
|
||||
cell?.click()
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -570,7 +673,42 @@ export function EmojiPicker({
|
||||
placeholder={stickersOnly ? 'Find the perfect sticker' : 'Find the perfect emoji'}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={onSearchKey}
|
||||
/>
|
||||
{/* Which skin tone the hands come in. Only where there are hands. */}
|
||||
{!stickersOnly && (
|
||||
<span className="emoji-tone">
|
||||
<button
|
||||
type="button"
|
||||
className="emoji-tone-button"
|
||||
title={`Skin tone: ${TONE_NAMES[tone] ?? TONE_NAMES[0]}`}
|
||||
aria-expanded={toneMenu}
|
||||
onClick={() => setToneMenu((open) => !open)}
|
||||
>
|
||||
{TONES[tone] ?? TONES[0]}
|
||||
</button>
|
||||
{toneMenu && (
|
||||
<span className="emoji-tone-menu" role="radiogroup" aria-label="Skin tone">
|
||||
{TONES.map((glyph, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={tone === i}
|
||||
className={`emoji-tone-choice${tone === i ? ' active' : ''}`}
|
||||
title={TONE_NAMES[i]}
|
||||
onClick={() => {
|
||||
setTone(i)
|
||||
setToneMenu(false)
|
||||
}}
|
||||
>
|
||||
{glyph}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="emoji-body">
|
||||
@@ -600,15 +738,7 @@ export function EmojiPicker({
|
||||
// heading of the section it is in.
|
||||
onMouseOver={(e) => {
|
||||
const cell = (e.target as HTMLElement).closest('.emoji-cell') as HTMLElement | null
|
||||
if (!cell) return
|
||||
const img = cell.querySelector('img')
|
||||
const heading = cell.closest('.emoji-grid-window')?.previousElementSibling?.textContent ?? undefined
|
||||
setHovered({
|
||||
name: cell.dataset.name || cell.title,
|
||||
from: heading,
|
||||
src: img?.getAttribute('src') ?? undefined,
|
||||
text: img || cell.querySelector('.lottie-sticker') ? undefined : cell.textContent ?? undefined
|
||||
})
|
||||
if (cell) describe(cell)
|
||||
}}
|
||||
>
|
||||
<PickerScroll.Provider value={scroll}>
|
||||
@@ -681,7 +811,7 @@ export function EmojiPicker({
|
||||
className={`emoji-cell${why ? ' locked' : ''}${art ? '' : ' emoji-token'}`}
|
||||
disabled={!!why}
|
||||
title={why || e.name}
|
||||
onClick={() => pick(e.id)}
|
||||
onClick={() => pick(sourcePick(src.service, e))}
|
||||
>
|
||||
{art ? <img src={media(art)} alt={e.name} loading="lazy" /> : e.name}
|
||||
</button>
|
||||
@@ -712,17 +842,14 @@ export function EmojiPicker({
|
||||
|
||||
{!stickersOnly && unicode.length > 0 && (
|
||||
<Section title="Emoji" anchorRef={(el) => (sectionRefs.current.unicode = el)}>
|
||||
{unicode.map((e) => (
|
||||
<button
|
||||
key={e.emoji}
|
||||
type="button"
|
||||
className="emoji-cell"
|
||||
title={e.name}
|
||||
onClick={() => pick(e.emoji)}
|
||||
>
|
||||
{e.emoji}
|
||||
</button>
|
||||
))}
|
||||
{unicode.map((e) => {
|
||||
const shown = withTone(e.emoji, tone)
|
||||
return (
|
||||
<button key={e.emoji} type="button" className="emoji-cell" title={e.name} onClick={() => pick(shown)}>
|
||||
{shown}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
@@ -803,7 +930,9 @@ export function EmojiPicker({
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="small muted">{stickersOnly ? 'Pick a sticker to send it' : 'Pick an emoji'}</span>
|
||||
<span className="small muted">
|
||||
{stickersOnly ? 'Pick a sticker to send it' : 'Pick an emoji'} · arrow keys to move, Enter to choose
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Icon, IconButton } from './Icon'
|
||||
import { IconButton } from './Icon'
|
||||
import { Modal } from './Modal'
|
||||
import { useStore } from '../state/hooks'
|
||||
|
||||
/** What the daemon read back off the server. */
|
||||
@@ -46,28 +46,15 @@ export function EventSource({
|
||||
.catch((e: Error) => setFailed(e.message))
|
||||
}, [bufferId, messageId])
|
||||
|
||||
// Escape closes it, the same as every other dialog here. A panel of JSON is
|
||||
// exactly the thing somebody opens by accident and wants gone.
|
||||
useEffect(() => {
|
||||
const key = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', key)
|
||||
return () => window.removeEventListener('keydown', key)
|
||||
}, [onClose])
|
||||
|
||||
const copy = (value: unknown): void => {
|
||||
void window.moho.copyText(JSON.stringify(value, null, 2))
|
||||
store.toast('info', 'Copied')
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="lightbox-backdrop" onClick={onClose}>
|
||||
<div className="event-source" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
|
||||
<div className="reason-prompt-head">
|
||||
<Icon name="data_object" size={18} />
|
||||
<span>Message source</span>
|
||||
</div>
|
||||
// Escape closes it, the same as every other dialog here: a panel of JSON is
|
||||
// exactly the thing somebody opens by accident and wants gone.
|
||||
return (
|
||||
<Modal title="Message source" icon="data_object" className="event-source" onClose={onClose}>
|
||||
<div className="small muted ellipsis">{messageId}</div>
|
||||
|
||||
{!source && !failed && <div className="small muted">Reading…</div>}
|
||||
@@ -94,9 +81,7 @@ export function EventSource({
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Icon } from './Icon'
|
||||
import { useState } from 'react'
|
||||
import { Modal } from './Modal'
|
||||
import { canReachBack, type ExportRange } from '../lib/exporter'
|
||||
|
||||
/**
|
||||
@@ -57,13 +56,6 @@ export function ExportDialog({
|
||||
const [to, setTo] = useState(() => localNow())
|
||||
const [media, setMedia] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') onCancel()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onCancel])
|
||||
|
||||
// A datetime-local value is already local wall-clock time, so `new Date` on
|
||||
// it means what was typed. Seconds are not offered, so the start is taken at
|
||||
@@ -78,13 +70,8 @@ export function ExportDialog({
|
||||
const wouldReachBack = !everything && !!oldestHeld && since > 0 && since < oldestHeld
|
||||
const shortfall = wouldReachBack && !canReachBack(service)
|
||||
|
||||
return createPortal(
|
||||
<div className="lightbox-backdrop" onClick={onCancel}>
|
||||
<div className="reason-prompt" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
|
||||
<div className="reason-prompt-head">
|
||||
<Icon name="download" size={18} />
|
||||
<span>Export {title}</span>
|
||||
</div>
|
||||
return (
|
||||
<Modal title={`Export ${title}`} icon="download" onClose={onCancel} className="reason-prompt">
|
||||
<p className="small muted">
|
||||
Written as a folder of HTML, the way it looks here, into your downloads.
|
||||
</p>
|
||||
@@ -162,7 +149,7 @@ export function ExportDialog({
|
||||
)}
|
||||
|
||||
<div className="reason-prompt-actions">
|
||||
<button type="button" className="button" onClick={onCancel}>
|
||||
<button type="button" className="button subtle" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@@ -174,8 +161,6 @@ export function ExportDialog({
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Icon } from './Icon'
|
||||
import { useActiveBuffer, useStore } from '../state/hooks'
|
||||
import { bufferDisplayName, fileNameOf } from '../lib/util'
|
||||
import { useActiveBuffer } from '../state/hooks'
|
||||
import { bufferDisplayName } from '../lib/util'
|
||||
import { stageFiles } from '../lib/staging'
|
||||
|
||||
/**
|
||||
* Dropping a file anywhere on the window.
|
||||
@@ -12,23 +13,15 @@ import { bufferDisplayName, fileNameOf } from '../lib/util'
|
||||
* a miss meant the browser navigated away from the app to display whatever
|
||||
* had been dropped.
|
||||
*
|
||||
* It asks before sending. A file dragged in is one gesture away from being
|
||||
* posted to a room full of people, and the wrong window happens to be
|
||||
* focused often enough that "which conversation is this going to" is worth a
|
||||
* sentence and a button rather than a surprise afterwards.
|
||||
* It does not send. A dropped file goes into the message box's tray, beside
|
||||
* anything already there, where it can be looked at, given a caption or taken
|
||||
* out again - and goes with the next message, as one chosen from the file
|
||||
* picker does. Nothing is posted to a room by a gesture that may have landed
|
||||
* on the wrong window.
|
||||
*/
|
||||
interface Dropped {
|
||||
/** Where the file is, for the daemon to read. */
|
||||
path: string
|
||||
/** The file itself, so a picture can be previewed without a path. */
|
||||
file: File
|
||||
}
|
||||
|
||||
export function FileDrop(): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const buffer = useActiveBuffer()
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [dropped, setDropped] = useState<Dropped[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
@@ -92,7 +85,7 @@ export function FileDrop(): JSX.Element | null {
|
||||
const items = files
|
||||
.map((file) => ({ file, path: window.moho.pathForFile(file) }))
|
||||
.filter((item) => !!item.path)
|
||||
if (items.length > 0) setDropped(items)
|
||||
if (items.length > 0) stageFiles(items)
|
||||
}
|
||||
|
||||
// Captured, so the flag is set before anything else in the app sees the
|
||||
@@ -115,120 +108,15 @@ export function FileDrop(): JSX.Element | null {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!dropped) return
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') setDropped(null)
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [dropped])
|
||||
|
||||
const send = (): void => {
|
||||
if (!buffer || !dropped) return
|
||||
// One message each, the same as several files staged in the composer:
|
||||
// every service here takes one file per message.
|
||||
for (const item of dropped) void store.sendMessage(buffer.id, '', item.path)
|
||||
setDropped(null)
|
||||
}
|
||||
|
||||
const where = buffer ? bufferDisplayName(buffer.name) : null
|
||||
|
||||
if (dropped) {
|
||||
return createPortal(
|
||||
<div className="lightbox-backdrop" onClick={() => setDropped(null)}>
|
||||
<div
|
||||
className="drop-confirm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="drop-confirm-head">
|
||||
<Icon name="upload_file" size={20} />
|
||||
<span>
|
||||
{where ? (
|
||||
<>
|
||||
Upload to <b>{where}</b>?
|
||||
</>
|
||||
) : (
|
||||
'Nowhere to upload to'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="drop-preview">
|
||||
{dropped.map((item) => (
|
||||
<DroppedItem key={item.path} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{where ? (
|
||||
<p className="small muted">
|
||||
{dropped.length === 1
|
||||
? 'It will be sent as its own message.'
|
||||
: `${dropped.length} files, each sent as its own message.`}
|
||||
</p>
|
||||
) : (
|
||||
<p className="small muted">Open a conversation first, then drop the file again.</p>
|
||||
)}
|
||||
|
||||
<div className="drop-confirm-actions">
|
||||
<button type="button" className="button" onClick={() => setDropped(null)} autoFocus>
|
||||
Cancel
|
||||
</button>
|
||||
{where && (
|
||||
<button type="button" className="button primary" onClick={send}>
|
||||
Upload
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
if (!dragging) return null
|
||||
|
||||
return createPortal(
|
||||
<div className="drop-hint">
|
||||
<Icon name="upload_file" size={34} />
|
||||
<span>{where ? `Drop to upload to ${where}` : 'Drop a file'}</span>
|
||||
<span>{where ? `Drop to add to your message in ${where}` : 'Open a conversation to send a file'}</span>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One dropped file in the dialog.
|
||||
*
|
||||
* A picture is shown from the File itself rather than from its path. The
|
||||
* guarded media scheme only serves what the daemon cached or what somebody
|
||||
* picked in a dialog, and widening it to take a path the renderer supplies
|
||||
* would be a real hole for one preview - where the dropped File is already
|
||||
* here and needs no filesystem access at all.
|
||||
*/
|
||||
function DroppedItem({ item }: { item: Dropped }): JSX.Element {
|
||||
const [url, setUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!item.file.type.startsWith('image/')) return
|
||||
const made = URL.createObjectURL(item.file)
|
||||
setUrl(made)
|
||||
// Revoked on unmount, or the blob is held for as long as the window is.
|
||||
return () => URL.revokeObjectURL(made)
|
||||
}, [item.file])
|
||||
|
||||
return (
|
||||
<div className="drop-item">
|
||||
{url ? (
|
||||
<img src={url} alt="" />
|
||||
) : (
|
||||
<span className="drop-item-file">
|
||||
<Icon name="description" size={26} />
|
||||
</span>
|
||||
)}
|
||||
<span className="small ellipsis">{fileNameOf(item.path)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { Modal } from './Modal'
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import { classes } from '../lib/util'
|
||||
import { filterPosts, postAge, type ForumPost } from '../lib/forum'
|
||||
import { formatMessage } from '../lib/format'
|
||||
import { RichText } from '../lib/richtext'
|
||||
import type { BufferEntry } from '../state/store'
|
||||
|
||||
interface ForumTag {
|
||||
id: string
|
||||
name: string
|
||||
emoji?: string | null
|
||||
moderated?: boolean
|
||||
}
|
||||
|
||||
interface Page {
|
||||
posts: ForumPost[]
|
||||
hasMore: boolean
|
||||
total?: number | null
|
||||
tags: ForumTag[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A Discord forum: a list of posts, each of which is a thread.
|
||||
*
|
||||
* Not a conversation, so it is not drawn as one - there is nothing in it to
|
||||
* scroll back through and nowhere to type. What it holds is the posts, and
|
||||
* what is worth seeing of each is the title, the words that began it, how many
|
||||
* have answered, how long ago, and a picture if there was one. Opening a post
|
||||
* opens it as the conversation it is, in the list under this forum.
|
||||
*/
|
||||
export function ForumPane({ buffer }: { buffer: BufferEntry }): JSX.Element {
|
||||
const store = useStore()
|
||||
const media = useMediaUrl(buffer.accountId)
|
||||
const cached = useChat((s) => s.forumPages)[buffer.id]
|
||||
const [posts, setPosts] = useState<ForumPost[]>(cached?.posts ?? [])
|
||||
const [tags, setTags] = useState<ForumTag[]>(cached?.tags ?? [])
|
||||
const [hasMore, setHasMore] = useState(cached?.hasMore ?? false)
|
||||
const [loading, setLoading] = useState(!cached)
|
||||
const [error, setError] = useState('')
|
||||
const [sort, setSort] = useState<'active' | 'created'>(cached?.sort ?? 'active')
|
||||
const [query, setQuery] = useState('')
|
||||
const [opening, setOpening] = useState('')
|
||||
const [writing, setWriting] = useState(false)
|
||||
|
||||
const load = useCallback(
|
||||
(offset: number) => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
void window.moho
|
||||
.rpc<Page>('listForumPosts', { bufferId: buffer.id, sort, offset })
|
||||
.then((page) => {
|
||||
setHasMore(page.hasMore)
|
||||
setTags(page.tags ?? [])
|
||||
setPosts((had) => {
|
||||
const next = offset === 0 ? page.posts : [...had, ...page.posts.filter((p) => !had.some((h) => h.id === p.id))]
|
||||
store.setForumPage(buffer.id, { at: Date.now(), sort, posts: next, hasMore: page.hasMore, tags: page.tags ?? [] })
|
||||
return next
|
||||
})
|
||||
})
|
||||
.catch((e: Error) => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[buffer.id, sort]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setQuery('')
|
||||
// As it was left, if that was a moment ago and the same sort.
|
||||
const fresh = cached && cached.sort === sort && Date.now() - cached.at < 60_000
|
||||
if (fresh) {
|
||||
setPosts(cached.posts)
|
||||
setHasMore(cached.hasMore)
|
||||
setTags(cached.tags)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setPosts([])
|
||||
load(0)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [load])
|
||||
|
||||
const open = (post: ForumPost): void => {
|
||||
if (opening) return
|
||||
setOpening(post.id)
|
||||
void window.moho
|
||||
.rpc<{ bufferId: string }>('openDiscordThread', { bufferId: buffer.id, threadId: post.id })
|
||||
.then((answer) => store.selectBuffer(answer.bufferId, true))
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
.finally(() => setOpening(''))
|
||||
}
|
||||
|
||||
const shown = useMemo(() => filterPosts(posts, query), [posts, query])
|
||||
const live = shown.filter((p) => !p.archived || p.pinned)
|
||||
const older = shown.filter((p) => p.archived && !p.pinned)
|
||||
|
||||
const card = (post: ForumPost): JSX.Element => (
|
||||
<button
|
||||
key={post.id}
|
||||
type="button"
|
||||
className={classes('forum-post', post.archived && 'older', opening === post.id && 'opening')}
|
||||
onClick={() => open(post)}
|
||||
>
|
||||
<span className="forum-post-main">
|
||||
<span className="forum-post-title">
|
||||
{post.pinned && <Icon name="push_pin" size={14} className="forum-pin" />}
|
||||
{post.name}
|
||||
</span>
|
||||
{!!post.tags.length && (
|
||||
<span className="forum-post-tags">
|
||||
{post.tags.map((t) => (
|
||||
<span key={t.name} className="forum-tag small">
|
||||
{t.emoji ? `${t.emoji} ` : ''}
|
||||
{t.name}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
{(post.author || post.content) && (
|
||||
<span className="forum-post-text small">
|
||||
{post.author && <strong>{post.author}: </strong>}
|
||||
<RichText html={formatMessage(post.content)} ownMarkup onOpenLink={() => {}} />
|
||||
</span>
|
||||
)}
|
||||
<span className="forum-post-meta small muted">
|
||||
<span className="forum-post-count">
|
||||
<Icon name="chat_bubble" size={13} /> {post.messageCount.toLocaleString()}
|
||||
</span>
|
||||
{post.reaction && (
|
||||
<span>
|
||||
{post.reaction.emoji.startsWith('<') ? '' : post.reaction.emoji} {post.reaction.count}
|
||||
</span>
|
||||
)}
|
||||
<span>{postAge(post.lastTs || post.createdTs)}</span>
|
||||
</span>
|
||||
</span>
|
||||
{post.thumbnail && <img className="forum-post-thumb" src={media(post.thumbnail)} alt="" loading="lazy" />}
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="forum-pane">
|
||||
<div className="forum-bar">
|
||||
<label className="forum-search">
|
||||
<Icon name="search" size={18} />
|
||||
<input
|
||||
placeholder="Search posts"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
aria-label="Search posts"
|
||||
/>
|
||||
</label>
|
||||
<button type="button" className="button primary" onClick={() => setWriting(true)}>
|
||||
<Icon name="add_comment" size={16} /> New Post
|
||||
</button>
|
||||
</div>
|
||||
<div className="forum-sorts" role="radiogroup" aria-label="Sort posts">
|
||||
{(
|
||||
[
|
||||
['active', 'Recently active'],
|
||||
['created', 'Date posted']
|
||||
] as const
|
||||
).map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={sort === id}
|
||||
className={classes('service-chip', sort === id && 'active')}
|
||||
onClick={() => setSort(id)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="forum-list">
|
||||
{error && <p className="small error-text">{error}</p>}
|
||||
{live.map(card)}
|
||||
{older.length > 0 && <div className="forum-older small muted">Older posts</div>}
|
||||
{older.map(card)}
|
||||
{!loading && !error && shown.length === 0 && (
|
||||
<div className="forum-empty muted">
|
||||
<Icon name="forum" size={32} />
|
||||
<p>{query ? 'No posts match that.' : 'No posts yet. Be the first.'}</p>
|
||||
</div>
|
||||
)}
|
||||
{loading && <p className="small muted forum-loading"><span className="spinner" /> Reading the forum…</p>}
|
||||
{!loading && hasMore && !query && (
|
||||
<button type="button" className="button subtle forum-more" onClick={() => load(posts.length)}>
|
||||
Show more posts
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{writing && (
|
||||
<NewPost
|
||||
buffer={buffer}
|
||||
tags={tags}
|
||||
onClose={() => setWriting(false)}
|
||||
onMade={(bufferId) => {
|
||||
setWriting(false)
|
||||
void store.selectBuffer(bufferId, true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The form for a new post: its title, what it says, and any tags the forum offers. */
|
||||
function NewPost({
|
||||
buffer,
|
||||
tags,
|
||||
onClose,
|
||||
onMade
|
||||
}: {
|
||||
buffer: BufferEntry
|
||||
tags: ForumTag[]
|
||||
onClose: () => void
|
||||
onMade: (bufferId: string) => void
|
||||
}): JSX.Element {
|
||||
const store = useStore()
|
||||
const [title, setTitle] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [chosen, setChosen] = useState<string[]>([])
|
||||
const [sending, setSending] = useState(false)
|
||||
const ready = title.trim() !== '' && body.trim() !== '' && !sending
|
||||
|
||||
const send = (): void => {
|
||||
if (!ready) return
|
||||
setSending(true)
|
||||
void window.moho
|
||||
.rpc<{ bufferId: string }>('createForumPost', { bufferId: buffer.id, title: title.trim(), body, tags: chosen })
|
||||
.then((answer) => onMade(answer.bufferId))
|
||||
.catch((e: Error) => {
|
||||
setSending(false)
|
||||
store.toast('error', e.message)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="New post" icon="add_comment" className="forum-new" onClose={onClose}>
|
||||
<label className="field">
|
||||
<span className="small muted">Title</span>
|
||||
<input className="text-field" value={title} maxLength={100} placeholder="What is this about?" onChange={(e) => setTitle(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="small muted">Message</span>
|
||||
<textarea
|
||||
className="reason-prompt-box"
|
||||
rows={6}
|
||||
value={body}
|
||||
placeholder="Say more…"
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{tags.length > 0 && (
|
||||
<div className="field">
|
||||
<span className="small muted">Tags</span>
|
||||
<div className="forum-new-tags">
|
||||
{tags
|
||||
.filter((t) => !t.moderated)
|
||||
.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
aria-pressed={chosen.includes(t.id)}
|
||||
className={classes('service-chip', chosen.includes(t.id) && 'active')}
|
||||
onClick={() => setChosen(chosen.includes(t.id) ? chosen.filter((c) => c !== t.id) : [...chosen, t.id])}
|
||||
>
|
||||
{t.emoji ? `${t.emoji} ` : ''}
|
||||
{t.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="field-row">
|
||||
<button type="button" className="button subtle" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="button primary" disabled={!ready} onClick={send}>
|
||||
{sending ? 'Posting…' : 'Post'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Icon } from './Icon'
|
||||
import { Modal } from './Modal'
|
||||
import { Avatar } from './Avatar'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import { bufferDisplayName } from '../lib/util'
|
||||
@@ -37,12 +36,7 @@ export function ForwardPicker({
|
||||
|
||||
useEffect(() => {
|
||||
box.current?.focus()
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
}, [])
|
||||
|
||||
const source = buffers.find((b) => b.id === bufferId)
|
||||
const choices = useMemo(() => {
|
||||
@@ -68,13 +62,8 @@ export function ForwardPicker({
|
||||
})
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="lightbox-backdrop" onClick={onClose}>
|
||||
<div className="forward-picker" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
|
||||
<div className="discord-modal-head">
|
||||
<Icon name="forward" size={18} />
|
||||
<span>Send this on</span>
|
||||
</div>
|
||||
return (
|
||||
<Modal title="Send this on" icon="forward" className="forward-picker" onClose={onClose}>
|
||||
<input
|
||||
ref={box}
|
||||
className="text-field"
|
||||
@@ -106,9 +95,7 @@ export function ForwardPicker({
|
||||
<p className="small muted emoji-empty">No conversation here by that name.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useEscapeLayer } from '../lib/layers'
|
||||
|
||||
/**
|
||||
* A panel hanging from a header button, over the conversation.
|
||||
@@ -67,20 +68,16 @@ export function HeaderPopover({
|
||||
return () => window.removeEventListener('resize', place)
|
||||
}, [anchor, width, children])
|
||||
|
||||
useEscapeLayer(onClose)
|
||||
useEffect(() => {
|
||||
const away = (e: MouseEvent): void => {
|
||||
if (box.current?.contains(e.target as Node)) return
|
||||
if (anchor?.contains(e.target as Node)) return
|
||||
onClose()
|
||||
}
|
||||
const key = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', away)
|
||||
document.addEventListener('keydown', key)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', away)
|
||||
document.removeEventListener('keydown', key)
|
||||
}
|
||||
}, [anchor, onClose])
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { HeaderPopover } from './HeaderPopover'
|
||||
import { RichText } from '../lib/richtext'
|
||||
import { formatMessage } from '../lib/format'
|
||||
|
||||
/**
|
||||
* What a conversation says it is for, on one line beside its name.
|
||||
*
|
||||
* Every client of this kind has it and it was the one thing here that did
|
||||
* not: an IRC channel's topic arrived as a line in the log and then scrolled
|
||||
* away. A click opens the whole of it, with its links live - a topic is very
|
||||
* often a rule, a schedule and a few links, and one line holds the start of
|
||||
* that.
|
||||
*/
|
||||
export function HeaderTopic({ topic }: { topic: string }): JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
const button = useRef<HTMLButtonElement>(null)
|
||||
// Through the same pipeline every other body goes through, so a link is a
|
||||
// link. Built only when opened.
|
||||
const html = useMemo(() => (open ? formatMessage(topic) : ''), [open, topic])
|
||||
const oneLine = topic.replace(/\s+/g, ' ').trim()
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={button}
|
||||
type="button"
|
||||
className="header-topic"
|
||||
title="Show the whole topic"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<span className="ellipsis">{oneLine}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<HeaderPopover anchor={button.current} width={460} onClose={() => setOpen(false)}>
|
||||
<div className="small muted">Topic</div>
|
||||
<div className="header-topic-full">
|
||||
<RichText html={html} />
|
||||
</div>
|
||||
</HeaderPopover>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -20,7 +20,10 @@ export function Icon({ name, size = 20, color, fill, className, style }: Props):
|
||||
width: size,
|
||||
height: size,
|
||||
color,
|
||||
...(fill ? { fontVariationSettings: "'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24" } : {}),
|
||||
// The glyph is drawn for its size: the font's optical-size axis runs
|
||||
// 20-48, and small ones are given a little more weight, or they read
|
||||
// as hairlines next to text.
|
||||
fontVariationSettings: `'FILL' ${fill ? 1 : 0}, 'wght' ${size <= 14 ? 500 : 400}, 'GRAD' 0, 'opsz' ${Math.min(48, Math.max(20, size))}`,
|
||||
...style
|
||||
}}
|
||||
>
|
||||
@@ -43,7 +46,8 @@ export function IconButton({
|
||||
disabled,
|
||||
onClick,
|
||||
className,
|
||||
style
|
||||
style,
|
||||
fill
|
||||
}: ButtonProps): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
@@ -55,7 +59,8 @@ export function IconButton({
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
>
|
||||
<Icon name={name} size={size} color={color} />
|
||||
{/* A toggle that is on is drawn filled as well as tinted, so it reads as on without the colour. */}
|
||||
<Icon name={name} size={size} color={color} fill={fill ?? /(^| )active( |$)/.test(className ?? '')} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,65 +15,125 @@ import { bufferDisplayName } from '../lib/util'
|
||||
*
|
||||
* Several at once are stacked rather than collapsed into a count: each is a
|
||||
* different person waiting on a different answer, and there is no sensible
|
||||
* single button for two of them.
|
||||
* single button for two of them. Discord's and Matrix's are in the one list,
|
||||
* in the one container - two containers at the same corner drew a Discord call
|
||||
* and a Matrix call on top of each other.
|
||||
*/
|
||||
export function IncomingCallPanel(): JSX.Element | null {
|
||||
|
||||
/** One ring, whichever service it came from. */
|
||||
interface Ring {
|
||||
key: string
|
||||
name: string
|
||||
avatarUrl?: string
|
||||
accountId?: string
|
||||
/** Who is ringing and on which account, said under the name. */
|
||||
detail: string
|
||||
answer: () => void
|
||||
/** Only where the offer carries a camera: answering with it is its own choice. */
|
||||
answerWithVideo?: () => void
|
||||
decline: () => void
|
||||
}
|
||||
export function IncomingCallPanel({ silent = false }: { silent?: boolean } = {}): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const calls = useChat((s) => s.incomingCalls)
|
||||
const matrix = useChat((s) => s.ringingCall)
|
||||
const steamRings = useChat((s) => s.steamRings)
|
||||
const buffers = useChat((s) => s.buffers)
|
||||
const accounts = useChat((s) => s.accounts)
|
||||
const ringtone = useRef<Ringtone>()
|
||||
|
||||
const rings: Ring[] = [
|
||||
...steamRings.map((ring): Ring => {
|
||||
const buffer = buffers.find((b) => b.accountId === ring.accountId && b.remoteId === ring.steamid)
|
||||
const account = accounts.find((a) => a.id === ring.accountId)
|
||||
return {
|
||||
key: `s:${ring.voiceChatId}`,
|
||||
name: buffer ? bufferDisplayName(buffer.name) : 'Someone',
|
||||
avatarUrl: buffer?.avatarUrl,
|
||||
accountId: ring.accountId,
|
||||
detail: `Incoming call${account ? ` · ${account.displayName || account.id}` : ''}`,
|
||||
answer: () => void store.answerSteamCall(ring),
|
||||
decline: () => void store.declineSteamCall(ring)
|
||||
}
|
||||
}),
|
||||
...calls.map((call): Ring => {
|
||||
const buffer = buffers.find((b) => b.id === call.bufferId)
|
||||
const account = accounts.find((a) => a.id === call.accountId)
|
||||
return {
|
||||
key: `d:${call.bufferId}`,
|
||||
// A call from a conversation this window has not loaded yet still has
|
||||
// to say something. The channel is not a name, but it is not nothing.
|
||||
name: buffer ? bufferDisplayName(buffer.name) : 'Someone',
|
||||
avatarUrl: buffer?.avatarUrl,
|
||||
accountId: buffer?.accountId,
|
||||
detail: `Incoming call${account ? ` · ${account.displayName || account.id}` : ''}`,
|
||||
answer: () => void store.acceptCall(call.bufferId),
|
||||
decline: () => void store.declineCall(call.bufferId)
|
||||
}
|
||||
}),
|
||||
...(matrix
|
||||
? [
|
||||
((): Ring => {
|
||||
const buffer = buffers.find((b) => b.id === matrix.bufferId)
|
||||
const account = accounts.find((a) => a.id === matrix.accountId)
|
||||
return {
|
||||
key: `m:${matrix.callId}`,
|
||||
name: buffer ? bufferDisplayName(buffer.name) : matrix.from,
|
||||
avatarUrl: buffer?.avatarUrl,
|
||||
accountId: buffer?.accountId ?? matrix.accountId,
|
||||
detail: `${matrix.from} is calling${matrix.video ? ' · video' : ''}${account ? ` · ${account.displayName || account.id}` : ''}`,
|
||||
answer: () => void store.answerMatrixCall(false),
|
||||
answerWithVideo: matrix.video ? () => void store.answerMatrixCall(true) : undefined,
|
||||
decline: () => store.declineMatrixCall()
|
||||
}
|
||||
})()
|
||||
]
|
||||
: [])
|
||||
]
|
||||
|
||||
// One ringtone for any number of calls: two people calling at once should
|
||||
// not ring twice as loudly and out of phase with each other.
|
||||
useEffect(() => {
|
||||
if (silent) return
|
||||
if (!ringtone.current) ringtone.current = new Ringtone()
|
||||
if (calls.length > 0) ringtone.current.start()
|
||||
if (rings.length > 0) ringtone.current.start()
|
||||
else ringtone.current.stop()
|
||||
}, [calls.length])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rings.length, silent])
|
||||
|
||||
// Silence on the way out. Without this a window closed mid-ring leaves an
|
||||
// oscillator running with nothing on screen to explain it.
|
||||
useEffect(() => () => ringtone.current?.stop(), [])
|
||||
|
||||
if (calls.length === 0) return null
|
||||
if (rings.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="incoming-calls">
|
||||
{calls.map((call) => {
|
||||
const buffer = buffers.find((b) => b.id === call.bufferId)
|
||||
const account = accounts.find((a) => a.id === call.accountId)
|
||||
// A call from a conversation this window has not loaded yet still has
|
||||
// to say something. The channel is not a name, but it is not nothing.
|
||||
const name = buffer ? bufferDisplayName(buffer.name) : 'Someone'
|
||||
return (
|
||||
<div key={call.bufferId} className="incoming-call">
|
||||
<Avatar name={name} url={buffer?.avatarUrl} size={44} accountId={buffer?.accountId} />
|
||||
<div className="incoming-call-who">
|
||||
<span className="ellipsis incoming-call-name">{name}</span>
|
||||
<span className="small muted ellipsis">
|
||||
Incoming call{account ? ` · ${account.displayName || account.id}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{rings.map((ring) => (
|
||||
<div key={ring.key} className="incoming-call">
|
||||
<Avatar name={ring.name} url={ring.avatarUrl} size={44} accountId={ring.accountId} />
|
||||
<div className="incoming-call-who">
|
||||
<span className="ellipsis incoming-call-name">{ring.name}</span>
|
||||
<span className="small muted ellipsis">{ring.detail}</span>
|
||||
</div>
|
||||
<button type="button" className="call-answer" title={`Answer ${ring.name}`} onClick={ring.answer}>
|
||||
<Icon name="call" size={20} />
|
||||
</button>
|
||||
{ring.answerWithVideo && (
|
||||
<button
|
||||
type="button"
|
||||
className="call-answer"
|
||||
title={`Answer ${name}`}
|
||||
onClick={() => void store.acceptCall(call.bufferId)}
|
||||
title={`Answer ${ring.name} with video`}
|
||||
onClick={ring.answerWithVideo}
|
||||
>
|
||||
<Icon name="call" size={20} />
|
||||
<Icon name="videocam" size={20} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="call-decline"
|
||||
title={`Decline ${name}`}
|
||||
onClick={() => void store.declineCall(call.bufferId)}
|
||||
>
|
||||
<Icon name="call_end" size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
)}
|
||||
<button type="button" className="call-decline" title={`Decline ${ring.name}`} onClick={ring.decline}>
|
||||
<Icon name="call_end" size={20} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,56 +1,55 @@
|
||||
import { Avatar } from './Avatar'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import { readInviteGroupId } from '../lib/groups'
|
||||
import { useChat, useInvitations, useStore } from '../state/hooks'
|
||||
import { currentInvitation } from '../lib/invitations'
|
||||
import { serviceLabel } from '../lib/util'
|
||||
|
||||
/**
|
||||
* A room somebody has asked you into, and the two answers to it.
|
||||
* The invitation being looked at, and the two answers to it.
|
||||
*
|
||||
* Shown where the conversation would be, because that is what is being
|
||||
* offered: the tile in the rail says one has arrived, and this says what it
|
||||
* is and who from. Both disappear together the moment it is answered - an
|
||||
* invitation is the one thing in the rail that stops existing once it has
|
||||
* been dealt with.
|
||||
* offered: the list on the left says which have arrived, and this says what
|
||||
* the chosen one is and who from. Accepting turns this pane into the
|
||||
* conversation it was offering; declining takes the invitation away. Either
|
||||
* way it disappears from the list - an invitation is the one thing here that
|
||||
* stops existing once it has been dealt with.
|
||||
*/
|
||||
export function InvitePanel({ groupId }: { groupId: string }): JSX.Element | null {
|
||||
export function InvitePanel(): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const invites = useChat((s) => s.matrixInvites)
|
||||
const accounts = useChat((s) => s.accounts)
|
||||
const named = readInviteGroupId(groupId)
|
||||
if (!named) return null
|
||||
const key = useChat((s) => s.activeInvitationKey)
|
||||
const invitation = currentInvitation(useInvitations(), key)
|
||||
// Answered, here or in another client. The page leaves with the last one.
|
||||
if (!invitation) return null
|
||||
|
||||
const invite = (invites[named.accountId] || []).find((i) => i.roomId === named.roomId)
|
||||
// Answered, here or in another client. The tile goes with it, so this is
|
||||
// only ever seen for the moment between the two.
|
||||
if (!invite) return null
|
||||
|
||||
const account = accounts.find((a) => a.id === named.accountId)
|
||||
const answer = (accept: boolean): void => {
|
||||
store.answerMatrixInvite(named.accountId, named.roomId, accept)
|
||||
// Back to the account the invitation was for: accepting puts the room in
|
||||
// its list a moment later, and declining leaves nothing to look at here.
|
||||
if (account) store.selectGroup(`account:${account.id}`)
|
||||
}
|
||||
const account = accounts.find((a) => a.id === invitation.accountId)
|
||||
const answer = (accept: boolean): void => void store.answerInvitation(invitation, accept)
|
||||
|
||||
return (
|
||||
<div className="invite-panel">
|
||||
<Avatar name={invite.name} url={invite.avatarUrl ?? undefined} size={64} />
|
||||
<h2 className="invite-panel-name ellipsis">{invite.name}</h2>
|
||||
<Avatar name={invitation.name} url={invitation.avatarUrl ?? undefined} size={64} />
|
||||
<h2 className="invite-panel-name ellipsis">{invitation.name}</h2>
|
||||
<p className="small muted">
|
||||
{invite.inviter ? `${invite.inviter} invited you` : 'You have been invited'}
|
||||
{invite.isDirect ? ' to a direct message' : ' to this room'}
|
||||
{account ? ` · ${account.displayName || account.id}` : ''}
|
||||
{invitation.detail}
|
||||
{account ? ` · ${serviceLabel(account.service)} · ${account.displayName || account.id}` : ''}
|
||||
</p>
|
||||
<div className="button-row">
|
||||
<button type="button" className="button primary" onClick={() => answer(true)}>
|
||||
Accept
|
||||
</button>
|
||||
<button type="button" className="button danger" onClick={() => answer(false)}>
|
||||
<button type="button" className="button subtle" onClick={() => answer(false)}>
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
<p className="small muted invite-panel-note">
|
||||
Accepting joins the room and starts syncing it. Declining leaves it, and the invitation
|
||||
goes away for good.
|
||||
{invitation.kind === 'friend'
|
||||
? 'Accepting makes you friends: the conversation with them opens here, and they can see when you are online. Declining removes the request.'
|
||||
: invitation.kind === 'verification'
|
||||
? 'Accepting starts the check: you will be shown emoji to compare with the other side. Only accept one you started or expected. Declining cancels it.'
|
||||
: invitation.direct
|
||||
? 'Accepting starts the conversation and opens it here. Declining leaves it, and the invitation goes away for good.'
|
||||
: invitation.service === 'steam'
|
||||
? 'Accepting joins the group chat and opens it here. Declining takes the invitation off this list; the message that carried it stays in your conversation with them.'
|
||||
: 'Accepting joins the room and starts syncing it. Declining leaves it, and the invitation goes away for good.'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { SwitchRow } from './Switch'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { ContextMenu } from './ContextMenu'
|
||||
import { RoomSearch } from './RoomSearch'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import { classes } from '../lib/util'
|
||||
import { classes, serviceLabel } from '../lib/util'
|
||||
import { steamGroupInviteCode } from '../lib/steamchat'
|
||||
import type { Account, DiscordFriend } from '../../../shared/wire'
|
||||
|
||||
/**
|
||||
@@ -43,15 +45,6 @@ const ON_DISCORD_ONLY: { label: string; icon: string; why: string }[] = [
|
||||
}
|
||||
]
|
||||
|
||||
/** A room this account has been invited to and not yet answered. */
|
||||
type MatrixInvite = {
|
||||
roomId: string
|
||||
name: string
|
||||
inviter: string | null
|
||||
avatarUrl: string | null
|
||||
isDirect: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-protocol join pages rather than one generic "join by name" field: each
|
||||
* service's mechanism is genuinely different (IRC joins by channel name,
|
||||
@@ -67,22 +60,55 @@ export function JoinPanel(): JSX.Element {
|
||||
const accountId = useChat((s) => s.joinPanelAccountId)
|
||||
const account = useChat((s) => s.accounts).find((a) => a.id === accountId)
|
||||
|
||||
const store = useStore()
|
||||
const all = useChat((s) => s.accounts)
|
||||
if (!account) return <div className="panel muted">No account selected.</div>
|
||||
|
||||
switch (account.service) {
|
||||
case 'irc':
|
||||
return <IrcJoin account={account} />
|
||||
case 'matrix':
|
||||
return <MatrixJoin account={account} />
|
||||
case 'discord':
|
||||
return <DiscordJoin account={account} />
|
||||
case 'kick':
|
||||
return <KickJoin account={account} />
|
||||
case 'sneedchat':
|
||||
return <SneedchatRooms account={account} />
|
||||
default:
|
||||
return <div className="panel muted">Joining isn't supported for this service yet.</div>
|
||||
}
|
||||
const body = ((): JSX.Element => {
|
||||
switch (account.service) {
|
||||
case 'irc':
|
||||
return <IrcJoin account={account} />
|
||||
case 'matrix':
|
||||
return <MatrixJoin account={account} />
|
||||
case 'discord':
|
||||
return <DiscordJoin account={account} />
|
||||
case 'kick':
|
||||
return <KickJoin account={account} />
|
||||
case 'sneedchat':
|
||||
return <SneedchatRooms account={account} />
|
||||
case 'steam':
|
||||
return <SteamJoin account={account} />
|
||||
default:
|
||||
return <div className="panel muted">Joining isn't supported for this service yet.</div>
|
||||
}
|
||||
})()
|
||||
const joinable = all.filter((a) => ['irc', 'matrix', 'discord', 'kick', 'sneedchat', 'steam'].includes(a.service))
|
||||
|
||||
return (
|
||||
<div className="join-frame">
|
||||
{/* Which account this is for, first: it decides everything below it,
|
||||
and was only ever the one selected in the footer. */}
|
||||
{joinable.length > 1 && (
|
||||
<div className="join-picker" role="radiogroup" aria-label="Join on">
|
||||
<span className="small muted">Join on</span>
|
||||
{joinable.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={a.id === account.id}
|
||||
className={`service-chip${a.id === account.id ? ' active' : ''}`}
|
||||
onClick={() => store.setActivePanel('join', a.id)}
|
||||
>
|
||||
{a.displayName || a.id}
|
||||
<span className="small muted"> · {serviceLabel(a.service)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{body}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A labelled field that fires on Enter and clears itself. */
|
||||
@@ -113,7 +139,7 @@ function SubmitField({
|
||||
onKeyDown={(e) => e.key === 'Enter' && submit()}
|
||||
/>
|
||||
<button type="button" className="button" onClick={submit} disabled={!value.trim()}>
|
||||
Go
|
||||
Join
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,40 +188,83 @@ function IrcChannelBrowser({ account }: { account: Account }): JSX.Element {
|
||||
const serverBuffer = useChat((s) => s.buffers).find(
|
||||
(b) => b.accountId === account.id && b.kind === 'server'
|
||||
)
|
||||
const [channels, setChannels] = useState<IrcChannelListing[] | null>(null)
|
||||
/** One page of the network's directory, from the daemon: the window never holds the whole of it. */
|
||||
const [directory, setDirectory] = useState<{ total: number; matching: number; channels: IrcChannelListing[] } | null>(null)
|
||||
const [asking, setAsking] = useState(false)
|
||||
const [filter, setFilter] = useState('')
|
||||
const [problem, setProblem] = useState('')
|
||||
const waiting = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
/** The busiest few that match, asked of the daemon, which holds the list. */
|
||||
const fetchPage = (query: string): void => {
|
||||
void window.moho
|
||||
.rpc<{ total: number; matching: number; channels: IrcChannelListing[] }>('getIrcChannels', {
|
||||
accountId: account.id,
|
||||
query,
|
||||
limit: 200
|
||||
})
|
||||
.then(setDirectory)
|
||||
.catch((e: Error) => setProblem(e.message))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return window.moho.onEvent((frame) => {
|
||||
if (frame.event !== 'ircChannelList') return
|
||||
const data = frame.data as { accountId: string; channels: IrcChannelListing[] }
|
||||
const data = frame.data as { accountId: string; count?: number; error?: string }
|
||||
if (data.accountId !== account.id) return
|
||||
if (waiting.current) clearTimeout(waiting.current)
|
||||
setAsking(false)
|
||||
setChannels(data.channels)
|
||||
// The network said no, or to come back later: said, rather than left
|
||||
// spinning for a list that is not coming.
|
||||
if (data.error) {
|
||||
setProblem(`The network did not give its list: ${data.error}`)
|
||||
return
|
||||
}
|
||||
setProblem('')
|
||||
fetchPage('')
|
||||
setFilter('')
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [account.id])
|
||||
|
||||
useEffect(() => () => {
|
||||
if (waiting.current) clearTimeout(waiting.current)
|
||||
}, [])
|
||||
|
||||
// Filtering is the daemon's to do, a moment after the last key.
|
||||
useEffect(() => {
|
||||
if (!directory) return
|
||||
const t = setTimeout(() => fetchPage(filter), 200)
|
||||
return () => clearTimeout(t)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filter])
|
||||
|
||||
const ask = (): void => {
|
||||
if (!serverBuffer) {
|
||||
store.toast('error', 'Not connected to this network yet')
|
||||
return
|
||||
}
|
||||
setAsking(true)
|
||||
setChannels(null)
|
||||
setProblem('')
|
||||
if (waiting.current) clearTimeout(waiting.current)
|
||||
// A network that never answers - some drop the request, and a few take
|
||||
// minutes - must not leave this waiting for good. A list that does arrive
|
||||
// later is still taken.
|
||||
waiting.current = setTimeout(() => {
|
||||
setAsking(false)
|
||||
setProblem('No answer after a minute. The network may be busy or may not allow the list; try again, or join a channel by name above.')
|
||||
}, 60_000)
|
||||
void window.moho.rpc('sendMessage', { bufferId: serverBuffer.id, body: '/list' }).catch((e: Error) => {
|
||||
if (waiting.current) clearTimeout(waiting.current)
|
||||
setAsking(false)
|
||||
store.toast('error', e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const shown = channels
|
||||
? channels.filter((c) => {
|
||||
const q = filter.trim().toLowerCase()
|
||||
if (!q) return true
|
||||
return c.name.toLowerCase().includes(q) || c.topic.toLowerCase().includes(q)
|
||||
})
|
||||
: []
|
||||
const stopWaiting = (): void => {
|
||||
if (waiting.current) clearTimeout(waiting.current)
|
||||
setAsking(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="field">
|
||||
@@ -203,33 +272,39 @@ function IrcChannelBrowser({ account }: { account: Account }): JSX.Element {
|
||||
<div className="field-row">
|
||||
<input
|
||||
className="text-field"
|
||||
placeholder={channels ? 'filter by name or topic' : 'ask the server for its list first'}
|
||||
placeholder={directory ? 'filter by name or topic' : 'ask the server for its list first'}
|
||||
value={filter}
|
||||
disabled={!channels}
|
||||
disabled={!directory}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
<button type="button" className="button" disabled={asking || !serverBuffer} onClick={ask}>
|
||||
{asking ? 'Asking…' : channels ? 'Refresh' : 'List channels'}
|
||||
</button>
|
||||
{asking ? (
|
||||
<button type="button" className="button subtle" onClick={stopWaiting}>
|
||||
Stop waiting
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="button" disabled={!serverBuffer} onClick={ask}>
|
||||
{directory ? 'Refresh' : 'List channels'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{asking && (
|
||||
<p className="small muted">
|
||||
A busy network can take a minute to answer, and some refuse the request entirely.
|
||||
<span className="spinner" /> Asking the network. A busy one can take a minute to answer, and some refuse the
|
||||
request entirely.
|
||||
</p>
|
||||
)}
|
||||
{problem && <p className="small error-text">{problem}</p>}
|
||||
|
||||
{channels && (
|
||||
{directory && (
|
||||
<>
|
||||
<p className="small muted">
|
||||
{channels.length.toLocaleString()} channels, busiest first
|
||||
{shown.length !== channels.length ? ` — ${shown.length.toLocaleString()} matching` : ''}
|
||||
{directory.total.toLocaleString()} channels, busiest first
|
||||
{directory.matching !== directory.total ? ` — ${directory.matching.toLocaleString()} matching` : ''}
|
||||
{directory.matching > directory.channels.length ? `; the busiest ${directory.channels.length} shown` : ''}
|
||||
</p>
|
||||
<div className="channel-browser">
|
||||
{/* Capped, because a filter that matches nothing in particular
|
||||
still matches forty thousand rows, and drawing them would
|
||||
freeze the window to no purpose. */}
|
||||
{shown.slice(0, 200).map((c) => (
|
||||
{directory.channels.map((c) => (
|
||||
<button
|
||||
key={c.name}
|
||||
type="button"
|
||||
@@ -286,6 +361,274 @@ function KickJoin({ account }: { account: Account }): JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
/** One person in a friend request. */
|
||||
interface SteamPerson {
|
||||
steamid: string
|
||||
name: string | null
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
interface SteamRequests {
|
||||
incoming: SteamPerson[]
|
||||
outgoing: SteamPerson[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Friends on Steam: asking somebody, and taking a request back.
|
||||
*
|
||||
* Steam makes you friends by one side asking and the other agreeing. Those who
|
||||
* have asked you are not here: each waits as a tile in the server list, beside
|
||||
* a Matrix room's invitations, and is answered there. What is here is the asking,
|
||||
* and the requests still waiting on the other side.
|
||||
*/
|
||||
function SteamJoin({ account }: { account: Account }): JSX.Element {
|
||||
const store = useStore()
|
||||
const [target, setTarget] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [requests, setRequests] = useState<SteamRequests>({ incoming: [], outgoing: [] })
|
||||
const [blocked, setBlocked] = useState<{ steamid: string; name: string }[]>([])
|
||||
const [links, setLinks] = useState<{ token: string; link: string; created: number }[] | null>(null)
|
||||
const connected = account.state === 'connected'
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
void window.moho
|
||||
.rpc<SteamRequests>('steamFriendRequests', { accountId: account.id })
|
||||
.then((r) => live && setRequests(r))
|
||||
.catch(() => {})
|
||||
const off = window.moho.onEvent((e) => {
|
||||
if (e.event !== 'steamFriendRequests') return
|
||||
const d = e.data as { accountId?: string; requests?: SteamRequests }
|
||||
if (d.accountId === account.id && d.requests) setRequests(d.requests)
|
||||
})
|
||||
// Who is blocked, and what links have been given out: neither has a place
|
||||
// in the conversation list, so they are here, where friends are added.
|
||||
void window.moho
|
||||
.rpc<{ steamid: string; name: string }[]>('steamBlockedList', { accountId: account.id })
|
||||
.then((b) => live && setBlocked(b))
|
||||
.catch(() => {})
|
||||
const offBlocked = window.moho.onEvent((e) => {
|
||||
if (e.event !== 'steamBlocked') return
|
||||
const d = e.data as { accountId?: string; blocked?: { steamid: string; name: string }[] }
|
||||
if (d.accountId === account.id && d.blocked) setBlocked(d.blocked)
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
off()
|
||||
offBlocked()
|
||||
}
|
||||
}, [account.id])
|
||||
|
||||
// The links, once the account is connected: they come from Steam's website.
|
||||
useEffect(() => {
|
||||
if (!connected) return
|
||||
let live = true
|
||||
void window.moho
|
||||
.rpc<{ token: string; link: string; created: number }[]>('steamInviteLinks', { accountId: account.id, action: 'list' })
|
||||
.then((l) => live && setLinks(l))
|
||||
.catch(() => live && setLinks([]))
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [account.id, connected])
|
||||
|
||||
const changeLinks = (action: 'create' | 'revoke', token?: string): void => {
|
||||
void window.moho
|
||||
.rpc<{ token: string; link: string; created: number }[]>('steamInviteLinks', { accountId: account.id, action, token })
|
||||
.then(setLinks)
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
|
||||
const copy = (text: string): void => {
|
||||
// Through the main process, as everywhere else in moho: the page's own clipboard is refused here.
|
||||
void window.moho
|
||||
.copyText(text)
|
||||
.then(() => store.toast('info', 'Copied'))
|
||||
.catch(() => store.toast('error', 'Could not copy'))
|
||||
}
|
||||
|
||||
const unblock = (p: { steamid: string; name: string }): void => {
|
||||
void window.moho
|
||||
.rpc('steamUnblock', { accountId: account.id, steamid: p.steamid })
|
||||
.then(() => store.toast('info', `Unblocked ${p.name}`))
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
|
||||
const label = (p: SteamPerson): string => p.name || p.steamid
|
||||
|
||||
const [groupLink, setGroupLink] = useState('')
|
||||
const [groupName, setGroupName] = useState('')
|
||||
const joinGroup = (): void => {
|
||||
const typed = groupLink.trim()
|
||||
if (!typed) return
|
||||
setBusy(true)
|
||||
void window.moho
|
||||
.rpc<{ groupId: string; name?: string }>('steamChatGroupAction', { accountId: account.id, action: 'join', invite: typed })
|
||||
.then((r) => {
|
||||
setGroupLink('')
|
||||
store.toast('info', `Joined ${r.name || 'the group'}`)
|
||||
store.selectGroup(r.groupId)
|
||||
})
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
.finally(() => setBusy(false))
|
||||
}
|
||||
const makeGroup = (): void => {
|
||||
const typed = groupName.trim()
|
||||
if (!typed) return
|
||||
setBusy(true)
|
||||
void window.moho
|
||||
.rpc<{ groupId: string }>('steamChatGroupAction', { accountId: account.id, action: 'create', name: typed })
|
||||
.then((r) => {
|
||||
setGroupName('')
|
||||
store.selectGroup(r.groupId)
|
||||
})
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
.finally(() => setBusy(false))
|
||||
}
|
||||
|
||||
const ask = (): void => {
|
||||
const typed = target.trim()
|
||||
if (!typed) return
|
||||
// A link to a group, pasted where a friend goes, is joined as the group it is.
|
||||
if (steamGroupInviteCode(typed)) {
|
||||
setGroupLink(typed)
|
||||
setTarget('')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
void window.moho
|
||||
.rpc<{ steamid: string; name: string | null }>('steamAddFriend', { accountId: account.id, target: typed })
|
||||
.then((r) => {
|
||||
setTarget('')
|
||||
store.toast('info', `Friend request sent to ${r.name || typed}`)
|
||||
})
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
.finally(() => setBusy(false))
|
||||
}
|
||||
|
||||
const withdraw = (p: SteamPerson): void => {
|
||||
void window.moho
|
||||
.rpc('steamRemoveFriend', { accountId: account.id, steamid: p.steamid })
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel join-panel">
|
||||
<span className="small muted">Add a friend</span>
|
||||
<div className="field-row">
|
||||
<input
|
||||
className="text-field"
|
||||
placeholder="Account name, Steam id, friend code or invite link"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
value={target}
|
||||
disabled={!connected || busy}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') ask()
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="button" disabled={!connected || busy || !target.trim()} onClick={ask}>
|
||||
Add friend
|
||||
</button>
|
||||
</div>
|
||||
{!connected && <p className="small muted">Friends can be added once this account is connected.</p>}
|
||||
|
||||
<span className="small muted">Join a group</span>
|
||||
<div className="field-row">
|
||||
<input
|
||||
className="text-field"
|
||||
placeholder="An invite link, like s.team/chat/…"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
value={groupLink}
|
||||
disabled={!connected || busy}
|
||||
onChange={(e) => setGroupLink(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') joinGroup()
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="button" disabled={!connected || busy || !groupLink.trim()} onClick={joinGroup}>
|
||||
Join
|
||||
</button>
|
||||
</div>
|
||||
<span className="small muted">Start a group</span>
|
||||
<div className="field-row">
|
||||
<input
|
||||
className="text-field"
|
||||
placeholder="A name for it"
|
||||
value={groupName}
|
||||
disabled={!connected || busy}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') makeGroup()
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="button" disabled={!connected || busy || !groupName.trim()} onClick={makeGroup}>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{requests.outgoing.length > 0 && (
|
||||
<>
|
||||
<span className="small muted">Waiting for them to accept</span>
|
||||
{requests.outgoing.map((p) => (
|
||||
<div key={p.steamid} className="field-row">
|
||||
<span className="grow">{label(p)}</span>
|
||||
<button type="button" className="button subtle small" onClick={() => withdraw(p)}>
|
||||
Cancel request
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* A link anybody can add this account by, even if it takes no requests
|
||||
from strangers: good for a month, and taken back here. */}
|
||||
{connected && links !== null && (
|
||||
<>
|
||||
<span className="small muted">Your invite links</span>
|
||||
{links.length === 0 && <p className="small muted">None yet. A link lets whoever has it add you, for thirty days.</p>}
|
||||
{links.map((l) => (
|
||||
<div key={l.token} className="field-row">
|
||||
<span className="grow ellipsis" title={l.link}>
|
||||
{l.link}
|
||||
</span>
|
||||
<button type="button" className="button subtle small" onClick={() => copy(l.link)}>
|
||||
Copy
|
||||
</button>
|
||||
<button type="button" className="button subtle small" onClick={() => changeLinks('revoke', l.token)}>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="field-row">
|
||||
<button type="button" className="button subtle small" onClick={() => changeLinks('create')}>
|
||||
Make a link
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{blocked.length > 0 && (
|
||||
<>
|
||||
<span className="small muted">Blocked</span>
|
||||
{blocked.map((p) => (
|
||||
<div key={p.steamid} className="field-row">
|
||||
<span className="grow ellipsis" title={p.steamid}>
|
||||
{p.name}
|
||||
</span>
|
||||
<button type="button" className="button subtle small" disabled={!connected} onClick={() => unblock(p)}>
|
||||
Unblock
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Making a room, or a space.
|
||||
*
|
||||
@@ -353,35 +696,25 @@ function CreateMatrixRoom({ account }: { account: Account }): JSX.Element {
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && create()}
|
||||
/>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={isSpace} onChange={(e) => setIsSpace(e.target.checked)} />
|
||||
<span>
|
||||
Make it a space
|
||||
<span className="small muted"> — a container for other rooms rather than a place to talk</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={isPublic} onChange={(e) => setIsPublic(e.target.checked)} />
|
||||
<span>
|
||||
Anyone can find and join it
|
||||
<span className="small muted"> — otherwise it is invite only</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={encrypted}
|
||||
disabled={isPublic}
|
||||
onChange={(e) => setEncrypted(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
Encrypt it
|
||||
<span className="small muted">
|
||||
{' '}
|
||||
— cannot be turned off later, so a room that might need it should have it from the start
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<SwitchRow
|
||||
title="Make it a space"
|
||||
description="A container for other rooms rather than a place to talk"
|
||||
checked={isSpace}
|
||||
onChange={setIsSpace}
|
||||
/>
|
||||
<SwitchRow
|
||||
title="Anyone can find and join it"
|
||||
description="Otherwise it is invite only"
|
||||
checked={isPublic}
|
||||
onChange={setIsPublic}
|
||||
/>
|
||||
<SwitchRow
|
||||
title="Encrypt it"
|
||||
description="Cannot be turned off later, so a room that might need it should have it from the start"
|
||||
checked={encrypted}
|
||||
disabled={isPublic}
|
||||
onChange={setEncrypted}
|
||||
/>
|
||||
<div className="field-row">
|
||||
<button type="button" className="button primary" disabled={busy || !name.trim()} onClick={create}>
|
||||
{busy ? 'Creating…' : 'Create'}
|
||||
@@ -394,101 +727,10 @@ function CreateMatrixRoom({ account }: { account: Account }): JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rooms somebody has asked this account to join.
|
||||
*
|
||||
* Shown above the join field because it is the one thing here that arrived
|
||||
* on its own: everything else on this page is a room you already know the
|
||||
* name of and went looking for. An invite was invisible until recently -
|
||||
* the daemon never read them out of sync - so it is worth being plain about
|
||||
* who sent it, since a room with no name of its own shows as its raw id and
|
||||
* the inviter is the only part that makes it answerable.
|
||||
*/
|
||||
function MatrixInvites({ account }: { account: Account }): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const [invites, setInvites] = useState<MatrixInvite[]>([])
|
||||
const [answering, setAnswering] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const load = (): void => {
|
||||
void window.moho
|
||||
.rpc<MatrixInvite[]>('listMatrixInvites', { accountId: account.id })
|
||||
// An older daemon has no such method, and no invites to show.
|
||||
.then(setInvites)
|
||||
.catch(() => setInvites([]))
|
||||
}
|
||||
load()
|
||||
// The daemon pushes the whole set whenever it changes, so accepting from
|
||||
// another client empties this without anybody refreshing.
|
||||
return window.moho.onEvent((frame) => {
|
||||
if (frame.event !== 'matrixInvites') return
|
||||
const data = frame.data as { accountId: string; invites: MatrixInvite[] }
|
||||
if (data.accountId === account.id) setInvites(data.invites ?? [])
|
||||
})
|
||||
}, [account.id])
|
||||
|
||||
if (invites.length === 0) return null
|
||||
|
||||
const answer = (invite: MatrixInvite, accept: boolean): void => {
|
||||
setAnswering(invite.roomId)
|
||||
void window.moho
|
||||
.rpc(accept ? 'acceptMatrixInvite' : 'declineMatrixInvite', {
|
||||
accountId: account.id,
|
||||
roomId: invite.roomId
|
||||
})
|
||||
// Not removed from the list here: the next sync stops listing it,
|
||||
// which is what actually confirms the server agreed.
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
.finally(() => setAnswering(null))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="field">
|
||||
<span className="small muted">
|
||||
{invites.length === 1 ? 'You have been invited to' : `You have ${invites.length} invitations`}
|
||||
</span>
|
||||
<div className="invite-list">
|
||||
{invites.map((invite) => (
|
||||
<div className="invite-row" key={invite.roomId}>
|
||||
<div className="invite-what">
|
||||
<span className="invite-name ellipsis">{invite.name}</span>
|
||||
{invite.inviter && (
|
||||
<span className="small muted ellipsis">
|
||||
from {invite.inviter}
|
||||
{invite.isDirect ? ' · direct message' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="invite-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button primary"
|
||||
disabled={answering === invite.roomId}
|
||||
onClick={() => answer(invite, true)}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
disabled={answering === invite.roomId}
|
||||
onClick={() => answer(invite, false)}
|
||||
>
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MatrixJoin({ account }: { account: Account }): JSX.Element {
|
||||
const store = useStore()
|
||||
return (
|
||||
<div className="panel join-panel">
|
||||
<MatrixInvites account={account} />
|
||||
<SubmitField
|
||||
label="Join a room or space"
|
||||
// A Space is just a room with an m.space creation type, joined through
|
||||
@@ -691,11 +933,12 @@ function DiscordJoin({ account }: { account: Account }): JSX.Element {
|
||||
if (live) setFriends(live)
|
||||
}, [live])
|
||||
|
||||
// Requests are their own list above the friends. They are not people you
|
||||
// can message and they are the thing somebody opened this panel to deal
|
||||
// with, so filtering them by whether they happen to be online - which is
|
||||
// what the toggle below does - would hide the ones that matter most.
|
||||
const waiting = friends.filter((f) => f.kind === 'incoming' || f.kind === 'outgoing')
|
||||
// Requests this account has sent are their own list above the friends: not
|
||||
// people you can message yet, so filtering them by whether they happen to be
|
||||
// online - which is what the toggle below does - would hide them. The ones
|
||||
// sent to this account are not here: they wait on the Invitations tile in
|
||||
// the server list with every other question that wants a yes or a no.
|
||||
const waiting = friends.filter((f) => f.kind === 'outgoing')
|
||||
const settled = friends.filter((f) => !f.kind || f.kind === 'friend')
|
||||
const shown = onlineOnly ? settled.filter((f) => f.status && f.status !== 'offline') : settled
|
||||
|
||||
@@ -751,7 +994,7 @@ function DiscordJoin({ account }: { account: Account }): JSX.Element {
|
||||
{waiting.length > 0 && (
|
||||
<>
|
||||
<div className="join-friends-header">
|
||||
<span className="setting-text">Requests</span>
|
||||
<span className="setting-text">Waiting for them to accept</span>
|
||||
</div>
|
||||
{waiting.map((f) => (
|
||||
<div key={f.userId} className="friend-row request">
|
||||
@@ -761,18 +1004,8 @@ function DiscordJoin({ account }: { account: Account }): JSX.Element {
|
||||
<span className="friend-avatar placeholder">{(f.globalName || f.username).slice(0, 1)}</span>
|
||||
)}
|
||||
<span className="ellipsis">{f.globalName || f.username}</span>
|
||||
<span className="small muted">
|
||||
{f.kind === 'incoming' ? 'wants to be friends' : 'asked, waiting'}
|
||||
</span>
|
||||
{f.kind === 'incoming' && (
|
||||
<button type="button" className="button" onClick={() => answer(f.userId, true)}>
|
||||
Accept
|
||||
</button>
|
||||
)}
|
||||
{/* Refusing one and taking back your own are the same call, and
|
||||
read differently enough to be worth different words. */}
|
||||
<button type="button" className="button subtle" onClick={() => answer(f.userId, false)}>
|
||||
{f.kind === 'incoming' ? 'Decline' : 'Cancel'}
|
||||
Cancel request
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -886,7 +1119,7 @@ function DiscordJoin({ account }: { account: Account }): JSX.Element {
|
||||
)}
|
||||
<span className="ellipsis">{f.globalName || f.username}</span>
|
||||
<span className="small muted ellipsis">{f.username}</span>
|
||||
<span className={`presence-dot ${f.status || 'offline'}`} />
|
||||
<span className={`status-dot ${f.status || 'offline'}`} />
|
||||
</button>
|
||||
))}
|
||||
|
||||
|
||||