Compare commits
88
Commits
v1.0.0-rc.5
...
master
@@ -99,9 +99,8 @@ jobs:
|
||||
build: ${{ steps.stamp.outputs.build }}
|
||||
|
||||
steps:
|
||||
# nobilis is a submodule, and on master its URL is the public GitHub
|
||||
# one - on development it points at Gitea. That is the other reason this
|
||||
# workflow only ever runs on master.
|
||||
# nobilis is a submodule at a commit its own master holds, which is why
|
||||
# this workflow only ever runs on master.
|
||||
#
|
||||
# Depth 1 is enough: nobilis/build.rs only asks git for a short sha and
|
||||
# a dirty flag, and falls back to "unknown" rather than failing.
|
||||
@@ -290,6 +289,12 @@ jobs:
|
||||
- name: scroll check
|
||||
run: xvfb-run -a npm run test:scroll
|
||||
|
||||
# Kick event cards: where they sit in each layout, measured against a
|
||||
# chat line beside them - the picture's column, the name's place, and the
|
||||
# air above.
|
||||
- name: kick card check
|
||||
run: xvfb-run -a npm run test:kick
|
||||
|
||||
# MOHO_BUILD is computed inside the npm script - a shell assignment on
|
||||
# the electron-builder line - so it cannot be injected from out here.
|
||||
# The only way to control what the artifact is called is to guarantee
|
||||
@@ -334,7 +339,7 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
# The daemon on its own, for nobilis's release - attached to that by
|
||||
# scripts/mirror-release.sh, since this job's token can only release
|
||||
# scripts/release-nobilis.sh, since this job's token can only release
|
||||
# here. Not an asset of moho's release: the AppImage already carries it.
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: github.ref_type == 'tag'
|
||||
|
||||
+32
-12
@@ -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 |
|
||||
|
||||
+213
-92
@@ -17,6 +17,219 @@ 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
|
||||
|
||||
- **Five protocols in one window:** IRC, Discord, Matrix with end-to-end encryption, Sneedchat, and Kick.
|
||||
- **Discord voice and video:** calls, cameras, screen sharing and Go Live, stage channels, the soundboard and per-person volume, all with Discord's DAVE end-to-end encryption.
|
||||
- **Matrix:** encrypted rooms with device verification and key backup, Element Call, sliding sync, spaces, and signing in by QR code from a phone.
|
||||
- **Per-account Tor and SOCKS5 routing:** Clearnet, Service tunnel or Strict - Strict sends every picture, link check and embed through the account's route too. Or send everything through Tor at once.
|
||||
- **One mentions inbox across every service,** link previews, YouTube cards that play in place, voice messages, polls, and exporting a conversation to disk.
|
||||
- **A background daemon** (nobilis) that keeps every connection alive with the window closed.
|
||||
- **Discord forums:** forum and media channels appear in the channel list and open as a pane of posts - title, tags, the first message's words, replies, age, picture and top reaction - with search, sorting, older posts, and a New Post form
|
||||
- **A redesigned interface:** Discord-shaped messages with date separators and an unread bar, red mention badges, a Ctrl+K quick switcher, keyboard navigation, a rail with mention counts and folders, a member list with faces, and motion throughout
|
||||
- **Settings rebuilt:** one click from the gear, a grouped rail with a search over every setting, and new Appearance (layout, spacing, zoom, clock, reduce motion), Notifications and Keybinds pages with a hotkey recorder
|
||||
- **A one-box message composer:** a + menu, Send only when there is something to send, Shift+Enter for a new line on every service, an attachment tray with spoilers, and dropped files going into the tray
|
||||
- **Discord notices and permissions:** Discord's own safety notices drawn as the cards they are, and a banner in place of the message box wherever nothing can be written
|
||||
- **A rebuilt tray:** an icon drawn for the system's colour scheme with the number of conversations waiting on it, a tooltip that says what is waiting, and a menu of what is new, Show/Hide, Status, Settings, Restart moho and Quit - Restart moho brings back both the window and the daemon
|
||||
- **Status by service:** the plaque and the tray offer only the statuses each service has - Discord all four, IRC away and back, Matrix online and offline, Kick and Sneedchat none - and Do not disturb silences desktop notifications on every service, and is Discord's real mode there
|
||||
- **GIFs on every service:** a linked GIF is drawn as the picture - silent, looping, without the player - whichever service it came from, and a GIF picker with trending, categories and search sends one from the composer. Klipy, Tenor and Giphy pages are never probed for what they are.
|
||||
- **Idle detection:** moho notices when nothing has been used for a while, from the system where it can say and from its own windows where it cannot, and puts an account that has an idle on Idle and back. It has a setting for it and for how long, and the plaque says Idle (away) while keeping what you chose checked.
|
||||
- **Link cards on every service:** a YouTube link gets a titled card with its channel on IRC, Matrix, Kick and Sneedchat as it does on Discord - including through Tor, which YouTube refuses the usual way - and one added in an edit, read back from history, or posted before cards existed gets its card too.
|
||||
- **Kick events as cards:** KICKs sent to a streamer, gifted subscriptions with who received them, subscriptions, raids, redeemed rewards, bans and timeouts, and the stream going live or ending are each a card in moho's own theme, sitting where a person's message does in every layout.
|
||||
- **Conversations open where you stopped reading,** as Discord's do: at the first unread when the run is short, otherwise at the end with a bar to go back by, and a return to a conversation no longer lands hundreds of messages up.
|
||||
|
||||
### Fixes, patches and changes
|
||||
|
||||
- Fixed Kick's KICKs never being heard - they arrive on a subscription that was never opened - and drew KICKs, gifted subscriptions with their recipients, raids and redeemed rewards as cards ([moho `7bae0ea`](https://github.com/Moho-Chat/moho/commit/7bae0ea), [nobilis `95ee3b1`](https://github.com/Moho-Chat/nobilis/commit/95ee3b1))
|
||||
- Every other Kick event is a card too: subscriptions, bans, timeouts, unbans, a cleared chat, and the stream going live or ending ([moho `f118395`](https://github.com/Moho-Chat/moho/commit/f118395), [nobilis `23489f8`](https://github.com/Moho-Chat/nobilis/commit/23489f8))
|
||||
- Kick cards start at the picture's edge with the name where a name is in the log, in comfy and bubbles, with the same air above as a message ([moho `64ade92`](https://github.com/Moho-Chat/moho/commit/64ade92))
|
||||
- A conversation opens as Discord's does, and the line from a visit is dropped when the conversation is left ([moho `39dcba5`](https://github.com/Moho-Chat/moho/commit/39dcba5))
|
||||
- Fixed an unpinned Kick message never leaving the screen ([nobilis `81a33a1`](https://github.com/Moho-Chat/nobilis/commit/81a33a1))
|
||||
- Fixed cancelling an outgoing IRC file offer that nobody had accepted doing nothing for the three minutes the offer lasts ([nobilis `b3e92b5`](https://github.com/Moho-Chat/nobilis/commit/b3e92b5))
|
||||
- Fixed the IRC capability list being cut short when a server sends it over several lines with the continuation mark in the other field ([nobilis `b3e92b5`](https://github.com/Moho-Chat/nobilis/commit/b3e92b5))
|
||||
- Fixed a removed account leaving six lists behind in memory, among them its silenced channels and what its network said about MONITOR ([nobilis `b3e92b5`](https://github.com/Moho-Chat/nobilis/commit/b3e92b5))
|
||||
- Fixed a raid of one viewer reading "1 viewers" ([nobilis `b3e92b5`](https://github.com/Moho-Chat/nobilis/commit/b3e92b5))
|
||||
- Every test was read; those that could not fail now can, tests that checked a copy of the code now check the code, and the untested cases mutation testing found are covered ([moho `8a7e3e7`](https://github.com/Moho-Chat/moho/commit/8a7e3e7), [moho `b0e6775`](https://github.com/Moho-Chat/moho/commit/b0e6775), [moho `47653c5`](https://github.com/Moho-Chat/moho/commit/47653c5), [moho `09c060d`](https://github.com/Moho-Chat/moho/commit/09c060d), [nobilis `2477514`](https://github.com/Moho-Chat/nobilis/commit/2477514), [nobilis `b522fe6`](https://github.com/Moho-Chat/nobilis/commit/b522fe6), [nobilis `b3e92b5`](https://github.com/Moho-Chat/nobilis/commit/b3e92b5))
|
||||
- Nothing in the code or documentation refers to the maintainer's own server any more ([moho `97611c8`](https://github.com/Moho-Chat/moho/commit/97611c8), [nobilis `1c70d32`](https://github.com/Moho-Chat/nobilis/commit/1c70d32))
|
||||
- Documentation brought up to date with the settings pages, how a conversation opens, presence, link cards, Kick events and the daemon as it is ([moho `d99ad2b`](https://github.com/Moho-Chat/moho/commit/d99ad2b), [nobilis `6a1bb8e`](https://github.com/Moho-Chat/nobilis/commit/6a1bb8e))
|
||||
- Added idle detection with a setting for it and for how long ([moho `23c87dd`](https://github.com/Moho-Chat/moho/commit/23c87dd), [nobilis `35ea0b7`](https://github.com/Moho-Chat/nobilis/commit/35ea0b7))
|
||||
- Added GIFs drawn as pictures on every service, and a GIF picker ([moho `55248eb`](https://github.com/Moho-Chat/moho/commit/55248eb), [nobilis `046fbb4`](https://github.com/Moho-Chat/nobilis/commit/046fbb4))
|
||||
- Fixed a Sneedchat picture from the forum's uploads host - an AVIF, or an MP4 - not drawing: its domain is taken off and it is fetched through the account's route, clearnet or Tor ([moho `07f4fc2`](https://github.com/Moho-Chat/moho/commit/07f4fc2), [nobilis `ca29a63`](https://github.com/Moho-Chat/nobilis/commit/ca29a63))
|
||||
- Fixed the tray's number drifting from the client's: it is now counted by the window, by the rail's rule, and told to the tray whenever it changes ([moho `1e314f8`](https://github.com/Moho-Chat/moho/commit/1e314f8))
|
||||
- Fixed YouTube cards never appearing for an account on Tor: YouTube answers every Tor exit with a 403 on its card lookup, so the video's own page is read when that happens ([moho `8064e79`](https://github.com/Moho-Chat/moho/commit/8064e79), [nobilis `340342b`](https://github.com/Moho-Chat/nobilis/commit/340342b))
|
||||
- Fixed link cards being lost when a message was edited, and missing from Matrix history, Discord catch-up and older messages ([moho `638b02e`](https://github.com/Moho-Chat/moho/commit/638b02e), [nobilis `dab394f`](https://github.com/Moho-Chat/nobilis/commit/dab394f))
|
||||
- Fixed scrolling through a long run of unread messages snapping back to where the "new messages" bar appeared ([moho `336335a`](https://github.com/Moho-Chat/moho/commit/336335a))
|
||||
- The number beside a conversation is what is addressed to you - mentions in a channel, each unread message in a direct message - and the bold name is the sign of activity ([moho `987f31c`](https://github.com/Moho-Chat/moho/commit/987f31c))
|
||||
- A muted channel keeps its mention count in the list, the rail, the switcher and the tray, and stops the popups; muting a Discord channel or direct message is now set on the account, so a phone is quiet too ([moho `7803cc3`](https://github.com/Moho-Chat/moho/commit/7803cc3), [nobilis `b0fa250`](https://github.com/Moho-Chat/nobilis/commit/b0fa250))
|
||||
- Reworked the tray icon and menu, and dropped Restart daemon and Stop daemon from it ([moho `24106e5`](https://github.com/Moho-Chat/moho/commit/24106e5))
|
||||
- Do not disturb now silences an account's desktop notifications and the taskbar flash; the tray no longer has a switch of its own for it ([moho `53fb0f3`](https://github.com/Moho-Chat/moho/commit/53fb0f3))
|
||||
- Idle from the tray reaches Discord and IRC and leaves Matrix, Kick and Sneedchat as they were; a service is no longer asked for a status it does not have ([moho `c1bcb72`](https://github.com/Moho-Chat/moho/commit/c1bcb72), [nobilis `a8441ab`](https://github.com/Moho-Chat/nobilis/commit/a8441ab))
|
||||
- Fixed Matrix's rate limit on changing status showing as M_LIMIT_EXCEEDED: a limited request now waits the time the server names, and a longer wait is applied when it is over ([moho `0dfff0d`](https://github.com/Moho-Chat/moho/commit/0dfff0d), [nobilis `43135c1`](https://github.com/Moho-Chat/nobilis/commit/43135c1))
|
||||
- Fixed the window being pulled back to the open conversation's server whenever you chose another: the daemon link coming up is acted on once, a refresh no longer follows the open conversation, and the tray is told only what changed ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a))
|
||||
- Matrix's invisible is called Offline in the plaque, its popout and the tray, which is what it sets ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a))
|
||||
- A room muted on a Matrix account can now be unmuted from the menu, including a mute set from Element ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a), [nobilis `59db82c`](https://github.com/Moho-Chat/nobilis/commit/59db82c))
|
||||
- Server rail tiles now count the mentions waiting in them rather than the messages, and a muted room counts for nothing, mentions included ([moho `0dc048c`](https://github.com/Moho-Chat/moho/commit/0dc048c))
|
||||
- Fixed a room unmuted on a Matrix account still being drawn as muted; a mute or unmute the server did not take is now reported with its reason, and Matrix requests to a server that does not answer give up ([moho `95f1784`](https://github.com/Moho-Chat/moho/commit/95f1784), [nobilis `d1bbcaf`](https://github.com/Moho-Chat/nobilis/commit/d1bbcaf))
|
||||
- Discord requests now give up on a connection that has gone quiet, a history catch-up always releases its place, and the gateway logs what it receives and why a message is dropped ([moho `b151ef8`](https://github.com/Moho-Chat/moho/commit/b151ef8), [nobilis `a34821e`](https://github.com/Moho-Chat/nobilis/commit/a34821e))
|
||||
- Fixed Discord reconnecting every few seconds after being told it is rate limited: it now waits out the minute Discord asks for ([nobilis `59db82c`](https://github.com/Moho-Chat/nobilis/commit/59db82c))
|
||||
- Fixed Sneedchat pictures posted back to back by a script merging into one unusable link ([moho `6edae7a`](https://github.com/Moho-Chat/moho/commit/6edae7a))
|
||||
- Discord files staged together now go as one message, with an upload ring that fills as it uploads ([moho `9888c80`](https://github.com/Moho-Chat/moho/commit/9888c80), [nobilis `9690905`](https://github.com/Moho-Chat/nobilis/commit/9690905))
|
||||
- Fixed the release mirror script printing a traceback while the release did not exist yet ([moho `a8d9f41`](https://github.com/Moho-Chat/moho/commit/a8d9f41))
|
||||
- Defined the design tokens the stylesheet used but never had, and applied one set across it ([moho `9db25c8`](https://github.com/Moho-Chat/moho/commit/9db25c8), [moho `3cdfe37`](https://github.com/Moho-Chat/moho/commit/3cdfe37))
|
||||
- Added a screenshot harness for before-and-after pictures of the interface ([moho `88375ba`](https://github.com/Moho-Chat/moho/commit/88375ba))
|
||||
- Unread channels now read as unread, mentions are red, and every badge is one style ([moho `4ca37dd`](https://github.com/Moho-Chat/moho/commit/4ca37dd))
|
||||
- Added date separators to the message list ([moho `b2464e4`](https://github.com/Moho-Chat/moho/commit/b2464e4))
|
||||
- Added Mark as read on channels, categories and servers, and Escape to read what you are in ([moho `2745a2f`](https://github.com/Moho-Chat/moho/commit/2745a2f))
|
||||
- Added a Ctrl+K quick switcher and keyboard navigation between channels ([moho `1eef37e`](https://github.com/Moho-Chat/moho/commit/1eef37e))
|
||||
- Voice panel: a horizontal microphone bar, the connection's real latency, and a share button ([moho `ad0f0c0`](https://github.com/Moho-Chat/moho/commit/ad0f0c0), [nobilis `e0f6169`](https://github.com/Moho-Chat/nobilis/commit/e0f6169))
|
||||
- Replies jump to the message they answer, and names and pictures open the profile ([moho `bf45147`](https://github.com/Moho-Chat/moho/commit/bf45147))
|
||||
- Added Copy and Copy text on every message, and Copy message link on Discord ([moho `0033955`](https://github.com/Moho-Chat/moho/commit/0033955))
|
||||
- The hover toolbar fades in, can be reached by keyboard, and offers Edit, Copy and your recent reactions ([moho `57e1856`](https://github.com/Moho-Chat/moho/commit/57e1856))
|
||||
- Fixed six interface bugs: a nested voice button, folded categories hiding unread, muted channels hiding mentions, overlapping corners, the typing strip moving the log, and pinned rows missing their service mark ([moho `6598043`](https://github.com/Moho-Chat/moho/commit/6598043))
|
||||
- Pending sends dim only their text, failed ones can be deleted, and Up in an empty box edits your last message in a multi-line editor ([moho `6a82c32`](https://github.com/Moho-Chat/moho/commit/6a82c32))
|
||||
- Server rail: mention counts, folders that say what is waiting and stay open, an instant name flyout and a red close button ([moho `8072b24`](https://github.com/Moho-Chat/moho/commit/8072b24))
|
||||
- Member list: faces, presence dots, and a click for the profile; Discord's member pictures now come with the list ([moho `c912ed3`](https://github.com/Moho-Chat/moho/commit/c912ed3), [nobilis `4099305`](https://github.com/Moho-Chat/nobilis/commit/4099305))
|
||||
- Voice channel list: faces, muted and deafened marks, a speaking ring and a collapsible heading ([moho `843288d`](https://github.com/Moho-Chat/moho/commit/843288d))
|
||||
- Comfy messages in Discord's shape, amber mention rows, one code and quote style, and marked system lines ([moho `3a7677e`](https://github.com/Moho-Chat/moho/commit/3a7677e))
|
||||
- Buttons are primary, subtle or the one danger style, and the restarts ask first ([moho `2c8609c`](https://github.com/Moho-Chat/moho/commit/2c8609c))
|
||||
- Escape closes only the top layer, and full-page panels close on Escape ([moho `c5e7619`](https://github.com/Moho-Chat/moho/commit/c5e7619))
|
||||
- Thread panel reads the display settings, groups replies and follows the newest ([moho `33129f9`](https://github.com/Moho-Chat/moho/commit/33129f9))
|
||||
- Toasts slide in and out, come in three kinds, pause on hover and fold repeats ([moho `2d1b8be`](https://github.com/Moho-Chat/moho/commit/2d1b8be))
|
||||
- Context menu: keyboard navigation, checked items, shortcut hints, and placement that holds while it animates ([moho `4065963`](https://github.com/Moho-Chat/moho/commit/4065963), [moho `fcb7df5`](https://github.com/Moho-Chat/moho/commit/fcb7df5))
|
||||
- A conversation opens at its first unread, with a bar saying how many are above ([moho `55fd192`](https://github.com/Moho-Chat/moho/commit/55fd192))
|
||||
- Added hover fades, turning fold arrows, arriving messages, a reaction pop and growing overlays ([moho `6d8a050`](https://github.com/Moho-Chat/moho/commit/6d8a050))
|
||||
- Every dialog is built from one shared frame with a close button, kept focus and a scrim below the title bar ([moho `5cb30ce`](https://github.com/Moho-Chat/moho/commit/5cb30ce), [moho `420a2f7`](https://github.com/Moho-Chat/moho/commit/420a2f7), [moho `6a329c6`](https://github.com/Moho-Chat/moho/commit/6a329c6))
|
||||
- Shift+Enter makes a new line on every service and pasted line breaks stay; IRC sends them as a batch or separate lines, Sneedchat as [br], Kick as one line ([moho `9ba630c`](https://github.com/Moho-Chat/moho/commit/9ba630c), [nobilis `ccc8de6`](https://github.com/Moho-Chat/nobilis/commit/ccc8de6))
|
||||
- Reply bar: its own surface, the author's face, Esc to cancel, and an @ switch to answer without pinging on Discord ([moho `f48185e`](https://github.com/Moho-Chat/moho/commit/f48185e), [nobilis `918fcfa`](https://github.com/Moho-Chat/nobilis/commit/918fcfa))
|
||||
- One style for the name, command and emoji lists, with member pictures and :shortcode emoji completion ([moho `28d8b3c`](https://github.com/Moho-Chat/moho/commit/28d8b3c))
|
||||
- Lightbox: step through a conversation's pictures, wheel and pinch zoom with panning, and Copy picture ([moho `817cb1e`](https://github.com/Moho-Chat/moho/commit/817cb1e))
|
||||
- Emoji picker: walk it with the arrow keys, Enter picks the top result, and skin tones are remembered ([moho `99fd0d3`](https://github.com/Moho-Chat/moho/commit/99fd0d3))
|
||||
- Attachment tray cards with name, size and a Discord spoiler toggle; dropped files go into the tray instead of being sent at once ([moho `15e7061`](https://github.com/Moho-Chat/moho/commit/15e7061), [nobilis `0f5669b`](https://github.com/Moho-Chat/nobilis/commit/0f5669b))
|
||||
- Composer: one box with the + on the left and emoji, stickers, voice and Send on the right ([moho `a59c1ab`](https://github.com/Moho-Chat/moho/commit/a59c1ab))
|
||||
- Several pictures in one message lay out as a grid, and SPOILER_ files are blurred until clicked ([moho `87ea3b8`](https://github.com/Moho-Chat/moho/commit/87ea3b8))
|
||||
- Pinned messages show as a banner over the log for Discord and Matrix, with a count on the header ([moho `3a74cf0`](https://github.com/Moho-Chat/moho/commit/3a74cf0))
|
||||
- Reactions say what they are and who gave them on hover ([moho `506140d`](https://github.com/Moho-Chat/moho/commit/506140d), [nobilis `1da3ad8`](https://github.com/Moho-Chat/nobilis/commit/1da3ad8))
|
||||
- User footer: muted and deafened are red, voice buttons stay on every service, and the status is a popout with a custom status for Discord and Matrix and Apply to all accounts ([moho `c026609`](https://github.com/Moho-Chat/moho/commit/c026609), [nobilis `f280563`](https://github.com/Moho-Chat/nobilis/commit/f280563))
|
||||
- The footer's join button is gone; the rail's + is the one way in, pinned under a rule above the settings cog while the server list scrolls ([moho `b4c1ced`](https://github.com/Moho-Chat/moho/commit/b4c1ced), [moho `dad5543`](https://github.com/Moho-Chat/moho/commit/dad5543))
|
||||
- Header: one 48px row across every column, filled toggles, and the channel topic from IRC, Matrix and Discord ([moho `2ef9e35`](https://github.com/Moho-Chat/moho/commit/2ef9e35), [nobilis `44d1611`](https://github.com/Moho-Chat/nobilis/commit/44d1611))
|
||||
- "(edited)" says when on hover, for edits seen live ([moho `7f4c356`](https://github.com/Moho-Chat/moho/commit/7f4c356), [nobilis `e51f139`](https://github.com/Moho-Chat/nobilis/commit/e51f139))
|
||||
- Incoming Discord and Matrix calls share one card style and stack ([moho `59d4893`](https://github.com/Moho-Chat/moho/commit/59d4893))
|
||||
- Settings: the gear opens it directly, with Accounts and Downloads as pages, a search, one switch for every boolean and a saved tick ([moho `66a0c0f`](https://github.com/Moho-Chat/moho/commit/66a0c0f), [moho `cc4c9f6`](https://github.com/Moho-Chat/moho/commit/cc4c9f6))
|
||||
- Settings: added Appearance, Notifications and Keybinds pages, the last with a recorder for the global hotkey ([moho `fe6ad97`](https://github.com/Moho-Chat/moho/commit/fe6ad97), [moho `adca84d`](https://github.com/Moho-Chat/moho/commit/adca84d), [moho `82a0bdf`](https://github.com/Moho-Chat/moho/commit/82a0bdf))
|
||||
- Accounts open by default with few accounts, Remove sits in a danger zone, and Join picks its account and says Join ([moho `f8dcccd`](https://github.com/Moho-Chat/moho/commit/f8dcccd))
|
||||
- Mentions page: service and unread filters, day groups, two-line bodies and put-away ([moho `078fe41`](https://github.com/Moho-Chat/moho/commit/078fe41))
|
||||
- Room settings is a tabbed dialog instead of a popover ([moho `f8936c6`](https://github.com/Moho-Chat/moho/commit/f8936c6))
|
||||
- Quiet buttons such as Cancel have their own fill, so they no longer vanish into a dialog ([moho `50aaa97`](https://github.com/Moho-Chat/moho/commit/50aaa97))
|
||||
- Fixed browsing an IRC network's channels freezing or blanking the window: the daemon holds the list and sends a page, it times out after a minute, and a refusal is reported ([moho `2b41c5e`](https://github.com/Moho-Chat/moho/commit/2b41c5e), [nobilis `152fdf1`](https://github.com/Moho-Chat/nobilis/commit/152fdf1))
|
||||
- Channels in the list no longer jump to the top as messages arrive ([moho `9f03d6b`](https://github.com/Moho-Chat/moho/commit/9f03d6b))
|
||||
- Fixed Discord's notices showing only a stand-in title: they are read as cards, and a banner replaces the message box in them and in channels this account cannot write in ([moho `ff7d313`](https://github.com/Moho-Chat/moho/commit/ff7d313), [moho `d7c4215`](https://github.com/Moho-Chat/moho/commit/d7c4215), [moho `2a4f797`](https://github.com/Moho-Chat/moho/commit/2a4f797), [nobilis `1b270be`](https://github.com/Moho-Chat/nobilis/commit/1b270be), [nobilis `97ac6bc`](https://github.com/Moho-Chat/nobilis/commit/97ac6bc), [nobilis `7561b88`](https://github.com/Moho-Chat/nobilis/commit/7561b88))
|
||||
- A window whose page dies is reloaded and the reason logged, instead of being left empty ([moho `6899694`](https://github.com/Moho-Chat/moho/commit/6899694), [moho `06bb812`](https://github.com/Moho-Chat/moho/commit/06bb812))
|
||||
- Discord forums, with live and archived posts read together ([moho `dd0c25e`](https://github.com/Moho-Chat/moho/commit/dd0c25e), [nobilis `d02bba0`](https://github.com/Moho-Chat/nobilis/commit/d02bba0), [nobilis `8363003`](https://github.com/Moho-Chat/nobilis/commit/8363003), [nobilis `3cf1a5b`](https://github.com/Moho-Chat/nobilis/commit/3cf1a5b), [nobilis `6abcb8e`](https://github.com/Moho-Chat/nobilis/commit/6abcb8e))
|
||||
- Added Rust crate tokio-util 0.7.19 ([nobilis `9690905`](https://github.com/Moho-Chat/nobilis/commit/9690905))
|
||||
- Channel rows in the sidebar now show a single `#` instead of two ([moho `ccdf090`](https://github.com/Moho-Chat/moho/commit/ccdf090))
|
||||
- Typing anywhere in the window now goes into the message box, without clicking it first ([moho `b5f920f`](https://github.com/Moho-Chat/moho/commit/b5f920f))
|
||||
- Added a formatting strip over selected text in the message box, offering the formats each service can carry ([moho `9ca477c`](https://github.com/Moho-Chat/moho/commit/9ca477c))
|
||||
- Added a right-click menu to every text field, with spelling suggestions, Undo, Cut, Copy, Paste and Select all ([moho `b2a6210`](https://github.com/Moho-Chat/moho/commit/b2a6210))
|
||||
- Fixed YouTube cards not appearing on Sneedchat when YouTube refused a Tor exit, so most links there had none ([moho `0368796`](https://github.com/Moho-Chat/moho/commit/0368796), [nobilis `71e6036`](https://github.com/Moho-Chat/nobilis/commit/71e6036))
|
||||
- Fixed live and premiere YouTube videos vanishing because they have no thumbnail yet ([moho `916abd4`](https://github.com/Moho-Chat/moho/commit/916abd4))
|
||||
- Fixed YouTube links with the video id after other parameters, `music.youtube.com` and `youtube-nocookie.com` links getting no card ([nobilis `f8409fc`](https://github.com/Moho-Chat/nobilis/commit/f8409fc))
|
||||
- Patched a flaw that let a message's content make moho load a local file ([moho `7138201`](https://github.com/Moho-Chat/moho/commit/7138201), [nobilis `f972611`](https://github.com/Moho-Chat/nobilis/commit/f972611))
|
||||
- Strict routing now sends a Tor or proxy account's pictures, links and embeds through its route instead of directly ([moho `cee8604`](https://github.com/Moho-Chat/moho/commit/cee8604), [nobilis `a3897ba`](https://github.com/Moho-Chat/nobilis/commit/a3897ba))
|
||||
- Discord voice and soundboard audio is now checked before it reaches the C decoder ([nobilis `8eca16e`](https://github.com/Moho-Chat/nobilis/commit/8eca16e))
|
||||
- Fixed Discord showing "connecting" after every successful resume ([nobilis `52b0001`](https://github.com/Moho-Chat/nobilis/commit/52b0001))
|
||||
- Fixed reconnect waits growing to a minute and never resetting ([nobilis `52b0001`](https://github.com/Moho-Chat/nobilis/commit/52b0001))
|
||||
- The interruption banner now counts down to the next reconnect attempt ([moho `89da9ba`](https://github.com/Moho-Chat/moho/commit/89da9ba))
|
||||
- Fixed Kick over Tor failing every channel lookup until a reconnect ([nobilis `5755c4e`](https://github.com/Moho-Chat/nobilis/commit/5755c4e))
|
||||
- Fixed failed Kick joins vanishing without a message ([nobilis `5755c4e`](https://github.com/Moho-Chat/nobilis/commit/5755c4e))
|
||||
- IRC now shows the server's own reason when it refuses a connection ([nobilis `9eeab7e`](https://github.com/Moho-Chat/nobilis/commit/9eeab7e))
|
||||
- Fixed Disconnect and Remove showing a "cancelled" error ([nobilis `8bcba8b`](https://github.com/Moho-Chat/nobilis/commit/8bcba8b))
|
||||
- Fixed new accounts not appearing on the server rail until a restart ([moho `e207224`](https://github.com/Moho-Chat/moho/commit/e207224))
|
||||
- Joining a channel now opens it ([moho `e207224`](https://github.com/Moho-Chat/moho/commit/e207224))
|
||||
- Removing an account now asks first ([moho `16fa8a2`](https://github.com/Moho-Chat/moho/commit/16fa8a2))
|
||||
- Fixed the Matrix form saying matrix.org takes no new accounts ([moho `16fa8a2`](https://github.com/Moho-Chat/moho/commit/16fa8a2))
|
||||
- Fixed Kick's mark drawing as a blank square in the accounts pane ([moho `0ea63f5`](https://github.com/Moho-Chat/moho/commit/0ea63f5))
|
||||
- Fixed a cleared service interruption staying on screen ([moho `fd09b6b`](https://github.com/Moho-Chat/moho/commit/fd09b6b))
|
||||
- Conversations now show at start, before their account has connected ([nobilis `7976452`](https://github.com/Moho-Chat/nobilis/commit/7976452))
|
||||
- Fixed Kick follows being saved before Kick confirmed the channel exists ([nobilis `c2e40dd`](https://github.com/Moho-Chat/nobilis/commit/c2e40dd), [nobilis `4d8f17b`](https://github.com/Moho-Chat/nobilis/commit/4d8f17b))
|
||||
- Kick requests are now spaced out, and a rate limit pauses every Kick request instead of retrying at once ([nobilis `f592a22`](https://github.com/Moho-Chat/nobilis/commit/f592a22), [nobilis `c99d52b`](https://github.com/Moho-Chat/nobilis/commit/c99d52b), [nobilis `2732d08`](https://github.com/Moho-Chat/nobilis/commit/2732d08))
|
||||
- Fixed a changed Sneedchat avatar never being fetched again ([nobilis `04cb759`](https://github.com/Moho-Chat/nobilis/commit/04cb759))
|
||||
- Every media cache now expires old files, with a way to fetch removed pictures again ([nobilis `25a0912`](https://github.com/Moho-Chat/nobilis/commit/25a0912), [moho `9e67ca6`](https://github.com/Moho-Chat/moho/commit/9e67ca6))
|
||||
- Fixed Kick emotes removed by the cache sweep not drawing again ([moho `70cd8d7`](https://github.com/Moho-Chat/moho/commit/70cd8d7))
|
||||
- Fixed Matrix reactions being counted more than once ([nobilis `8ea9a70`](https://github.com/Moho-Chat/nobilis/commit/8ea9a70))
|
||||
- Fixed the scrollback being written to disk once per message ([nobilis `bd33f88`](https://github.com/Moho-Chat/nobilis/commit/bd33f88))
|
||||
- Fixed the migration deleting the client's own config directory ([nobilis `9e885a4`](https://github.com/Moho-Chat/nobilis/commit/9e885a4), [nobilis `207de58`](https://github.com/Moho-Chat/nobilis/commit/207de58))
|
||||
- Fixed a removed account leaving its data behind ([nobilis `2dd755e`](https://github.com/Moho-Chat/nobilis/commit/2dd755e))
|
||||
- Fixed new members of an encrypted Matrix room not receiving its keys ([nobilis `b894dea`](https://github.com/Moho-Chat/nobilis/commit/b894dea))
|
||||
- Fixed two ways a Matrix sign-in could leave an account permanently unusable ([nobilis `ded9cc5`](https://github.com/Moho-Chat/nobilis/commit/ded9cc5))
|
||||
- Fixed sliding sync failing against Synapse ([nobilis `e2bb988`](https://github.com/Moho-Chat/nobilis/commit/e2bb988))
|
||||
- Fixed Discord media links expiring on screen; they now refresh themselves ([moho `dc15c4b`](https://github.com/Moho-Chat/moho/commit/dc15c4b), [nobilis `f5a455b`](https://github.com/Moho-Chat/nobilis/commit/f5a455b))
|
||||
- Repaired Discord messages stored with attachments in their text ([nobilis `15007d6`](https://github.com/Moho-Chat/nobilis/commit/15007d6))
|
||||
- Fixed Discord streams opening a second connection ([nobilis `7b64414`](https://github.com/Moho-Chat/nobilis/commit/7b64414))
|
||||
- Fixed lost video packets, phone streams and stream sound in Discord calls ([nobilis `92dffca`](https://github.com/Moho-Chat/nobilis/commit/92dffca))
|
||||
- Fixed the Discord stream heartbeat using the wrong protocol version ([nobilis `64dfa08`](https://github.com/Moho-Chat/nobilis/commit/64dfa08))
|
||||
- Fixed a running upload being reported as failed ([moho `ee60271`](https://github.com/Moho-Chat/moho/commit/ee60271))
|
||||
- Fixed thumbnails opening as the thumbnail instead of the picture ([moho `6af6ac3`](https://github.com/Moho-Chat/moho/commit/6af6ac3))
|
||||
- Fixed avif links not drawing as pictures ([moho `1511c4b`](https://github.com/Moho-Chat/moho/commit/1511c4b), [nobilis `4e3c65d`](https://github.com/Moho-Chat/nobilis/commit/4e3c65d))
|
||||
- Fixed links in brackets not being recognised ([nobilis `dd450eb`](https://github.com/Moho-Chat/nobilis/commit/dd450eb))
|
||||
- Fixed a full-size photo being downloaded to draw a thumbnail ([nobilis `873a908`](https://github.com/Moho-Chat/nobilis/commit/873a908))
|
||||
- Fixed a crash in the room preview panel ([moho `04a6b7d`](https://github.com/Moho-Chat/moho/commit/04a6b7d))
|
||||
- Fixed call renegotiation not waiting for its answer ([moho `db0f651`](https://github.com/Moho-Chat/moho/commit/db0f651))
|
||||
- Fixed a screen share on Wayland asking twice ([moho `2c146d9`](https://github.com/Moho-Chat/moho/commit/2c146d9))
|
||||
- Discord no longer attempts what it puts behind a captcha ([nobilis `8da2e7a`](https://github.com/Moho-Chat/nobilis/commit/8da2e7a))
|
||||
- The renderer now runs sandboxed ([moho `e791fcf`](https://github.com/Moho-Chat/moho/commit/e791fcf))
|
||||
- The installer warns when Windows Smart App Control will block moho ([moho `5a8d7f7`](https://github.com/Moho-Chat/moho/commit/5a8d7f7))
|
||||
- The uninstaller now offers to remove saved sign-ins and history, and stops the daemon first ([moho `76eabac`](https://github.com/Moho-Chat/moho/commit/76eabac))
|
||||
- Linux builds now run on Ubuntu 22.04 and later ([moho `4c6e24b`](https://github.com/Moho-Chat/moho/commit/4c6e24b))
|
||||
- Updated Rust crate h2 from 0.4.15 to 0.4.19 ([nobilis `2f5918e`](https://github.com/Moho-Chat/nobilis/commit/2f5918e))
|
||||
- Added Rust crate opus2 0.4.0 ([nobilis `f9b5be5`](https://github.com/Moho-Chat/nobilis/commit/f9b5be5))
|
||||
- Added Rust crate aes-gcm 0.10.3 ([nobilis `9d50723`](https://github.com/Moho-Chat/nobilis/commit/9d50723))
|
||||
- Added Rust crate chacha20poly1305 0.10.1 ([nobilis `9d50723`](https://github.com/Moho-Chat/nobilis/commit/9d50723))
|
||||
- Added Rust crate davey 0.1.4 ([nobilis `8ae2148`](https://github.com/Moho-Chat/nobilis/commit/8ae2148))
|
||||
- Removed Rust crate async-trait 0.1.92 ([nobilis `cbb044d`](https://github.com/Moho-Chat/nobilis/commit/cbb044d))
|
||||
- Removed Rust crate songbird 0.6.0 ([nobilis `cbb044d`](https://github.com/Moho-Chat/nobilis/commit/cbb044d))
|
||||
- Added Rust crate socket2 0.6.5 ([nobilis `92dffca`](https://github.com/Moho-Chat/nobilis/commit/92dffca))
|
||||
- Added Rust crate sonora 0.2.0 ([nobilis `72ef1b5`](https://github.com/Moho-Chat/nobilis/commit/72ef1b5))
|
||||
- Updated Rust crate arti-client from 0.44.0 to 0.47.0 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
|
||||
- Updated Rust crate matrix-sdk-common from 0.18.0 to 0.19.1 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
|
||||
- Updated Rust crate matrix-sdk-crypto from 0.18.0 to 0.19.1 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
|
||||
- Updated Rust crate matrix-sdk-sqlite from 0.18.0 to 0.19.1 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
|
||||
- Updated Rust crate ruma-client-api from 0.24.0 to 0.25.0 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
|
||||
- Updated Rust crate ruma-common from 0.19.0 to 0.20.0 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
|
||||
- Updated Rust crate ruma-events from 0.34.0 to 0.35.0 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
|
||||
- Updated Rust crate rusqlite from 0.37.0 to 0.40.2 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
|
||||
- Updated Rust crate tor-rtcompat from 0.44.0 to 0.47.0 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
|
||||
- Updated Rust crate rustls from 0.23.43 to 0.23.45 ([nobilis `2f5918e`](https://github.com/Moho-Chat/nobilis/commit/2f5918e))
|
||||
- Updated npm package electron-builder from 25.1.8 to 26.15.3 ([moho `e791fcf`](https://github.com/Moho-Chat/moho/commit/e791fcf))
|
||||
- Updated npm package electron from 33.4.11 to 44.4.4 ([moho `932a07a`](https://github.com/Moho-Chat/moho/commit/932a07a))
|
||||
- Added npm package lottie-web 5.13.0 ([moho `f67c753`](https://github.com/Moho-Chat/moho/commit/f67c753))
|
||||
- Added npm package happy-dom 15.11.7 ([moho `53a6f92`](https://github.com/Moho-Chat/moho/commit/53a6f92))
|
||||
- Added npm package vitest 2.1.9 ([moho `53a6f92`](https://github.com/Moho-Chat/moho/commit/53a6f92))
|
||||
|
||||
## [1.0.0-rc.5]
|
||||
|
||||
### Major features
|
||||
@@ -124,95 +337,3 @@ Each section has two parts:
|
||||
- A window whose page dies is reloaded and the reason logged, instead of being left empty ([moho `6899694`](https://github.com/Moho-Chat/moho/commit/6899694), [moho `06bb812`](https://github.com/Moho-Chat/moho/commit/06bb812))
|
||||
- Discord forums, with live and archived posts read together ([moho `dd0c25e`](https://github.com/Moho-Chat/moho/commit/dd0c25e), [nobilis `d02bba0`](https://github.com/Moho-Chat/nobilis/commit/d02bba0), [nobilis `8363003`](https://github.com/Moho-Chat/nobilis/commit/8363003), [nobilis `3cf1a5b`](https://github.com/Moho-Chat/nobilis/commit/3cf1a5b), [nobilis `6abcb8e`](https://github.com/Moho-Chat/nobilis/commit/6abcb8e))
|
||||
- Added Rust crate tokio-util 0.7.19 ([nobilis `9690905`](https://github.com/Moho-Chat/nobilis/commit/9690905))
|
||||
|
||||
## [1.0.0]
|
||||
|
||||
### Major features
|
||||
|
||||
- **Five protocols in one window:** IRC, Discord, Matrix with end-to-end encryption, Sneedchat, and Kick.
|
||||
- **Discord voice and video:** calls, cameras, screen sharing and Go Live, stage channels, the soundboard and per-person volume, all with Discord's DAVE end-to-end encryption.
|
||||
- **Matrix:** encrypted rooms with device verification and key backup, Element Call, sliding sync, spaces, and signing in by QR code from a phone.
|
||||
- **Per-account Tor and SOCKS5 routing:** Clearnet, Service tunnel or Strict - Strict sends every picture, link check and embed through the account's route too. Or send everything through Tor at once.
|
||||
- **One mentions inbox across every service,** link previews, YouTube cards that play in place, voice messages, polls, and exporting a conversation to disk.
|
||||
- **A background daemon** (nobilis) that keeps every connection alive with the window closed.
|
||||
|
||||
### Fixes, patches and changes
|
||||
|
||||
- Channel rows in the sidebar now show a single `#` instead of two ([moho `ccdf090`](https://github.com/Moho-Chat/moho/commit/ccdf090))
|
||||
- Typing anywhere in the window now goes into the message box, without clicking it first ([moho `b5f920f`](https://github.com/Moho-Chat/moho/commit/b5f920f))
|
||||
- Added a formatting strip over selected text in the message box, offering the formats each service can carry ([moho `9ca477c`](https://github.com/Moho-Chat/moho/commit/9ca477c))
|
||||
- Added a right-click menu to every text field, with spelling suggestions, Undo, Cut, Copy, Paste and Select all ([moho `b2a6210`](https://github.com/Moho-Chat/moho/commit/b2a6210))
|
||||
- Fixed YouTube cards not appearing on Sneedchat when YouTube refused a Tor exit, so most links there had none ([moho `0368796`](https://github.com/Moho-Chat/moho/commit/0368796), [nobilis `71e6036`](https://github.com/Moho-Chat/nobilis/commit/71e6036))
|
||||
- Fixed live and premiere YouTube videos vanishing because they have no thumbnail yet ([moho `916abd4`](https://github.com/Moho-Chat/moho/commit/916abd4))
|
||||
- Fixed YouTube links with the video id after other parameters, `music.youtube.com` and `youtube-nocookie.com` links getting no card ([nobilis `f8409fc`](https://github.com/Moho-Chat/nobilis/commit/f8409fc))
|
||||
- Patched a flaw that let a message's content make moho load a local file ([moho `7138201`](https://github.com/Moho-Chat/moho/commit/7138201), [nobilis `f972611`](https://github.com/Moho-Chat/nobilis/commit/f972611))
|
||||
- Strict routing now sends a Tor or proxy account's pictures, links and embeds through its route instead of directly ([moho `cee8604`](https://github.com/Moho-Chat/moho/commit/cee8604), [nobilis `a3897ba`](https://github.com/Moho-Chat/nobilis/commit/a3897ba))
|
||||
- Discord voice and soundboard audio is now checked before it reaches the C decoder ([nobilis `8eca16e`](https://github.com/Moho-Chat/nobilis/commit/8eca16e))
|
||||
- Fixed Discord showing "connecting" after every successful resume ([nobilis `52b0001`](https://github.com/Moho-Chat/nobilis/commit/52b0001))
|
||||
- Fixed reconnect waits growing to a minute and never resetting ([nobilis `52b0001`](https://github.com/Moho-Chat/nobilis/commit/52b0001))
|
||||
- The interruption banner now counts down to the next reconnect attempt ([moho `89da9ba`](https://github.com/Moho-Chat/moho/commit/89da9ba))
|
||||
- Fixed Kick over Tor failing every channel lookup until a reconnect ([nobilis `5755c4e`](https://github.com/Moho-Chat/nobilis/commit/5755c4e))
|
||||
- Fixed failed Kick joins vanishing without a message ([nobilis `5755c4e`](https://github.com/Moho-Chat/nobilis/commit/5755c4e))
|
||||
- IRC now shows the server's own reason when it refuses a connection ([nobilis `9eeab7e`](https://github.com/Moho-Chat/nobilis/commit/9eeab7e))
|
||||
- Fixed Disconnect and Remove showing a "cancelled" error ([nobilis `8bcba8b`](https://github.com/Moho-Chat/nobilis/commit/8bcba8b))
|
||||
- Fixed new accounts not appearing on the server rail until a restart ([moho `e207224`](https://github.com/Moho-Chat/moho/commit/e207224))
|
||||
- Joining a channel now opens it ([moho `e207224`](https://github.com/Moho-Chat/moho/commit/e207224))
|
||||
- Removing an account now asks first ([moho `16fa8a2`](https://github.com/Moho-Chat/moho/commit/16fa8a2))
|
||||
- Fixed the Matrix form saying matrix.org takes no new accounts ([moho `16fa8a2`](https://github.com/Moho-Chat/moho/commit/16fa8a2))
|
||||
- Fixed Kick's mark drawing as a blank square in the accounts pane ([moho `0ea63f5`](https://github.com/Moho-Chat/moho/commit/0ea63f5))
|
||||
- Fixed a cleared service interruption staying on screen ([moho `fd09b6b`](https://github.com/Moho-Chat/moho/commit/fd09b6b))
|
||||
- Conversations now show at start, before their account has connected ([nobilis `7976452`](https://github.com/Moho-Chat/nobilis/commit/7976452))
|
||||
- Fixed Kick follows being saved before Kick confirmed the channel exists ([nobilis `c2e40dd`](https://github.com/Moho-Chat/nobilis/commit/c2e40dd), [nobilis `4d8f17b`](https://github.com/Moho-Chat/nobilis/commit/4d8f17b))
|
||||
- Kick requests are now spaced out, and a rate limit pauses every Kick request instead of retrying at once ([nobilis `f592a22`](https://github.com/Moho-Chat/nobilis/commit/f592a22), [nobilis `c99d52b`](https://github.com/Moho-Chat/nobilis/commit/c99d52b), [nobilis `2732d08`](https://github.com/Moho-Chat/nobilis/commit/2732d08))
|
||||
- Fixed a changed Sneedchat avatar never being fetched again ([nobilis `04cb759`](https://github.com/Moho-Chat/nobilis/commit/04cb759))
|
||||
- Every media cache now expires old files, with a way to fetch removed pictures again ([nobilis `25a0912`](https://github.com/Moho-Chat/nobilis/commit/25a0912), [moho `9e67ca6`](https://github.com/Moho-Chat/moho/commit/9e67ca6))
|
||||
- Fixed Kick emotes removed by the cache sweep not drawing again ([moho `70cd8d7`](https://github.com/Moho-Chat/moho/commit/70cd8d7))
|
||||
- Fixed Matrix reactions being counted more than once ([nobilis `8ea9a70`](https://github.com/Moho-Chat/nobilis/commit/8ea9a70))
|
||||
- Fixed the scrollback being written to disk once per message ([nobilis `bd33f88`](https://github.com/Moho-Chat/nobilis/commit/bd33f88))
|
||||
- Fixed the migration deleting the client's own config directory ([nobilis `9e885a4`](https://github.com/Moho-Chat/nobilis/commit/9e885a4), [nobilis `207de58`](https://github.com/Moho-Chat/nobilis/commit/207de58))
|
||||
- Fixed a removed account leaving its data behind ([nobilis `2dd755e`](https://github.com/Moho-Chat/nobilis/commit/2dd755e))
|
||||
- Fixed new members of an encrypted Matrix room not receiving its keys ([nobilis `b894dea`](https://github.com/Moho-Chat/nobilis/commit/b894dea))
|
||||
- Fixed two ways a Matrix sign-in could leave an account permanently unusable ([nobilis `ded9cc5`](https://github.com/Moho-Chat/nobilis/commit/ded9cc5))
|
||||
- Fixed sliding sync failing against Synapse ([nobilis `e2bb988`](https://github.com/Moho-Chat/nobilis/commit/e2bb988))
|
||||
- Fixed Discord media links expiring on screen; they now refresh themselves ([moho `dc15c4b`](https://github.com/Moho-Chat/moho/commit/dc15c4b), [nobilis `f5a455b`](https://github.com/Moho-Chat/nobilis/commit/f5a455b))
|
||||
- Repaired Discord messages stored with attachments in their text ([nobilis `15007d6`](https://github.com/Moho-Chat/nobilis/commit/15007d6))
|
||||
- Fixed Discord streams opening a second connection ([nobilis `7b64414`](https://github.com/Moho-Chat/nobilis/commit/7b64414))
|
||||
- Fixed lost video packets, phone streams and stream sound in Discord calls ([nobilis `92dffca`](https://github.com/Moho-Chat/nobilis/commit/92dffca))
|
||||
- Fixed the Discord stream heartbeat using the wrong protocol version ([nobilis `64dfa08`](https://github.com/Moho-Chat/nobilis/commit/64dfa08))
|
||||
- Fixed a running upload being reported as failed ([moho `ee60271`](https://github.com/Moho-Chat/moho/commit/ee60271))
|
||||
- Fixed thumbnails opening as the thumbnail instead of the picture ([moho `6af6ac3`](https://github.com/Moho-Chat/moho/commit/6af6ac3))
|
||||
- Fixed avif links not drawing as pictures ([moho `1511c4b`](https://github.com/Moho-Chat/moho/commit/1511c4b), [nobilis `4e3c65d`](https://github.com/Moho-Chat/nobilis/commit/4e3c65d))
|
||||
- Fixed links in brackets not being recognised ([nobilis `dd450eb`](https://github.com/Moho-Chat/nobilis/commit/dd450eb))
|
||||
- Fixed a full-size photo being downloaded to draw a thumbnail ([nobilis `873a908`](https://github.com/Moho-Chat/nobilis/commit/873a908))
|
||||
- Fixed a crash in the room preview panel ([moho `04a6b7d`](https://github.com/Moho-Chat/moho/commit/04a6b7d))
|
||||
- Fixed call renegotiation not waiting for its answer ([moho `db0f651`](https://github.com/Moho-Chat/moho/commit/db0f651))
|
||||
- Fixed a screen share on Wayland asking twice ([moho `2c146d9`](https://github.com/Moho-Chat/moho/commit/2c146d9))
|
||||
- Discord no longer attempts what it puts behind a captcha ([nobilis `8da2e7a`](https://github.com/Moho-Chat/nobilis/commit/8da2e7a))
|
||||
- The renderer now runs sandboxed ([moho `e791fcf`](https://github.com/Moho-Chat/moho/commit/e791fcf))
|
||||
- The installer warns when Windows Smart App Control will block moho ([moho `5a8d7f7`](https://github.com/Moho-Chat/moho/commit/5a8d7f7))
|
||||
- The uninstaller now offers to remove saved sign-ins and history, and stops the daemon first ([moho `76eabac`](https://github.com/Moho-Chat/moho/commit/76eabac))
|
||||
- Linux builds now run on Ubuntu 22.04 and later ([moho `4c6e24b`](https://github.com/Moho-Chat/moho/commit/4c6e24b))
|
||||
- Updated Rust crate h2 from 0.4.15 to 0.4.19 ([nobilis `2f5918e`](https://github.com/Moho-Chat/nobilis/commit/2f5918e))
|
||||
- Added Rust crate opus2 0.4.0 ([nobilis `f9b5be5`](https://github.com/Moho-Chat/nobilis/commit/f9b5be5))
|
||||
- Added Rust crate aes-gcm 0.10.3 ([nobilis `9d50723`](https://github.com/Moho-Chat/nobilis/commit/9d50723))
|
||||
- Added Rust crate chacha20poly1305 0.10.1 ([nobilis `9d50723`](https://github.com/Moho-Chat/nobilis/commit/9d50723))
|
||||
- Added Rust crate davey 0.1.4 ([nobilis `8ae2148`](https://github.com/Moho-Chat/nobilis/commit/8ae2148))
|
||||
- Removed Rust crate async-trait 0.1.92 ([nobilis `cbb044d`](https://github.com/Moho-Chat/nobilis/commit/cbb044d))
|
||||
- Removed Rust crate songbird 0.6.0 ([nobilis `cbb044d`](https://github.com/Moho-Chat/nobilis/commit/cbb044d))
|
||||
- Added Rust crate socket2 0.6.5 ([nobilis `92dffca`](https://github.com/Moho-Chat/nobilis/commit/92dffca))
|
||||
- Added Rust crate sonora 0.2.0 ([nobilis `72ef1b5`](https://github.com/Moho-Chat/nobilis/commit/72ef1b5))
|
||||
- Updated Rust crate arti-client from 0.44.0 to 0.47.0 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
|
||||
- Updated Rust crate matrix-sdk-common from 0.18.0 to 0.19.1 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
|
||||
- Updated Rust crate matrix-sdk-crypto from 0.18.0 to 0.19.1 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
|
||||
- Updated Rust crate matrix-sdk-sqlite from 0.18.0 to 0.19.1 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
|
||||
- Updated Rust crate ruma-client-api from 0.24.0 to 0.25.0 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
|
||||
- Updated Rust crate ruma-common from 0.19.0 to 0.20.0 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
|
||||
- Updated Rust crate ruma-events from 0.34.0 to 0.35.0 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
|
||||
- Updated Rust crate rusqlite from 0.37.0 to 0.40.2 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
|
||||
- Updated Rust crate tor-rtcompat from 0.44.0 to 0.47.0 ([nobilis `c34b751`](https://github.com/Moho-Chat/nobilis/commit/c34b751))
|
||||
- Updated Rust crate rustls from 0.23.43 to 0.23.45 ([nobilis `2f5918e`](https://github.com/Moho-Chat/nobilis/commit/2f5918e))
|
||||
- Updated npm package electron-builder from 25.1.8 to 26.15.3 ([moho `e791fcf`](https://github.com/Moho-Chat/moho/commit/e791fcf))
|
||||
- Updated npm package electron from 33.4.11 to 44.4.4 ([moho `932a07a`](https://github.com/Moho-Chat/moho/commit/932a07a))
|
||||
- Added npm package lottie-web 5.13.0 ([moho `f67c753`](https://github.com/Moho-Chat/moho/commit/f67c753))
|
||||
- Added npm package happy-dom 15.11.7 ([moho `53a6f92`](https://github.com/Moho-Chat/moho/commit/53a6f92))
|
||||
- Added npm package vitest 2.1.9 ([moho `53a6f92`](https://github.com/Moho-Chat/moho/commit/53a6f92))
|
||||
|
||||
@@ -1,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
-1
Submodule nobilis updated: d232be9c5e...289b5db54a
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "moho",
|
||||
"version": "1.0.0-rc.5",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "moho",
|
||||
"version": "1.0.0-rc.5",
|
||||
"version": "1.1.0",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
|
||||
+4
-3
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "moho",
|
||||
"version": "1.0.0-rc.5",
|
||||
"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": {
|
||||
@@ -24,6 +24,7 @@
|
||||
"test:tray": "electron-vite build && node scripts/tray-check.mjs",
|
||||
"test:gif": "electron-vite build && node scripts/gif-check.mjs",
|
||||
"test:scroll": "electron-vite build && node scripts/scroll-check.mjs",
|
||||
"test:kick": "electron-vite build && node scripts/kick-check.mjs",
|
||||
"shots": "electron-vite build && node scripts/ui-shots.mjs",
|
||||
"pack": "npm run daemon && npm run build && MOHO_BUILD=$(git rev-parse --short HEAD)$(git diff --quiet HEAD || echo -dirty) electron-builder --linux AppImage",
|
||||
"pack:deb": "npm run daemon && npm run build && MOHO_BUILD=$(git rev-parse --short HEAD)$(git diff --quiet HEAD || echo -dirty) electron-builder --linux deb",
|
||||
@@ -78,7 +79,7 @@
|
||||
"AppImage",
|
||||
"deb"
|
||||
],
|
||||
"maintainer": "Salastil <salastil@users.noreply.github.com>",
|
||||
"maintainer": "Salastil <salastil@cock.li>",
|
||||
"category": "Network",
|
||||
"synopsis": "Unified desktop chat client",
|
||||
"icon": "resources/icons/moho.png",
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Checks where a Kick event card sits in the log, in each layout.
|
||||
*
|
||||
* A card is what a person did, so it takes the column a person's picture would
|
||||
* (and not the mark a join gets), what is written in it starts where a name
|
||||
* does, and it has the air above it that a message and an embed have. These
|
||||
* are measured against a real chat line beside it rather than against numbers,
|
||||
* so they hold when the layout's own numbers change.
|
||||
*
|
||||
* npm run test:kick (builds first; needs the daemon built too)
|
||||
*/
|
||||
import { fakeIrc, launchApp, openIrcChannel, sleep, until, click } from './lib/harness.mjs'
|
||||
|
||||
let failures = 0
|
||||
const check = (ok, what, detail = '') => {
|
||||
console.log(`${ok ? 'ok ' : 'FAIL'} ${what}${ok ? '' : detail ? ` - ${detail}` : ''}`)
|
||||
if (!ok) failures++
|
||||
}
|
||||
setTimeout(() => {
|
||||
console.log('FAIL timed out')
|
||||
process.exit(1)
|
||||
}, 120000).unref()
|
||||
|
||||
const irc = await fakeIrc({ people: ['bob'], onJoin: (channel, _nick, send) => send(`:bob!b@h PRIVMSG ${channel} :hello there`) })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
|
||||
const stage = `(() => {
|
||||
const shots = window.__mohoShots
|
||||
const s = shots.state()
|
||||
const id = s.activeBufferId
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const row = (n, kind, body, embed, from) => ({ id: 'k' + n, bufferId: id, from, senderId: from, body, ts: now - 600 + n * 70, isAction: false, isHighlight: false, kind, isOwn: false, embeds: embed ? [embed] : [] })
|
||||
shots.patch({ messagesByBuffer: { ...s.messagesByBuffer, [id]: [
|
||||
row(1, 'chat', 'that was a clean run', null, 'bob'),
|
||||
row(2, 'chat', 'and then it got worse', null, 'carol'),
|
||||
row(3, 'kicks', 'Currington sent 1 Kick', { kind: 'kicks', author: 'Currington', title: '1', color: 0x53fc18 }, 'kicker'),
|
||||
row(4, 'sub', 'ItsDez gifted 2 subscriptions', { kind: 'gift-sub', author: 'ItsDez', title: '2', description: 'a\\nb' }, 'kicker'),
|
||||
row(5, 'reward', 'poo_nana redeemed MUTE', { kind: 'redeem', author: 'poo_nana', title: 'MUTE (user) 30 MINUTES', description: 'O_C_T_O', color: 0xff4b1f }, 'kicker'),
|
||||
row(6, 'raid', 'x raided with 25 viewers', { kind: 'raid', author: 'kreationX', title: '25' }, 'kicker'),
|
||||
row(8, 'sub', 'bigkhernandez subscribed - 21 months', { kind: 'subscribe', author: 'bigkhernandez', title: '21' }, 'kicker'),
|
||||
row(9, 'moderation', 'troll was timed out by mod', { kind: 'moderation', author: 'troll', title: 'was timed out', description: 'mod' }, 'kicker'),
|
||||
row(10, 'stream', 'went live', { kind: 'stream', author: 'streamer', title: 'went live', icon: 'live' }, 'kicker'),
|
||||
row(11, 'chat', 'thanks everybody', null, 'dave')
|
||||
] } })
|
||||
})()`
|
||||
|
||||
const measure = `(() => {
|
||||
const x = (el) => (el ? Math.round(el.getBoundingClientRect().left) : null)
|
||||
const rows = [...document.querySelectorAll('.message-row')]
|
||||
const chat = rows.find((r) => !r.classList.contains('has-kick-event') && r.querySelector('.message-from'))
|
||||
const cards = rows.filter((r) => r.classList.contains('has-kick-event'))
|
||||
const margin = (r) => parseFloat(getComputedStyle(r).marginTop)
|
||||
return {
|
||||
marks: cards.filter((r) => r.querySelector('.system-mark')).length,
|
||||
cards: cards.length,
|
||||
chatEdge: x(chat.querySelector('.message-avatar img, .message-avatar .avatar-fallback') || chat.querySelector('.message-content')),
|
||||
chatName: x(chat.querySelector('.message-from')),
|
||||
cardEdge: cards.map((r) => x(r.querySelector('.kick-event'))),
|
||||
cardName: cards.map((r) => x(r.querySelector('.kick-event-name'))),
|
||||
gapAbove: cards.map(margin),
|
||||
chatGap: margin(chat) || 0,
|
||||
wide: cards.map((r) => Math.round(r.querySelector('.kick-event').getBoundingClientRect().width))
|
||||
}
|
||||
})()`
|
||||
|
||||
try {
|
||||
await until(page, 'window.__mohoShots', 'the harness hook')
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#lobby'], waitFor: 'hello there' })
|
||||
for (const mode of ['comfy', 'bubbles', 'classic']) {
|
||||
await page.evaluate(`window.moho.prefs.set('display.messageMode', ${JSON.stringify(mode)})`)
|
||||
await page.call('Page.reload')
|
||||
await until(page, 'window.__mohoShots && document.querySelector("nav[aria-label=Servers] [aria-label]")', 'the window after a reload')
|
||||
await sleep(500)
|
||||
await page.evaluate(click('nav[aria-label=Servers] [aria-label*="localhost"]'))
|
||||
await sleep(300)
|
||||
await page.evaluate(click('.buffer-row', '#lobby'))
|
||||
await sleep(500)
|
||||
await page.evaluate(stage)
|
||||
await sleep(700)
|
||||
const m = await page.evaluate(measure)
|
||||
console.log(`${mode}: ${JSON.stringify(m)}`)
|
||||
check(m.cards === 7, `${mode}: every kind of card is drawn`, `${m.cards}`)
|
||||
check(m.marks === 0, `${mode}: no join mark beside a card`, `${m.marks}`)
|
||||
if (mode !== 'classic') {
|
||||
// The card starts at the picture's edge.
|
||||
check(m.cardEdge.every((e) => Math.abs(e - m.chatEdge) <= 2), `${mode}: a card starts where a person's picture does`, `${m.cardEdge} vs ${m.chatEdge}`)
|
||||
// And what is written in it starts where a name does.
|
||||
check(m.cardName.every((n) => Math.abs(n - m.chatName) <= 2), `${mode}: the name in a card is where a name is in the log`, `${m.cardName} vs ${m.chatName}`)
|
||||
}
|
||||
check(m.gapAbove.every((g) => g >= (mode === 'comfy' ? m.chatGap : 4) - 0.5), `${mode}: a card has air above it`, `${m.gapAbove} vs ${m.chatGap}`)
|
||||
check(m.wide.every((w) => w > 200), `${mode}: and is not squeezed or centred away`, `${m.wide}`)
|
||||
}
|
||||
} finally {
|
||||
await stop()
|
||||
irc.close()
|
||||
}
|
||||
console.log(failures ? `${failures} FAILED` : 'all passed')
|
||||
process.exit(failures ? 1 : 0)
|
||||
@@ -207,10 +207,12 @@ function moveAside(pid) {
|
||||
* The built app, against a scratch home, with a debugging port. Returns the
|
||||
* page and a `stop()` that takes everything down again.
|
||||
*/
|
||||
export async function launchApp({ env = {}, args = [] } = {}) {
|
||||
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
|
||||
@@ -281,7 +283,7 @@ export async function launchApp({ env = {}, args = [] } = {}) {
|
||||
await stop()
|
||||
throw e
|
||||
}
|
||||
return { page, stop, port }
|
||||
return { page, stop, port, scratch }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Mirrors a published GitHub release of moho to Gitea, and makes nobilis's
|
||||
# release of the same version on both hosts. Run here, by hand, once the
|
||||
# release workflow has published the tag on GitHub - Gitea releases are never
|
||||
# made by Actions.
|
||||
#
|
||||
# scripts/mirror-release.sh v1.0.0
|
||||
#
|
||||
# moho on Gitea: the same title, notes, pre-release flag and files as GitHub.
|
||||
# nobilis, on GitHub and Gitea: tagged at the commit moho's tag points to,
|
||||
# named after the same version, its notes the version's major features and
|
||||
# the nobilis lines of the changelog, with the two daemon binaries the
|
||||
# release's run built.
|
||||
#
|
||||
# Safe to run again: a release that exists is updated rather than repeated.
|
||||
set -euo pipefail
|
||||
|
||||
TAG="${1:?usage: scripts/mirror-release.sh vX.Y.Z}"
|
||||
VERSION="${TAG#v}"
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
# Through Cloudflare by default, which refuses a request body over 100 MB. The
|
||||
# files that are bigger go to Gitea directly across the network, which is what
|
||||
# MIRROR_GITEA is for - the same instance, reached by its own address:
|
||||
#
|
||||
# MIRROR_GITEA=http://192.168.1.212:8991/api/v1 MIRROR_MAX_BYTES=1000000000 \
|
||||
# scripts/mirror-release.sh v1.0.0
|
||||
GITEA="${MIRROR_GITEA:-https://git.salastil.com/api/v1}"
|
||||
TOKEN="$(cat "$HOME/.config/gitea/token")"
|
||||
# What Cloudflare in front of Gitea will take in one request.
|
||||
MAX_UPLOAD="${MIRROR_MAX_BYTES:-100000000}"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
gitea() { # method path [curl args...]
|
||||
local method="$1" path="$2"; shift 2
|
||||
curl -fsS -X "$method" -H "Authorization: token $TOKEN" "$GITEA$path" "$@"
|
||||
}
|
||||
|
||||
# Creates or updates a Gitea release and makes its files exactly those given.
|
||||
gitea_release() { # repo title notes-file prerelease file...
|
||||
local repo="$1" title="$2" notes="$3" pre="$4"; shift 4
|
||||
local body id
|
||||
body="$(python3 -c 'import json,sys; print(json.dumps({"tag_name": sys.argv[1], "name": sys.argv[2], "body": open(sys.argv[3]).read(), "prerelease": sys.argv[4] == "true"}))' "$TAG" "$title" "$notes" "$pre")"
|
||||
if id="$(gitea GET "/repos/$repo/releases/tags/$TAG" 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' 2>/dev/null)"; then
|
||||
gitea PATCH "/repos/$repo/releases/$id" -H 'Content-Type: application/json' -d "$body" >/dev/null
|
||||
for asset in $(gitea GET "/repos/$repo/releases/$id/assets" | python3 -c 'import json,sys; print(" ".join(str(a["id"]) for a in json.load(sys.stdin)))'); do
|
||||
gitea DELETE "/repos/$repo/releases/$id/assets/$asset" >/dev/null
|
||||
done
|
||||
else
|
||||
id="$(gitea POST "/repos/$repo/releases" -H 'Content-Type: application/json' -d "$body" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
|
||||
fi
|
||||
for f in "$@"; do
|
||||
# Gitea is behind Cloudflare, which refuses a request body over 100 MB (a
|
||||
# 413, or a 502 when the connection is cut mid-upload). A file over that
|
||||
# stays on GitHub only, said plainly rather than failing the whole mirror
|
||||
# and leaving everything after it - nobilis's releases - undone.
|
||||
if [ "$(stat -c %s "$f")" -gt "$MAX_UPLOAD" ]; then
|
||||
echo " SKIPPED $(basename "$f") ($(( $(stat -c %s "$f") / 1000000 )) MB) - over Cloudflare's ${MAX_UPLOAD} byte limit; it is on GitHub only" >&2
|
||||
continue
|
||||
fi
|
||||
gitea POST "/repos/$repo/releases/$id/assets?name=$(basename "$f")" -F "attachment=@$f" >/dev/null
|
||||
echo " uploaded $(basename "$f") -> Gitea $repo"
|
||||
done
|
||||
}
|
||||
|
||||
# --- moho -------------------------------------------------------------------
|
||||
echo "moho $TAG: GitHub -> Gitea"
|
||||
read -r PRE TITLE < <(gh release view "$TAG" --repo Moho-Chat/moho --json isPrerelease,name -q '"\(.isPrerelease) \(.name)"')
|
||||
gh release view "$TAG" --repo Moho-Chat/moho --json body -q .body > "$WORK/moho-notes.md"
|
||||
mkdir -p "$WORK/moho"
|
||||
gh release download "$TAG" --repo Moho-Chat/moho --dir "$WORK/moho"
|
||||
git -C "$ROOT" push -q origin "refs/tags/$TAG" 2>/dev/null || true
|
||||
gitea_release Salastil/moho "$TITLE" "$WORK/moho-notes.md" "$PRE" "$WORK"/moho/*
|
||||
|
||||
# --- nobilis ------------------------------------------------------------------
|
||||
NOBILIS_SHA="$(git -C "$ROOT" rev-parse "$TAG:nobilis")"
|
||||
echo "nobilis $TAG at ${NOBILIS_SHA:0:7}"
|
||||
if ! git -C "$ROOT/nobilis" rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
|
||||
git -C "$ROOT/nobilis" tag -a "$TAG" "$NOBILIS_SHA" -m "nobilis $VERSION"
|
||||
fi
|
||||
git -C "$ROOT/nobilis" push -q github "refs/tags/$TAG"
|
||||
git -C "$ROOT/nobilis" push -q origin "refs/tags/$TAG"
|
||||
|
||||
# Its notes: the version's major features, and the nobilis lines only.
|
||||
SECTION="$VERSION"
|
||||
grep -q "^## \[$VERSION\]" "$ROOT/CHANGELOG.md" || SECTION="${VERSION%%-*}"
|
||||
awk -v v="$SECTION" '
|
||||
index($0, "## [" v "]") == 1 { on = 1; next }
|
||||
on && /^## \[/ { exit }
|
||||
!on { next }
|
||||
/^### / { fixes = ($0 ~ /^### Fixes/); print; next }
|
||||
/^- / { if (!fixes || /\[nobilis /) print; next }
|
||||
{ print }
|
||||
' "$ROOT/CHANGELOG.md" > "$WORK/nobilis-notes.md"
|
||||
printf '\nnobilis is the daemon inside moho %s; these are its own builds, for running it on its own.\n' "$VERSION" >> "$WORK/nobilis-notes.md"
|
||||
|
||||
# The daemon binaries, from the run that built this release.
|
||||
RUN="$(gh run list --repo Moho-Chat/moho --workflow release.yml --branch "$TAG" --status success --limit 1 --json databaseId -q '.[0].databaseId')"
|
||||
[ -n "$RUN" ] || { echo "no successful release run for $TAG" >&2; exit 1; }
|
||||
mkdir -p "$WORK/nobilis"
|
||||
gh run download "$RUN" --repo Moho-Chat/moho -n daemon-linux -D "$WORK/d-linux"
|
||||
gh run download "$RUN" --repo Moho-Chat/moho -n daemon-windows -D "$WORK/d-win"
|
||||
install -m 755 "$WORK/d-linux/nobilis" "$WORK/nobilis/nobilis-$VERSION-linux-x86_64"
|
||||
install -m 644 "$WORK/d-win/nobilis.exe" "$WORK/nobilis/nobilis-$VERSION-windows-x86_64.exe"
|
||||
|
||||
NOBILIS_FLAGS=(--latest)
|
||||
[ "$PRE" = "true" ] && NOBILIS_FLAGS=(--prerelease --latest=false)
|
||||
if gh release view "$TAG" --repo Moho-Chat/nobilis >/dev/null 2>&1; then
|
||||
gh release edit "$TAG" --repo Moho-Chat/nobilis --notes-file "$WORK/nobilis-notes.md"
|
||||
gh release upload "$TAG" --repo Moho-Chat/nobilis "$WORK"/nobilis/* --clobber
|
||||
else
|
||||
gh release create "$TAG" --repo Moho-Chat/nobilis --verify-tag --title "nobilis $VERSION" \
|
||||
--notes-file "$WORK/nobilis-notes.md" "${NOBILIS_FLAGS[@]}" "$WORK"/nobilis/*
|
||||
fi
|
||||
echo " GitHub Moho-Chat/nobilis $TAG"
|
||||
gitea_release Salastil/nobilis "nobilis $VERSION" "$WORK/nobilis-notes.md" "$PRE" "$WORK"/nobilis/*
|
||||
echo "done: moho and nobilis $TAG on GitHub and Gitea"
|
||||
@@ -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()
|
||||
}
|
||||
Executable
+71
@@ -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
|
||||
@@ -30,7 +30,7 @@ const irc = await fakeIrc({
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
try {
|
||||
await until(page, 'window.__mohoShots', 'the harness hook')
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#lobby'], waitFor: 'hello there' })
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#lobby', '#other'], waitFor: 'hello there' })
|
||||
|
||||
// Three hundred messages, the last two hundred of them unread.
|
||||
await page.evaluate(`(() => {
|
||||
@@ -48,7 +48,19 @@ try {
|
||||
dividerTsByBuffer: { ...s.dividerTsByBuffer, [id]: list[99].ts }
|
||||
})
|
||||
})()`)
|
||||
await until(page, `!!document.querySelector('.new-divider')`, 'the New line')
|
||||
// Two hundred unread is more than a screenful: opened at its end, as
|
||||
// Discord does, with the bar saying how much is above and not the log
|
||||
// dropped hundreds of messages up.
|
||||
await until(page, `!!document.querySelector('.unread-bar')`, 'the unread bar')
|
||||
await sleep(600)
|
||||
const opened = await page.evaluate(`(() => {
|
||||
const el = document.querySelector('.messagelist-scroll')
|
||||
return { fromBottom: el.scrollHeight - el.scrollTop - el.clientHeight, line: !!document.querySelector('.new-divider'), bar: document.querySelector('.unread-bar-text')?.textContent }
|
||||
})()`)
|
||||
check(opened.fromBottom < 8 && !opened.line, 'a long unread run opens at the end of the conversation', JSON.stringify(opened))
|
||||
check(/^200 new messages/.test(opened.bar ?? ''), 'and the bar says how much there is', opened.bar)
|
||||
await page.evaluate(`document.querySelector('.unread-bar-text').click()`)
|
||||
await until(page, `!!document.querySelector('.new-divider')`, 'the New line after pressing the bar')
|
||||
await sleep(600)
|
||||
|
||||
// Reading down past the line, the way a wheel does: the bar comes up.
|
||||
@@ -93,6 +105,60 @@ try {
|
||||
// And the position itself did not fall back towards where the bar appeared.
|
||||
const after = await page.evaluate(`document.querySelector('.messagelist-scroll').scrollTop`)
|
||||
check(after >= before - 4, 'the view did not go back up the log', `${before} -> ${after}`)
|
||||
|
||||
// Leaving, and coming back after more has been said. The line was kept from
|
||||
// the first visit, so the return went up the log to a place nothing was
|
||||
// unread any more.
|
||||
const ids = await page.evaluate(`(() => {
|
||||
const s = window.__mohoShots.state()
|
||||
return { lobby: s.activeBufferId, other: s.buffers.find((b) => b.name.endsWith('#other')).id }
|
||||
})()`)
|
||||
const said = (count) => `(() => {
|
||||
const shots = window.__mohoShots
|
||||
const s = shots.state()
|
||||
const id = ${JSON.stringify(ids.lobby)}
|
||||
const base = Math.max(...s.messagesByBuffer[id].map((m) => m.ts)) + 1
|
||||
const more = Array.from({ length: ${count} }, (_, n) => ({
|
||||
id: 'later' + Date.now() + '-' + n, bufferId: id, from: 'bob', senderId: 'bob', body: 'said while away ' + n, ts: base + n,
|
||||
isAction: false, isHighlight: false, kind: 'chat', isOwn: false
|
||||
}))
|
||||
shots.patch({
|
||||
messagesByBuffer: { ...s.messagesByBuffer, [id]: [...s.messagesByBuffer[id], ...more] },
|
||||
buffers: s.buffers.map((b) => (b.id === id ? { ...b, unread: ${count} } : b))
|
||||
})
|
||||
})()`
|
||||
const away = async (count) => {
|
||||
await page.evaluate(`window.__mohoShots.store.selectBuffer(${JSON.stringify(ids.other)}, false)`)
|
||||
await sleep(500)
|
||||
await page.evaluate(said(count))
|
||||
await page.evaluate(`window.__mohoShots.store.selectBuffer(${JSON.stringify(ids.lobby)}, false)`)
|
||||
await sleep(1200)
|
||||
return page.evaluate(`(() => {
|
||||
const el = document.querySelector('.messagelist-scroll')
|
||||
const line = document.querySelector('.new-divider')
|
||||
const rows = [...el.querySelectorAll('[data-msg-id]')]
|
||||
const after = line ? rows.filter((r) => line.compareDocumentPosition(r) & Node.DOCUMENT_POSITION_FOLLOWING).length : -1
|
||||
return { fromBottom: el.scrollHeight - el.scrollTop - el.clientHeight, line: !!line, after, bar: document.querySelector('.unread-bar-text')?.textContent ?? '' }
|
||||
})()`)
|
||||
}
|
||||
|
||||
// A few unread: opens at the first of them, and the line is this visit's -
|
||||
// thirty rows under it, not the two hundred of the visit before.
|
||||
const few = await away(30)
|
||||
check(few.line && few.after === 30, 'a short unread run opens at its first message, with the line where this visit began', JSON.stringify(few))
|
||||
|
||||
// A great many: opens at the end, with the bar to go back by.
|
||||
const many = await away(100)
|
||||
check(many.fromBottom < 8, 'a long one opens at the end and not hundreds of messages up', JSON.stringify(many))
|
||||
check(/^100 new messages/.test(many.bar), 'with the bar to go back by', many.bar)
|
||||
|
||||
// Nothing unread: the end.
|
||||
await page.evaluate(`window.__mohoShots.store.selectBuffer(${JSON.stringify(ids.other)}, false)`)
|
||||
await sleep(500)
|
||||
await page.evaluate(`window.__mohoShots.store.selectBuffer(${JSON.stringify(ids.lobby)}, false)`)
|
||||
await sleep(1000)
|
||||
const nothing = await page.evaluate(`(() => { const el = document.querySelector('.messagelist-scroll'); return { fromBottom: el.scrollHeight - el.scrollTop - el.clientHeight, line: !!document.querySelector('.new-divider') } })()`)
|
||||
check(nothing.fromBottom < 8 && !nothing.line, 'a conversation left read and returned to is at its end', JSON.stringify(nothing))
|
||||
} finally {
|
||||
await stop()
|
||||
irc.close()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -352,6 +352,40 @@ const SCENES = [
|
||||
name: 'gif-picker',
|
||||
setup: async (p) => { await setMode(p, 'comfy'); await openChannel(p, '#general'); await openGifPicker(p) }
|
||||
},
|
||||
{
|
||||
// What somebody did on a Kick stream: KICKs sent, subscriptions gifted. Put
|
||||
// on a channel's messages directly - a stream is not here to do it.
|
||||
name: 'kick-events',
|
||||
setup: async (p, mode = 'classic') => {
|
||||
await setMode(p, mode)
|
||||
await openChannel(p, '#general')
|
||||
await p.evaluate(`(() => {
|
||||
const shots = window.__mohoShots
|
||||
const s = shots.state()
|
||||
const id = s.activeBufferId
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const row = (n, kind, body, embed, from = 'bob') => ({ id: 'k' + n, bufferId: id, from, senderId: from, body, ts: now - 60 + n, isAction: false, isHighlight: false, kind, isOwn: false, embeds: embed ? [embed] : [] })
|
||||
const chat = (n, from, body) => row(n, 'chat', body, null, from)
|
||||
shots.patch({ messagesByBuffer: { ...s.messagesByBuffer, [id]: [
|
||||
chat(1, 'bob', 'that was a clean run'),
|
||||
row(2, 'kicks', 'Currington sent 1 Kick', { kind: 'kicks', author: 'Currington', authorColor: '#75FD46', title: '1', provider: 'Hell Yeah', color: 0x53fc18 }),
|
||||
chat(3, 'carol', 'lets gooo'),
|
||||
row(4, 'kicks', 'Maverick sent 500 Kicks: take my money', { kind: 'kicks', author: 'Maverick', authorColor: '#E26EFF', title: '500', provider: 'Full Send', description: 'take my money', color: 0xb45cff }),
|
||||
row(5, 'sub', 'ItsDez gifted 5 subscriptions', { kind: 'gift-sub', author: 'ItsDez', title: '5', description: 'Interstelar\\nRon_OSRS\\nAuzzzii\\nMeowtimeJay\\nstradivarius31' }),
|
||||
row(6, 'sub', 'LurkinLeepa gifted a subscription', { kind: 'gift-sub', author: 'LurkinLeepa', title: '1', description: 'onkhell' }),
|
||||
row(10, 'sub', 'bigkhernandez subscribed - 21 months', { kind: 'subscribe', author: 'bigkhernandez', title: '21' }),
|
||||
row(11, 'moderation', 'troll was timed out by mod', { kind: 'moderation', author: 'troll', title: 'was timed out', description: 'mod' }),
|
||||
row(12, 'stream', 'went live', { kind: 'stream', author: 'streamer', title: 'went live', icon: 'live' }),
|
||||
row(7, 'raid', 'kreationX raided with 25 viewers', { kind: 'raid', author: 'kreationX', title: '25' }),
|
||||
row(8, 'reward', 'onkhell redeemed 5 TONGUE TWISTERS', { kind: 'redeem', author: 'onkhell', title: '5 TONGUE TWISTERS', color: 0xff55b3 }),
|
||||
chat(9, 'bob', 'thanks everybody')
|
||||
] } })
|
||||
})()`)
|
||||
await sleep(500)
|
||||
}
|
||||
},
|
||||
{ name: 'kick-events-comfy', setup: async (p) => { await SCENES.find((x) => x.name === 'kick-events').setup(p, 'comfy') } },
|
||||
{ name: 'kick-events-bubbles', setup: async (p) => { await SCENES.find((x) => x.name === 'kick-events').setup(p, 'bubbles') } },
|
||||
{ name: 'irc-channel-comfy', setup: async (p) => { await setMode(p, 'comfy'); await openChannel(p, '#general') } },
|
||||
{
|
||||
// History from several days, scrolled to its start: where date
|
||||
|
||||
+11
-2
@@ -902,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
|
||||
|
||||
@@ -156,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.
|
||||
*
|
||||
|
||||
@@ -7,13 +7,14 @@ 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'
|
||||
@@ -34,6 +35,7 @@ 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 { useDmCategories } from './state/useDmCategories'
|
||||
import { bufferDisplayName, setHourFormat } from './lib/util'
|
||||
import type { BufferEntry } from './state/store'
|
||||
import { loadLocalEmotes, recoverMissingEmotes } from './lib/emotecache'
|
||||
@@ -53,6 +55,8 @@ export default function App(): JSX.Element {
|
||||
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)
|
||||
@@ -320,6 +324,7 @@ 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 />
|
||||
@@ -410,7 +415,7 @@ 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 />
|
||||
|
||||
+410
-2
@@ -1572,6 +1572,208 @@ button.video-tile {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* What somebody did on a Kick stream - KICKs sent, subscriptions gifted - as
|
||||
the card Kick draws it: a bar down the edge, who and what at the left, and
|
||||
the thing itself at the right. On this client's own surfaces; the accent is
|
||||
the gift's tier (Kick green for an ordinary one), tinted into the plate so a
|
||||
large gift is bigger at a glance without the card leaving the theme. */
|
||||
.message-row.has-kick-event {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Where the card sits in the log, which is where the picture of the person
|
||||
speaking would: it starts at the picture's edge, in the column a join's mark
|
||||
would have taken, and what is written inside it starts where a name does. So
|
||||
the indent is the picture, the gap after it, and - in a bubble - the
|
||||
bubble's own padding. A message row that is not a person speaking gets no
|
||||
avatar, and a card with none lets the content start at the edge. */
|
||||
.message-row.comfy:not(.bubbles).has-kick-event {
|
||||
--kick-lead: var(--avatar);
|
||||
--kick-indent: calc(var(--avatar) + var(--spacing-m));
|
||||
}
|
||||
|
||||
.message-row.bubbles.system.has-kick-event {
|
||||
--kick-lead: 28px;
|
||||
--kick-indent: calc(28px + var(--spacing-s) + var(--spacing-s));
|
||||
justify-content: flex-start;
|
||||
margin-top: var(--spacing-s);
|
||||
}
|
||||
|
||||
.message-row.bubbles.system.has-kick-event .message-content {
|
||||
flex: 1 1 auto;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
background: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.message-row.classic.has-kick-event,
|
||||
.message-row:not(.comfy).has-kick-event {
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.message-row.comfy.has-kick-event .message-content {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-row.comfy.has-kick-event .kick-event {
|
||||
position: relative;
|
||||
max-width: min(100%, 560px);
|
||||
/* Less the bar, which is in the card's own edge. */
|
||||
padding-left: calc(var(--kick-indent) - var(--kick-border, 3px));
|
||||
}
|
||||
|
||||
.message-row.comfy.has-kick-event .kick-event.raid,
|
||||
.message-row.comfy.has-kick-event .kick-event.stream {
|
||||
--kick-border: 1px;
|
||||
}
|
||||
|
||||
/* The mark at the left takes the picture's column - centred on where the
|
||||
picture would be - instead of sitting in the text's way. */
|
||||
.message-row.comfy.has-kick-event .kick-event > .kick-event-badge.lead {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: var(--kick-lead);
|
||||
}
|
||||
|
||||
.kick-event {
|
||||
--kick-accent: #53fc18;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-l);
|
||||
min-width: 260px;
|
||||
max-width: min(100%, 520px);
|
||||
padding: var(--spacing-s) var(--spacing-m);
|
||||
border: 1px solid var(--outline-medium);
|
||||
border-left: 3px solid var(--kick-accent);
|
||||
border-radius: var(--corner-radius);
|
||||
background: color-mix(in srgb, var(--kick-accent) 7%, var(--surface-container-high));
|
||||
color: var(--surface-text);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* Kick draws a gifted subscription in purple and a raid with no bar at all; a
|
||||
redeemed reward takes the streamer's colour, set inline. */
|
||||
.kick-event.gift {
|
||||
--kick-accent: #a970ff;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.kick-event.redeem,
|
||||
.kick-event.subscribe,
|
||||
.kick-event.moderation {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.kick-event.subscribe {
|
||||
--kick-accent: #a970ff;
|
||||
}
|
||||
|
||||
/* A ban, a timeout, a chat cleared: said in the room's own danger colour. */
|
||||
.kick-event.moderation {
|
||||
--kick-accent: var(--danger);
|
||||
}
|
||||
|
||||
/* The stream going live or ending is a plate like a raid's, and the mark is
|
||||
the part that says which. */
|
||||
.kick-event.stream {
|
||||
justify-content: flex-start;
|
||||
border: 1px solid var(--outline-medium);
|
||||
border-radius: var(--radius-pill);
|
||||
padding-inline: var(--spacing-m);
|
||||
background: var(--surface-container-high);
|
||||
}
|
||||
|
||||
.kick-event.stream.live .kick-event-badge {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.kick-event.stream.offline .kick-event-badge {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.kick-event.raid {
|
||||
justify-content: flex-start;
|
||||
border: 1px solid var(--outline-medium);
|
||||
border-radius: var(--radius-pill);
|
||||
padding-inline: var(--spacing-m);
|
||||
background: var(--surface-container-high);
|
||||
}
|
||||
|
||||
.kick-event.raid .kick-event-badge {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.kick-event-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kick-event-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.kick-event-name {
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.kick-event-verb {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.kick-event-amount {
|
||||
font-size: var(--font-size-large, 1.15em);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.kick-event-message {
|
||||
color: var(--surface-text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.kick-event-recipients {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.kick-event-recipient {
|
||||
padding: 0 var(--spacing-s);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-container-highest, var(--surface-hover));
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
|
||||
.kick-event-badge {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--kick-accent);
|
||||
}
|
||||
|
||||
.kicks-mark {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--kick-accent);
|
||||
color: #0b0e0a;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.message-row.comfy {
|
||||
align-items: flex-start;
|
||||
padding-top: var(--spacing-xxs);
|
||||
@@ -1590,7 +1792,8 @@ button.video-tile {
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
|
||||
.message-row.comfy:not(.bubbles):not(.grouped):not(.system) {
|
||||
.message-row.comfy:not(.bubbles):not(.grouped):not(.system),
|
||||
.message-row.comfy:not(.bubbles):not(.grouped).has-kick-event {
|
||||
margin-top: var(--spacing-l);
|
||||
}
|
||||
|
||||
@@ -2343,6 +2546,45 @@ img.composer-emoji {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.nick-row.comfy {
|
||||
gap: var(--spacing-s);
|
||||
padding: 4px var(--spacing-s);
|
||||
}
|
||||
|
||||
/* The name over what they are doing, in one column that gives way to the
|
||||
badge at the right rather than pushing it off. */
|
||||
.nick-text,
|
||||
.buffer-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* A game, a track, or words they wrote: small, under the name, with a picture
|
||||
only where there is a kind of thing to show. */
|
||||
.activity-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.activity-line .icon {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Steam draws a sticker as a picture of its own, about the size it is in Steam's
|
||||
own chat. Qualified with the element because `img.custom-emoji` sizes every
|
||||
emoji to the line, and wins over a bare class. */
|
||||
img.custom-emoji.steam-sticker {
|
||||
height: 128px;
|
||||
width: auto;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.nick-prefix {
|
||||
color: var(--surface-variant-text);
|
||||
flex: none;
|
||||
@@ -2483,6 +2725,13 @@ img.composer-emoji {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-row > .grow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-self: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.field.port {
|
||||
flex: 0 0 80px;
|
||||
}
|
||||
@@ -5386,6 +5635,18 @@ video.lightbox-media {
|
||||
color: var(--surface-text);
|
||||
}
|
||||
|
||||
/* A conversation over the heading that will take it: the whole category is lit, heading and
|
||||
rows, since the heading is where it is going to be and not what it is going to be above. */
|
||||
.category.drop-into {
|
||||
border-radius: var(--corner-radius-small);
|
||||
background: color-mix(in srgb, var(--primary) 16%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 55%, transparent);
|
||||
}
|
||||
|
||||
.category.drop-into .category-head {
|
||||
color: var(--surface-text);
|
||||
}
|
||||
|
||||
/* A channel being carried. Faded like the heading, and for the same reason:
|
||||
the row stays in place so the list does not reflow under the cursor and
|
||||
move the heading being aimed at. */
|
||||
@@ -6661,7 +6922,12 @@ video.lightbox-media {
|
||||
}
|
||||
|
||||
.header-nameplate .main-header-title {
|
||||
flex: 1;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-nameplate .header-status {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -7464,6 +7730,35 @@ video.lightbox-media {
|
||||
|
||||
/* Label and value on one line, labels aligned, so the card is read down its
|
||||
left edge rather than hunted through. */
|
||||
.profile-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xxs);
|
||||
padding: var(--spacing-s);
|
||||
margin-bottom: var(--spacing-s);
|
||||
border-radius: var(--corner-radius-small);
|
||||
background: var(--surface-container-high);
|
||||
}
|
||||
|
||||
.profile-section-title {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: var(--font-size-xs, 11px);
|
||||
}
|
||||
|
||||
.profile-status-presence {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.profile-status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.profile-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -9163,6 +9458,57 @@ video.lightbox-media {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* A warning the service put on the conversation, over it, in red, until it is
|
||||
dismissed. Louder than the interruption banner above: that is about the
|
||||
connection, and this is about who is on the other end. */
|
||||
.notice-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
padding: var(--spacing-s) var(--spacing-m);
|
||||
border-bottom: 1px solid var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 22%, var(--surface));
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.notice-banner .icon {
|
||||
flex: none;
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.notice-banner-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
.notice-banner-dismiss {
|
||||
flex: none;
|
||||
padding: var(--spacing-xs) var(--spacing-m);
|
||||
border-radius: var(--corner-radius-small);
|
||||
background: var(--danger);
|
||||
color: var(--on-accent);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.notice-banner-dismiss:hover {
|
||||
background: var(--danger-hover);
|
||||
}
|
||||
|
||||
/* The same warning, said in the log where it stays: red, so it is not scrolled
|
||||
past, and not the italic grey of an ordinary system line. */
|
||||
.message-row.warning {
|
||||
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||
box-shadow: inset 3px 0 0 var(--danger);
|
||||
}
|
||||
|
||||
.message-row.warning .message-body {
|
||||
color: var(--danger-text);
|
||||
font-style: normal;
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
/* A rail entry whose service has not answered yet - on start, or after an
|
||||
outage. The face dims and a spinner sits over it until it connects. */
|
||||
.rail-tile.connecting .rail-face {
|
||||
@@ -10505,3 +10851,65 @@ html[data-reduce-motion] *::after {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* The names somebody has had, on their card: newest first, each with when. */
|
||||
.profile-name-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-name-history li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-s);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* A Steam chat group's settings: tabs over a scrolling body, as a room's are. */
|
||||
.steam-group {
|
||||
width: 560px;
|
||||
max-width: calc(100vw - var(--spacing-xl));
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-s);
|
||||
padding: var(--spacing-m);
|
||||
border: 1px solid var(--outline-medium);
|
||||
border-radius: var(--corner-radius);
|
||||
background: var(--surface-container-high);
|
||||
box-shadow: var(--shadow-3);
|
||||
}
|
||||
|
||||
.steam-group-body {
|
||||
padding-top: var(--spacing-s);
|
||||
}
|
||||
|
||||
.steam-group-row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.steam-group-person {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
padding-bottom: var(--spacing-xs);
|
||||
border-bottom: 1px solid var(--outline-medium);
|
||||
}
|
||||
|
||||
.steam-group-perms {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-xs) 0 var(--spacing-xs) var(--spacing-m);
|
||||
}
|
||||
|
||||
.steam-group-x {
|
||||
margin-left: var(--spacing-xs);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -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,8 +1,9 @@
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { SwitchRow } from './Switch'
|
||||
import { useEffect, useState } from 'react'
|
||||
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'
|
||||
@@ -23,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. */
|
||||
@@ -134,6 +135,7 @@ 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
|
||||
@@ -456,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 })} />
|
||||
@@ -1192,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,16 +6,21 @@ 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 { presenceClass, presenceLabel } from '../lib/presence'
|
||||
import { useChat, useIdSetPref, useMapPref, usePref, useStore } from '../state/hooks'
|
||||
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,
|
||||
@@ -34,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,
|
||||
@@ -72,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')
|
||||
@@ -129,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
|
||||
@@ -158,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.
|
||||
*
|
||||
@@ -178,6 +190,7 @@ 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])
|
||||
|
||||
@@ -231,10 +244,48 @@ export function BufferList(): JSX.Element {
|
||||
* since that is the order the server arranged them in and the reason it
|
||||
* 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))
|
||||
@@ -244,7 +295,7 @@ 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
|
||||
@@ -252,16 +303,14 @@ export function BufferList(): JSX.Element {
|
||||
/** The call that sets a mute on the account itself, for a service that keeps one. */
|
||||
const accountMute = (accountId: string): string | null => {
|
||||
const service = accounts.find((a) => a.id === accountId)?.service
|
||||
return service === 'matrix' ? 'setMatrixRoomMuted' : service === 'discord' ? 'setDiscordChannelMuted' : null
|
||||
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)
|
||||
}
|
||||
@@ -354,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)}
|
||||
@@ -418,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 => {
|
||||
@@ -437,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">
|
||||
@@ -533,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))
|
||||
)
|
||||
@@ -557,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)}
|
||||
@@ -640,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>
|
||||
@@ -665,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
|
||||
@@ -713,7 +798,11 @@ export function BufferList(): JSX.Element {
|
||||
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))
|
||||
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[])
|
||||
: [])
|
||||
@@ -722,6 +811,20 @@ export function BufferList(): JSX.Element {
|
||||
/>
|
||||
)}
|
||||
|
||||
{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 />
|
||||
@@ -937,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
|
||||
@@ -980,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.
|
||||
@@ -1015,7 +1124,9 @@ function BufferRow({
|
||||
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 (
|
||||
@@ -1043,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
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} 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'
|
||||
|
||||
@@ -37,7 +38,7 @@ interface StagedAttachment {
|
||||
preview?: string
|
||||
/** In bytes, once it has been asked. */
|
||||
size?: number | null
|
||||
/** Sent hidden behind a spoiler: Discord only. */
|
||||
/** Sent hidden behind a spoiler: Discord and Steam. */
|
||||
spoiler: boolean
|
||||
}
|
||||
|
||||
@@ -239,7 +240,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
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
|
||||
@@ -249,7 +250,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
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([]))
|
||||
}
|
||||
@@ -257,8 +258,9 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
// 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.
|
||||
@@ -436,12 +438,29 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
|
||||
const stage = (path: string, file?: File): void => {
|
||||
const name = fileNameOf(path)
|
||||
// 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) => setStaged((s) => s.map((a) => (a.id === id ? { ...a, size } : a))))
|
||||
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) {
|
||||
@@ -452,7 +471,10 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const pickAndStage = (): void => void window.moho.pickFile().then((p) => p && stage(p))
|
||||
// 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.
|
||||
@@ -527,6 +549,15 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
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)
|
||||
@@ -1017,7 +1048,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
{/* 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 === 'discord' || service === 'steam') && (
|
||||
<button
|
||||
type="button"
|
||||
className={classes('staged-action', att.spoiler && 'on')}
|
||||
@@ -1273,7 +1304,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
{/* GIFs, wherever there is a Discord account to ask for them: the
|
||||
picker borrows that account's way of looking, and what it finds can
|
||||
be sent in any conversation. */}
|
||||
{!thread && accounts.some((a) => a.service === 'discord') && (
|
||||
{!thread && service !== 'steam' && accounts.some((a) => a.service === 'discord') && (
|
||||
<button
|
||||
ref={gifButtonRef}
|
||||
type="button"
|
||||
@@ -1293,7 +1324,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
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. */}
|
||||
{!thread && (service === 'matrix' || service === 'discord') && (
|
||||
{!thread && (service === 'matrix' || service === 'discord' || service === 'steam') && (
|
||||
<button
|
||||
ref={stickerButtonRef}
|
||||
type="button"
|
||||
@@ -1367,14 +1398,19 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
// 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
|
||||
|
||||
@@ -6,6 +6,7 @@ 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'
|
||||
@@ -51,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({
|
||||
@@ -61,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),
|
||||
@@ -69,17 +72,17 @@ 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 === 'matrix' || account?.service === 'steam'
|
||||
? () =>
|
||||
void window.moho
|
||||
.rpc('setMatrixRoomMuted', { bufferId: buffer.id, muted: false })
|
||||
.rpc(account.service === 'steam' ? 'steamSetFriendMuted' : 'setMatrixRoomMuted', { bufferId: buffer.id, muted: false })
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
: undefined,
|
||||
onToggleAutojoin:
|
||||
@@ -108,7 +111,9 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
|
||||
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
|
||||
@@ -166,6 +171,9 @@ 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} />}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -615,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
|
||||
@@ -949,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}`}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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'
|
||||
@@ -72,6 +72,8 @@ export const COMMON_EMOJI: { emoji: string; name: string }[] = [
|
||||
* round and flags animation. Neither is negotiable - the server parses it.
|
||||
*/
|
||||
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}>`
|
||||
}
|
||||
@@ -205,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({
|
||||
@@ -217,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.
|
||||
@@ -248,7 +257,7 @@ export function EmojiPicker({
|
||||
/** 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.
|
||||
@@ -350,8 +359,8 @@ export function EmojiPicker({
|
||||
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),
|
||||
@@ -802,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>
|
||||
|
||||
@@ -37,11 +37,25 @@ export function IncomingCallPanel({ silent = false }: { silent?: boolean } = {})
|
||||
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)
|
||||
|
||||
@@ -1,44 +1,36 @@
|
||||
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)}>
|
||||
@@ -49,8 +41,15 @@ export function InvitePanel({ groupId }: { groupId: string }): JSX.Element | nul
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ContextMenu } from './ContextMenu'
|
||||
import { RoomSearch } from './RoomSearch'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import { classes, serviceLabel } from '../lib/util'
|
||||
import { steamGroupInviteCode } from '../lib/steamchat'
|
||||
import type { Account, DiscordFriend } from '../../../shared/wire'
|
||||
|
||||
/**
|
||||
@@ -44,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,
|
||||
@@ -84,11 +76,13 @@ export function JoinPanel(): JSX.Element {
|
||||
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'].includes(a.service))
|
||||
const joinable = all.filter((a) => ['irc', 'matrix', 'discord', 'kick', 'sneedchat', 'steam'].includes(a.service))
|
||||
|
||||
return (
|
||||
<div className="join-frame">
|
||||
@@ -367,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.
|
||||
*
|
||||
@@ -465,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
|
||||
@@ -762,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
|
||||
|
||||
@@ -822,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">
|
||||
@@ -832,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>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Icon } from './Icon'
|
||||
import { embedColor } from '../lib/format'
|
||||
import { nickColor } from '../lib/util'
|
||||
import type { Embed } from '../../../shared/wire'
|
||||
|
||||
/** The embed kinds the daemon draws as these cards instead of as a link preview. */
|
||||
export const KICK_EVENT_KINDS = ['kicks', 'gift-sub', 'raid', 'redeem', 'subscribe', 'moderation', 'stream']
|
||||
|
||||
export function isKickEvent(embed: Embed): boolean {
|
||||
return !!embed.kind && KICK_EVENT_KINDS.includes(embed.kind)
|
||||
}
|
||||
|
||||
/** Kick's own mark: a K on a disc. Drawn rather than fetched - it is a few shapes. */
|
||||
function KicksMark({ size = 16 }: { size?: number }): JSX.Element {
|
||||
return (
|
||||
<span className="kicks-mark" style={{ width: size, height: size, fontSize: size * 0.68 }} aria-hidden="true">
|
||||
K
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What somebody did on a Kick stream, as the card Kick draws it - a bar down
|
||||
* the edge, a mark for what it was, who did it in bold, and the words - on this
|
||||
* client's own surfaces and tokens rather than Kick's page.
|
||||
*
|
||||
* Every kind of event a Kick channel has, all from the daemon
|
||||
* (nobilis/src/backend/kick/chat.rs): KICKs sent, a subscription and one gifted
|
||||
* to somebody, a raid, a channel reward redeemed - which keeps the colour the
|
||||
* streamer gave it - a ban, a timeout or a chat cleared, and the stream going
|
||||
* live or ending.
|
||||
*/
|
||||
export function KickEventCard({ embed }: { embed: Embed }): JSX.Element | null {
|
||||
const who = embed.author
|
||||
if (!who) return null
|
||||
const name = (colour?: string): JSX.Element => (
|
||||
<strong className="kick-event-name" style={{ color: colour ?? embed.authorColor ?? nickColor(who) }}>
|
||||
{who}
|
||||
</strong>
|
||||
)
|
||||
const accent = embedColor(embed.color) || undefined
|
||||
const tint = accent ? ({ '--kick-accent': accent } as React.CSSProperties) : undefined
|
||||
|
||||
if (embed.kind === 'kicks') {
|
||||
return (
|
||||
<div className="kick-event kicks selectable" style={tint}>
|
||||
<div className="kick-event-main">
|
||||
<div className="kick-event-line">
|
||||
{name()}
|
||||
<span className="kick-event-verb">sent</span>
|
||||
<KicksMark />
|
||||
<strong className="kick-event-amount">{embed.title}</strong>
|
||||
{embed.provider && <span className="kick-event-gift small muted">{embed.provider}</span>}
|
||||
</div>
|
||||
{embed.description && <div className="kick-event-message">{embed.description}</div>}
|
||||
</div>
|
||||
<span className="kick-event-badge">
|
||||
<KicksMark size={30} />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (embed.kind === 'raid') {
|
||||
return (
|
||||
<div className="kick-event raid selectable">
|
||||
<span className="kick-event-badge lead">
|
||||
<Icon name="groups" size={20} />
|
||||
</span>
|
||||
<div className="kick-event-main">
|
||||
<div className="kick-event-line">
|
||||
{name()}
|
||||
<span className="kick-event-verb">
|
||||
{embed.title ? 'has raided with' : 'has raided the channel'}
|
||||
</span>
|
||||
{embed.title && (
|
||||
<>
|
||||
<strong className="kick-event-amount">{embed.title}</strong>
|
||||
<span className="kick-event-verb">{embed.title === '1' ? 'viewer.' : 'viewers.'}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{embed.description && <div className="kick-event-message">{embed.description}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (embed.kind === 'redeem') {
|
||||
return (
|
||||
<div className="kick-event redeem selectable" style={tint}>
|
||||
<span className="kick-event-badge lead">
|
||||
<Icon name="stars" size={22} />
|
||||
</span>
|
||||
<div className="kick-event-main">
|
||||
{name(accent)}
|
||||
<div className="kick-event-line">
|
||||
<span className="kick-event-verb">has redeemed</span>
|
||||
<strong>{embed.title}</strong>
|
||||
</div>
|
||||
{embed.description && <div className="kick-event-message">{embed.description}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (embed.kind === 'subscribe') {
|
||||
return (
|
||||
<div className="kick-event subscribe selectable">
|
||||
<span className="kick-event-badge lead">
|
||||
<Icon name="workspace_premium" size={22} />
|
||||
</span>
|
||||
<div className="kick-event-line">
|
||||
{name()}
|
||||
<span className="kick-event-verb">subscribed</span>
|
||||
{embed.title && (
|
||||
<>
|
||||
<span className="kick-event-verb">-</span>
|
||||
<strong>{embed.title}</strong>
|
||||
<span className="kick-event-verb">months</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (embed.kind === 'moderation') {
|
||||
return (
|
||||
<div className="kick-event moderation selectable">
|
||||
<span className="kick-event-badge lead">
|
||||
<Icon name="gavel" size={20} />
|
||||
</span>
|
||||
<div className="kick-event-line">
|
||||
<strong className="kick-event-name">{who}</strong>
|
||||
<span className="kick-event-verb">{embed.title}</span>
|
||||
{embed.description && (
|
||||
<>
|
||||
<span className="kick-event-verb">by</span>
|
||||
<strong>{embed.description}</strong>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (embed.kind === 'stream') {
|
||||
const live = embed.icon === 'live'
|
||||
return (
|
||||
<div className={`kick-event stream selectable ${live ? 'live' : 'offline'}`}>
|
||||
<span className="kick-event-badge lead">
|
||||
<Icon name={live ? 'sensors' : 'sensors_off'} size={20} />
|
||||
</span>
|
||||
<div className="kick-event-line">
|
||||
<strong className="kick-event-name">{who}</strong>
|
||||
<span className="kick-event-verb">{embed.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const count = Number(embed.title) || 1
|
||||
const recipients = (embed.description ?? '').split('\n').filter(Boolean)
|
||||
return (
|
||||
<div className="kick-event gift selectable">
|
||||
<span className="kick-event-badge lead">
|
||||
<Icon name="redeem" size={22} />
|
||||
</span>
|
||||
<div className="kick-event-main">
|
||||
<div className="kick-event-line">
|
||||
{name()}
|
||||
<span className="kick-event-verb">gifted {count === 1 ? 'a sub' : `${count} subs`}</span>
|
||||
{recipients.length === 1 && (
|
||||
<>
|
||||
<span className="kick-event-verb">to</span>
|
||||
<strong style={{ color: nickColor(recipients[0]) }}>{recipients[0]}</strong>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{recipients.length > 1 && (
|
||||
<div className="kick-event-recipients">
|
||||
<span className="kick-event-verb">to</span>
|
||||
{recipients.map((r) => (
|
||||
<strong key={r} className="kick-event-recipient" style={{ color: nickColor(r) }}>
|
||||
{r}
|
||||
</strong>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -113,7 +113,7 @@ export function MediaEmbed({
|
||||
const [seen, setSeen] = useState<{ width: number; height: number } | null>(null)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
// Hidden until it is clicked, as Discord does with a file sent as a spoiler.
|
||||
const spoiler = !!attachment?.filename?.startsWith(SPOILER_PREFIX)
|
||||
const spoiler = !!attachment?.filename?.startsWith(SPOILER_PREFIX) || !!item?.spoiler
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
const hidden = spoiler && !revealed
|
||||
// What it is called, less the marker: that is not part of the name.
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CallView } from './CallView'
|
||||
import { CallStage, RoomCallBar } from './CallStage'
|
||||
import { StreamStage } from './StreamStage'
|
||||
import { ServiceBanner } from './ServiceBanner'
|
||||
import { ConversationNotice } from './ConversationNotice'
|
||||
import { RouteAccount, useStrictRoute } from '../lib/route'
|
||||
import { useChat, usePref, useStore } from '../state/hooks'
|
||||
import { bufferDisplayName, dayLabel, isChatKind, startsNewDay } from '../lib/util'
|
||||
@@ -165,6 +166,16 @@ const RENDER_WINDOW = 120
|
||||
const RENDER_STEP = 120
|
||||
/** Rows kept below a jumped-to message, so it has somewhere to sit. */
|
||||
const JUMP_MARGIN = 40
|
||||
/**
|
||||
* The most unread a conversation can have and still open at its first unread
|
||||
* message. Discord's rule, and for the same reason: its first screenful of
|
||||
* history is fifty messages, so an unread run that fits in it is somewhere to
|
||||
* start reading, and one that does not is somewhere nobody wants to be taken -
|
||||
* hundreds or thousands of messages up, with the conversation they came for
|
||||
* a long scroll away. Past it the log opens at its end, as any other does,
|
||||
* and the bar across the top says how much there is and goes back to it.
|
||||
*/
|
||||
const OPEN_AT_UNREAD_LIMIT = 50
|
||||
|
||||
export function MessageList(): JSX.Element {
|
||||
const store = useStore()
|
||||
@@ -527,13 +538,21 @@ export function MessageList(): JSX.Element {
|
||||
// of switching, because what has been read from the daemon may not have
|
||||
// arrived yet - the line has to be in the drawn part of the log, which is a
|
||||
// tail, before it can be scrolled to.
|
||||
// The divider marks the first message newer than the snapshot taken when the
|
||||
// buffer was opened; it deliberately doesn't move as more arrive.
|
||||
const dividerIndex = dividerTs > 0 ? messages.findIndex((m) => m.ts > dividerTs) : -1
|
||||
// Where it is against what is drawn, for the callbacks below that have to
|
||||
// know whether the line exists on the page or only in the log.
|
||||
const dividerRef = useRef({ index: -1, start: 0, length: 0 })
|
||||
dividerRef.current = { index: dividerIndex, start, length: messages.length }
|
||||
|
||||
const openedAtRef = useRef('')
|
||||
useLayoutEffect(() => {
|
||||
const key = `${bufferId}|${dividerTs}`
|
||||
if (openedAtRef.current === key || messages.length === 0) return
|
||||
openedAtRef.current = key
|
||||
const first = dividerTs > 0 ? messages.findIndex((m) => m.ts > dividerTs) : -1
|
||||
if (first >= 0 && messages.length - first > 8) {
|
||||
if (first >= 0 && messages.length - first > 8 && messages.length - first <= OPEN_AT_UNREAD_LIMIT) {
|
||||
wantDividerRef.current = true
|
||||
setShown((n) => Math.max(n, messages.length - first + JUMP_MARGIN))
|
||||
}
|
||||
@@ -543,8 +562,13 @@ export function MessageList(): JSX.Element {
|
||||
const measureDivider = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
const line = el?.querySelector<HTMLElement>('.new-divider')
|
||||
if (!el || !line) {
|
||||
setDividerAbove(false)
|
||||
if (!el) return
|
||||
if (!line) {
|
||||
// Not drawn is not the same as not there. A conversation opened with a
|
||||
// long unread run is at its end, and its line is up in the log beyond
|
||||
// what is built - which is exactly when the bar is wanted.
|
||||
const { index, start } = dividerRef.current
|
||||
setDividerAbove(index >= 0 && index < start)
|
||||
return
|
||||
}
|
||||
// Clearly above, not merely just off the top: with the line a few pixels
|
||||
@@ -556,7 +580,18 @@ export function MessageList(): JSX.Element {
|
||||
const toDivider = useCallback((smooth: boolean) => {
|
||||
const el = scrollRef.current
|
||||
const line = el?.querySelector<HTMLElement>('.new-divider')
|
||||
if (!el || !line) return
|
||||
if (!el) return
|
||||
if (!line) {
|
||||
// In the log and not on the page: build the log back to it, and the
|
||||
// layout effect below scrolls to it once it is there.
|
||||
const { index, length } = dividerRef.current
|
||||
if (index < 0) return
|
||||
anchor(false)
|
||||
setMissedCount(0)
|
||||
wantDividerRef.current = true
|
||||
setShown((n) => Math.max(n, length - index + JUMP_MARGIN))
|
||||
return
|
||||
}
|
||||
anchor(false)
|
||||
// Going back to the line is not missing anything: what is below is what
|
||||
// was already there, not news.
|
||||
@@ -566,6 +601,13 @@ export function MessageList(): JSX.Element {
|
||||
el.scrollTo({ top: Math.max(0, target), behavior: smooth ? 'smooth' : 'auto' })
|
||||
}, [anchor, messages])
|
||||
|
||||
// Whether the line is out of sight, asked as the log changes as well as when
|
||||
// it is scrolled: a conversation opened at its end never scrolls, and is
|
||||
// the one with the most to say about what is above it.
|
||||
useLayoutEffect(() => {
|
||||
measureDivider()
|
||||
}, [bufferId, dividerTs, messages.length, shown, measureDivider])
|
||||
|
||||
// Once the line is drawn, start there.
|
||||
useLayoutEffect(() => {
|
||||
if (!wantDividerRef.current) return
|
||||
@@ -728,10 +770,6 @@ export function MessageList(): JSX.Element {
|
||||
return () => window.removeEventListener('moho:jump-to-present', go)
|
||||
}, [])
|
||||
|
||||
// The divider marks the first message newer than the snapshot taken when the
|
||||
// buffer was opened; it deliberately doesn't move as more arrive.
|
||||
const dividerIndex = dividerTs > 0 ? messages.findIndex((m) => m.ts > dividerTs) : -1
|
||||
|
||||
return (
|
||||
// Whose conversation this is, for everything drawn inside it: a strictly
|
||||
// routed account's media goes through its route (#257). The attribute is
|
||||
@@ -741,6 +779,8 @@ export function MessageList(): JSX.Element {
|
||||
{/* First, because it explains everything under it: a conversation that
|
||||
is not receiving looks like a quiet one otherwise. */}
|
||||
<ServiceBanner bufferId={bufferId} />
|
||||
{/* A warning the service put on this conversation, in red, until it is dismissed. */}
|
||||
<ConversationNotice bufferId={bufferId} />
|
||||
{/* Above the log and outside the scroller. Who is talking is only
|
||||
useful while it can be seen, and a panel that scrolled away with
|
||||
the backlog would be gone the moment anybody read anything. */}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { steamRoomIds } from '../lib/steamchat'
|
||||
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Icon } from './Icon'
|
||||
@@ -7,7 +8,8 @@ import { EventSource } from './EventSource'
|
||||
import { ForwardPicker } from './ForwardPicker'
|
||||
import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu'
|
||||
import { MediaEmbed } from './MediaEmbed'
|
||||
import { COMMON_EMOJI, EmojiPicker, readRecent } from './EmojiPicker'
|
||||
import { KickEventCard, isKickEvent } from './KickEventCard'
|
||||
import { COMMON_EMOJI, EmojiPicker, readRecent, type StickerEntry } from './EmojiPicker'
|
||||
import { reactionLabel, reactorsSentence } from '../lib/reactions'
|
||||
import { UploadMeter } from './UploadMeter'
|
||||
import { RichText } from '../lib/richtext'
|
||||
@@ -29,6 +31,7 @@ import {
|
||||
embedColor,
|
||||
extractCodeBlocks,
|
||||
extractMedia,
|
||||
markSpoilered,
|
||||
extractQuoteBlocks,
|
||||
formatMessage,
|
||||
normalizeBBCode,
|
||||
@@ -39,6 +42,8 @@ import {
|
||||
stripEmbeddedUrls,
|
||||
stripQuoteBlocks,
|
||||
discordEmojiUrl,
|
||||
steamPictureUrl,
|
||||
steamTokenOf,
|
||||
type ChannelIndex
|
||||
} from '../lib/format'
|
||||
import {
|
||||
@@ -50,6 +55,7 @@ import {
|
||||
hasDirectMessages,
|
||||
isChatKind,
|
||||
isNotice,
|
||||
isWarning,
|
||||
isReward,
|
||||
isWhisper,
|
||||
nickColor
|
||||
@@ -383,7 +389,16 @@ function MessageRowBody({
|
||||
// messages; IRC has no such concept.
|
||||
const canEditDelete =
|
||||
!!message.isOwn && (service === 'discord' || service === 'sneedchat' || service === 'matrix')
|
||||
const canReact = service === 'discord' || service === 'matrix'
|
||||
const canReact = service === 'discord' || service === 'matrix' || service === 'steam'
|
||||
// Steam's stickers, asked for when the reaction picker opens: it reacts with them too.
|
||||
const [steamStickers, setSteamStickers] = useState<StickerEntry[]>([])
|
||||
useEffect(() => {
|
||||
if (!pickerOpen || service !== 'steam') return
|
||||
void window.moho
|
||||
.rpc<StickerEntry[]>('listSteamStickers', { bufferId })
|
||||
.then(setSteamStickers)
|
||||
.catch(() => setSteamStickers([]))
|
||||
}, [pickerOpen, service, bufferId])
|
||||
// Read again after the picker has been used, which is when it changes.
|
||||
const quick = useMemo(
|
||||
() => quickReactions(store.accountFor(bufferId)?.id),
|
||||
@@ -406,6 +421,9 @@ function MessageRowBody({
|
||||
// label on.
|
||||
const [highlightNotices] = usePref<boolean>('matrix.highlightNotices', true)
|
||||
const reward = isReward(message.kind)
|
||||
// KICKs sent, or subscriptions gifted: the daemon says which and to whom in
|
||||
// a card, and the line of text is what a copy or a search finds.
|
||||
const kickEvent = (message.embeds ?? []).find(isKickEvent)
|
||||
// Both of the modes that draw an avatar column. Bubbles is otherwise
|
||||
// nothing like comfy, but it wants the same picture beside the same first
|
||||
// line of a group.
|
||||
@@ -455,14 +473,14 @@ function MessageRowBody({
|
||||
// this function again and neither of which is re-stored.
|
||||
const raw = clearnetLinks(message.body || '')
|
||||
const normalized = normalizeBBCode(raw)
|
||||
const media = extractMedia(normalized, {
|
||||
const media = markSpoilered(extractMedia(normalized, {
|
||||
contentSniffing,
|
||||
sniffed,
|
||||
onNeedSniff: (url) => sniffUrl(url, strictRoute),
|
||||
// Read from the un-normalised body: the pairing lives in the BBCode,
|
||||
// and normalising unwraps both tags into two unrelated bare URLs.
|
||||
thumbnails: thumbnailLinks(raw)
|
||||
})
|
||||
}), normalized)
|
||||
const codeBlocks = extractCodeBlocks(normalized)
|
||||
const quoteBlocks = extractQuoteBlocks(stripCodeBlocks(normalized))
|
||||
|
||||
@@ -552,7 +570,8 @@ function MessageRowBody({
|
||||
}
|
||||
] as MenuEntry[])
|
||||
: []),
|
||||
{ label: 'Reply', icon: 'reply', onClick: () => reply() },
|
||||
// Steam chat has no replies - a message is a line of text and nothing points at another.
|
||||
...(service === 'steam' ? [] : ([{ label: 'Reply', icon: 'reply', onClick: () => reply() }] as MenuEntry[])),
|
||||
// What was selected when the menu opened, first: right-clicking on
|
||||
// highlighted text and finding no Copy is the one thing this menu must not
|
||||
// do, having replaced the one the window would have given.
|
||||
@@ -642,6 +661,17 @@ function MessageRowBody({
|
||||
}
|
||||
] as MenuEntry[])
|
||||
: []),
|
||||
// Your own words in a room of a Steam group can be taken back.
|
||||
...(service === 'steam' && message.isOwn && steamRoomIds(store.getSnapshot().buffers.find((b) => b.id === bufferId))
|
||||
? ([
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: 'delete',
|
||||
danger: true,
|
||||
onClick: () => void store.deleteMessage(bufferId, message.id)
|
||||
}
|
||||
] as MenuEntry[])
|
||||
: []),
|
||||
...(permissions.canRedactOthers && !message.isOwn
|
||||
? ([
|
||||
{
|
||||
@@ -782,10 +812,12 @@ function MessageRowBody({
|
||||
message.isHighlight && 'highlight',
|
||||
whispered && 'whisper',
|
||||
notice && highlightNotices && 'notice',
|
||||
isWarning(message.kind, message.id) && 'warning',
|
||||
message.pending && 'pending',
|
||||
message.failed && 'failed',
|
||||
isSystem && 'system',
|
||||
reward && 'reward',
|
||||
kickEvent && 'has-kick-event',
|
||||
grouped && 'grouped',
|
||||
fresh && 'fresh',
|
||||
comfy && 'comfy',
|
||||
@@ -828,7 +860,7 @@ function MessageRowBody({
|
||||
{/* A join, a part, a topic change: not somebody speaking, so no face -
|
||||
but a small mark in the picture's own column, so the line's text
|
||||
starts where every other line's does instead of at the edge. */}
|
||||
{mode === 'comfy' && isSystem && (
|
||||
{mode === 'comfy' && isSystem && !kickEvent && (
|
||||
<span className="message-avatar system-mark" aria-hidden>
|
||||
<Icon name={SYSTEM_MARKS[message.kind] ?? 'info'} size={16} />
|
||||
</span>
|
||||
@@ -1058,6 +1090,8 @@ function MessageRowBody({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : kickEvent ? (
|
||||
<KickEventCard embed={kickEvent} />
|
||||
) : (
|
||||
parts.html && (
|
||||
<span className="message-body selectable">
|
||||
@@ -1101,7 +1135,7 @@ function MessageRowBody({
|
||||
))}
|
||||
|
||||
{(message.embeds || []).map((embed, i) =>
|
||||
embed.kind === 'gif' ? null : (
|
||||
embed.kind === 'gif' || isKickEvent(embed) ? null : (
|
||||
<div
|
||||
key={`e${i}`}
|
||||
className={classes('rich-embed selectable', embed.kind === 'notice' && 'notice')}
|
||||
@@ -1361,7 +1395,10 @@ function MessageRowBody({
|
||||
answer. */}
|
||||
{!isSystem && !message.pending && !message.failed && (
|
||||
<div className="hover-toolbar">
|
||||
{/* Steam reacts with its own emoticons and stickers, so the
|
||||
one-click row of Unicode emoji is not offered there. */}
|
||||
{canReact &&
|
||||
service !== 'steam' &&
|
||||
quick.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
@@ -1384,9 +1421,11 @@ function MessageRowBody({
|
||||
<Icon name="add_reaction" size={15} />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="toolbar-button" title="Reply" onClick={reply}>
|
||||
<Icon name="reply" size={15} />
|
||||
</button>
|
||||
{service !== 'steam' && (
|
||||
<button type="button" className="toolbar-button" title="Reply" onClick={reply}>
|
||||
<Icon name="reply" size={15} />
|
||||
</button>
|
||||
)}
|
||||
{canEditDelete && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1422,6 +1461,19 @@ function MessageRowBody({
|
||||
anchor={reactButtonRef.current}
|
||||
customEmoji={bufferEmoji}
|
||||
accountId={store.accountFor(bufferId)?.id}
|
||||
// Steam reacts with what the account was given and nothing else: its
|
||||
// emoticons, and its stickers on their own tab.
|
||||
bufferId={service === 'steam' ? bufferId : undefined}
|
||||
fixedSet={service === 'steam'}
|
||||
stickers={service === 'steam' ? steamStickers : undefined}
|
||||
onSticker={
|
||||
service === 'steam'
|
||||
? (sticker) => {
|
||||
void store.toggleReaction(bufferId, message.id, `[steam:sticker:${sticker.name}]`, true)
|
||||
setPickerOpen(false)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSelect={(emoji) => {
|
||||
void store.toggleReaction(bufferId, message.id, emoji, true)
|
||||
setPickerOpen(false)
|
||||
@@ -1557,6 +1609,9 @@ function ReactionPill({
|
||||
*/
|
||||
function ReactionEmoji({ emoji }: { emoji: string }): JSX.Element {
|
||||
const media = useMediaUrl()
|
||||
// Steam's, held as a token naming the emoticon or sticker.
|
||||
const steam = steamTokenOf(emoji)
|
||||
if (steam) return <img className="reaction-emoji" src={steamPictureUrl(steam.kind, steam.name)} alt={steam.name} />
|
||||
const custom = emoji.match(/^<?a?:?([A-Za-z0-9_~]{2,32}):(\d+)>?$/)
|
||||
if (!custom) return <span>{emoji}</span>
|
||||
return <img className="reaction-emoji" src={media(discordEmojiUrl(custom[2]))} alt={custom[1]} />
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Modal } from './Modal'
|
||||
|
||||
/**
|
||||
* A name asked for before something is made: a folder on a service that keeps
|
||||
* its own, which is made the moment it has one. Enter makes it, Escape leaves.
|
||||
*
|
||||
* A dialog and not a field in the list, because what is made is not a heading
|
||||
* of ours that can be renamed afterwards for free - it is a change to the
|
||||
* account on the service, and one that is called "New category" because the
|
||||
* field lost focus is a folder every other client of the account then shows.
|
||||
*/
|
||||
export function NamePrompt({
|
||||
title,
|
||||
detail,
|
||||
placeholder,
|
||||
confirmLabel,
|
||||
icon = 'create_new_folder',
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: {
|
||||
title: string
|
||||
detail: string
|
||||
placeholder: string
|
||||
confirmLabel: string
|
||||
icon?: string
|
||||
onConfirm: (name: string) => void
|
||||
onCancel: () => void
|
||||
}): JSX.Element {
|
||||
const [name, setName] = useState('')
|
||||
const box = useRef<HTMLInputElement>(null)
|
||||
useEffect(() => {
|
||||
box.current?.focus()
|
||||
}, [])
|
||||
const ready = name.trim().length > 0
|
||||
return (
|
||||
<Modal title={title} icon={icon} onClose={onCancel} className="reason-prompt">
|
||||
<p className="small muted">{detail}</p>
|
||||
<input
|
||||
ref={box}
|
||||
className="reason-prompt-box"
|
||||
value={name}
|
||||
placeholder={placeholder}
|
||||
maxLength={64}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && ready) {
|
||||
e.preventDefault()
|
||||
onConfirm(name.trim())
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="reason-prompt-actions">
|
||||
<button type="button" className="button subtle" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="button" disabled={!ready} onClick={() => onConfirm(name.trim())}>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { activityLabel } from '../lib/activity'
|
||||
import { ActivityLine } from './ActivityLine'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Avatar } from './Avatar'
|
||||
import { Icon, IconButton } from './Icon'
|
||||
import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu'
|
||||
import { useActiveBuffer, useChat, useIdSetPref, useStore } from '../state/hooks'
|
||||
import { useActiveBuffer, useChat, useComfy, useIdSetPref, useStore } from '../state/hooks'
|
||||
import { classes, hasDirectMessages, nickColor } from '../lib/util'
|
||||
import { KickPoints } from './KickPoints'
|
||||
import { ReasonPrompt } from './ReasonPrompt'
|
||||
import type { Member } from '../../../shared/wire'
|
||||
import { steamRoomIds, type SteamChatGroup } from '../lib/steamchat'
|
||||
|
||||
/**
|
||||
* The member list for the active channel. Two grouping modes, both driven off
|
||||
@@ -48,6 +51,7 @@ export type ModerationAction = 'kick' | 'ban' | 'mute' | 'op' | 'deop' | 'voice'
|
||||
*/
|
||||
function memberTitle(member: Member): string {
|
||||
const lines = [member.nick]
|
||||
if (member.activity) lines.push(activityLabel(member.activity))
|
||||
if (member.account) lines.push(`identified as ${member.account}`)
|
||||
if (member.host) lines.push(member.host)
|
||||
if (lines.length === 1 && member.userId) return member.userId
|
||||
@@ -126,6 +130,7 @@ interface DiscordPowers {
|
||||
|
||||
export function NickList(): JSX.Element {
|
||||
const store = useStore()
|
||||
const comfy = useComfy()
|
||||
const buffer = useActiveBuffer()
|
||||
const presenceByBuffer = useChat((s) => s.presenceByBuffer)
|
||||
const accounts = useChat((s) => s.accounts)
|
||||
@@ -174,8 +179,28 @@ export function NickList(): JSX.Element {
|
||||
})
|
||||
}, [account?.service, account?.id, buffer?.id])
|
||||
|
||||
// Steam answers from the group: what the account's roles allow there.
|
||||
const [steamPowers, setSteamPowers] = useState<Record<string, boolean>>({})
|
||||
const steamRoom = account?.service === 'steam' ? steamRoomIds(buffer) : null
|
||||
useEffect(() => {
|
||||
setSteamPowers({})
|
||||
if (!steamRoom || !account) return
|
||||
let live = true
|
||||
void window.moho
|
||||
.rpc<SteamChatGroup[]>('steamChatGroups', { accountId: account.id, groupId: steamRoom.group })
|
||||
.then((g) => live && g[0] && setSteamPowers({ canKick: g[0].mayModerate, canBan: g[0].mayModerate, canMute: false }))
|
||||
.catch(() => {
|
||||
/* advisory only: no answer means no moderation entries */
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [account?.id, steamRoom?.group])
|
||||
|
||||
const perms =
|
||||
account?.service === 'irc'
|
||||
account?.service === 'steam'
|
||||
? steamPowers
|
||||
: account?.service === 'irc'
|
||||
? ircPermissions(members, account.currentNick)
|
||||
: account?.service === 'kick'
|
||||
? kickPermissions(members, account.currentNick)
|
||||
@@ -263,6 +288,7 @@ export function NickList(): JSX.Element {
|
||||
<MemberRow
|
||||
key={member.userId || member.nick}
|
||||
member={member}
|
||||
comfy={comfy}
|
||||
blockKey={`${buffer?.accountId}|${member.nick}`}
|
||||
blocked={isBlocked(`${buffer?.accountId}|${member.nick}`)}
|
||||
canOpenDm={hasDirectMessages(account?.service)}
|
||||
@@ -353,6 +379,24 @@ export function NickList(): JSX.Element {
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
return
|
||||
}
|
||||
if (account.service === 'steam') {
|
||||
const group = steamRoomIds(buffer)?.group
|
||||
if (!group || !member.userId || (action !== 'kick' && action !== 'ban' && action !== 'mute')) return
|
||||
// Mute is ten minutes, as it is on Discord; kick and ban are
|
||||
// the group's own.
|
||||
void window.moho
|
||||
.rpc('steamChatGroupAction', {
|
||||
accountId: account.id,
|
||||
groupId: group,
|
||||
action,
|
||||
steamid: member.userId,
|
||||
seconds: action === 'mute' ? 600 : 3600,
|
||||
banned: true
|
||||
})
|
||||
.then(() => store.toast('info', `${action === 'ban' ? 'Banned' : action === 'mute' ? 'Muted' : 'Removed'} ${member.nick}`))
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
return
|
||||
}
|
||||
if (account.service === 'discord') {
|
||||
// Discord's own three, and "mute" is a timeout there -
|
||||
// the same word this menu uses everywhere, meaning what
|
||||
@@ -440,6 +484,12 @@ export function NickList(): JSX.Element {
|
||||
|
||||
interface MemberRowProps {
|
||||
member: Member
|
||||
/**
|
||||
* Comfy draws the list as Discord does - a face on everybody, with what
|
||||
* they are doing under their name - on every service. Classic draws names,
|
||||
* as IRC and Sneedchat always have.
|
||||
*/
|
||||
comfy: boolean
|
||||
blockKey: string
|
||||
blocked: boolean
|
||||
/** The protocol can start a conversation from a member list. */
|
||||
@@ -487,6 +537,7 @@ interface MemberRowProps {
|
||||
|
||||
function MemberRow({
|
||||
member,
|
||||
comfy,
|
||||
blockKey,
|
||||
blocked,
|
||||
canOpenDm,
|
||||
@@ -638,24 +689,17 @@ function MemberRow({
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={classes('nick-row', member.away && 'away', blocked && 'blocked')}
|
||||
className={classes('nick-row', comfy && 'comfy', member.away && 'away', blocked && 'blocked')}
|
||||
onContextMenu={open}
|
||||
// Who they are is the click, as in Discord, Slack and Element;
|
||||
// mentioning is Shift+click and the menu's own entry.
|
||||
onClick={(e) => (onProfile && !e.shiftKey ? onProfile() : onMention())}
|
||||
title={memberTitle(member)}
|
||||
>
|
||||
{/* A face and a dot, where the service has presence to report. The
|
||||
others - IRC, Sneedchat, Kick - are lists of names, and a column
|
||||
of initials on those would be decoration. */}
|
||||
{member.status !== undefined && (
|
||||
<Avatar
|
||||
name={member.nick}
|
||||
url={member.avatarUrl}
|
||||
size={24}
|
||||
status={member.status}
|
||||
/>
|
||||
)}
|
||||
{/* A face on everybody in comfy, with a dot where the service has
|
||||
presence to report; classic is a list of names, and a column of
|
||||
initials on those would be decoration. */}
|
||||
{comfy && <Avatar name={member.nick} url={member.avatarUrl} size={32} status={member.status} />}
|
||||
{/* IRC's rank is one character and sits in a fixed slot before the
|
||||
name. Kick's is a word - "moderator", "subscriber" - which
|
||||
overflowed that eight-pixel slot and painted straight over the
|
||||
@@ -664,9 +708,18 @@ function MemberRow({
|
||||
{member.prefix && member.prefix.length === 1 && (
|
||||
<span className="nick-prefix">{member.prefix}</span>
|
||||
)}
|
||||
<span className="ellipsis" style={{ color: nickColor(member.nick) }}>
|
||||
{member.nick}
|
||||
</span>
|
||||
{comfy ? (
|
||||
<span className="nick-text">
|
||||
<span className="ellipsis" style={{ color: nickColor(member.nick) }}>
|
||||
{member.nick}
|
||||
</span>
|
||||
{member.activity && <ActivityLine activity={member.activity} />}
|
||||
</span>
|
||||
) : (
|
||||
<span className="ellipsis" style={{ color: nickColor(member.nick) }}>
|
||||
{member.nick}
|
||||
</span>
|
||||
)}
|
||||
{member.prefix && member.prefix.length > 1 && (
|
||||
<span className={`sender-badge small ${member.prefix}`} title={member.prefix}>
|
||||
{shortBadge(member.prefix)}
|
||||
|
||||
@@ -2,8 +2,10 @@ import { Modal } from './Modal'
|
||||
import { Avatar } from './Avatar'
|
||||
import { Icon, IconButton } from './Icon'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import { formatFullTime } from '../lib/util'
|
||||
import { formatFullTime, serviceLabel } from '../lib/util'
|
||||
import { presenceColor, presenceLabel } from '../lib/presence'
|
||||
import { profileActivity } from '../lib/activity'
|
||||
import { ActivityLine } from './ActivityLine'
|
||||
|
||||
/**
|
||||
* Who somebody is, whichever service they belong to.
|
||||
@@ -20,13 +22,20 @@ import { presenceColor, presenceLabel } from '../lib/presence'
|
||||
*/
|
||||
export function ProfileCard(): JSX.Element | null {
|
||||
const profile = useChat((s) => s.profile)
|
||||
const presence = useChat((s) => s.presenceByBuffer)
|
||||
const buffers = useChat((s) => s.buffers)
|
||||
const store = useStore()
|
||||
|
||||
if (!profile) return null
|
||||
|
||||
// Their status, as its own section: how they are showing and what they are
|
||||
// doing. An away message is words they wrote for this, so it stands in for
|
||||
// an activity where the service gave none.
|
||||
const activity = profileActivity(profile, presence, buffers) ?? (profile.away ? ({ kind: 'custom', text: profile.away } as const) : undefined)
|
||||
const hasStatus = !!profile.status || !!activity
|
||||
|
||||
const rows: [string, string][] = []
|
||||
if (profile.handle && profile.handle !== profile.name) rows.push(['Address', profile.handle])
|
||||
if (profile.away) rows.push(['Away', profile.away])
|
||||
if (profile.createdTs) rows.push(['Account made', formatFullTime(profile.createdTs)])
|
||||
if (profile.joinedTs) rows.push([joinedLabel(profile.service), formatFullTime(profile.joinedTs)])
|
||||
if (profile.lastActiveTs) rows.push(['Last seen', formatFullTime(profile.lastActiveTs)])
|
||||
@@ -40,7 +49,7 @@ export function ProfileCard(): JSX.Element | null {
|
||||
<div className="profile-title">
|
||||
<span className="profile-name ellipsis">{profile.name}</span>
|
||||
<span className="small muted">
|
||||
{profile.status ? presenceLabel(profile.status) : profile.service}
|
||||
{serviceLabel(profile.service)}
|
||||
{profile.isModerator ? ' · moderator here' : ''}
|
||||
</span>
|
||||
</div>
|
||||
@@ -65,6 +74,21 @@ export function ProfileCard(): JSX.Element | null {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status, first of the facts: it is the one that changes while the
|
||||
card is open, and the one somebody opens it to see. */}
|
||||
{hasStatus && (
|
||||
<section className="profile-status" aria-label="Status">
|
||||
<span className="small muted profile-section-title">Status</span>
|
||||
{profile.status && (
|
||||
<span className="profile-status-presence">
|
||||
<span className="profile-status-dot" style={{ background: presenceColor(profile.status) }} />
|
||||
{presenceLabel(profile.status)}
|
||||
</span>
|
||||
)}
|
||||
{activity && <ActivityLine activity={activity} />}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<dl className="profile-rows">
|
||||
{rows.map(([label, value]) => (
|
||||
<div key={label + value} className="profile-row">
|
||||
@@ -76,6 +100,24 @@ export function ProfileCard(): JSX.Element | null {
|
||||
))}
|
||||
</dl>
|
||||
|
||||
{/* What they have been called, where the service keeps it: Steam does,
|
||||
and a friend who renamed is otherwise somebody you no longer recognise. */}
|
||||
{!!profile.nameHistory?.length && (
|
||||
<section className="profile-status" aria-label="Previous names">
|
||||
<span className="small muted profile-section-title">Previous names</span>
|
||||
<ul className="profile-name-history">
|
||||
{profile.nameHistory.map((n) => (
|
||||
<li key={n.name + n.when}>
|
||||
<span className="ellipsis" title={n.name}>
|
||||
{n.name}
|
||||
</span>
|
||||
{n.when && <span className="small muted">{n.when}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Shared channels are the useful half of an IRC lookup, and on a
|
||||
well-connected nick there are dozens - so they wrap. */}
|
||||
{!!profile.channels?.length && (
|
||||
@@ -121,7 +163,7 @@ export function ProfileCard(): JSX.Element | null {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!profile.pending && rows.length === 0 && !profile.roles?.length && !profile.channels?.length && (
|
||||
{!profile.pending && rows.length === 0 && !hasStatus && !profile.roles?.length && !profile.channels?.length && (
|
||||
<p className="small muted">
|
||||
<Icon name="info" size={14} /> {profile.service} keeps nothing else about them.
|
||||
</p>
|
||||
|
||||
@@ -8,13 +8,15 @@ import { Modal } from './Modal'
|
||||
import { Avatar } from './Avatar'
|
||||
import { LeaveConfirm } from './LeaveConfirm'
|
||||
import { ircNetworkFor } from '../lib/networks'
|
||||
import { useChat, usePref, useStore } from '../state/hooks'
|
||||
import { useChat, useInvitations, usePref, useStore } from '../state/hooks'
|
||||
import { RAIL_TIP, hideRailTip, showRailTip, type RailTipDetail } from '../lib/railtip'
|
||||
import { bufferDisplayName, classes, nickColor, resolveMediaUrl, serviceIcon } from '../lib/util'
|
||||
import { SpaceRooms } from './SpaceRooms'
|
||||
import { SteamGroupSettings } from './SteamGroupSettings'
|
||||
import { steamGroupOfRail } from '../lib/steamchat'
|
||||
import {
|
||||
isFixedEntry,
|
||||
inviteGroup,
|
||||
invitationsGroup,
|
||||
orderedGroups,
|
||||
railEntries,
|
||||
fileInFolder,
|
||||
@@ -75,6 +77,8 @@ interface TileProps {
|
||||
onLeave?: () => void
|
||||
/** Only a Matrix space has rooms to look through. */
|
||||
onBrowse?: () => void
|
||||
/** Only a Steam chat group keeps settings of its own: rooms, roles, invite links. */
|
||||
onSettings?: () => void
|
||||
onSelect: () => void
|
||||
onDragStart: () => void
|
||||
onDragOver: (merge: boolean) => void
|
||||
@@ -107,7 +111,7 @@ function GroupFace({ group, customIcon }: { group: RailGroup; customIcon?: strin
|
||||
// An invitation reads as one rather than as an unfamiliar server: the room
|
||||
// usually has no picture and often no name, and initials taken off a raw
|
||||
// room id say nothing at all.
|
||||
if (group.kind === 'invite') return <Icon name="mark_email_unread" size={22} />
|
||||
if (group.kind === 'invitations') return <Icon name="mark_email_unread" size={22} />
|
||||
if (group.iconUrl) return <img className="rail-icon" src={media(group.iconUrl)} alt="" draggable={false} />
|
||||
if (group.kind === 'dms') return <Icon name="forum" size={22} />
|
||||
if (group.kind === 'account' && service.mark && service.colour) {
|
||||
@@ -284,6 +288,15 @@ function RailTile(props: TileProps): JSX.Element {
|
||||
} as const
|
||||
]
|
||||
: []),
|
||||
...(props.onSettings
|
||||
? [
|
||||
{
|
||||
label: 'Group settings',
|
||||
icon: 'settings',
|
||||
onClick: props.onSettings
|
||||
} as const
|
||||
]
|
||||
: []),
|
||||
// Only where there is something to leave. An account's own entry
|
||||
// stands for the connection itself, which is disconnected from
|
||||
// the accounts pane rather than left.
|
||||
@@ -369,7 +382,7 @@ export function ServerRail(): JSX.Element | null {
|
||||
// the selector's result by identity, so returning a new object each render
|
||||
// reports a change every time and loops until React gives up.
|
||||
const groups = useChat((s) => s.groups)
|
||||
const matrixInvites = useChat((s) => s.matrixInvites)
|
||||
const invitations = useInvitations()
|
||||
const activeGroupId = useChat((s) => s.activeGroupId)
|
||||
const activeBufferId = useChat((s) => s.activeBufferId)
|
||||
const buffers = useChat((s) => s.buffers)
|
||||
@@ -404,9 +417,12 @@ export function ServerRail(): JSX.Element | null {
|
||||
// The tile a drop would merge into, as opposed to land beside.
|
||||
const [merging, setMerging] = useState('')
|
||||
/** The rail entry a leave has been asked about, pending confirmation. */
|
||||
const [leaving, setLeaving] = useState<RailGroup | null>(null)
|
||||
const leaving = useChat((s) => s.leaveRequest)
|
||||
const setLeaving = (g: { id: string; name: string; kind: string } | null): void => store.requestLeave(g)
|
||||
/** The space whose contents are being looked through, if any. */
|
||||
const [browsing, setBrowsing] = useState<RailGroup | null>(null)
|
||||
/** The Steam chat group whose settings are open, if any. */
|
||||
const [configuring, setConfiguring] = useState<RailGroup | null>(null)
|
||||
/** Hovering the space below the column, which means "put it last". */
|
||||
const [tailTarget, setTailTarget] = useState(false)
|
||||
|
||||
@@ -457,11 +473,9 @@ export function ServerRail(): JSX.Element | null {
|
||||
// inbox in the header, which is where the unread count already is, and a
|
||||
// permanent tile for it only competed with the servers this column is for.
|
||||
if (pinned.length > 0) extras.push(pinnedGroup())
|
||||
// An invitation arrives as its own tile and leaves when it is answered -
|
||||
// it behaves like a new conversation, not like a mark on an account.
|
||||
for (const [accountId, list] of Object.entries(matrixInvites)) {
|
||||
for (const invite of list) extras.push(inviteGroup(accountId, invite))
|
||||
}
|
||||
// Invitations from every service share one tile, which is there while
|
||||
// anything waits on an answer and gone once nothing does.
|
||||
if (invitations.length > 0) extras.push(invitationsGroup(invitations.length))
|
||||
const ordered = orderedGroups([...shown, ...extras], railOrder)
|
||||
|
||||
// The rail always draws, even with nothing in it.
|
||||
@@ -561,6 +575,7 @@ export function ServerRail(): JSX.Element | null {
|
||||
g.kind === 'guild' || g.kind === 'space' ? () => setLeaving(g) : undefined
|
||||
}
|
||||
onBrowse={g.kind === 'space' ? () => setBrowsing(g) : undefined}
|
||||
onSettings={steamGroupOfRail(g.id) ? () => setConfiguring(g) : undefined}
|
||||
unread={t?.unread ?? 0}
|
||||
highlight={t?.highlight ?? false}
|
||||
mentions={t?.mentions ?? 0}
|
||||
@@ -734,6 +749,7 @@ export function ServerRail(): JSX.Element | null {
|
||||
/>
|
||||
)}
|
||||
|
||||
{configuring && <SteamGroupSettings accountId={configuring.accountId} railId={configuring.id} onClose={() => setConfiguring(null)} />}
|
||||
{browsing && (
|
||||
<SpaceRooms
|
||||
accountId={browsing.accountId}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { useStore } from '../state/hooks'
|
||||
import { formatFullTime } from '../lib/util'
|
||||
import type { Account } from '../../../shared/wire'
|
||||
|
||||
/** What Steam says of the account, as the daemon sends it. */
|
||||
interface Facts {
|
||||
limited?: boolean
|
||||
familyView?: { enabled: boolean; holdsFriendsBack: boolean } | null
|
||||
textFilter?: { on: boolean; setting: string; ignoreFriends: boolean; words: number } | null
|
||||
privacy?: { profile?: string; inventory?: string; games?: string; playtime?: string; friends?: string } | null
|
||||
}
|
||||
|
||||
interface Session {
|
||||
tokenId: string
|
||||
description: string
|
||||
platform: number
|
||||
lastSeen: number
|
||||
place: string
|
||||
current: boolean
|
||||
}
|
||||
|
||||
/** Which kind of place a session is, in the words Steam's own list uses. */
|
||||
function platformLabel(platform: number): string {
|
||||
return platform === 3 ? 'Mobile app' : platform === 2 ? 'Web browser' : platform === 1 ? 'Steam client' : 'Signed in'
|
||||
}
|
||||
|
||||
function privacyWord(state?: string | null): string {
|
||||
return state === 'public' ? 'public' : state === 'friends' ? 'friends only' : state === 'private' ? 'private' : 'not said'
|
||||
}
|
||||
|
||||
/**
|
||||
* What the account is, and where it is signed in.
|
||||
*
|
||||
* Steam holds things back from an account without saying so where it matters: a
|
||||
* limited account cannot write to everybody, Family View can hold the friends
|
||||
* list back. Said here in words, with the privacy the account has set, the text
|
||||
* filter it uses, and the places it is signed in - any of which, other than the
|
||||
* one in use, can be signed out.
|
||||
*/
|
||||
export function SteamAccountTools({ account }: { account: Account }): JSX.Element {
|
||||
const store = useStore()
|
||||
const [facts, setFacts] = useState<Facts | null>(null)
|
||||
const [sessions, setSessions] = useState<Session[] | null>(null)
|
||||
const [confirming, setConfirming] = useState('')
|
||||
const connected = account.state === 'connected'
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
void window.moho
|
||||
.rpc<Facts>('steamAccountFacts', { accountId: account.id })
|
||||
.then((f) => live && setFacts(f))
|
||||
.catch(() => {})
|
||||
const off = window.moho.onEvent((e) => {
|
||||
if (e.event !== 'steamAccountFacts') return
|
||||
const d = e.data as { accountId?: string; facts?: Facts }
|
||||
if (d.accountId === account.id && d.facts) setFacts(d.facts)
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
off()
|
||||
}
|
||||
}, [account.id, connected])
|
||||
|
||||
const loadSessions = (): void => {
|
||||
void window.moho
|
||||
.rpc<Session[]>('steamSessions', { accountId: account.id })
|
||||
.then(setSessions)
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
|
||||
const signOut = (s: Session): void => {
|
||||
setConfirming('')
|
||||
void window.moho
|
||||
.rpc<Session[]>('steamRevokeSession', { accountId: account.id, tokenId: s.tokenId })
|
||||
.then((list) => {
|
||||
setSessions(list)
|
||||
store.toast('info', `Signed out ${s.description}`)
|
||||
})
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
|
||||
const notes: string[] = []
|
||||
if (facts?.limited) {
|
||||
notes.push('This is a limited account: Steam stops it from messaging anyone who is not a friend, and from sending files or many links, until it has spent enough on Steam.')
|
||||
}
|
||||
if (facts?.familyView?.enabled) {
|
||||
notes.push(
|
||||
facts.familyView.holdsFriendsBack
|
||||
? 'Family View is on and holds friends and chat back. Unlock it in Steam to chat from here.'
|
||||
: 'Family View is on. It does not hold friends and chat back.'
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sasl-block">
|
||||
<span className="small muted">What Steam says about this account</span>
|
||||
{!facts && <p className="small muted">{connected ? 'Asking Steam…' : 'Connect the account to read this.'}</p>}
|
||||
{notes.map((n) => (
|
||||
<div key={n} className="warning-note">
|
||||
<Icon name="info" size={16} />
|
||||
<span>{n}</span>
|
||||
</div>
|
||||
))}
|
||||
{facts && !facts.limited && !facts.familyView?.enabled && <p className="small muted">Not limited, and Family View is off.</p>}
|
||||
{facts?.privacy && (
|
||||
<p className="small muted">
|
||||
Privacy: profile {privacyWord(facts.privacy.profile)}, games {privacyWord(facts.privacy.games)}, friends list{' '}
|
||||
{privacyWord(facts.privacy.friends)}, inventory {privacyWord(facts.privacy.inventory)}. Changed on Steam, not here.
|
||||
</p>
|
||||
)}
|
||||
{facts?.textFilter && (
|
||||
<p className="small muted">
|
||||
Text filter: {facts.textFilter.setting}
|
||||
{facts.textFilter.on
|
||||
? `. ${facts.textFilter.words > 0 ? `The ${facts.textFilter.words} words you added are hidden` : 'No words of your own are added'}${facts.textFilter.ignoreFriends ? ', for people who are not friends' : ''}. Steam's own list of what to hide is not applied here.`
|
||||
: '.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<span className="small muted">Where this account is signed in</span>
|
||||
{sessions === null ? (
|
||||
<div className="field-row">
|
||||
<button type="button" className="button subtle small" disabled={!connected} onClick={loadSessions}>
|
||||
Show where it is signed in
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((s) => (
|
||||
<div key={s.tokenId} className="field-row">
|
||||
<span className="grow">
|
||||
{s.description}
|
||||
<span className="small muted">
|
||||
{' '}
|
||||
· {platformLabel(s.platform)}
|
||||
{s.place ? ` · ${s.place}` : ''}
|
||||
{s.lastSeen ? ` · ${formatFullTime(s.lastSeen)}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
{s.current ? (
|
||||
<span className="small muted">this one</span>
|
||||
) : confirming === s.tokenId ? (
|
||||
<>
|
||||
<button type="button" className="button danger small" onClick={() => signOut(s)}>
|
||||
Sign it out
|
||||
</button>
|
||||
<button type="button" className="button subtle small" onClick={() => setConfirming('')}>
|
||||
Keep
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className="button subtle small" onClick={() => setConfirming(s.tokenId)}>
|
||||
Sign out
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Modal } from './Modal'
|
||||
import { ReasonPrompt } from './ReasonPrompt'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import { bufferDisplayName } from '../lib/util'
|
||||
|
||||
/**
|
||||
* The questions about a Steam friend that want an answer before anything is
|
||||
* done: what to call them, and the two that cannot be quietly undone.
|
||||
*
|
||||
* One place, mounted once, because the menus that ask them are in two places -
|
||||
* a conversation's row and its header - and a dialog belongs to neither.
|
||||
*/
|
||||
export function SteamFriendDialogs(): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const dialog = useChat((s) => s.steamDialog)
|
||||
const buffer = useChat((s) => s.buffers.find((b) => b.id === dialog?.bufferId))
|
||||
if (!dialog || !buffer) return null
|
||||
const name = bufferDisplayName(buffer.name)
|
||||
|
||||
if (dialog.what === 'nickname') {
|
||||
return (
|
||||
<ReasonPrompt
|
||||
title={`Nickname for ${name}`}
|
||||
detail="What you call them. It is kept by Steam for your account, so it is the name on your other devices too. Leave it empty to go back to the name they chose."
|
||||
placeholder="A name for them"
|
||||
confirmLabel="Save"
|
||||
optional
|
||||
onCancel={() => store.closeSteamDialog()}
|
||||
onConfirm={(nickname) => void store.doSteamFriend('nickname', dialog.bufferId, nickname)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const removing = dialog.what === 'remove'
|
||||
return (
|
||||
<Modal title={removing ? `Remove ${name}?` : `Block ${name}?`} icon="warning" iconColor="var(--warning)" onClose={() => store.closeSteamDialog()}>
|
||||
<p className="small">
|
||||
{removing
|
||||
? `${name} will no longer be on your friends list, and you will no longer see when they are online. They can ask again.`
|
||||
: `${name} will be removed from your friends and will not be able to message you or ask to be your friend again, until you unblock them in Steam.`}
|
||||
</p>
|
||||
<div className="leave-confirm-actions">
|
||||
<button type="button" className="button subtle" autoFocus onClick={() => store.closeSteamDialog()}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="button danger" onClick={() => void store.doSteamFriend(dialog.what, dialog.bufferId)}>
|
||||
{removing ? 'Remove friend' : 'Block'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Modal } from './Modal'
|
||||
import { Icon } from './Icon'
|
||||
import { Switch, SwitchRow } from './Switch'
|
||||
import { ConfirmButton } from './ConfirmButton'
|
||||
import { useStore } from '../state/hooks'
|
||||
import { STEAM_KICK_LENGTHS, STEAM_NOTIFY_LEVELS, steamGroupOfRail, type SteamChatGroup } from '../lib/steamchat'
|
||||
|
||||
/**
|
||||
* Asks the daemon; or, in a window the screenshot harness started, takes the
|
||||
* answer it left in `__rpcFixture`, since the harness has no Steam to ask.
|
||||
*/
|
||||
function ask<T>(method: string, params: Record<string, unknown>): Promise<T> {
|
||||
const w = window as unknown as { __mohoShots?: unknown; __rpcFixture?: Record<string, (p: Record<string, unknown>) => unknown> }
|
||||
const stub = w.__mohoShots ? w.__rpcFixture?.[method] : undefined
|
||||
return stub ? Promise.resolve(stub(params) as T) : window.moho.rpc<T>(method, params)
|
||||
}
|
||||
|
||||
type Tab = 'general' | 'rooms' | 'people' | 'roles' | 'invites'
|
||||
|
||||
interface Link {
|
||||
code: string
|
||||
link: string
|
||||
expires?: number | null
|
||||
}
|
||||
|
||||
/** What a role may do, by the names the daemon gives each permission. */
|
||||
type Permissions = Record<string, boolean>
|
||||
|
||||
const LINK_LIFETIMES: { label: string; seconds: number }[] = [
|
||||
{ label: 'An hour', seconds: 3600 },
|
||||
{ label: 'A day', seconds: 86400 },
|
||||
{ label: 'A week', seconds: 604800 }
|
||||
]
|
||||
|
||||
/**
|
||||
* Everything about one Steam chat group that the group itself keeps: its name
|
||||
* and tagline, its rooms, who is in it and what they may do, and the links
|
||||
* that let people in.
|
||||
*
|
||||
* Read from the daemon each time it is opened and after every change, so what
|
||||
* is shown is what Steam holds rather than what this thought it asked for.
|
||||
* What the account may not change is shown but not offered; Steam checks again
|
||||
* and refuses, which is said in its own words.
|
||||
*/
|
||||
export function SteamGroupSettings({
|
||||
accountId,
|
||||
railId,
|
||||
onClose
|
||||
}: {
|
||||
accountId: string
|
||||
railId: string
|
||||
onClose: () => void
|
||||
}): JSX.Element {
|
||||
const store = useStore()
|
||||
const groupId = steamGroupOfRail(railId) ?? ''
|
||||
const [group, setGroup] = useState<SteamChatGroup | null>(null)
|
||||
const [tab, setTab] = useState<Tab>('general')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const all = await ask<SteamChatGroup[]>('steamChatGroups', { accountId, groupId })
|
||||
setGroup(all[0] ?? null)
|
||||
}, [accountId, groupId])
|
||||
|
||||
useEffect(() => {
|
||||
void reload().catch((e: Error) => store.toast('error', e.message))
|
||||
}, [reload, store])
|
||||
|
||||
/** One change, then the group read again; the error is Steam's own. */
|
||||
const act = useCallback(
|
||||
async <T,>(action: string, extra: Record<string, unknown> = {}): Promise<T | undefined> => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const done = await ask<T>('steamChatGroupAction', { accountId, groupId, action, ...extra })
|
||||
await reload()
|
||||
return done
|
||||
} catch (e) {
|
||||
store.toast('error', (e as Error).message)
|
||||
return undefined
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
},
|
||||
[accountId, groupId, reload, store]
|
||||
)
|
||||
|
||||
if (!group) {
|
||||
return (
|
||||
<Modal title="Group" icon="groups" onClose={onClose} className="steam-group">
|
||||
<p className="small muted">
|
||||
<span className="spinner" /> Asking Steam…
|
||||
</p>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const tabs: [Tab, string][] = [
|
||||
['general', 'General'],
|
||||
['rooms', 'Rooms'],
|
||||
['people', 'People'],
|
||||
['roles', 'Roles'],
|
||||
['invites', 'Invites']
|
||||
]
|
||||
|
||||
return (
|
||||
<Modal title={group.name || 'Group'} icon="groups" onClose={onClose} className="steam-group">
|
||||
<div className="room-settings-tabs" role="tablist">
|
||||
{tabs.map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === id}
|
||||
className={`room-settings-tab${tab === id ? ' active' : ''}`}
|
||||
onClick={() => setTab(id)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="room-settings-body steam-group-body">
|
||||
{tab === 'general' && <General group={group} busy={busy} act={act} onLeft={onClose} />}
|
||||
{tab === 'rooms' && <Rooms group={group} busy={busy} act={act} />}
|
||||
{tab === 'people' && <People group={group} busy={busy} act={act} />}
|
||||
{tab === 'roles' && <Roles group={group} busy={busy} act={act} />}
|
||||
{tab === 'invites' && <Invites group={group} act={act} />}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
type Act = <T>(action: string, extra?: Record<string, unknown>) => Promise<T | undefined>
|
||||
|
||||
function General({ group, busy, act, onLeft }: { group: SteamChatGroup; busy: boolean; act: Act; onLeft: () => void }): JSX.Element {
|
||||
const store = useStore()
|
||||
const [name, setName] = useState(group.name)
|
||||
const [tagline, setTagline] = useState(group.tagline)
|
||||
const mayChange = group.mayManage
|
||||
return (
|
||||
<>
|
||||
<div className="field">
|
||||
<span className="small muted">Picture</span>
|
||||
<div className="field-row steam-group-row">
|
||||
<span className="grow small muted">{group.mayManage ? 'Shown on the rail and in the channel list.' : 'Only those who may change the group can change it.'}</span>
|
||||
{/* Sent to Steam's site and then named to the group; the new picture
|
||||
arrives on the rail when Steam says the group changed. */}
|
||||
<button
|
||||
type="button"
|
||||
className="button subtle small"
|
||||
disabled={!group.mayManage || busy}
|
||||
onClick={() => {
|
||||
void window.moho.pickFile(['jpg', 'jpeg', 'png', 'gif']).then((path) => path && void act('avatar', { path }))
|
||||
}}
|
||||
>
|
||||
Change picture
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="small muted">Name</span>
|
||||
<div className="field-row">
|
||||
<input className="text-field" value={name} disabled={!mayChange || busy} onChange={(e) => setName(e.target.value)} />
|
||||
<button type="button" className="button" disabled={!mayChange || busy || !name.trim() || name === group.name} onClick={() => void act('rename', { name })}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="small muted">Tagline</span>
|
||||
<div className="field-row">
|
||||
<input className="text-field" value={tagline} disabled={!mayChange || busy} onChange={(e) => setTagline(e.target.value)} />
|
||||
<button type="button" className="button" disabled={!mayChange || busy || tagline === group.tagline} onClick={() => void act('tagline', { text: tagline })}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!mayChange && <p className="small muted">Only {group.myRankWord === 'Member' ? 'its officers' : 'someone with more say'} can change these.</p>}
|
||||
<div className="field">
|
||||
<span className="small muted">Tell me about</span>
|
||||
<div className="segmented" role="radiogroup" aria-label="Notifications">
|
||||
{STEAM_NOTIFY_LEVELS.map((l) => (
|
||||
<button key={l.value} type="button" role="radio" aria-checked={group.notify === l.value} className={group.notify === l.value ? 'active' : ''} disabled={busy} onClick={() => void act('notify', { level: l.value })}>
|
||||
{l.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Separate from the level, as Steam keeps them: being told of everything shows the marker again. */}
|
||||
{group.notify !== 4 && <SwitchRow title="Show unread messages" description="The marker on the group when something was written in it" checked={!group.muted} disabled={busy} onChange={(show) => void act('notify', { muted: !show })} />}
|
||||
<p className="small muted">Kept by Steam for your account, so it is the same on your other devices.</p>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="small muted">You are</span>
|
||||
<span>{group.myRankWord}</span>
|
||||
</div>
|
||||
<div className="leave-confirm-actions">
|
||||
<ConfirmButton
|
||||
label="Leave group"
|
||||
question={`Leave ${group.name}?`}
|
||||
confirmLabel="Leave"
|
||||
danger
|
||||
onConfirm={() => {
|
||||
// The window's own leave: it takes the rail entry away with it.
|
||||
void store.leaveGroup(group.railId).then((left) => left && onLeft())
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Rooms({ group, busy, act }: { group: SteamChatGroup; busy: boolean; act: Act }): JSX.Element {
|
||||
const [fresh, setFresh] = useState('')
|
||||
const [withVoice, setWithVoice] = useState(false)
|
||||
const [editing, setEditing] = useState<{ id: string; name: string } | null>(null)
|
||||
return (
|
||||
<>
|
||||
{group.rooms.map((r) => (
|
||||
<div key={r.id} className="field-row steam-group-row">
|
||||
{editing?.id === r.id ? (
|
||||
<>
|
||||
<input className="text-field grow" autoFocus value={editing.name} onChange={(e) => setEditing({ id: r.id, name: e.target.value })} />
|
||||
<button
|
||||
type="button"
|
||||
className="button small"
|
||||
disabled={busy}
|
||||
onClick={() => void act('renameRoom', { roomId: r.id, name: editing.name }).then(() => setEditing(null))}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button type="button" className="button subtle small" onClick={() => setEditing(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name={r.voice ? 'volume_up' : 'tag'} size={16} />
|
||||
<span className="grow ellipsis">{r.name || 'General'}</span>
|
||||
{r.voice && <span className="small muted">voice</span>}
|
||||
{group.mayManage && (
|
||||
<>
|
||||
<button type="button" className="button subtle small" onClick={() => setEditing({ id: r.id, name: r.name })}>
|
||||
Rename
|
||||
</button>
|
||||
{group.rooms.length > 1 && (
|
||||
<ConfirmButton label="Delete" question={`Delete ${r.name || 'General'} and what was said in it?`} confirmLabel="Delete" danger onConfirm={() => void act('deleteRoom', { roomId: r.id })} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{group.mayManage && (
|
||||
<>
|
||||
<div className="field-row">
|
||||
<input className="text-field" placeholder="A new room" value={fresh} onChange={(e) => setFresh(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && fresh.trim() && void act('createRoom', { name: fresh, voice: withVoice }).then(() => setFresh(''))} />
|
||||
<button type="button" className="button" disabled={busy || !fresh.trim()} onClick={() => void act('createRoom', { name: fresh, voice: withVoice }).then(() => setFresh(''))}>
|
||||
Add room
|
||||
</button>
|
||||
</div>
|
||||
<SwitchRow title="With voice" description="People in it can talk, as well as write; it is chosen when the room is made" checked={withVoice} onChange={setWithVoice} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function People({ group, busy, act }: { group: SteamChatGroup; busy: boolean; act: Act }): JSX.Element {
|
||||
const store = useStore()
|
||||
const [bans, setBans] = useState<{ steamid: string; name: string }[] | null>(null)
|
||||
const [kickFor, setKickFor] = useState(3600)
|
||||
const roleName = (id: string): string => group.roles.find((r) => r.id === id)?.name ?? id
|
||||
const loadBans = (): void => {
|
||||
void act<{ bans: { steamid: string; name: string }[] }>('bans').then((r) => r && setBans(r.bans))
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{group.members.map((m) => {
|
||||
const own = m.steamid === group.owner
|
||||
return (
|
||||
<div key={m.steamid} className="steam-group-person">
|
||||
<div className="field-row steam-group-row">
|
||||
<span className="grow ellipsis" title={m.steamid}>
|
||||
{m.name}
|
||||
</span>
|
||||
<span className="small muted">{m.rankWord}</span>
|
||||
{group.mayModerate && !own && (
|
||||
<>
|
||||
<button type="button" className="button subtle small" disabled={busy} onClick={() => void act('mute', { steamid: m.steamid, seconds: 600 }).then(() => store.toast('info', `Muted ${m.name} for 10 minutes`))}>
|
||||
Mute 10 min
|
||||
</button>
|
||||
<select className="text-field small" value={kickFor} aria-label="How long a removal lasts" onChange={(e) => setKickFor(Number(e.target.value))}>
|
||||
{STEAM_KICK_LENGTHS.map((l) => (
|
||||
<option key={l.seconds} value={l.seconds}>
|
||||
{l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ConfirmButton label="Remove" question={`Remove ${m.name} for ${STEAM_KICK_LENGTHS.find((l) => l.seconds === kickFor)?.label.toLowerCase()}?`} confirmLabel="Remove" danger onConfirm={() => void act('kick', { steamid: m.steamid, seconds: kickFor })} />
|
||||
<ConfirmButton label="Ban" question={`Ban ${m.name}?`} confirmLabel="Ban" danger onConfirm={() => void act('ban', { steamid: m.steamid, banned: true }).then(() => store.toast('info', `Banned ${m.name}`))} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{m.roles.length > 0 && (
|
||||
<div className="profile-roles">
|
||||
{m.roles.map((r) => (
|
||||
<span key={r} className="profile-role small">
|
||||
{roleName(r)}
|
||||
{group.mayAssignRoles && !own && r !== group.defaultRole && (
|
||||
<button type="button" className="steam-group-x" aria-label={`Take ${roleName(r)} from ${m.name}`} onClick={() => void act('takeRole', { steamid: m.steamid, roleId: r })}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{group.mayAssignRoles && group.roles.some((r) => r.id !== group.defaultRole && !m.roles.includes(r.id)) && (
|
||||
<select
|
||||
className="text-field small"
|
||||
value=""
|
||||
aria-label={`Give ${m.name} a role`}
|
||||
onChange={(e) => e.target.value && void act('giveRole', { steamid: m.steamid, roleId: e.target.value })}
|
||||
>
|
||||
<option value="">Give a role…</option>
|
||||
{group.roles
|
||||
.filter((r) => r.id !== group.defaultRole && !m.roles.includes(r.id))
|
||||
.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{group.mayModerate && (
|
||||
<>
|
||||
<span className="small muted">Banned</span>
|
||||
{bans === null ? (
|
||||
<button type="button" className="button subtle small" onClick={loadBans}>
|
||||
Show who is banned
|
||||
</button>
|
||||
) : bans.length === 0 ? (
|
||||
<p className="small muted">Nobody.</p>
|
||||
) : (
|
||||
bans.map((b) => (
|
||||
<div key={b.steamid} className="field-row steam-group-row">
|
||||
<span className="grow ellipsis">{b.name}</span>
|
||||
<button type="button" className="button subtle small" onClick={() => void act('ban', { steamid: b.steamid, banned: false }).then(loadBans)}>
|
||||
Unban
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Roles({ group, busy, act }: { group: SteamChatGroup; busy: boolean; act: Act }): JSX.Element {
|
||||
const [fresh, setFresh] = useState('')
|
||||
const [open, setOpen] = useState<string | null>(null)
|
||||
const [perms, setPerms] = useState<Permissions | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const expand = (id: string, current: string): void => {
|
||||
if (open === id) {
|
||||
setOpen(null)
|
||||
return
|
||||
}
|
||||
setOpen(id)
|
||||
setPerms(null)
|
||||
setName(current)
|
||||
void act<Permissions>('roleActions', { roleId: id }).then((p) => p && setPerms(p))
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{group.roles.map((r) => (
|
||||
<div key={r.id} className="steam-group-person">
|
||||
<div className="field-row steam-group-row">
|
||||
<Icon name="shield" size={16} />
|
||||
<span className="grow ellipsis">{r.name}</span>
|
||||
<button type="button" className="button subtle small" onClick={() => expand(r.id, r.name)}>
|
||||
{open === r.id ? 'Close' : 'Edit'}
|
||||
</button>
|
||||
</div>
|
||||
{open === r.id && (
|
||||
<div className="steam-group-perms">
|
||||
{group.mayChangeRoles && r.id !== group.defaultRole && (
|
||||
<div className="field-row">
|
||||
<input className="text-field" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<button type="button" className="button small" disabled={busy || !name.trim() || name === r.name} onClick={() => void act('renameRole', { roleId: r.id, name })}>
|
||||
Rename
|
||||
</button>
|
||||
<ConfirmButton label="Delete" question={`Delete ${r.name}?`} confirmLabel="Delete" danger onConfirm={() => void act('deleteRole', { roleId: r.id }).then(() => setOpen(null))} />
|
||||
</div>
|
||||
)}
|
||||
{perms === null ? (
|
||||
<p className="small muted">
|
||||
<span className="spinner" /> Asking Steam…
|
||||
</p>
|
||||
) : (
|
||||
group.permissions.map((p) => (
|
||||
<div key={p.key} className="switch-row">
|
||||
<span className="switch-text">
|
||||
<span>{p.label}</span>
|
||||
</span>
|
||||
<Switch
|
||||
checked={!!perms[p.key]}
|
||||
label={p.label}
|
||||
disabled={!group.mayChangeRoles || busy}
|
||||
onChange={(next) => {
|
||||
const updated = { ...perms, [p.key]: next }
|
||||
setPerms(updated)
|
||||
void act('setRoleActions', { roleId: r.id, actions: updated })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{group.mayChangeRoles && (
|
||||
<div className="field-row">
|
||||
<input className="text-field" placeholder="A new role" value={fresh} onChange={(e) => setFresh(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && fresh.trim() && void act('createRole', { name: fresh }).then(() => setFresh(''))} />
|
||||
<button type="button" className="button" disabled={busy || !fresh.trim()} onClick={() => void act('createRole', { name: fresh }).then(() => setFresh(''))}>
|
||||
Add role
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Invites({ group, act }: { group: SteamChatGroup; act: Act }): JSX.Element {
|
||||
const store = useStore()
|
||||
const [links, setLinks] = useState<Link[] | null>(null)
|
||||
const [seconds, setSeconds] = useState(86400)
|
||||
const load = useCallback(() => {
|
||||
void act<{ links: Link[] }>('links').then((r) => r && setLinks(r.links))
|
||||
// act reads the group again, which is not needed here but is harmless.
|
||||
}, [act])
|
||||
useEffect(() => {
|
||||
load()
|
||||
// Once per opening of the tab.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
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'))
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{links === null && (
|
||||
<p className="small muted">
|
||||
<span className="spinner" /> Asking Steam…
|
||||
</p>
|
||||
)}
|
||||
{links?.length === 0 && <p className="small muted">No links. Make one to let people in.</p>}
|
||||
{links?.map((l) => (
|
||||
<div key={l.code} className="field-row steam-group-row">
|
||||
<span className="grow ellipsis" title={l.link}>
|
||||
{l.link}
|
||||
</span>
|
||||
{l.expires ? <span className="small muted">until {new Date(l.expires * 1000).toLocaleString()}</span> : null}
|
||||
<button type="button" className="button subtle small" onClick={() => copy(l.link)}>
|
||||
Copy
|
||||
</button>
|
||||
{group.mayInvite && (
|
||||
<button type="button" className="button subtle small" onClick={() => void act('revokeInvite', { code: l.code }).then(load)}>
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{group.mayInvite && (
|
||||
<div className="field-row">
|
||||
<select className="text-field" value={seconds} aria-label="How long a link lasts" onChange={(e) => setSeconds(Number(e.target.value))}>
|
||||
{LINK_LIFETIMES.map((l) => (
|
||||
<option key={l.seconds} value={l.seconds}>
|
||||
{l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="button" onClick={() => void act('makeInvite', { seconds }).then(load)}>
|
||||
Make a link
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Avatar } from './Avatar'
|
||||
import { Icon, IconButton } from './Icon'
|
||||
import { useChat, useIdSetPref, useStore } from '../state/hooks'
|
||||
import { classes } from '../lib/util'
|
||||
import { steamGroupOfRail } from '../lib/steamchat'
|
||||
import type { RailGroup } from '../lib/groups'
|
||||
|
||||
/**
|
||||
* The rooms of a Steam group that have voice, listed under its text ones the
|
||||
* way a Discord server's voice channels are, with who is in each shown before
|
||||
* you join: that is what anyone decides on.
|
||||
*
|
||||
* Steam keeps who is in a room's voice chat with the room, so the list is what
|
||||
* the daemon last said, and joining is this window's: see lib/steamvoice.ts.
|
||||
*/
|
||||
export function SteamVoiceRooms({ group }: { group: RailGroup }): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const groupId = steamGroupOfRail(group.id)
|
||||
const rooms = useChat((s) => (groupId ? s.steamVoiceRooms[`${group.accountId}|${groupId}`] : undefined)) ?? []
|
||||
const now = useChat((s) => s.steamVoiceNow)
|
||||
const [, toggleFolded, isFolded] = useIdSetPref('collapsedCategories')
|
||||
const foldKey = `steamvoice|${group.id}`
|
||||
const here = (roomId: string): boolean => now?.target.kind === 'room' && now.accountId === group.accountId && now.target.groupId === groupId && now.target.roomId === roomId
|
||||
const inThisGroup = rooms.some((r) => here(r.roomId))
|
||||
const live = useLive(inThisGroup)
|
||||
|
||||
useEffect(() => {
|
||||
if (groupId) void store.refreshSteamVoiceRooms(group.accountId, groupId)
|
||||
}, [store, group.accountId, groupId])
|
||||
|
||||
if (!groupId || rooms.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="bufferlist-section category-head"
|
||||
onClick={() => toggleFolded(foldKey)}
|
||||
title={isFolded(foldKey) ? 'Show voice rooms' : 'Hide voice rooms'}
|
||||
>
|
||||
<Icon name="expand_more" size={14} className={classes('fold-chevron', isFolded(foldKey) && 'folded')} />
|
||||
<span>Voice</span>
|
||||
</button>
|
||||
|
||||
{rooms
|
||||
.filter((r) => !isFolded(foldKey) || here(r.roomId))
|
||||
.map((r) => {
|
||||
const connected = here(r.roomId)
|
||||
return (
|
||||
<div key={r.roomId} className={`voice-channel${connected ? ' active' : ''}`}>
|
||||
<div className="voice-channel-row">
|
||||
<button
|
||||
type="button"
|
||||
className="voice-channel-main"
|
||||
title={connected ? `Connected to ${r.name}` : `Join ${r.name}`}
|
||||
onClick={() => (connected ? void store.leaveVoice(group.accountId) : void store.joinSteamVoice(group.accountId, groupId, r.roomId))}
|
||||
>
|
||||
<Icon name="volume_up" size={16} />
|
||||
<span className="ellipsis">{r.name}</span>
|
||||
{connected && now?.phase !== 'connected' && <span className="spinner" />}
|
||||
</button>
|
||||
{connected && <IconButton name="call_end" size={16} title="Disconnect" onClick={() => void store.leaveVoice(group.accountId)} />}
|
||||
</div>
|
||||
{r.members.map((m) => {
|
||||
const status = live.statuses.get(m.userId)
|
||||
return (
|
||||
<div key={m.userId} className={classes('voice-member small', live.speakers.has(m.userId) && 'speaking')}>
|
||||
<span className="voice-member-face">
|
||||
<Avatar name={m.nick} url={m.avatarUrl ?? undefined} size={20} accountId={group.accountId} />
|
||||
</span>
|
||||
<span className="ellipsis">{m.nick}</span>
|
||||
{m.isSelf && <span className="muted voice-you">you</span>}
|
||||
{status?.outputMuted ? (
|
||||
<span className="voice-state" title={`${m.nick} has deafened themselves`}>
|
||||
<Icon name="headset_off" size={13} />
|
||||
</span>
|
||||
) : (
|
||||
(status?.micMuted || status?.noMic) && (
|
||||
<span className="voice-state" title={status?.noMic ? `${m.nick} has no microphone` : `${m.nick} is muted`}>
|
||||
<Icon name="mic_off" size={13} />
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Who is audible and how each person says they are, read from the call this window holds. */
|
||||
function useLive(inCall: boolean): { speakers: Set<string>; statuses: Map<string, { micMuted: boolean; outputMuted: boolean; noMic: boolean }> } {
|
||||
const store = useStore()
|
||||
const [, tick] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!inCall) return
|
||||
// Often enough that the ring follows speech; not while nobody can see it.
|
||||
const timer = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') tick((n) => n + 1)
|
||||
}, 200)
|
||||
return () => clearInterval(timer)
|
||||
}, [inCall])
|
||||
const v = store.steamVoiceLive
|
||||
return { speakers: inCall ? v.speakers : new Set(), statuses: inCall ? v.statuses : new Map() }
|
||||
}
|
||||
@@ -62,7 +62,7 @@ export function UserFooter({ account }: { account?: Account }): JSX.Element {
|
||||
// Mute and deafen are the whole client's, for whatever call is up - so
|
||||
// shown on a service that has calls, or anywhere while one is running,
|
||||
// and not beside an IRC or Kick account with nothing to apply them to.
|
||||
const showVoice = account.service === 'discord' || account.service === 'matrix' || inACall
|
||||
const showVoice = account.service === 'discord' || account.service === 'matrix' || account.service === 'steam' || inACall
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -16,7 +16,8 @@ import { useChat, useStore } from '../state/hooks'
|
||||
*/
|
||||
export function VerificationDialog(): JSX.Element | null {
|
||||
const active = useChat((s) => s.matrixVerification)
|
||||
if (!active) return null
|
||||
// Waiting on an answer, it is on the Invitations tile: this is for the check itself.
|
||||
if (!active || active.incoming) return null
|
||||
|
||||
// Keyed on the verification so its QR code is fetched afresh for a new one,
|
||||
// rather than a picture of the last session being shown against this one.
|
||||
|
||||
@@ -42,6 +42,12 @@ export function VoicePanel(): JSX.Element | null {
|
||||
setRtt(staged.rttMs)
|
||||
return
|
||||
}
|
||||
// A Steam call is held by this window, which is where its level is.
|
||||
if (session.accountId.startsWith('steam:')) {
|
||||
setLevel(store.steamVoiceLive.level)
|
||||
setRtt(store.steamVoiceLive.rttMs)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const levels = await window.moho.rpc<{ accountId: string; micPeak: number; rttMs?: number | null }[]>(
|
||||
'getVoiceLevels'
|
||||
@@ -60,13 +66,13 @@ export function VoicePanel(): JSX.Element | null {
|
||||
if (document.visibilityState === 'visible') void tick()
|
||||
}, 150)
|
||||
return () => clearInterval(timer)
|
||||
}, [session])
|
||||
}, [session, store])
|
||||
|
||||
if (!session) return null
|
||||
|
||||
// A one-to-one call has no guild to go back to; its home is the
|
||||
// conversation itself, which the DM page holds.
|
||||
const groupId = session.guildId ? `${session.accountId}|guild:${session.guildId}` : DM_GROUP_ID
|
||||
const groupId = session.railId ?? (session.guildId ? `${session.accountId}|guild:${session.guildId}` : DM_GROUP_ID)
|
||||
const guildName = session.isDirect ? '' : (groups.find((g) => g.id === groupId)?.name ?? '')
|
||||
const fill = levelFill(level)
|
||||
const quality = rtt === null ? null : rtt < 80 ? 'good' : rtt < 160 ? 'fair' : 'poor'
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Member } from '../../../shared/wire'
|
||||
import type { BufferEntry } from '../state/store'
|
||||
import { activityIcon, activityLabel, dmActivity, profileActivity } from './activity'
|
||||
|
||||
const dm = { id: 'steam:me|Gabe', kind: 'dm', accountId: 'steam:me', name: 'Gabe' } as BufferEntry
|
||||
const member = (extra: Partial<Member>): Member => ({ nick: 'Gabe', status: 'online', ...extra }) as Member
|
||||
|
||||
describe('what somebody is doing, under their name', () => {
|
||||
it('has a gamepad for a game, a note for a track, and nothing for words they wrote', () => {
|
||||
expect(activityIcon({ kind: 'game', text: 'Resident Evil 4' })).toBe('sports_esports')
|
||||
expect(activityIcon({ kind: 'music', text: 'Ramones' })).toBe('music_note')
|
||||
expect(activityIcon({ kind: 'custom', text: 'ask questions' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads as a sentence where there is no room for a picture', () => {
|
||||
expect(activityLabel({ kind: 'game', text: 'Dota 2' })).toBe('Playing Dota 2')
|
||||
expect(activityLabel({ kind: 'music', text: 'Ramones' })).toBe('Listening to Ramones')
|
||||
expect(activityLabel({ kind: 'custom', text: 'ask questions' })).toBe('ask questions')
|
||||
})
|
||||
|
||||
it('is the one person of a direct message, and nobody else', () => {
|
||||
const playing = { kind: 'game', text: 'Dota 2' } as const
|
||||
expect(dmActivity(dm, { [dm.id]: [member({ activity: playing })] })).toEqual(playing)
|
||||
expect(dmActivity(dm, { [dm.id]: [member({ activity: null })] })).toBeUndefined()
|
||||
expect(dmActivity(dm, { [dm.id]: [member({})] })).toBeUndefined()
|
||||
expect(dmActivity(dm, {})).toBeUndefined()
|
||||
const room = { ...dm, kind: 'channel' } as BufferEntry
|
||||
expect(dmActivity(room, { [room.id]: [member({ activity: playing })] })).toBeUndefined()
|
||||
expect(dmActivity(dm, { [dm.id]: [member({ activity: playing }), member({ nick: 'x', activity: playing })] })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('what a profile card says somebody is doing', () => {
|
||||
const playing = { kind: 'game', text: 'Dota 2' } as const
|
||||
const buffers = [
|
||||
{ id: 'discord:1|#a', accountId: 'discord:1', kind: 'channel', name: '#a' },
|
||||
{ id: 'discord:2|#b', accountId: 'discord:2', kind: 'channel', name: '#b' }
|
||||
] as BufferEntry[]
|
||||
const who = { accountId: 'discord:1', id: '42', name: 'Gabe', handle: 'gabe#1' }
|
||||
|
||||
it('is what the lookup said, first', () => {
|
||||
expect(profileActivity({ ...who, activity: { kind: 'custom', text: 'brb' } }, {}, buffers)).toEqual({ kind: 'custom', text: 'brb' })
|
||||
})
|
||||
|
||||
it('is otherwise what the member lists already say, found by id or by name', () => {
|
||||
const byId = { 'discord:1|#a': [member({ nick: 'someone else', userId: '42', activity: playing })] }
|
||||
expect(profileActivity(who, byId, buffers)).toEqual(playing)
|
||||
const byName = { 'discord:1|#a': [member({ nick: 'GABE', activity: playing })] }
|
||||
expect(profileActivity(who, byName, buffers)).toEqual(playing)
|
||||
})
|
||||
|
||||
it('does not borrow it from another account, nor from somebody else', () => {
|
||||
expect(profileActivity(who, { 'discord:2|#b': [member({ nick: 'Gabe', userId: '42', activity: playing })] }, buffers)).toBeUndefined()
|
||||
expect(profileActivity(who, { 'discord:1|#a': [member({ nick: 'Other', userId: '7', activity: playing })] }, buffers)).toBeUndefined()
|
||||
expect(profileActivity(who, { 'discord:1|#a': [member({ nick: 'Gabe', userId: '42' })] }, buffers)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Activity, Member, Profile } from '../../../shared/wire'
|
||||
import type { BufferEntry } from '../state/store'
|
||||
|
||||
/** The picture before the line: a gamepad for a game, a note for a track, none for words. */
|
||||
export function activityIcon(activity: Activity): string | undefined {
|
||||
return activity.kind === 'game' ? 'sports_esports' : activity.kind === 'music' ? 'music_note' : undefined
|
||||
}
|
||||
|
||||
/** The same thing as a sentence, for a tooltip or a header where there is no picture. */
|
||||
export function activityLabel(activity: Activity): string {
|
||||
return activity.kind === 'game' ? `Playing ${activity.text}` : activity.kind === 'music' ? `Listening to ${activity.text}` : activity.text
|
||||
}
|
||||
|
||||
/**
|
||||
* What the one person in a direct message is doing, where the service says.
|
||||
* Nothing for a room, and nothing for a list of several: it is one person's.
|
||||
*/
|
||||
export function dmActivity(buffer: BufferEntry, presence: Record<string, Member[]>): Activity | undefined {
|
||||
if (buffer.kind !== 'dm') return undefined
|
||||
const own = presence[buffer.id]
|
||||
return (own?.length === 1 && own[0].activity) || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* What somebody is doing, for the profile card.
|
||||
*
|
||||
* What the service said in the lookup itself, and otherwise what their
|
||||
* presence already says wherever they are listed: the member lists carry it
|
||||
* for every service, so a card opened from a message needs no second question
|
||||
* to know the game somebody is in.
|
||||
*/
|
||||
export function profileActivity(
|
||||
profile: Pick<Profile, 'activity' | 'accountId' | 'id' | 'name' | 'handle'>,
|
||||
presence: Record<string, Member[]>,
|
||||
buffers: BufferEntry[]
|
||||
): Activity | undefined {
|
||||
if (profile.activity) return profile.activity
|
||||
const names = [profile.name, profile.handle].filter((n): n is string => !!n).map((n) => n.toLowerCase())
|
||||
for (const buffer of buffers) {
|
||||
if (buffer.accountId !== profile.accountId) continue
|
||||
const found = presence[buffer.id]?.find(
|
||||
(m) => m.activity && ((profile.id && m.userId === profile.id) || names.includes(m.nick.toLowerCase()))
|
||||
)
|
||||
if (found?.activity) return found.activity
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export function railConnecting(
|
||||
const accountOf = (id: string): Account | undefined => accounts.find((a) => a.id === id)
|
||||
const retrying = (b: Buffer): boolean => bufferLink(b, accountOf(b.accountId), details[b.accountId])?.retrying === true
|
||||
if (group.kind === 'dms') return buffers.some((b) => b.kind === 'dm' && retrying(b))
|
||||
if (group.kind === 'pinned' || group.kind === 'invite' || !group.accountId) return false
|
||||
if (group.kind === 'pinned' || group.kind === 'invitations' || !group.accountId) return false
|
||||
const account = accountOf(group.accountId)
|
||||
if (account && account.state === 'connecting') return true
|
||||
return buffers.some((b) => b.groupId === group.id && retrying(b))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferMenuEntries } from './buffermenu'
|
||||
import { bufferMenuEntries, leaveLabel } from './buffermenu'
|
||||
|
||||
const noop = (): void => {}
|
||||
|
||||
@@ -47,3 +47,19 @@ describe('a mute held on the account', () => {
|
||||
expect(entry?.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('what leaving is called', () => {
|
||||
const room = { id: 'b', accountId: 'steam:me', name: 'Raiders/#General', kind: 'channel' } as never
|
||||
const friend = { id: 'b', accountId: 'steam:me', name: 'Gabe', kind: 'dm' } as never
|
||||
|
||||
it('is leaving the group for a room of a Steam group, which cannot be left on its own', () => {
|
||||
expect(leaveLabel(room, 'steam')).toBe('Leave group…')
|
||||
})
|
||||
|
||||
it('is leaving the conversation for a friend, and the same words as before elsewhere', () => {
|
||||
expect(leaveLabel(friend, 'steam')).toBe('Leave conversation')
|
||||
expect(leaveLabel(room, 'matrix')).toBe('Leave room')
|
||||
expect(leaveLabel(room, 'irc')).toBe('Leave channel')
|
||||
expect(leaveLabel(room, 'discord')).toBe('Remove from list')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,6 +62,17 @@ export interface BufferMenuInput {
|
||||
markedUnread?: boolean
|
||||
/** Clears what is waiting here; offered only while something is. */
|
||||
onMarkRead?: () => void
|
||||
/**
|
||||
* What can be done about a Steam friend - what to call them, ending the
|
||||
* friendship, blocking them. Present only on a conversation with one.
|
||||
*/
|
||||
onSteamFriend?: (what: 'nickname' | 'remove' | 'block') => void
|
||||
/**
|
||||
* Opens the card for the person this conversation is with, the one a name
|
||||
* opens everywhere else. Steam's: a direct message there has no member list
|
||||
* to click a name in, so the way to it is the conversation's own menu.
|
||||
*/
|
||||
onProfile?: () => void
|
||||
}
|
||||
|
||||
/** What leaving is called, which depends on what is being left. */
|
||||
@@ -69,6 +80,8 @@ export function leaveLabel(buffer: BufferEntry, service?: string): string {
|
||||
if (buffer.kind === 'dm') return service === 'discord' ? 'Close conversation' : 'Leave conversation'
|
||||
if (service === 'matrix') return 'Leave room'
|
||||
if (service === 'irc') return 'Leave channel'
|
||||
// A room of a Steam group is left by leaving the group.
|
||||
if (service === 'steam') return 'Leave group…'
|
||||
if (service === 'discord') return 'Remove from list'
|
||||
return 'Close'
|
||||
}
|
||||
@@ -104,9 +117,16 @@ export function bufferMenuEntries({
|
||||
onDock,
|
||||
onMarkRead,
|
||||
onMarkUnread,
|
||||
markedUnread
|
||||
markedUnread,
|
||||
onSteamFriend,
|
||||
onProfile
|
||||
}: BufferMenuInput): MenuEntry[] {
|
||||
return [
|
||||
// First: it is the one that goes and looks at the person, where everything
|
||||
// below acts on the conversation.
|
||||
...(onProfile
|
||||
? ([{ label: 'Profile', icon: 'person', onClick: onProfile }, { separator: true }] as MenuEntry[])
|
||||
: []),
|
||||
...(canCall
|
||||
? ([
|
||||
inCall
|
||||
@@ -238,6 +258,15 @@ export function bufferMenuEntries({
|
||||
{ separator: true }
|
||||
] as MenuEntry[])
|
||||
: []),
|
||||
...(onSteamFriend
|
||||
? ([
|
||||
{ separator: true },
|
||||
{ label: 'Set nickname…', icon: 'edit', onClick: () => onSteamFriend('nickname') },
|
||||
{ label: 'Remove friend', icon: 'person_remove', danger: true, onClick: () => onSteamFriend('remove') },
|
||||
{ label: 'Block', icon: 'block', danger: true, onClick: () => onSteamFriend('block') },
|
||||
{ separator: true }
|
||||
] as MenuEntry[])
|
||||
: []),
|
||||
{ label: 'Hide', icon: 'visibility_off', onClick: onHide },
|
||||
...(buffer.kind === 'server'
|
||||
? []
|
||||
|
||||
@@ -24,7 +24,12 @@ const CAPABILITIES: Record<string, FormatKind[]> = {
|
||||
sneedchat: ['bold', 'italic', 'underline', 'strike', 'code', 'spoiler', 'quote', 'color'],
|
||||
// Quote is left out of IRC: a quote is a line of its own, and a newline
|
||||
// reaching IRC is a malformed message.
|
||||
irc: ['bold', 'italic', 'underline', 'strike', 'code', 'spoiler', 'color']
|
||||
irc: ['bold', 'italic', 'underline', 'strike', 'code', 'spoiler', 'color'],
|
||||
// Steam's friend chat takes no formatting: found against the real service,
|
||||
// which turns [b], [i], [u] and [strike] into literal text and drops [code],
|
||||
// [quote] and [spoiler] to their contents. So there is nothing to offer, and
|
||||
// what it does read - emoticons - is not a format.
|
||||
steam: []
|
||||
}
|
||||
|
||||
export function formatsFor(service: string | undefined): FormatKind[] {
|
||||
@@ -195,9 +200,15 @@ export function serialize(root: BoxNode, service: string | undefined): string {
|
||||
for (let i = 0; i < node.childNodes.length; i++) {
|
||||
const n = node.childNodes[i]
|
||||
if (n.nodeType === TEXT_NODE) {
|
||||
out += n.nodeValue ?? ''
|
||||
// Steam reads BBCode, so a bracket somebody typed is escaped: a "[b]"
|
||||
// they meant literally must not turn into bold.
|
||||
out += service === 'steam' ? (n.nodeValue ?? '').replace(/\[/g, '\\[') : (n.nodeValue ?? '')
|
||||
} else if (n.nodeName === 'IMG') {
|
||||
out += n.dataset?.token ?? ''
|
||||
const token = n.dataset?.token ?? ''
|
||||
// An emoticon leaves as the name between colons, which is how Steam
|
||||
// is told one was meant.
|
||||
const steam = service === 'steam' ? token.match(/^\[steam:emote:([A-Za-z0-9_-]+)\]$/) : null
|
||||
out += steam ? `:${steam[1]}:` : token
|
||||
} else if (n.nodeName === 'BR') {
|
||||
out += '\n'
|
||||
} else if (n.nodeType === ELEMENT_NODE) {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Account } from '../../../shared/wire'
|
||||
import type { BufferEntry } from '../state/store'
|
||||
import { dmCategoryId, dmCategoryName, planDmCategories, type DmCategoryState } from './dmcategories'
|
||||
|
||||
const account = (id: string, service: Account['service'], displayName = ''): Account => ({ id, service, displayName }) as Account
|
||||
const dm = (id: string, accountId: string): BufferEntry => ({ id, accountId, kind: 'dm', name: id }) as BufferEntry
|
||||
const channel = (id: string, accountId: string): BufferEntry => ({ id, accountId, kind: 'channel', name: id }) as BufferEntry
|
||||
|
||||
const empty: DmCategoryState = { categories: [], assignment: {}, filed: [], made: [] }
|
||||
const steam = account('steam:salastil', 'steam', 'salastil')
|
||||
const discord = account('discord:1', 'discord', 'Salastil')
|
||||
const kick = account('kick:me', 'kick', 'me')
|
||||
|
||||
describe('the heading for an account', () => {
|
||||
it('reads Protocol - Username', () => {
|
||||
expect(dmCategoryName(steam)).toBe('Steam - salastil')
|
||||
expect(dmCategoryName(discord)).toBe('Discord - Salastil')
|
||||
})
|
||||
|
||||
it('falls back to the id, without its prefix, when the account has no name yet', () => {
|
||||
expect(dmCategoryName(account('matrix:@me:poa.st', 'matrix'))).toBe('Matrix - @me:poa.st')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sorting direct messages under their account', () => {
|
||||
it('makes a heading for each account that has direct messages, once', () => {
|
||||
const plan = planDmCategories([steam, discord, kick], [], empty)!
|
||||
expect(plan.categories.map((c) => c.name)).toEqual(['Steam - salastil', 'Discord - Salastil'])
|
||||
expect(plan.made).toEqual(['steam:salastil', 'discord:1'])
|
||||
expect(plan.made).not.toContain('kick:me')
|
||||
})
|
||||
|
||||
it('files conversations that already exist, which is how an account added long ago gets sorted', () => {
|
||||
const plan = planDmCategories([steam, discord], [dm('a', 'steam:salastil'), dm('b', 'discord:1'), dm('c', 'steam:salastil')], empty)!
|
||||
expect(plan.assignment).toEqual({ a: dmCategoryId('steam:salastil'), b: dmCategoryId('discord:1'), c: dmCategoryId('steam:salastil') })
|
||||
expect(plan.filed.sort()).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('leaves channels alone', () => {
|
||||
const plan = planDmCategories([steam], [channel('#x', 'steam:salastil')], empty)!
|
||||
expect(plan.assignment).toEqual({})
|
||||
expect(plan.filed).toEqual([])
|
||||
})
|
||||
|
||||
it('does nothing once everything has been done, so nothing is written', () => {
|
||||
const first = planDmCategories([steam], [dm('a', 'steam:salastil')], empty)!
|
||||
expect(planDmCategories([steam], [dm('a', 'steam:salastil')], first)).toBeNull()
|
||||
})
|
||||
|
||||
it('files a new conversation when it arrives', () => {
|
||||
const first = planDmCategories([steam], [dm('a', 'steam:salastil')], empty)!
|
||||
const next = planDmCategories([steam], [dm('a', 'steam:salastil'), dm('b', 'steam:salastil')], first)!
|
||||
expect(next.assignment.b).toBe(dmCategoryId('steam:salastil'))
|
||||
})
|
||||
|
||||
it('does not put back a conversation that was moved out by hand', () => {
|
||||
const first = planDmCategories([steam], [dm('a', 'steam:salastil')], empty)!
|
||||
const moved = { ...first, assignment: {} }
|
||||
expect(planDmCategories([steam], [dm('a', 'steam:salastil')], moved)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not overwrite a heading somebody chose for it', () => {
|
||||
const plan = planDmCategories([steam], [dm('a', 'steam:salastil')], { ...empty, assignment: { a: 'mine' } })!
|
||||
expect(plan.assignment.a).toBe('mine')
|
||||
})
|
||||
|
||||
it('does not make a deleted heading again, and files nothing under it', () => {
|
||||
const first = planDmCategories([steam], [], empty)!
|
||||
const deleted = { ...first, categories: [] }
|
||||
expect(planDmCategories([steam], [], deleted)).toBeNull()
|
||||
const later = planDmCategories([steam], [dm('a', 'steam:salastil')], deleted)!
|
||||
expect(later.assignment).toEqual({})
|
||||
expect(later.filed).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('keeps a heading somebody renamed', () => {
|
||||
const first = planDmCategories([steam], [], empty)!
|
||||
const renamed = { ...first, categories: [{ id: dmCategoryId('steam:salastil'), name: 'Games' }] }
|
||||
expect(planDmCategories([steam], [dm('a', 'steam:salastil')], renamed)!.categories[0].name).toBe('Games')
|
||||
})
|
||||
|
||||
it('removes the heading of an account that has gone, and makes it afresh if it comes back', () => {
|
||||
const first = planDmCategories([steam, discord], [], empty)!
|
||||
const gone = planDmCategories([discord], [], first)!
|
||||
expect(gone.categories.map((c) => c.id)).toEqual([dmCategoryId('discord:1')])
|
||||
expect(gone.made).toEqual(['discord:1'])
|
||||
const back = planDmCategories([steam, discord], [], gone)!
|
||||
expect(back.categories.map((c) => c.id)).toContain(dmCategoryId('steam:salastil'))
|
||||
})
|
||||
|
||||
it('leaves the headings people made themselves alone', () => {
|
||||
const mine = { id: 'abc', name: 'Friends' }
|
||||
const plan = planDmCategories([steam], [], { ...empty, categories: [mine] })!
|
||||
expect(plan.categories[0]).toEqual(mine)
|
||||
})
|
||||
|
||||
it('waits for accounts before saying anything, and for an account before filing its conversations', () => {
|
||||
expect(planDmCategories([], [dm('a', 'steam:salastil')], empty)).toBeNull()
|
||||
const plan = planDmCategories([discord], [dm('a', 'steam:salastil')], empty)!
|
||||
expect(plan.filed).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Account } from '../../../shared/wire'
|
||||
import type { BufferEntry } from '../state/store'
|
||||
import type { CustomCategory } from './categories'
|
||||
import { serviceLabel } from './util'
|
||||
|
||||
/**
|
||||
* Direct messages, sorted under a heading per account.
|
||||
*
|
||||
* Every protocol added puts its conversations on the one direct-messages page,
|
||||
* which stops being readable after the second. Each account therefore gets a
|
||||
* heading of its own there - "Steam - salastil" - made the first time the
|
||||
* account is seen, and its conversations are filed under it as they arrive.
|
||||
*
|
||||
* The headings are the ordinary ones a person can make for themselves, so
|
||||
* everything about them still works: they can be renamed, reordered, collapsed
|
||||
* or deleted, and a conversation can be dragged to another. Because of that,
|
||||
* this only ever acts once for each thing. A heading that was deleted is not
|
||||
* made again, and a conversation that was moved out is not put back; what has
|
||||
* been done is remembered rather than re-derived on every change.
|
||||
*/
|
||||
|
||||
/** The services whose conversations land on the direct-messages page. */
|
||||
const DM_SERVICES = new Set(['irc', 'discord', 'matrix', 'steam'])
|
||||
|
||||
const PREFIX = 'dm:'
|
||||
|
||||
/** The id of an account's heading. Stable, so a rename does not lose it. */
|
||||
export function dmCategoryId(accountId: string): string {
|
||||
return `${PREFIX}${accountId}`
|
||||
}
|
||||
|
||||
/** "Protocol - Username", the way the heading reads. */
|
||||
export function dmCategoryName(account: Pick<Account, 'id' | 'service' | 'displayName'>): string {
|
||||
const user = account.displayName || account.id.replace(/^[a-z]+:/, '')
|
||||
return `${serviceLabel(account.service)} - ${user}`
|
||||
}
|
||||
|
||||
export interface DmCategoryState {
|
||||
/** The headings on the direct-messages page. */
|
||||
categories: CustomCategory[]
|
||||
/** Which heading each conversation is under, by conversation. */
|
||||
assignment: Record<string, string>
|
||||
/** The conversations already filed once, wherever they are now. */
|
||||
filed: string[]
|
||||
/** The accounts that have had a heading made, whether or not it is still there. */
|
||||
made: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* What to change so every account has its heading and every conversation that
|
||||
* has not been filed yet is under its account's. Null when there is nothing to
|
||||
* do, so the caller writes nothing on the great majority of calls.
|
||||
*/
|
||||
export function planDmCategories(
|
||||
accounts: Account[],
|
||||
buffers: BufferEntry[],
|
||||
current: DmCategoryState
|
||||
): DmCategoryState | null {
|
||||
// Before accounts are known there is nothing to say: an empty list is not
|
||||
// "every account was removed".
|
||||
if (accounts.length === 0) return null
|
||||
|
||||
let categories = current.categories
|
||||
let made = current.made
|
||||
const assignment = { ...current.assignment }
|
||||
const filed = new Set(current.filed)
|
||||
let changed = false
|
||||
|
||||
// A removed account's heading goes with it, and with it the memory of having
|
||||
// made it, so adding the account again starts it afresh.
|
||||
const live = new Set(accounts.map((a) => a.id))
|
||||
const orphan = (c: CustomCategory): boolean => c.id.startsWith(PREFIX) && !live.has(c.id.slice(PREFIX.length))
|
||||
if (categories.some(orphan)) {
|
||||
categories = categories.filter((c) => !orphan(c))
|
||||
changed = true
|
||||
}
|
||||
if (made.some((id) => !live.has(id))) {
|
||||
made = made.filter((id) => live.has(id))
|
||||
changed = true
|
||||
}
|
||||
|
||||
// The heading, the first time the account is seen.
|
||||
for (const account of accounts) {
|
||||
if (!DM_SERVICES.has(account.service) || made.includes(account.id)) continue
|
||||
if (!categories.some((c) => c.id === dmCategoryId(account.id))) {
|
||||
categories = [...categories, { id: dmCategoryId(account.id), name: dmCategoryName(account) }]
|
||||
}
|
||||
made = [...made, account.id]
|
||||
changed = true
|
||||
}
|
||||
|
||||
// The conversations, once each. One whose heading has been deleted, or that
|
||||
// was put somewhere by hand already, is marked filed and left alone.
|
||||
for (const buffer of buffers) {
|
||||
if (buffer.kind !== 'dm' || filed.has(buffer.id) || !live.has(buffer.accountId)) continue
|
||||
const heading = dmCategoryId(buffer.accountId)
|
||||
if (!assignment[buffer.id] && categories.some((c) => c.id === heading)) assignment[buffer.id] = heading
|
||||
filed.add(buffer.id)
|
||||
changed = true
|
||||
}
|
||||
|
||||
return changed ? { categories, assignment, filed: [...filed], made } : null
|
||||
}
|
||||
@@ -34,6 +34,19 @@ export interface MediaItem {
|
||||
* somebody opens it.
|
||||
*/
|
||||
page?: string
|
||||
/** Sent as a spoiler: hidden until it is clicked. */
|
||||
spoiler?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of these pictures sit inside a `||spoiler||`, and so are hidden the
|
||||
* way the words beside them are. Steam writes a spoilered picture that way;
|
||||
* Discord says it with the file's name instead.
|
||||
*/
|
||||
export function markSpoilered(items: MediaItem[], text: string): MediaItem[] {
|
||||
if (items.length === 0 || !text.includes('||')) return items
|
||||
const hidden = [...text.matchAll(/\|\|([\s\S]*?)\|\|/g)].map((m) => m[1])
|
||||
return items.map((item) => (hidden.some((h) => h.includes(item.url)) ? { ...item, spoiler: true } : item))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,6 +82,30 @@ export function kickEmoteUrl(id: string): string {
|
||||
return resolveMediaUrl(drawableEmoteUrl(`https://files.kick.com/emotes/${id}/fullsize`))
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture for one of Steam's emoticons or stickers, by name.
|
||||
*
|
||||
* A name is all a message carries - `[steam:emote:steamhappy]` - and the
|
||||
* picture is a public one at an address built from it, the way Discord's is
|
||||
* built from an id. So it draws for an emoticon this account does not have,
|
||||
* which is the point of one somebody else sent.
|
||||
*/
|
||||
export function steamPictureUrl(kind: 'emote' | 'sticker', name: string): string {
|
||||
const base = 'https://community.fastly.steamstatic.com/economy/'
|
||||
return resolveMediaUrl(`${base}${kind === 'emote' ? 'emoticon' : 'sticker'}/${encodeURIComponent(name)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* What a Steam token names. An emoticon is one word; a sticker is Steam's own
|
||||
* name for it, which has spaces in it - `[steam:sticker:Nyan GLaDOS]`.
|
||||
*/
|
||||
export function steamTokenOf(text: string): { kind: 'emote' | 'sticker'; name: string } | null {
|
||||
const m = text.match(/^\[steam:(emote|sticker):([^\]\r\n"]{1,64})\]$/)
|
||||
if (!m) return null
|
||||
if (m[1] === 'emote' && !/^[A-Za-z0-9_-]+$/.test(m[2])) return null
|
||||
return { kind: m[1] as 'emote' | 'sticker', name: m[2] }
|
||||
}
|
||||
|
||||
/** What something picked out of the emoji list looks like, when it isn't text. */
|
||||
export interface EmojiPreview {
|
||||
/** Possibly a local path - run it through resolveMediaUrl before use. */
|
||||
@@ -98,11 +135,70 @@ export function emojiPreview(text: string, smilies: SmilieEntry[] = []): EmojiPr
|
||||
if (custom) return { src: discordEmojiUrl(custom[2]), label: `:${custom[1]}:` }
|
||||
const kick = text.match(/^\[emote:(\d+):([^\]]{0,64})\]$/)
|
||||
if (kick) return { src: kickEmoteUrl(kick[1]), label: `:${kick[2]}:` }
|
||||
const steam = steamTokenOf(text)
|
||||
if (steam) return { src: steamPictureUrl(steam.kind, steam.name), label: `:${steam.name}:` }
|
||||
const smilie = smilies.find((s) => s.aliases?.includes(text))
|
||||
if (smilie?.url) return { src: smilie.url, label: smilie.label }
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* What a cell in a service's own section sends back.
|
||||
*
|
||||
* Sneedchat's id is the shortcode itself, so the id is the text. Steam's is a
|
||||
* `steam:name` key, which is no message at all: it was inserted as the words
|
||||
* "steam:steambored". It leaves as the emoticon token, which the box draws.
|
||||
*/
|
||||
export function sourcePick(service: string, e: { id: string }): string {
|
||||
return service === 'steam' && e.id.startsWith('steam:') ? `[steam:emote:${e.id.slice(6)}]` : e.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether what was typed into a Steam conversation is what Steam logged.
|
||||
*
|
||||
* They are never spelled alike when a message holds an emoticon or a bracket:
|
||||
* the box sends `:steambored:` and Steam hands it back as a picture, which the
|
||||
* daemon writes as `[steam:emote:steambored]`; a bracket typed literally leaves
|
||||
* as `\[` and comes back as `[` with an invisible mark after it. Matched
|
||||
* strictly, the message sat grey until it timed out as failed, while its own
|
||||
* echo showed beside it.
|
||||
*/
|
||||
export function steamSameText(sent: string, logged: string): boolean {
|
||||
const sentForm = sent.replace(/\\\[/g, '[')
|
||||
const loggedForm = logged
|
||||
.replace(/\[steam:emote:([A-Za-z0-9_-]+)\]/g, ':$1:')
|
||||
// A link Steam made into a picture or a card comes back as the address, or as
|
||||
// words that link to it - which is what was typed.
|
||||
.replace(/\[steam:(?:image|video):(https?:\/\/[^\s\]]+)\]/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\(https?:\/\/[^\s)]+\)/g, '$1')
|
||||
.replace(/\u200b/g, '')
|
||||
.trim()
|
||||
return sentForm.trim() === loggedForm
|
||||
}
|
||||
|
||||
/**
|
||||
* A link that ends a clause is not a link ending in the clause's punctuation:
|
||||
* "see https://x.test/a, then" links `https://x.test/a`. A closing bracket goes
|
||||
* too, unless the address has the opening one - which is how a Wikipedia page
|
||||
* such as `.../Foo_(bar)` keeps its own. A semicolon stays, because the text is
|
||||
* escaped by now and `&` inside an address ends in one.
|
||||
*/
|
||||
export function splitTrailingPunctuation(found: string): [string, string] {
|
||||
let address = found
|
||||
let tail = ''
|
||||
for (;;) {
|
||||
const last = address.slice(-1)
|
||||
const opens = (address.match(/\(/g) ?? []).length
|
||||
const closes = (address.match(/\)/g) ?? []).length
|
||||
if (last && '.,:!?\'*'.includes(last) || (last === ')' && closes > opens)) {
|
||||
tail = last + tail
|
||||
address = address.slice(0, -1)
|
||||
} else {
|
||||
return [address, tail]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function escapeHtml(s: string): string {
|
||||
// Quotes as well as brackets: the link passes write a URL from the text
|
||||
// into an attribute, and a quote in it would end the attribute early.
|
||||
@@ -120,6 +216,13 @@ export function normalizeBBCode(text: string): string {
|
||||
if (!text) return text
|
||||
let out = text
|
||||
|
||||
// What Steam sends for a file, as the daemon first stored it, before it
|
||||
// wrote a marker: the tag with its address as an attribute, which was read
|
||||
// as `[img src=ADDRESS ...]ADDRESS` or as the address twice and a stray
|
||||
// `[/video]`. Read into the marker, so what was stored then draws as it does now.
|
||||
out = out.replace(/\[(img|video)\s+src="?(https?:\/\/[^\s"\]]+)"?[^\]]*\](?:\s*\2)?(?:\[\/\1\])?/gi, (_m, tag: string, url: string) => `\n[steam:${tag.toLowerCase() === 'img' ? 'image' : 'video'}:${url}]\n`)
|
||||
out = out.replace(/(https?:\/\/[^\s\]]+)\s*\n\s*\1\[\/video\]/gi, '\n[steam:video:$1]\n')
|
||||
|
||||
// [br] -> a real newline, so it flows through the same \n -> <br> step
|
||||
// below. Left alone, the catch-all "strip unrecognised BBCode" pass would
|
||||
// delete it with nothing in its place, gluing both sides together.
|
||||
@@ -385,6 +488,9 @@ export function ircFormat(text: string, mode: 'render' | 'strip'): string {
|
||||
|
||||
export function formatMessage(text: string, opts: FormatOptions = {}): string {
|
||||
if (!text) return text
|
||||
// A Steam picture or video named in a line that is not drawing its media - a
|
||||
// quoted reply, a preview - is the address it names.
|
||||
text = text.replace(STEAM_MEDIA_MARK_RE, '$2')
|
||||
const revealed = opts.revealedSpoilers || {}
|
||||
// First, because the codes are invisible characters sitting anywhere in the
|
||||
// line: a colour code inside a URL or between a pair of asterisks would
|
||||
@@ -450,6 +556,17 @@ export function formatMessage(text: string, opts: FormatOptions = {}): string {
|
||||
stow(`<img src="${kickEmoteUrl(id)}" class="custom-emoji" alt=":${name}:">`)
|
||||
)
|
||||
|
||||
// Steam's emoticons and stickers, which arrive as `[steam:emote:name]`. Like
|
||||
// the two above the picture is public and named by the token, so one that
|
||||
// somebody else sent draws whether or not this account has it. A sticker is
|
||||
// drawn larger, as Steam draws it.
|
||||
out = out.replace(/\[steam:(emote|sticker):([^\]\r\n]{1,80})\]/g, (m, kind: string, shown: string) => {
|
||||
// Escaped by now, and a name that was written with an ampersand in it is whole again.
|
||||
const name = shown.replace(/&/g, '&')
|
||||
if (!steamTokenOf(`[steam:${kind}:${name}]`)) return m
|
||||
return stow(`<img src="${steamPictureUrl(kind as 'emote' | 'sticker', name)}" class="${kind === 'sticker' ? 'custom-emoji steam-sticker' : 'custom-emoji'}" alt=":${shown}:">`)
|
||||
})
|
||||
|
||||
// Discord channel links. What arrives is `<#1393001234568164748>` and
|
||||
// nothing else - no name anywhere in the payload - so this is the only
|
||||
// chance to make it read as the "#general" the sender saw when they typed
|
||||
@@ -532,7 +649,10 @@ export function formatMessage(text: string, opts: FormatOptions = {}): string {
|
||||
// something to offer the reader to open (#247). And not into an escaped
|
||||
// bracket: `<https://x>` is how Discord suppresses an embed, and arrives
|
||||
// here as `<https://x>`.
|
||||
out = out.replace(/((?:https?|ircs?):\/\/(?:(?!>|<|")[^\s<])+)/g, '<a href="$1">$1</a>')
|
||||
out = out.replace(/((?:https?|ircs?):\/\/(?:(?!>|<|")[^\s<])+)/g, (_m, found: string) => {
|
||||
const [address, tail] = splitTrailingPunctuation(found)
|
||||
return `<a href="${address}">${address}</a>${tail}`
|
||||
})
|
||||
|
||||
// Anything else BBCode-shaped that wasn't recognised (e.g. [USER=..]
|
||||
// mentions, [sub]/[sup], a typo'd tag) - stripped rather than shown as
|
||||
@@ -580,6 +700,24 @@ export function hostnameOf(url: string): string {
|
||||
*/
|
||||
export const OPAQUE_IMAGE_HOSTS = ['i.ddos.lgbt']
|
||||
|
||||
/**
|
||||
* A picture Steam holds for somebody who sent it in chat. Its address has no
|
||||
* file extension anywhere - `.../ugc/<id>/<hash>/` - so it can only be
|
||||
* recognised by where it is kept.
|
||||
*/
|
||||
const STEAM_UPLOAD_RE = /^https:\/\/(?:images\.steamusercontent\.com|steamuserimages-a\.akamaihd\.net|images\.akamai\.steamstatic\.com)\/ugc\//i
|
||||
|
||||
/**
|
||||
* What the daemon writes for a file Steam holds for a sender: `[steam:image:URL]`
|
||||
* or `[steam:video:URL]`. Its address does not say which it is - there is no
|
||||
* extension anywhere in it - so the message does.
|
||||
*/
|
||||
const STEAM_MEDIA_MARK_RE = /\[steam:(image|video):(https?:\/\/[^\s\]]+)\]/g
|
||||
|
||||
export function isSteamUpload(url: string): boolean {
|
||||
return STEAM_UPLOAD_RE.test(url)
|
||||
}
|
||||
|
||||
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|avif)(\?\S*)?$/i
|
||||
const VIDEO_EXT_RE = /\.(mp4|webm|mov|mkv|ogv)(\?\S*)?$/i
|
||||
|
||||
@@ -679,6 +817,14 @@ export function extractMedia(
|
||||
// - two strings, one video - and keying on the string showed it twice.
|
||||
const seen = new Set<string>()
|
||||
|
||||
// Steam says what a file is, and these come first so the plain scan below
|
||||
// finds nothing left of them to guess at.
|
||||
for (const m of text.matchAll(STEAM_MEDIA_MARK_RE)) {
|
||||
if (seen.has(m[2])) continue
|
||||
seen.add(m[2])
|
||||
result.push({ url: m[2], kind: m[1] as 'image' | 'video' })
|
||||
}
|
||||
|
||||
// What the thumbnails were linked to. Those URLs are in the text as well -
|
||||
// inside the `[url=]` that wrapped the picture - and each is the same
|
||||
// picture as the thumbnail naming it, so drawing both shows the message
|
||||
@@ -686,7 +832,7 @@ export function extractMedia(
|
||||
const targets = new Set(Object.values(opts.thumbnails ?? {}))
|
||||
|
||||
for (const raw of urls) {
|
||||
const url = raw.replace(/[),.;!?]+$/, '')
|
||||
const url = raw.replace(/[),.;!?|]+$/, '')
|
||||
const yt = youtubeId(url)
|
||||
const identity = yt ? `yt:${yt}` : url
|
||||
if (seen.has(identity) || targets.has(url) || isForumUpload(url)) continue
|
||||
@@ -703,6 +849,9 @@ export function extractMedia(
|
||||
} else if (OPAQUE_IMAGE_HOSTS.includes(hostnameOf(url))) {
|
||||
seen.add(identity)
|
||||
result.push({ url, kind: 'image', ...biggerCopy(url, opts.thumbnails) })
|
||||
} else if (isSteamUpload(url)) {
|
||||
seen.add(identity)
|
||||
result.push({ url, kind: 'image', ...biggerCopy(url, opts.thumbnails) })
|
||||
} else if (isGifPage(url)) {
|
||||
// A page, not a file: see GIF_PAGE_RE. Left as the link it is.
|
||||
continue
|
||||
@@ -734,6 +883,8 @@ export function stripEmbeddedUrls(text: string, mediaItems: MediaItem[]): string
|
||||
if (!text || mediaItems.length === 0) return text
|
||||
let out = text
|
||||
for (const item of mediaItems) out = out.split(item.url).join('')
|
||||
// What is left of a Steam marker once its address has been lifted out.
|
||||
out = out.replace(/\[steam:(?:image|video):\]/g, '')
|
||||
|
||||
// A link left holding nothing, which is what the wrapper round a thumbnail
|
||||
// becomes once the picture it was wrapping has been lifted out and drawn as
|
||||
@@ -755,6 +906,8 @@ export function stripEmbeddedUrls(text: string, mediaItems: MediaItem[]): string
|
||||
if (id && shown.has(id)) out = out.split(raw).join('')
|
||||
}
|
||||
}
|
||||
// A spoiler that held only a picture is empty now it has been lifted out.
|
||||
out = out.replace(/\|\|\s*\|\|/g, '')
|
||||
return out.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MatrixInvite, BufferGroup } from '../../../shared/wire'
|
||||
import type { BufferGroup } from '../../../shared/wire'
|
||||
import type { BufferEntry } from '../state/store'
|
||||
|
||||
/**
|
||||
@@ -8,44 +8,29 @@ import type { BufferEntry } from '../state/store'
|
||||
* actually sends.
|
||||
*/
|
||||
export type RailGroup = Omit<BufferGroup, 'kind'> & {
|
||||
kind: BufferGroup['kind'] | 'pinned' | 'invite'
|
||||
kind: BufferGroup['kind'] | 'pinned' | 'invitations'
|
||||
}
|
||||
|
||||
/**
|
||||
* The rail entry for a room somebody has invited you to.
|
||||
* The page gathering every invitation waiting on an answer, from every service.
|
||||
*
|
||||
* A tile of its own rather than a mark on the account's, because that is what
|
||||
* it behaves like: it arrives on its own, it is answered once, and then it is
|
||||
* gone. A badge on an account tile would still be there after the answer,
|
||||
* saying nothing, until something else redrew it.
|
||||
* One tile rather than one per invitation, because what they have in common
|
||||
* is that they are waiting on you and expire once answered, not which service
|
||||
* sent them. Like the pinned page it belongs to no account and exists only
|
||||
* while there is something on it.
|
||||
*/
|
||||
export const INVITE_PREFIX = 'invite:'
|
||||
export const INVITATIONS_GROUP_ID = '~invitations'
|
||||
|
||||
export function inviteGroupId(accountId: string, roomId: string): string {
|
||||
return `${INVITE_PREFIX}${accountId}|${roomId}`
|
||||
}
|
||||
|
||||
/** The account and room an invite entry stands for, or null. */
|
||||
export function readInviteGroupId(groupId: string): { accountId: string; roomId: string } | null {
|
||||
if (!groupId.startsWith(INVITE_PREFIX)) return null
|
||||
const rest = groupId.slice(INVITE_PREFIX.length)
|
||||
const split = rest.indexOf('|')
|
||||
if (split < 0) return null
|
||||
return { accountId: rest.slice(0, split), roomId: rest.slice(split + 1) }
|
||||
}
|
||||
|
||||
export function inviteGroup(accountId: string, invite: MatrixInvite): RailGroup {
|
||||
export function invitationsGroup(count: number): RailGroup {
|
||||
return {
|
||||
id: inviteGroupId(accountId, invite.roomId),
|
||||
accountId,
|
||||
service: 'matrix',
|
||||
kind: 'invite',
|
||||
name: invite.name,
|
||||
iconUrl: invite.avatarUrl ?? undefined,
|
||||
// Ahead of everything, including direct messages: it is the only entry
|
||||
// in the rail that expires if nobody answers it.
|
||||
id: INVITATIONS_GROUP_ID,
|
||||
accountId: '',
|
||||
service: '',
|
||||
kind: 'invitations',
|
||||
name: count > 1 ? `Invitations (${count})` : 'Invitations',
|
||||
// Ahead of everything: it is the only entry that expires if nobody answers.
|
||||
position: -2000
|
||||
} as RailGroup
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,7 +144,7 @@ export function visibleGroups(groups: BufferGroup[], buffers: BufferEntry[]): Bu
|
||||
|
||||
/** Entries the user cannot drag, and which always lead the rail. */
|
||||
export function isFixedEntry(group: RailGroup): boolean {
|
||||
return group.kind === 'dms' || group.kind === 'pinned' || group.kind === 'invite'
|
||||
return group.kind === 'dms' || group.kind === 'pinned' || group.kind === 'invitations'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,10 +158,10 @@ export function isFixedEntry(group: RailGroup): boolean {
|
||||
*/
|
||||
export function orderedGroups(groups: RailGroup[], order: string[]): RailGroup[] {
|
||||
const rank = new Map(order.map((id, i) => [id, i]))
|
||||
// Direct messages, then pinned. Stated as a rule rather than left to the
|
||||
// Invitations, then direct messages, then pinned. Stated as a rule rather than left to the
|
||||
// position numbers, which are the backend's business and were never meant
|
||||
// to encode this.
|
||||
const leadRank = (g: RailGroup): number => (g.kind === 'dms' ? 0 : 1)
|
||||
const leadRank = (g: RailGroup): number => (g.kind === 'invitations' ? -1 : g.kind === 'dms' ? 0 : 1)
|
||||
const lead = groups
|
||||
.filter(isFixedEntry)
|
||||
.sort((a, b) => leadRank(a) - leadRank(b) || a.position - b.position)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Account } from '../../../shared/wire'
|
||||
import { INVITATIONS_GROUP_ID, invitationsGroup, isFixedEntry, orderedGroups, type RailGroup } from './groups'
|
||||
import { currentInvitation, listInvitations, type InvitationSources } from './invitations'
|
||||
|
||||
const account = (id: string, service: Account['service']): Account => ({ id, service }) as Account
|
||||
|
||||
const accounts = [account('matrix:@me:x', 'matrix'), account('steam:me', 'steam'), account('discord:1', 'discord')]
|
||||
|
||||
const sources = (part: Partial<InvitationSources>): InvitationSources => ({ rooms: {}, friends: {}, discordFriends: {}, verification: null, ...part })
|
||||
const room = { roomId: '!room:x', name: 'Room', inviter: 'alice', avatarUrl: null, isDirect: false }
|
||||
const dm = { roomId: '!dm:x', name: 'Bob', inviter: 'bob', avatarUrl: 'mxc://x/b', isDirect: true }
|
||||
const friend = { id: '76561197960287930', name: 'Some One', avatarUrl: 'https://example.test/a.jpg' }
|
||||
|
||||
describe('the invitations from every service', () => {
|
||||
const list = listInvitations(sources({ rooms: { 'matrix:@me:x': [room, dm] }, friends: { 'steam:me': [friend] } }), accounts)
|
||||
|
||||
it('are one list, in the order the accounts are listed', () => {
|
||||
expect(list.map((i) => [i.service, i.kind, i.name])).toEqual([
|
||||
['matrix', 'room', 'Room'],
|
||||
['matrix', 'room', 'Bob'],
|
||||
['steam', 'friend', 'Some One']
|
||||
])
|
||||
})
|
||||
|
||||
it('say what accepting makes: a conversation with a person, or a room', () => {
|
||||
expect(list.map((i) => i.direct)).toEqual([false, true, true])
|
||||
})
|
||||
|
||||
it('say who is asking and for what', () => {
|
||||
expect(list[0].detail).toBe('alice invited you to this room')
|
||||
expect(list[1].detail).toBe('bob invited you to a direct message')
|
||||
expect(list[2].detail).toBe('wants to be your friend')
|
||||
})
|
||||
|
||||
it('include the groups a Steam friend has invited the account to, as rooms', () => {
|
||||
const steam = listInvitations(sources({ rooms: { 'steam:me': [{ roomId: '78995476', name: 'Raiders', inviter: 'Gabe', avatarUrl: null, isDirect: false }] } }), accounts)
|
||||
expect(steam.map((i) => [i.service, i.kind, i.name, i.detail])).toEqual([['steam', 'room', 'Raiders', 'Gabe invited you to a group chat']])
|
||||
})
|
||||
|
||||
it('are told apart by account and kind, so two with the same id are two', () => {
|
||||
const twin = listInvitations(sources({ rooms: { 'matrix:@me:x': [{ ...room, roomId: '7' }] }, friends: { 'steam:me': [{ id: '7', name: null }] } }), accounts)
|
||||
expect(new Set(twin.map((i) => i.key)).size).toBe(2)
|
||||
})
|
||||
|
||||
it('call a person by their id until the service says who they are', () => {
|
||||
expect(listInvitations(sources({ friends: { 'steam:me': [{ id: '42', name: null }] } }), accounts)[0].name).toBe('42')
|
||||
})
|
||||
|
||||
it('leave out an account that has gone', () => {
|
||||
expect(listInvitations(sources({ rooms: { 'matrix:@gone:x': [room] }, friends: { 'steam:gone': [friend] } }), accounts)).toEqual([])
|
||||
})
|
||||
|
||||
it('take in the requests Discord keeps among its friends, and only the ones sent to us', () => {
|
||||
const found = listInvitations(
|
||||
sources({
|
||||
discordFriends: {
|
||||
'discord:1': [
|
||||
{ userId: '1', username: 'pal', kind: 'friend' },
|
||||
{ userId: '2', username: 'asker', globalName: 'Asker', kind: 'incoming' },
|
||||
{ userId: '3', username: 'asked', kind: 'outgoing' },
|
||||
{ userId: '4', username: 'plain' }
|
||||
]
|
||||
}
|
||||
}),
|
||||
accounts
|
||||
)
|
||||
expect(found.map((i) => [i.service, i.kind, i.id, i.name])).toEqual([['discord', 'friend', '2', 'Asker']])
|
||||
})
|
||||
|
||||
it('take in a Matrix verification only while it is waiting on an answer', () => {
|
||||
const verification = { accountId: 'matrix:@me:x', verificationId: 'v1', state: 'requested', otherUser: '@carol:x', isSelf: false, incoming: true }
|
||||
const waiting = listInvitations(sources({ verification }), accounts)
|
||||
expect(waiting.map((i) => [i.kind, i.name, i.detail])).toEqual([['verification', '@carol:x', 'wants to verify with you']])
|
||||
expect(listInvitations(sources({ verification: { ...verification, incoming: false } }), accounts)).toEqual([])
|
||||
// One we started is not an invitation to anybody.
|
||||
expect(listInvitations(sources({ verification: { ...verification, incoming: undefined } }), accounts)).toEqual([])
|
||||
// One of our own sessions is named as that, with the device.
|
||||
const mine = listInvitations(sources({ verification: { ...verification, isSelf: true, otherUser: undefined, otherDevice: 'Phone' } }), accounts)
|
||||
expect([mine[0].name, mine[0].detail]).toEqual(['Another of your sessions', 'wants to verify (Phone)'])
|
||||
})
|
||||
|
||||
it('show the one chosen, or the first when none is or it has been answered', () => {
|
||||
expect(currentInvitation(list, list[2].key)?.name).toBe('Some One')
|
||||
expect(currentInvitation(list, 'gone')?.name).toBe('Room')
|
||||
expect(currentInvitation([], 'x')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the invitations tile', () => {
|
||||
it('is one fixed tile that leads the rail, ahead of direct messages', () => {
|
||||
const tile = invitationsGroup(3)
|
||||
expect(tile.id).toBe(INVITATIONS_GROUP_ID)
|
||||
expect(tile.name).toBe('Invitations (3)')
|
||||
expect(invitationsGroup(1).name).toBe('Invitations')
|
||||
expect(isFixedEntry(tile)).toBe(true)
|
||||
const others = [
|
||||
{ id: 'dm', kind: 'dms', position: -1000, name: 'Direct Messages' },
|
||||
{ id: 'g', kind: 'guild', position: 1, name: 'g' }
|
||||
] as RailGroup[]
|
||||
expect(orderedGroups([...others, tile], []).map((g) => g.id)).toEqual([INVITATIONS_GROUP_ID, 'dm', 'g'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Account, DiscordFriend, FriendRequest, MatrixInvite, MatrixVerification } from '../../../shared/wire'
|
||||
|
||||
/**
|
||||
* Something waiting on a yes or a no from you: a room somebody invited you to,
|
||||
* somebody asking to be your friend, a session asking to be verified. Every
|
||||
* service's are one list, because what they share is that they are offered,
|
||||
* answered once, and then gone - not which protocol they came by.
|
||||
*
|
||||
* Anything a service asks that has exactly those two answers belongs here, and
|
||||
* not on a page of its own for that service.
|
||||
*/
|
||||
export interface Invitation {
|
||||
/** Unique across accounts and kinds; what selection is by. */
|
||||
key: string
|
||||
accountId: string
|
||||
service: string
|
||||
kind: InvitationKind
|
||||
/** The service's own id: a room id, or the person's id. */
|
||||
id: string
|
||||
name: string
|
||||
avatarUrl: string | null
|
||||
/** Who is asking, and for what, in a few words. */
|
||||
detail: string
|
||||
/** Whether accepting makes a conversation with one person. */
|
||||
direct: boolean
|
||||
}
|
||||
|
||||
export type InvitationKind = 'room' | 'friend' | 'verification'
|
||||
|
||||
export function invitationKey(accountId: string, kind: InvitationKind, id: string): string {
|
||||
return `${kind}|${accountId}|${id}`
|
||||
}
|
||||
|
||||
/** What is waiting, by where each service keeps it. */
|
||||
export interface InvitationSources {
|
||||
/** Matrix rooms and direct messages somebody invited the account to. */
|
||||
rooms: Record<string, MatrixInvite[]>
|
||||
/** Steam's requests, by account. */
|
||||
friends: Record<string, FriendRequest[]>
|
||||
/** Discord's relationships, by account; the ones somebody sent are requests. */
|
||||
discordFriends: Record<string, DiscordFriend[]>
|
||||
/** A Matrix verification, if somebody has asked and it is not answered. */
|
||||
verification: MatrixVerification | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every pending invitation, across accounts, in the order the accounts are
|
||||
* listed and each account's own order within. Requests for an account that is
|
||||
* no longer there are dropped rather than shown with nobody to answer as.
|
||||
*/
|
||||
export function listInvitations(sources: InvitationSources, accounts: Account[]): Invitation[] {
|
||||
const out: Invitation[] = []
|
||||
for (const account of accounts) {
|
||||
for (const invite of sources.rooms[account.id] ?? []) {
|
||||
out.push({
|
||||
key: invitationKey(account.id, 'room', invite.roomId),
|
||||
accountId: account.id,
|
||||
service: account.service,
|
||||
kind: 'room',
|
||||
id: invite.roomId,
|
||||
name: invite.name,
|
||||
avatarUrl: invite.avatarUrl,
|
||||
detail: `${invite.inviter ? `${invite.inviter} invited you` : 'You have been invited'}${invite.isDirect ? ' to a direct message' : account.service === 'steam' ? ' to a group chat' : ' to this room'}`,
|
||||
direct: invite.isDirect
|
||||
})
|
||||
}
|
||||
const people: { id: string; name: string; avatarUrl: string | null }[] = [
|
||||
...(sources.friends[account.id] ?? []).map((r) => ({ id: r.id, name: r.name || r.id, avatarUrl: r.avatarUrl ?? null })),
|
||||
...(sources.discordFriends[account.id] ?? [])
|
||||
.filter((f) => f.kind === 'incoming')
|
||||
.map((f) => ({ id: f.userId, name: f.globalName || f.username, avatarUrl: f.avatarUrl ?? null }))
|
||||
]
|
||||
for (const person of people) {
|
||||
out.push({
|
||||
key: invitationKey(account.id, 'friend', person.id),
|
||||
accountId: account.id,
|
||||
service: account.service,
|
||||
kind: 'friend',
|
||||
...person,
|
||||
detail: 'wants to be your friend',
|
||||
direct: true
|
||||
})
|
||||
}
|
||||
const v = sources.verification
|
||||
if (v && v.incoming && v.accountId === account.id) {
|
||||
const who = v.otherUser || v.fromUser || ''
|
||||
const mine = v.isSelf !== false
|
||||
out.push({
|
||||
key: invitationKey(account.id, 'verification', v.verificationId),
|
||||
accountId: account.id,
|
||||
service: account.service,
|
||||
kind: 'verification',
|
||||
id: v.verificationId,
|
||||
name: mine ? 'Another of your sessions' : who || 'Somebody',
|
||||
avatarUrl: null,
|
||||
detail: mine ? `wants to verify${v.otherDevice ? ` (${v.otherDevice})` : ''}` : 'wants to verify with you',
|
||||
direct: false
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The one being looked at: the one chosen, or the first when none is or it has gone. */
|
||||
export function currentInvitation(list: Invitation[], key: string): Invitation | undefined {
|
||||
return list.find((i) => i.key === key) ?? list[0]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldOffer } from './matrixcall'
|
||||
|
||||
describe('which end of a Matrix call offers', () => {
|
||||
it('is exactly one end of every pair, whichever way round they ask', () => {
|
||||
// Both ends compute this about each other and must not agree, or the call
|
||||
// collides with itself.
|
||||
expect(shouldOffer('@a:example.org|AAA', '@b:example.org|BBB')).toBe(true)
|
||||
expect(shouldOffer('@b:example.org|BBB', '@a:example.org|AAA')).toBe(false)
|
||||
})
|
||||
|
||||
it('tells two devices of the same person apart', () => {
|
||||
// The case that makes a user-id comparison alone wrong.
|
||||
expect(shouldOffer('@a:example.org|AAA', '@a:example.org|ZZZ')).toBe(true)
|
||||
expect(shouldOffer('@a:example.org|ZZZ', '@a:example.org|AAA')).toBe(false)
|
||||
})
|
||||
|
||||
it('never has an end offer to itself', () => {
|
||||
expect(shouldOffer('@a:example.org|AAA', '@a:example.org|AAA')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,8 @@
|
||||
* What to call a reaction, and who to say gave it.
|
||||
*/
|
||||
|
||||
import { steamTokenOf } from './format'
|
||||
|
||||
/**
|
||||
* The name to show for a reaction's emoji. A custom one carries its own, in
|
||||
* the token it is stored as (`<:name:id>`, or `<a:name:id>` when it moves); a
|
||||
@@ -10,6 +12,8 @@
|
||||
export function reactionLabel(emoji: string, unicodeNames: ReadonlyMap<string, string> = new Map()): string {
|
||||
const custom = emoji.match(/^<a?:([A-Za-z0-9_~]{2,32}):\d+>$/)
|
||||
if (custom) return `:${custom[1]}:`
|
||||
const steam = steamTokenOf(emoji)
|
||||
if (steam) return `:${steam.name}:`
|
||||
const name = unicodeNames.get(emoji)
|
||||
return name ? `:${name}:` : emoji
|
||||
}
|
||||
|
||||
@@ -65,7 +65,8 @@ const ALLOWED_CLASSES = new Set([
|
||||
'smilie',
|
||||
'channel-mention',
|
||||
'unknown',
|
||||
'custom-emoji'
|
||||
'custom-emoji',
|
||||
'steam-sticker'
|
||||
])
|
||||
|
||||
/** Hex or a plain CSS colour keyword - nothing that could carry a url() or expression. */
|
||||
|
||||
@@ -14,6 +14,10 @@ describe('the statuses a service has', () => {
|
||||
expect(statusesFor('matrix')).toEqual(['online', 'dnd', 'invisible'])
|
||||
})
|
||||
|
||||
it('gives Steam online, away and invisible, and moho\'s own do not disturb', () => {
|
||||
expect(statusesFor('steam')).toEqual(['online', 'idle', 'dnd', 'invisible'])
|
||||
})
|
||||
|
||||
it('gives Kick and Sneedchat only the one that is moho\'s own', () => {
|
||||
expect(statusesFor('kick')).toEqual(['online', 'dnd'])
|
||||
expect(statusesFor('sneedchat')).toEqual(['online', 'dnd'])
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* daemon, which also refuses what a service has no state for.
|
||||
*
|
||||
* Offered by what each can actually express rather than by what could be faked
|
||||
* from something nearby: Discord has all four; IRC has away and back - idle and
|
||||
* from something nearby: Discord has all four; Steam has online, away and
|
||||
* invisible, with do not disturb moho's own as everywhere; IRC has away and back - idle and
|
||||
* online; Matrix has online and offline presence, so invisible is real and idle
|
||||
* is not; Kick and Sneedchat have none. Do not disturb is online with moho's own
|
||||
* desktop notifications silenced, which any service can have, so it is on all of
|
||||
@@ -20,9 +21,9 @@ export function supportsStatus(service: string, status: Choosable): boolean {
|
||||
case 'dnd':
|
||||
return true
|
||||
case 'idle':
|
||||
return service === 'discord' || service === 'irc'
|
||||
return service === 'discord' || service === 'irc' || service === 'steam'
|
||||
case 'invisible':
|
||||
return service === 'discord' || service === 'matrix'
|
||||
return service === 'discord' || service === 'matrix' || service === 'steam'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { steamGroupInviteCode, steamGroupOfRail, steamRoomIds } from './steamchat'
|
||||
|
||||
describe('Steam chat groups', () => {
|
||||
it('tells a group on the rail from every other entry', () => {
|
||||
expect(steamGroupOfRail('steam:me|steamgroup:78989756')).toBe('78989756')
|
||||
expect(steamGroupOfRail('steam:me|guild:5')).toBeNull()
|
||||
expect(steamGroupOfRail('steam:me|steamgroup:')).toBeNull()
|
||||
})
|
||||
|
||||
it('finds the group and room a channel is, and nothing in a friend conversation', () => {
|
||||
expect(steamRoomIds({ kind: 'channel', remoteId: '78989756:316198645' })).toEqual({ group: '78989756', room: '316198645' })
|
||||
expect(steamRoomIds({ kind: 'dm', remoteId: '76561197960265729' })).toBeNull()
|
||||
expect(steamRoomIds({ kind: 'channel', remoteId: 'x:y' })).toBeNull()
|
||||
expect(steamRoomIds(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('reads the invitation to a group out of a link, and refuses what is not one', () => {
|
||||
expect(steamGroupInviteCode('https://s.team/chat/Ga3kODnC')).toBe('Ga3kODnC')
|
||||
expect(steamGroupInviteCode(' s.team/chat/Ga3kODnC ')).toBe('Ga3kODnC')
|
||||
expect(steamGroupInviteCode('https://steamcommunity.com/chat/invite/Ga3kODnC?x=1')).toBe('Ga3kODnC')
|
||||
expect(steamGroupInviteCode('https://s.team/p/abc-def/XYZ')).toBeNull()
|
||||
expect(steamGroupInviteCode('someone')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Buffer } from '../../../shared/wire'
|
||||
|
||||
/**
|
||||
* Steam chat groups, as the window tells them from everything else Steam is.
|
||||
*
|
||||
* A group is a server on the rail and its rooms are channels; a friend is a
|
||||
* direct message. Both are Steam buffers, so what separates them is the shape
|
||||
* of the id the daemon files them under, which is kept in one place so no
|
||||
* component has to know it.
|
||||
*/
|
||||
|
||||
/** The group a rail entry is, when it is a Steam chat group: its number as the daemon writes it. */
|
||||
export function steamGroupOfRail(railId: string): string | null {
|
||||
const at = railId.indexOf('|steamgroup:')
|
||||
return at < 0 ? null : railId.slice(at + '|steamgroup:'.length) || null
|
||||
}
|
||||
|
||||
/** The group and the room a Steam channel is, from the id the daemon keeps on it. */
|
||||
export function steamRoomIds(buffer: Pick<Buffer, 'remoteId' | 'kind'> | null | undefined): { group: string; room: string } | null {
|
||||
if (!buffer || buffer.kind !== 'channel' || !buffer.remoteId) return null
|
||||
const [group, room] = buffer.remoteId.split(':')
|
||||
return group && room && /^\d+$/.test(group) && /^\d+$/.test(room) ? { group, room } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The code in an invitation to a group, from a link or the bare code; null
|
||||
* for anything else - a friend's invite link, a name - so one box can take
|
||||
* either and the right thing is done with each.
|
||||
*/
|
||||
export function steamGroupInviteCode(text: string): string | null {
|
||||
const t = text.trim()
|
||||
const link = /(?:^|\/\/)(?:s\.team\/chat\/|steamcommunity\.com\/chat\/invite\/)([A-Za-z0-9]{4,32})(?:[/?#]|$)/.exec(t)
|
||||
return link ? link[1] : null
|
||||
}
|
||||
|
||||
/** What the daemon says about one of the account's groups. */
|
||||
export interface SteamChatGroup {
|
||||
id: string
|
||||
railId: string
|
||||
name: string
|
||||
tagline: string
|
||||
owner: string
|
||||
myRank: number
|
||||
myRankWord: string
|
||||
mayModerate: boolean
|
||||
mayManage: boolean
|
||||
mayInvite: boolean
|
||||
mayChangeRoles: boolean
|
||||
/** Giving roles to people and taking them away: a different permission, and not in a community group's chat. */
|
||||
mayAssignRoles: boolean
|
||||
mayMentionAll: boolean
|
||||
defaultRole: string
|
||||
clan: boolean
|
||||
muted: boolean
|
||||
notify: number
|
||||
permissions: { key: string; label: string }[]
|
||||
rooms: { id: string; name: string; voice: boolean }[]
|
||||
roles: { id: string; name: string }[]
|
||||
members: { steamid: string; name: string; rank: number; rankWord: string; roles: string[] }[]
|
||||
kicked: { steamid: string; name: string }[]
|
||||
}
|
||||
|
||||
/** Notification levels as Steam numbers them, in the order the menu lists them. */
|
||||
/** How long a removal keeps somebody out, as the Steam client offers it. */
|
||||
export const STEAM_KICK_LENGTHS: { label: string; seconds: number }[] = [
|
||||
{ label: 'An hour', seconds: 3600 },
|
||||
{ label: 'A day', seconds: 86400 },
|
||||
{ label: 'A week', seconds: 604800 }
|
||||
]
|
||||
|
||||
export const STEAM_NOTIFY_LEVELS: { value: number; label: string }[] = [
|
||||
{ value: 4, label: 'All messages' },
|
||||
{ value: 3, label: '@everyone and me' },
|
||||
{ value: 2, label: '@me only' },
|
||||
{ value: 1, label: 'Nothing' }
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { STEAM_MAX_BYTES, steamFileRefusal } from './steamfiles'
|
||||
|
||||
describe('the files Steam takes in a chat', () => {
|
||||
it('takes the types its own client takes, by extension in any case', () => {
|
||||
for (const name of ['a.jpg', 'a.JPEG', 'a.png', 'a.gif', 'a.webm', 'a.mpg', 'a.mp4', 'a.mpeg', 'a.ogv', 'a.webp', 'a.avif']) {
|
||||
expect(steamFileRefusal(name, 10), name).toBeNull()
|
||||
}
|
||||
})
|
||||
it('refuses the rest in words that name what it does take', () => {
|
||||
expect(steamFileRefusal('paper.pdf', 10)).toContain('.pdf')
|
||||
expect(steamFileRefusal('paper.pdf', 10)).toContain('webm')
|
||||
expect(steamFileRefusal('noextension')).toContain('that kind of')
|
||||
})
|
||||
it('refuses a file over thirty megabytes, and an empty one', () => {
|
||||
expect(steamFileRefusal('a.png', STEAM_MAX_BYTES)).toBeNull()
|
||||
expect(steamFileRefusal('a.png', STEAM_MAX_BYTES + 1)).toContain('30 MB')
|
||||
expect(steamFileRefusal('a.png', 0)).toContain('empty')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* What Steam takes as a file in a chat: the types and the size its own client
|
||||
* checks before it will start an upload. The daemon checks the same before it
|
||||
* sends; this is here so a file that will be refused says so at once, beside
|
||||
* the box, instead of after the message has been written and sent.
|
||||
*/
|
||||
export const STEAM_FILE_TYPES = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'mp4', 'webm', 'mpg', 'mpeg', 'ogv']
|
||||
|
||||
/** Thirty megabytes, the client's own limit. */
|
||||
export const STEAM_MAX_BYTES = 30 * 1024 * 1024
|
||||
|
||||
/** Why Steam will not take this file, or null if it will. */
|
||||
export function steamFileRefusal(name: string, size?: number): string | null {
|
||||
const dot = name.lastIndexOf('.')
|
||||
const extension = dot < 0 ? '' : name.slice(dot + 1).toLowerCase()
|
||||
if (!STEAM_FILE_TYPES.includes(extension)) {
|
||||
return `Steam does not take ${extension ? `.${extension}` : 'that kind of'} files in chat - it takes ${STEAM_FILE_TYPES.join(', ')}`
|
||||
}
|
||||
if (size === 0) return `${name} is empty`
|
||||
if (size !== undefined && size > STEAM_MAX_BYTES) return `${name} is over the ${STEAM_MAX_BYTES / 1024 / 1024} MB Steam allows in chat`
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { emojiPreview, extractMedia, formatMessage, splitTrailingPunctuation, normalizeBBCode, isSteamUpload, markSpoilered, stripEmbeddedUrls, sourcePick, steamSameText } from './format'
|
||||
import { formatsFor, serialize, wrap, type BoxNode } from './composeFormat'
|
||||
import { reactionLabel } from './reactions'
|
||||
import { isWarning } from './util'
|
||||
|
||||
const text = (value: string): BoxNode => ({ nodeType: 3, nodeValue: value, nodeName: '#text', childNodes: [] })
|
||||
const el = (nodeName: string, children: BoxNode[], dataset?: Record<string, string>): BoxNode => ({ nodeType: 1, nodeValue: null, nodeName, childNodes: children, dataset })
|
||||
const emoticon = (name: string): BoxNode => el('IMG', [], { token: `[steam:emote:${name}]` })
|
||||
|
||||
describe('what Steam sends is drawn', () => {
|
||||
it('draws an emoticon by its name, as a picture, and a sticker larger', () => {
|
||||
const html = formatMessage('hi [steam:emote:steamhappy] and [steam:sticker:CatSurprise]')
|
||||
expect(html).toContain('economy/emoticon/steamhappy')
|
||||
expect(html).toContain('class="custom-emoji"')
|
||||
expect(html).toContain('economy/sticker/CatSurprise')
|
||||
expect(html).toContain('custom-emoji steam-sticker')
|
||||
expect(html).not.toContain('[steam:')
|
||||
})
|
||||
|
||||
it('does not take other square brackets for pictures', () => {
|
||||
const html = formatMessage('[steam:emote:bad name] [steam:other:x] [steam:emote:]')
|
||||
expect(html).not.toContain('<img')
|
||||
})
|
||||
|
||||
it('draws the formatting the daemon read into marks', () => {
|
||||
const html = formatMessage('**bold** and *italic* and [u]under[/u] and ~~struck~~ and ||secret|| and [the site](https://example.com)')
|
||||
expect(html).toContain('<b>bold</b>')
|
||||
expect(html).toContain('<i>italic</i>')
|
||||
expect(html).toMatch(/<u>under<\/u>/)
|
||||
expect(html).toMatch(/<(s|del|strike)>struck<\/(s|del|strike)>/)
|
||||
expect(html).toContain('spoiler')
|
||||
expect(html).toContain('href="https://example.com"')
|
||||
})
|
||||
|
||||
it('previews an emoticon picked out of the picker', () => {
|
||||
expect(emojiPreview('[steam:emote:steamhappy]')?.label).toBe(':steamhappy:')
|
||||
expect(emojiPreview('[steam:emote:steamhappy]')?.src).toContain('economy/emoticon/steamhappy')
|
||||
expect(emojiPreview('[steam:emote:a b]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('what is written for Steam', () => {
|
||||
it('offers no formatting, because Steam takes none', () => {
|
||||
expect(formatsFor('steam')).toEqual([])
|
||||
expect(wrap('steam', 'bold', 'x')).toBe('x')
|
||||
})
|
||||
|
||||
it('escapes a bracket somebody typed, so it stays a bracket', () => {
|
||||
expect(serialize(el('DIV', [text('a [b]literal[/b] tag')]), 'steam')).toBe('a \\[b]literal\\[/b] tag')
|
||||
// Other services are not touched.
|
||||
expect(serialize(el('DIV', [text('a [b]x[/b]')]), 'discord')).toBe('a [b]x[/b]')
|
||||
})
|
||||
|
||||
it('sends bold typed in the box as plain text, not as tags Steam would show literally', () => {
|
||||
expect(serialize(el('DIV', [text('hi '), el('B', [text('there [x]')])]), 'steam')).toBe('hi there \\[x]')
|
||||
})
|
||||
|
||||
it('sends an emoticon as its name between colons, and only for Steam', () => {
|
||||
expect(serialize(el('DIV', [text('hello '), emoticon('steamhappy')]), 'steam')).toBe('hello :steamhappy:')
|
||||
expect(serialize(el('DIV', [emoticon('steamhappy')]), 'discord')).toBe('[steam:emote:steamhappy]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a reaction with a Steam emoticon', () => {
|
||||
it('is named by the emoticon', () => {
|
||||
expect(reactionLabel('[steam:emote:steamhappy]')).toBe(':steamhappy:')
|
||||
expect(reactionLabel('[steam:sticker:CatSurprise]')).toBe(':CatSurprise:')
|
||||
expect(reactionLabel('[steam:emote:a b]')).toBe('[steam:emote:a b]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('picking a Steam emoticon from the picker', () => {
|
||||
it('sends its token, which the box draws and sends as the colon form', () => {
|
||||
const entry = { id: 'steam:steambored', name: 'steambored', url: 'https://community.fastly.steamstatic.com/economy/emoticon/steambored', animated: false, locked: false }
|
||||
const token = sourcePick('steam', entry)
|
||||
expect(token).toBe('[steam:emote:steambored]')
|
||||
expect(emojiPreview(token)?.src).toContain('economy/emoticon/steambored')
|
||||
expect(serialize(el('DIV', [text('hi '), el('IMG', [], { token })]), 'steam')).toBe('hi :steambored:')
|
||||
// Sneedchat's id is its shortcode, and stays that.
|
||||
expect(sourcePick('sneedchat', { ...entry, id: ':lol:' })).toBe(':lol:')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a Steam message is the one just sent', () => {
|
||||
it('matches what was typed to what Steam logged, emoticons and brackets included', () => {
|
||||
expect(steamSameText('hi', 'hi')).toBe(true)
|
||||
expect(steamSameText(':steambored:', '[steam:emote:steambored]')).toBe(true)
|
||||
expect(steamSameText('a :steamhappy: b :steamsad:', 'a [steam:emote:steamhappy] b [steam:emote:steamsad]')).toBe(true)
|
||||
expect(steamSameText('not \\[b]bold', 'not [\u200bb]bold')).toBe(true)
|
||||
expect(steamSameText(':steambored:', '[steam:emote:steamhappy]')).toBe(false)
|
||||
expect(steamSameText('hello', 'hello there')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a picture sent through Steam', () => {
|
||||
const url = 'https://images.steamusercontent.com/ugc/1269778906054997137/522C3130A7C6AE1FC52CB49820609316A34ECD01/'
|
||||
it('is known by where Steam keeps it, since its address has no extension', () => {
|
||||
expect(isSteamUpload(url)).toBe(true)
|
||||
expect(isSteamUpload('https://example.com/ugc/1/2/')).toBe(false)
|
||||
expect(isSteamUpload('https://images.steamusercontent.com/other/1')).toBe(false)
|
||||
})
|
||||
it('is drawn as a picture', () => {
|
||||
expect(extractMedia(`\n${url}\n`).map((m) => [m.url, m.kind])).toEqual([[url, 'image']])
|
||||
})
|
||||
it('is drawn as what the message says it is, a video having no extension to say so', () => {
|
||||
const video = 'https://cdn.steamusercontent.com/ugc/9279387166147417773/0642C6E9889E1C2419DD7F85743538C67DD8CA36/'
|
||||
const items = extractMedia(`\n[steam:video:${video}]\n`)
|
||||
expect(items.map((m) => [m.url, m.kind])).toEqual([[video, 'video']])
|
||||
expect(stripEmbeddedUrls(`hi\n[steam:video:${video}]\n`, items)).toBe('hi')
|
||||
expect(formatMessage(`[steam:video:${video}]`)).toContain('cdn.steamusercontent.com')
|
||||
expect(formatMessage(`[steam:video:${video}]`)).not.toContain('steam:video')
|
||||
})
|
||||
it('is read from how the daemon first stored a video, too', () => {
|
||||
const video = 'https://cdn.steamusercontent.com/ugc/9279387166147417773/0642C6E9889E1C2419DD7F85743538C67DD8CA36/'
|
||||
const stored = `||\n${video}\n${video}[/video]||`
|
||||
const items = markSpoilered(extractMedia(normalizeBBCode(stored)), normalizeBBCode(stored))
|
||||
expect(items.map((m) => [m.kind, m.spoiler])).toEqual([['video', true]])
|
||||
})
|
||||
it('is hidden when Steam sent it as a spoiler, and leaves no empty bars behind', () => {
|
||||
const body = `||\n[steam:image:${url}]\n||`
|
||||
const items = markSpoilered(extractMedia(body), body)
|
||||
expect(items.map((m) => m.spoiler)).toEqual([true])
|
||||
expect(stripEmbeddedUrls(body, items)).toBe('')
|
||||
const plain = `${url}`
|
||||
expect(markSpoilered(extractMedia(plain), plain).map((m) => m.spoiler)).toEqual([undefined])
|
||||
})
|
||||
it('is read from the tag as it was stored before the daemon read it', () => {
|
||||
const stored = `[img src=${url} thumbnail_src=${url} srcset="" width=479 height=450]${url}`
|
||||
expect(normalizeBBCode(stored).trim()).toBe(`[steam:image:${url}]`)
|
||||
expect(extractMedia(normalizeBBCode(stored)).map((m) => [m.url, m.kind])).toEqual([[url, 'image']])
|
||||
})
|
||||
it('hides a picture a Sneedchat or Discord message spoilers, by the same rule', () => {
|
||||
const png = 'https://example.com/a.png'
|
||||
const sneed = normalizeBBCode(`[spoiler][img]${png}[/img][/spoiler]`)
|
||||
expect(markSpoilered(extractMedia(sneed), sneed).map((m) => m.spoiler)).toEqual([true])
|
||||
const discord = `look ||${png}||`
|
||||
expect(markSpoilered(extractMedia(discord), discord).map((m) => m.spoiler)).toEqual([true])
|
||||
const open = `look ${png}`
|
||||
expect(markSpoilered(extractMedia(open), open).map((m) => m.spoiler)).toEqual([undefined])
|
||||
})
|
||||
})
|
||||
|
||||
describe('a Steam sticker is named by words', () => {
|
||||
it('draws one whose name has spaces in it, larger, from an address that takes them', () => {
|
||||
const html = formatMessage('[steam:sticker:Nyan GLaDOS]')
|
||||
expect(html).toContain('economy/sticker/Nyan%20GLaDOS')
|
||||
expect(html).toContain('steam-sticker')
|
||||
expect(html).not.toContain('[steam:')
|
||||
expect(emojiPreview('[steam:sticker:Keyboard Headcrab]')?.label).toBe(':Keyboard Headcrab:')
|
||||
})
|
||||
it('keeps an emoticon one word', () => {
|
||||
expect(emojiPreview('[steam:emote:a b]')).toBeNull()
|
||||
expect(formatMessage('[steam:emote:a b]')).not.toContain('<img')
|
||||
})
|
||||
it('is held as a reaction under that name', () => {
|
||||
expect(reactionLabel('[steam:sticker:Nyan GLaDOS]')).toBe(':Nyan GLaDOS:')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a link in a sentence', () => {
|
||||
it('does not take the sentence\'s punctuation', () => {
|
||||
expect(splitTrailingPunctuation('https://x.test/a,')).toEqual(['https://x.test/a', ','])
|
||||
expect(splitTrailingPunctuation('https://x.test/a.')).toEqual(['https://x.test/a', '.'])
|
||||
expect(splitTrailingPunctuation('https://x.test/a?!')).toEqual(['https://x.test/a', '?!'])
|
||||
expect(splitTrailingPunctuation('https://x.test/a)')).toEqual(['https://x.test/a', ')'])
|
||||
expect(splitTrailingPunctuation('https://x.test/Foo_(bar)')).toEqual(['https://x.test/Foo_(bar)', ''])
|
||||
expect(splitTrailingPunctuation('https://x.test/?a=1&b=2')).toEqual(['https://x.test/?a=1&b=2', ''])
|
||||
expect(splitTrailingPunctuation('https://x.test/a?b=1')).toEqual(['https://x.test/a?b=1', ''])
|
||||
})
|
||||
it('is drawn with the punctuation outside it', () => {
|
||||
const html = formatMessage('read https://x.test/a, then https://x.test/b.')
|
||||
expect(html).toContain('<a href="https://x.test/a">https://x.test/a</a>,')
|
||||
expect(html).toContain('<a href="https://x.test/b">https://x.test/b</a>.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the message just sent, when Steam made something of its link', () => {
|
||||
it('is the one that came back as a picture, a video or a card', () => {
|
||||
const png = 'https://upload.wikimedia.org/a/b.png'
|
||||
expect(steamSameText(png, `[steam:image:${png}]`)).toBe(true)
|
||||
expect(steamSameText('https://x.test/v', '[steam:video:https://x.test/v]')).toBe(true)
|
||||
expect(steamSameText('x.salastil.com', '[x.salastil.com](http://x.salastil.com)')).toBe(true)
|
||||
expect(steamSameText('https://x.test/p', 'https://x.test/p')).toBe(true)
|
||||
expect(steamSameText(png, '[steam:image:https://other.test/c.png]')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('a warning line in a conversation', () => {
|
||||
it('is red whether it is written as a warning or was stored as Steam\'s notice before there was a kind for it', () => {
|
||||
expect(isWarning('warning')).toBe(true)
|
||||
expect(isWarning('system', 'notice:100:1')).toBe(true)
|
||||
expect(isWarning('system', '1790000000:1')).toBe(false)
|
||||
expect(isWarning('chat', 'notice:100:1')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Account } from '../../../shared/wire'
|
||||
import type { BufferEntry } from '../state/store'
|
||||
import { bufferMenuEntries, type BufferMenuInput } from './buffermenu'
|
||||
|
||||
const buffer = { id: 'steam:me|Gabe', accountId: 'steam:me', kind: 'dm', name: 'Gabe', remoteId: '76561197960265829' } as BufferEntry
|
||||
const account = { id: 'steam:me', service: 'steam' } as Account
|
||||
const noop = (): void => {}
|
||||
|
||||
function menu(extra: Partial<BufferMenuInput> = {}): string[] {
|
||||
const entries = bufferMenuEntries({
|
||||
buffer, account, muted: false, pinned: false, filed: false, inCall: false, poppedOut: false, canCall: false,
|
||||
live: false, watched: false, onTogglePin: noop, onToggleMute: noop, onHide: noop, onClose: noop, onCall: noop,
|
||||
onHangUp: noop, onFile: noop, onPopOut: noop, onDock: noop, ...extra
|
||||
})
|
||||
return entries.flatMap((e) => ('label' in e && e.label ? [e.label] : []))
|
||||
}
|
||||
|
||||
describe('the menu of a conversation with a Steam friend', () => {
|
||||
it('offers what to call them, ending the friendship and blocking them, when it is told they are one', () => {
|
||||
const asked: string[] = []
|
||||
const labels = menu({ onSteamFriend: (what) => asked.push(what) })
|
||||
expect(labels).toEqual(expect.arrayContaining(['Set nickname…', 'Remove friend', 'Block']))
|
||||
const entries = bufferMenuEntries({
|
||||
buffer, account, muted: false, pinned: false, filed: false, inCall: false, poppedOut: false, canCall: false,
|
||||
live: false, watched: false, onTogglePin: noop, onToggleMute: noop, onHide: noop, onClose: noop, onCall: noop,
|
||||
onHangUp: noop, onFile: noop, onPopOut: noop, onDock: noop, onSteamFriend: (what) => asked.push(what)
|
||||
})
|
||||
for (const label of ['Set nickname…', 'Remove friend', 'Block']) {
|
||||
const entry = entries.find((e) => 'label' in e && e.label === label)
|
||||
if (entry && 'onClick' in entry) entry.onClick?.()
|
||||
}
|
||||
expect(asked).toEqual(['nickname', 'remove', 'block'])
|
||||
})
|
||||
|
||||
it('does not, anywhere else', () => {
|
||||
const labels = menu()
|
||||
expect(labels).not.toContain('Remove friend')
|
||||
expect(labels).not.toContain('Block')
|
||||
expect(labels).not.toContain('Set nickname…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the way to a Steam friend\'s profile', () => {
|
||||
it('is the first thing on the menu of a conversation with one', () => {
|
||||
let opened = 0
|
||||
const entries = bufferMenuEntries({
|
||||
buffer, account, muted: false, pinned: false, filed: false, inCall: false, poppedOut: false, canCall: false,
|
||||
live: false, watched: false, onTogglePin: noop, onToggleMute: noop, onHide: noop, onClose: noop, onCall: noop,
|
||||
onHangUp: noop, onFile: noop, onPopOut: noop, onDock: noop, onProfile: () => opened++
|
||||
})
|
||||
expect('label' in entries[0] && entries[0].label).toBe('Profile')
|
||||
const first = entries[0]
|
||||
if ('onClick' in first) first.onClick?.()
|
||||
expect(opened).toBe(1)
|
||||
})
|
||||
|
||||
it('is not offered where there is no such column', () => {
|
||||
expect(menu()).not.toContain('Profile')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Account } from '../../../shared/wire'
|
||||
import type { BufferEntry } from '../state/store'
|
||||
import { dmCategoryId } from './dmcategories'
|
||||
import { folderName, headingName, parseSteamCategory, seedSteamFolders, steamCategoryId, type SteamGroup } from './steamgroups'
|
||||
|
||||
const account = { id: 'steam:me', service: 'steam', displayName: 'me' } as Account
|
||||
const dm = (name: string, remoteId: string): BufferEntry => ({ id: `steam:me|${name}`, accountId: 'steam:me', kind: 'dm', name, remoteId }) as BufferEntry
|
||||
const GABE = '76561197960265729'
|
||||
const ALYX = '76561197960287930'
|
||||
const heading = dmCategoryId('steam:me')
|
||||
|
||||
type Current = { categories: { id: string; name: string }[]; assignment: Record<string, string>; seeded: string[] }
|
||||
const start = (over: Partial<Current> = {}): Current => ({ categories: [{ id: heading, name: 'Steam - me' }], assignment: {}, seeded: [], ...over })
|
||||
const groups = (list: SteamGroup[]): Record<string, SteamGroup[]> => ({ 'steam:me': list })
|
||||
const buffers = [dm('Gabe', GABE), dm('Alyx', ALYX)]
|
||||
const filed = { 'steam:me|Gabe': heading, 'steam:me|Alyx': heading }
|
||||
|
||||
describe('a Steam folder is named for its id and read back from it', () => {
|
||||
it('has a stable heading id', () => {
|
||||
expect(steamCategoryId('steam:me', 7)).toBe('sg:steam:me:7')
|
||||
expect(parseSteamCategory('sg:steam:me:7')).toEqual({ accountId: 'steam:me', groupId: 7 })
|
||||
expect(parseSteamCategory(heading)).toBeNull()
|
||||
expect(parseSteamCategory('sg:nonsense')).toBeNull()
|
||||
})
|
||||
|
||||
it('is shown without the "Steam - " some of them carry, and a heading from it is called protocol, then folder', () => {
|
||||
expect(folderName('Steam - Goons')).toBe('Goons')
|
||||
expect(folderName('Friends')).toBe('Friends')
|
||||
expect(headingName(account, { id: 1, name: 'Steam - Goons', members: [] })).toBe('Steam - Goons')
|
||||
expect(headingName(account, { id: 3, name: 'Friends', members: [] })).toBe('Steam - Friends')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the direct-messages page starts from an account\'s folders, once', () => {
|
||||
it('makes a heading for each, with their friends under it, the first time they are heard', () => {
|
||||
const plan = seedSteamFolders([account], buffers, groups([{ id: 7, name: 'Steam - Raiders', members: [GABE] }]), start({ assignment: filed }))!
|
||||
expect(plan.categories.find((c) => c.id === 'sg:steam:me:7')?.name).toBe('Steam - Raiders')
|
||||
expect(plan.assignment['steam:me|Gabe']).toBe('sg:steam:me:7')
|
||||
expect(plan.assignment['steam:me|Alyx']).toBe(heading)
|
||||
expect(plan.seeded).toEqual(['steam:me'])
|
||||
})
|
||||
|
||||
it('leaves a conversation a person has put somewhere themselves where it is', () => {
|
||||
const plan = seedSteamFolders([account], buffers, groups([{ id: 7, name: 'Raiders', members: [GABE] }]), start({ assignment: { ...filed, 'steam:me|Gabe': 'cat-mine' } }))!
|
||||
expect(plan.assignment['steam:me|Gabe']).toBe('cat-mine')
|
||||
})
|
||||
|
||||
it('does nothing before Steam has spoken, and nothing for an account with no folders but marks it', () => {
|
||||
expect(seedSteamFolders([account], buffers, {}, start())).toBeNull()
|
||||
const none = seedSteamFolders([account], buffers, groups([]), start())!
|
||||
expect(none.categories).toEqual(start().categories)
|
||||
expect(none.seeded).toEqual(['steam:me'])
|
||||
})
|
||||
|
||||
it('never does it again: a folder made on Steam later is not made here, and one deleted here is not made again', () => {
|
||||
const first = seedSteamFolders([account], buffers, groups([{ id: 7, name: 'Raiders', members: [GABE] }]), start({ assignment: filed }))!
|
||||
const later = groups([{ id: 7, name: 'Raiders', members: [GABE] }, { id: 8, name: 'Work', members: [ALYX] }])
|
||||
expect(seedSteamFolders([account], buffers, later, { categories: first.categories, assignment: first.assignment, seeded: first.seeded })).toBeNull()
|
||||
const deleted = first.categories.filter((c) => c.id !== 'sg:steam:me:7')
|
||||
expect(seedSteamFolders([account], buffers, later, { categories: deleted, assignment: first.assignment, seeded: first.seeded })).toBeNull()
|
||||
})
|
||||
|
||||
it('follows nothing: a rename or a move on Steam does not reach the page', () => {
|
||||
const first = seedSteamFolders([account], buffers, groups([{ id: 7, name: 'Raiders', members: [GABE] }]), start({ assignment: filed }))!
|
||||
const moved = groups([{ id: 7, name: 'Renamed', members: [ALYX] }])
|
||||
expect(seedSteamFolders([account], buffers, moved, { categories: first.categories, assignment: first.assignment, seeded: first.seeded })).toBeNull()
|
||||
})
|
||||
|
||||
it('only marks an account whose page already has headings from its folders, and leaves them as they are', () => {
|
||||
const had = [{ id: heading, name: 'Steam - me' }, { id: 'sg:steam:me:7', name: 'Steam - Raiders' }, { id: 'sg:steam:me:8', name: 'Mine now' }]
|
||||
const plan = seedSteamFolders([account], buffers, groups([{ id: 7, name: 'Steam - Raiders', members: [GABE] }]), { categories: had, assignment: { 'steam:me|Gabe': 'sg:steam:me:7' }, seeded: [] })!
|
||||
expect(plan.categories.map((c) => c.name)).toEqual(['Steam - me', 'Steam - Raiders', 'Mine now'])
|
||||
expect(plan.assignment['steam:me|Gabe']).toBe('sg:steam:me:7')
|
||||
expect(plan.seeded).toEqual(['steam:me'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Account } from '../../../shared/wire'
|
||||
import type { BufferEntry } from '../state/store'
|
||||
import type { CustomCategory } from './categories'
|
||||
import { dmCategoryId } from './dmcategories'
|
||||
import { serviceLabel } from './util'
|
||||
|
||||
/**
|
||||
* Steam's friend folders, and the headings on the direct-messages page that start from them.
|
||||
*
|
||||
* A Steam account keeps folders for its friends - "Raiders", "Work" - and a friend can be in
|
||||
* several. The direct-messages page has headings that a person makes, renames, fills by dragging
|
||||
* and deletes, and those are this window's own: nothing done to them is done on Steam, and nothing
|
||||
* Steam does reaches them. The folders are mirrored both ways only on the Steam account's own page,
|
||||
* where they are the page's headings and not copies of anything.
|
||||
*
|
||||
* What this does is start the direct-messages page from them, once: the first time an account's
|
||||
* folders are heard, if it has any, there is a heading for each, and each friend's conversation is
|
||||
* under the folder they are in. After that the headings are the person's, so a folder made on Steam
|
||||
* later is not made here, one deleted here is not made again, and none of it is sent to Steam.
|
||||
*/
|
||||
|
||||
export interface SteamGroup {
|
||||
id: number
|
||||
name: string
|
||||
/** Friends in it, by 64-bit id as text. */
|
||||
members: string[]
|
||||
}
|
||||
|
||||
const PREFIX = 'sg:'
|
||||
|
||||
/** The heading that stands for one of an account's groups. Stable across renames. */
|
||||
export function steamCategoryId(accountId: string, groupId: number): string {
|
||||
return `${PREFIX}${accountId}:${groupId}`
|
||||
}
|
||||
|
||||
/** Which account and group a heading stands for, if it stands for one. */
|
||||
export function parseSteamCategory(id: string): { accountId: string; groupId: number } | null {
|
||||
if (!id.startsWith(PREFIX)) return null
|
||||
const rest = id.slice(PREFIX.length)
|
||||
const colon = rest.lastIndexOf(':')
|
||||
const groupId = Number(rest.slice(colon + 1))
|
||||
return colon > 0 && Number.isInteger(groupId) ? { accountId: rest.slice(0, colon), groupId } : null
|
||||
}
|
||||
|
||||
/** A change to make on Steam. */
|
||||
export interface GroupCall {
|
||||
accountId: string
|
||||
action: 'add' | 'remove' | 'rename' | 'delete' | 'create'
|
||||
groupId: number
|
||||
steamid?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
/** A folder's name as it is shown: Steam's own list holds some with "Steam - " in front, which its own client does not show. */
|
||||
export function folderName(name: string): string {
|
||||
return name.replace(/^Steam - /, '').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* What a heading made from a folder is called on the direct-messages page: the protocol, then the
|
||||
* folder, as that page's headings for an account are ("Steam - Goons"), so a folder is not taken
|
||||
* for another protocol's heading of the same name.
|
||||
*/
|
||||
export function headingName(account: Pick<Account, 'service'>, group: SteamGroup): string {
|
||||
const name = folderName(group.name) || group.name
|
||||
return `${serviceLabel(account.service)} - ${name}`
|
||||
}
|
||||
|
||||
export interface SteamSeed {
|
||||
categories: CustomCategory[]
|
||||
assignment: Record<string, string>
|
||||
/** The accounts whose folders have been looked at, whether or not there was anything to make. */
|
||||
seeded: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the direct-messages page from an account's folders, once. Null when there is nothing to do,
|
||||
* so the caller writes nothing on the great majority of calls. `groups` has an entry only for an
|
||||
* account whose folders have been heard: one that has not said yet is not one with none.
|
||||
*
|
||||
* An account whose page already has headings from its folders - from before this was done once, when
|
||||
* they were followed - is only marked, and its headings are left as they are.
|
||||
*/
|
||||
export function seedSteamFolders(
|
||||
accounts: Account[],
|
||||
buffers: BufferEntry[],
|
||||
groups: Record<string, SteamGroup[] | undefined>,
|
||||
current: { categories: CustomCategory[]; assignment: Record<string, string>; seeded: string[] }
|
||||
): SteamSeed | null {
|
||||
let categories = current.categories.map((c) => ({ ...c }))
|
||||
const assignment = { ...current.assignment }
|
||||
const seeded = [...current.seeded]
|
||||
let changed = false
|
||||
|
||||
for (const account of accounts) {
|
||||
if (account.service !== 'steam' || seeded.includes(account.id)) continue
|
||||
const heard = groups[account.id]
|
||||
if (!heard) continue
|
||||
const own = `${PREFIX}${account.id}:`
|
||||
// Headings from its folders already on the page - from before this was done once - are left as
|
||||
// they are: they are the person's.
|
||||
if (!categories.some((c) => c.id.startsWith(own))) {
|
||||
const heading = dmCategoryId(account.id)
|
||||
const sorted = [...heard].sort((a, b) => a.id - b.id)
|
||||
for (const g of sorted) categories.push({ id: steamCategoryId(account.id, g.id), name: headingName(account, g) })
|
||||
for (const b of buffers) {
|
||||
if (b.accountId !== account.id || b.kind !== 'dm' || !b.remoteId) continue
|
||||
const home = sorted.find((g) => g.members.includes(b.remoteId!))
|
||||
// Only a conversation still where the account's heading put it: one a person has put somewhere is theirs.
|
||||
if (home && (!assignment[b.id] || assignment[b.id] === heading)) assignment[b.id] = steamCategoryId(account.id, home.id)
|
||||
}
|
||||
}
|
||||
seeded.push(account.id)
|
||||
changed = true
|
||||
}
|
||||
return changed ? { categories, assignment, seeded } : null
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AGAIN, SteamVoice, browserOf, plainFailure, shapeOffer, speakersOf } from './steamvoice'
|
||||
|
||||
const hooks = (rpc: (p: Record<string, unknown>) => Promise<unknown> = async () => ({})) => ({
|
||||
rpc: rpc as never,
|
||||
onChange: () => {},
|
||||
toast: () => {},
|
||||
muted: () => ({ micMuted: false, deafened: false }),
|
||||
nameOf: (_a: string, s: string) => s,
|
||||
mayRing: () => true
|
||||
})
|
||||
|
||||
describe('Steam voice', () => {
|
||||
it('asks for discontinuous transmission on the opus line and leaves the rest of the offer alone', () => {
|
||||
const offer = ['v=0', 'm=audio 9 UDP/TLS/RTP/SAVPF 111 63', 'a=rtpmap:111 opus/48000/2', 'a=fmtp:111 minptime=10;useinbandfec=1', 'a=rtpmap:63 red/48000/2', 'a=fmtp:63 111/111', ''].join('\r\n')
|
||||
expect(shapeOffer(offer).split('\r\n')).toEqual(['v=0', 'm=audio 9 UDP/TLS/RTP/SAVPF 111 63', 'a=rtpmap:111 opus/48000/2', 'a=fmtp:111 minptime=10;useinbandfec=1;usedtx=1', 'a=rtpmap:63 red/48000/2', 'a=fmtp:63 111/111', ''])
|
||||
expect(shapeOffer('v=0\r\n')).toBe('v=0\r\n')
|
||||
})
|
||||
|
||||
it('says it is Chrome, which is what Electron is, with the version it has', () => {
|
||||
expect(browserOf('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) moho/1.0 Chrome/152.0.7977.130 Electron/44.4.4 Safari/537.36')).toEqual({ name: 'Chrome', version: '152.0.7977.130' })
|
||||
expect(browserOf('nothing')).toEqual({ name: 'Chrome', version: '0' })
|
||||
})
|
||||
|
||||
it('finds who is speaking from what each stream carries, ignoring streams nobody owns and quiet ones', () => {
|
||||
const owners = new Map([[1, '7656'], [2, '7657'], [3, '7658']])
|
||||
const sources = [{ source: 1, audioLevel: 0.4 }, { source: 2, audioLevel: 0.001 }, { source: 9, audioLevel: 0.9 }, { source: 3 }]
|
||||
expect([...speakersOf(sources, owners)]).toEqual(['7656'])
|
||||
})
|
||||
|
||||
it('shows a call as ringing until it is answered or ended, one ring a person', () => {
|
||||
const v = new SteamVoice(hooks())
|
||||
v.handle({ accountId: 'steam:me', kind: 'callRequested', data: { steamid: '7656', voiceChatId: '10' } })
|
||||
v.handle({ accountId: 'steam:me', kind: 'callRequested', data: { steamid: '7657', voiceChatId: '11' } })
|
||||
v.handle({ accountId: 'steam:me', kind: 'callRequested', data: { steamid: '7656', voiceChatId: '12' } })
|
||||
expect(v.rings.map((r) => [r.steamid, r.voiceChatId])).toEqual([['7657', '11'], ['7656', '12']])
|
||||
v.handle({ accountId: 'steam:me', kind: 'ended', data: { voiceChatId: '11' } })
|
||||
expect(v.rings.map((r) => r.steamid)).toEqual(['7656'])
|
||||
})
|
||||
|
||||
it('does not ring for somebody who may not ring: not a friend, or while the account is busy', () => {
|
||||
const v = new SteamVoice({ ...hooks(), mayRing: (_a, s) => s === '7656' })
|
||||
v.handle({ accountId: 'steam:me', kind: 'callRequested', data: { steamid: '7657', voiceChatId: '11' } })
|
||||
expect(v.rings).toEqual([])
|
||||
v.handle({ accountId: 'steam:me', kind: 'callRequested', data: { steamid: '7656', voiceChatId: '12' } })
|
||||
expect(v.rings.map((r) => r.steamid)).toEqual(['7656'])
|
||||
expect(v.ringFrom('steam:me', '7656')?.voiceChatId).toBe('12')
|
||||
})
|
||||
|
||||
it('declining a call says no to Steam and takes the ring away', async () => {
|
||||
const sent: Record<string, unknown>[] = []
|
||||
const v = new SteamVoice(hooks(async (p) => (sent.push(p), {})))
|
||||
v.handle({ accountId: 'steam:me', kind: 'callRequested', data: { steamid: '7656', voiceChatId: '10' } })
|
||||
await v.decline(v.rings[0])
|
||||
expect(v.rings).toEqual([])
|
||||
expect(sent).toEqual([{ op: 'answerCall', accountId: 'steam:me', steamid: '7656', voiceChatId: '10', accept: false }])
|
||||
})
|
||||
|
||||
it('takes the mark off a failure that may pass before it is said', () => {
|
||||
expect(plainFailure(`${AGAIN}Steam is not answering just now`)).toBe('Steam is not answering just now')
|
||||
expect(plainFailure('this room has no voice')).toBe('this room has no voice')
|
||||
})
|
||||
|
||||
it('does nothing about a call it is not in', () => {
|
||||
const v = new SteamVoice(hooks())
|
||||
v.handle({ accountId: 'steam:me', kind: 'sessionConnected', data: { ssrc: 1 } })
|
||||
v.handle({ accountId: 'steam:me', kind: 'remoteDescription', data: { sdp: '{}' } })
|
||||
expect(v.phase).toBe('idle')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* Steam voice: one call, or one room's voice chat, held in the window.
|
||||
*
|
||||
* Steam's voice is WebRTC with Valve's own server as the only peer. The window
|
||||
* offers, the server answers with a description of a relay it holds for the
|
||||
* session, and from there audio goes to the relay and comes back mixed, with
|
||||
* the server renegotiating as people come and go and saying who is who by stream
|
||||
* number. Chromium does all the media; the daemon carries the signalling on the
|
||||
* account's connection (backend/steam/voice.rs, which says where each call was
|
||||
* read from), and everything it hears comes back as `steamVoice` events.
|
||||
*
|
||||
* The order is the Steam client's own: open the microphone, make the offer,
|
||||
* `initiate`; the server says the session is `sessionConnected`; once the media
|
||||
* path is up either `joinRoom` or `requestCall`; then `data`, which names the
|
||||
* stream that is ours and is what makes the chat count this connection as being
|
||||
* in it. Everything before that is plumbing and nobody is told of it.
|
||||
*/
|
||||
|
||||
/** What a call is doing, in the words a person would use. */
|
||||
export type SteamVoicePhase = 'idle' | 'starting' | 'connecting' | 'ringing' | 'connected'
|
||||
|
||||
/** Where the voice is. */
|
||||
export type SteamVoiceTarget =
|
||||
| { kind: 'room'; groupId: string; roomId: string; bufferId: string; name: string; groupName: string; railId: string }
|
||||
| { kind: 'friend'; steamid: string; bufferId: string; name: string; voiceChatId?: string }
|
||||
|
||||
/** How somebody in the call is showing, as they last said. */
|
||||
export interface SteamVoiceStatus {
|
||||
micMuted: boolean
|
||||
outputMuted: boolean
|
||||
noMic: boolean
|
||||
}
|
||||
|
||||
/** What the daemon sends up. */
|
||||
export interface SteamVoiceEvent {
|
||||
accountId: string
|
||||
kind: string
|
||||
data: Record<string, any>
|
||||
}
|
||||
|
||||
/** Somebody calling this account. */
|
||||
export interface SteamRing {
|
||||
accountId: string
|
||||
steamid: string
|
||||
voiceChatId: string
|
||||
}
|
||||
|
||||
export interface SteamVoiceHooks {
|
||||
/** One call to the daemon's `steamVoice`. */
|
||||
rpc: (params: Record<string, unknown>) => Promise<any>
|
||||
/** Something about the call that a window shows changed. */
|
||||
onChange: () => void
|
||||
toast: (kind: 'info' | 'error', message: string) => void
|
||||
/** Whether the microphone is muted, and the output, as the footer buttons say. */
|
||||
muted: () => { micMuted: boolean; deafened: boolean }
|
||||
/** Who a Steam id is, for the name on a ring. */
|
||||
nameOf: (accountId: string, steamid: string) => string
|
||||
/** Whether a call from this person may ring: a friend, while the account is not invisible or busy. */
|
||||
mayRing: (accountId: string, steamid: string) => boolean
|
||||
}
|
||||
|
||||
/** How long a call may take to connect before it is given up on. */
|
||||
const CONNECT_TIMEOUT_MS = 60_000
|
||||
/**
|
||||
* What the daemon puts at the front of a failure that may be gone in a moment, as the Steam client
|
||||
* reads them: it tries again, after a little longer each time, for up to a minute.
|
||||
*/
|
||||
export const AGAIN = '[again] '
|
||||
|
||||
/** How long to wait before each new try: the Steam client's own, quick at first and then slower. */
|
||||
const RETRY_DELAYS_MS = [250, 1000, 1000, 5000, 5000, 10000, 10000, 10000, 10000, 10000, 30000]
|
||||
|
||||
/** The words of a failure, without the mark that says it may pass. */
|
||||
export function plainFailure(message: string): string {
|
||||
return message.startsWith(AGAIN) ? message.slice(AGAIN.length) : message
|
||||
}
|
||||
|
||||
/** How long somebody has to answer. */
|
||||
const RING_TIMEOUT_MS = 300_000
|
||||
|
||||
/**
|
||||
* The offer as the server wants it: the opus line says what the Steam client's own
|
||||
* says - discontinuous transmission on, which is what keeps a silent microphone
|
||||
* from filling the relay with packets of nothing.
|
||||
*/
|
||||
export function shapeOffer(sdp: string): string {
|
||||
const lines = sdp.split('\r\n')
|
||||
const pt = lines.map((l) => /^a=rtpmap:(\d+)\sopus/.exec(l)?.[1]).find(Boolean)
|
||||
if (!pt) return sdp
|
||||
return lines.map((l) => (l.startsWith(`a=fmtp:${pt} `) ? `a=fmtp:${pt} minptime=10;useinbandfec=1;usedtx=1` : l)).join('\r\n')
|
||||
}
|
||||
|
||||
/** The browser as the server is told it: its name and its version. */
|
||||
export function browserOf(userAgent: string): { name: string; version: string } {
|
||||
const chrome = /Chrome\/([\d.]+)/.exec(userAgent)
|
||||
return { name: 'Chrome', version: chrome?.[1] ?? '0' }
|
||||
}
|
||||
|
||||
/** Who is speaking, from what each stream is carrying and who each stream belongs to. */
|
||||
export function speakersOf(
|
||||
sources: { source: number; audioLevel?: number }[],
|
||||
owners: Map<number, string>,
|
||||
threshold = 0.02
|
||||
): Set<string> {
|
||||
const out = new Set<string>()
|
||||
for (const s of sources) {
|
||||
const who = owners.get(s.source)
|
||||
if (who && (s.audioLevel ?? 0) > threshold) out.add(who)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export class SteamVoice {
|
||||
accountId = ''
|
||||
target: SteamVoiceTarget | null = null
|
||||
phase: SteamVoicePhase = 'idle'
|
||||
/** The loudest the microphone has been in the last moment, 0 to 1. */
|
||||
level = 0
|
||||
rttMs: number | null = null
|
||||
/** Steam ids of whoever is audible. */
|
||||
speakers = new Set<string>()
|
||||
/** What the others say about themselves. */
|
||||
statuses = new Map<string, SteamVoiceStatus>()
|
||||
rings: SteamRing[] = []
|
||||
|
||||
private hooks: SteamVoiceHooks
|
||||
private pc: RTCPeerConnection | null = null
|
||||
private mic: MediaStream | null = null
|
||||
private audio: AudioContext | null = null
|
||||
private analyser: AnalyserNode | null = null
|
||||
private sinks = new Map<string, HTMLAudioElement>()
|
||||
private voiceChatId = ''
|
||||
private session: { ssrc: number; clientIp: number; clientPort: number; serverIp: number; serverPort: number } | null = null
|
||||
private iceUp = false
|
||||
private joining = false
|
||||
private accepted = false
|
||||
private owners = new Map<number, string>()
|
||||
private updating = false
|
||||
private pending: Record<string, any> | null = null
|
||||
private meters: ReturnType<typeof setInterval> | null = null
|
||||
private stats: ReturnType<typeof setInterval> | null = null
|
||||
private deadline: ReturnType<typeof setTimeout> | null = null
|
||||
/** Bumped on every start and stop, so what comes back late for a call that is gone is dropped. */
|
||||
private epoch = 0
|
||||
/** How many times this call has been tried again, and when it first began. */
|
||||
private attempt = 0
|
||||
private began = 0
|
||||
|
||||
constructor(hooks: SteamVoiceHooks) {
|
||||
this.hooks = hooks
|
||||
}
|
||||
|
||||
get active(): boolean {
|
||||
return this.phase !== 'idle'
|
||||
}
|
||||
|
||||
/** Begins voice in a room, or a call to a friend, on an account. */
|
||||
async start(accountId: string, target: SteamVoiceTarget, retrying = false): Promise<void> {
|
||||
if (this.active) await this.stop()
|
||||
if (!retrying) {
|
||||
this.attempt = 0
|
||||
this.began = Date.now()
|
||||
}
|
||||
const epoch = ++this.epoch
|
||||
this.accountId = accountId
|
||||
this.target = target
|
||||
this.phase = 'starting'
|
||||
this.voiceChatId = target.kind === 'friend' ? (target.voiceChatId ?? '') : ''
|
||||
this.session = null
|
||||
this.iceUp = false
|
||||
this.joining = false
|
||||
this.accepted = target.kind === 'friend' && !!target.voiceChatId
|
||||
this.hooks.onChange()
|
||||
this.deadline = setTimeout(() => this.fail('Voice did not connect'), CONNECT_TIMEOUT_MS)
|
||||
try {
|
||||
await this.openMic()
|
||||
if (epoch !== this.epoch) return
|
||||
await this.offer(epoch)
|
||||
} catch (e) {
|
||||
this.failed(epoch, e as Error, "Couldn't start voice")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A call went wrong: if it may pass, and it is not yet a minute since it began, start it again after
|
||||
* the next of the waits the Steam client uses; otherwise say so. What is late for a call that is gone
|
||||
* is dropped.
|
||||
*/
|
||||
private failed(epoch: number, error: Error, what: string): void {
|
||||
if (epoch !== this.epoch) return
|
||||
const target = this.target
|
||||
if (error.message.startsWith(AGAIN) && target && Date.now() - this.began < CONNECT_TIMEOUT_MS) {
|
||||
const account = this.accountId
|
||||
const delay = RETRY_DELAYS_MS[Math.min(this.attempt++, RETRY_DELAYS_MS.length - 1)]
|
||||
this.epoch++
|
||||
this.teardown()
|
||||
setTimeout(() => void this.start(account, target, true), delay)
|
||||
return
|
||||
}
|
||||
this.fail(`${what}: ${plainFailure(error.message)}`)
|
||||
}
|
||||
|
||||
/** Leaves: a room is left, and a call is hung up on, which ends it for both. */
|
||||
async stop(): Promise<void> {
|
||||
const { target, accountId, voiceChatId } = this
|
||||
const was = this.phase
|
||||
this.epoch++
|
||||
this.teardown()
|
||||
if (!target || was === 'idle') return
|
||||
try {
|
||||
if (target.kind === 'room') {
|
||||
if (was === 'connected' || voiceChatId) await this.hooks.rpc({ op: 'leaveRoom', accountId, groupId: target.groupId, roomId: target.roomId })
|
||||
} else if (voiceChatId) {
|
||||
await this.hooks.rpc({ op: 'endCall', accountId, steamid: target.steamid })
|
||||
}
|
||||
} catch {
|
||||
// Already gone: Steam ends it on its side when the connection drops.
|
||||
}
|
||||
}
|
||||
|
||||
private fail(message: string): void {
|
||||
this.hooks.toast('error', message)
|
||||
void this.stop()
|
||||
}
|
||||
|
||||
private teardown(): void {
|
||||
for (const t of [this.deadline]) if (t) clearTimeout(t)
|
||||
this.deadline = null
|
||||
if (this.meters) clearInterval(this.meters)
|
||||
if (this.stats) clearInterval(this.stats)
|
||||
this.meters = this.stats = null
|
||||
this.pc?.close()
|
||||
this.pc = null
|
||||
this.mic?.getTracks().forEach((t) => t.stop())
|
||||
this.mic = null
|
||||
void this.audio?.close().catch(() => {})
|
||||
this.audio = null
|
||||
this.analyser = null
|
||||
for (const el of this.sinks.values()) {
|
||||
el.pause()
|
||||
el.srcObject = null
|
||||
el.remove()
|
||||
}
|
||||
this.sinks.clear()
|
||||
this.phase = 'idle'
|
||||
this.target = null
|
||||
this.level = 0
|
||||
this.rttMs = null
|
||||
this.speakers = new Set()
|
||||
this.statuses.clear()
|
||||
this.owners.clear()
|
||||
this.pending = null
|
||||
this.updating = false
|
||||
this.voiceChatId = ''
|
||||
this.session = null
|
||||
this.hooks.onChange()
|
||||
}
|
||||
|
||||
private async openMic(): Promise<void> {
|
||||
try {
|
||||
this.mic = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } })
|
||||
} catch {
|
||||
// Listening without a microphone is still a call; the others are told there is none.
|
||||
this.mic = null
|
||||
}
|
||||
this.applyMute()
|
||||
}
|
||||
|
||||
/** Follows the footer's microphone and deafen buttons. */
|
||||
applyMute(): void {
|
||||
const { micMuted, deafened } = this.hooks.muted()
|
||||
this.mic?.getAudioTracks().forEach((t) => (t.enabled = !(micMuted || deafened)))
|
||||
for (const el of this.sinks.values()) el.muted = deafened
|
||||
if (this.phase === 'connected') void this.sendStatus()
|
||||
}
|
||||
|
||||
private async sendStatus(): Promise<void> {
|
||||
if (!this.voiceChatId) return
|
||||
const { micMuted, deafened } = this.hooks.muted()
|
||||
await this.hooks
|
||||
.rpc({ op: 'status', accountId: this.accountId, voiceChatId: this.voiceChatId, micMuted: micMuted || deafened, outputMuted: deafened, noMic: !this.mic?.getAudioTracks().length, sampleRate: this.audio?.sampleRate ?? 48000 })
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
private async offer(epoch: number): Promise<void> {
|
||||
const pc = new RTCPeerConnection()
|
||||
this.pc = pc
|
||||
this.mic?.getAudioTracks().forEach((t) => pc.addTrack(t, this.mic as MediaStream))
|
||||
pc.ontrack = (e) => this.hear(e.track, e.streams[0])
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
if (pc !== this.pc) return
|
||||
if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') {
|
||||
this.iceUp = true
|
||||
void this.tryJoin(epoch)
|
||||
} else if (pc.iceConnectionState === 'failed') {
|
||||
this.fail('The connection to Steam voice failed')
|
||||
}
|
||||
}
|
||||
this.startMeters()
|
||||
const made = await pc.createOffer({ offerToReceiveAudio: true, voiceActivityDetection: true } as RTCOfferOptions)
|
||||
if (epoch !== this.epoch) return
|
||||
const sdp = shapeOffer(made.sdp ?? '')
|
||||
await pc.setLocalDescription({ type: 'offer', sdp })
|
||||
this.phase = 'connecting'
|
||||
this.hooks.onChange()
|
||||
const browser = browserOf(navigator.userAgent)
|
||||
const answer = await this.hooks.rpc({ op: 'initiate', accountId: this.accountId, sdp: JSON.stringify({ type: 'offer', sdp }), browserName: browser.name, browserVersion: browser.version })
|
||||
if (epoch !== this.epoch || pc !== this.pc) return
|
||||
const remote = JSON.parse(answer.remoteDescription as string) as RTCSessionDescriptionInit
|
||||
await pc.setRemoteDescription(remote)
|
||||
}
|
||||
|
||||
/** The media path is up and Steam has said the session is connected: join the chat. */
|
||||
private async tryJoin(epoch: number): Promise<void> {
|
||||
if (epoch !== this.epoch || this.joining || !this.iceUp || !this.session || !this.target) return
|
||||
this.joining = true
|
||||
const target = this.target
|
||||
try {
|
||||
if (target.kind === 'room') {
|
||||
const joined = await this.hooks.rpc({ op: 'joinRoom', accountId: this.accountId, groupId: target.groupId, roomId: target.roomId })
|
||||
if (epoch !== this.epoch) return
|
||||
this.voiceChatId = joined.voiceChatId ?? ''
|
||||
await this.connectData(epoch)
|
||||
} else if (this.voiceChatId) {
|
||||
// Somebody called, and has been answered.
|
||||
await this.connectData(epoch)
|
||||
} else {
|
||||
const asked = await this.hooks.rpc({ op: 'requestCall', accountId: this.accountId, steamid: target.steamid })
|
||||
if (epoch !== this.epoch) return
|
||||
this.voiceChatId = asked.voiceChatId ?? ''
|
||||
this.phase = 'ringing'
|
||||
if (this.deadline) clearTimeout(this.deadline)
|
||||
this.deadline = setTimeout(() => this.fail(`${target.name} did not answer`), RING_TIMEOUT_MS)
|
||||
this.hooks.onChange()
|
||||
if (this.accepted) await this.connectData(epoch)
|
||||
}
|
||||
} catch (e) {
|
||||
this.failed(epoch, e as Error, "Couldn't join voice")
|
||||
}
|
||||
}
|
||||
|
||||
/** Tells the chat which stream is ours, which is what makes this connection part of it. */
|
||||
private async connectData(epoch: number): Promise<void> {
|
||||
const s = this.session
|
||||
if (!s || !this.voiceChatId) return
|
||||
await this.hooks.rpc({ op: 'data', accountId: this.accountId, voiceChatId: this.voiceChatId, ssrc: s.ssrc, clientIp: s.clientIp, clientPort: s.clientPort, serverIp: s.serverIp, serverPort: s.serverPort, userAgent: navigator.userAgent, worklets: true, dataChannel: false })
|
||||
if (epoch !== this.epoch) return
|
||||
if (this.deadline) clearTimeout(this.deadline)
|
||||
this.deadline = null
|
||||
this.phase = 'connected'
|
||||
this.hooks.onChange()
|
||||
void this.sendStatus()
|
||||
}
|
||||
|
||||
/** What the server says: the session connecting, a new offer, people in the call, a call arriving or ending. */
|
||||
handle(event: SteamVoiceEvent): void {
|
||||
// Calls arriving and ending concern a window that is not in one too.
|
||||
if (event.kind !== 'callRequested' && event.kind !== 'ended' && event.accountId !== this.accountId) return
|
||||
const d = event.data
|
||||
switch (event.kind) {
|
||||
case 'sessionConnected':
|
||||
if (!this.active) return
|
||||
this.session = { ssrc: d.ssrc, clientIp: d.clientIp, clientPort: d.clientPort, serverIp: d.serverIp, serverPort: d.serverPort }
|
||||
void this.tryJoin(this.epoch)
|
||||
break
|
||||
case 'remoteDescription':
|
||||
if (!this.active || !this.pc) return
|
||||
if (this.updating) this.pending = d
|
||||
else void this.renegotiate(d)
|
||||
break
|
||||
case 'status': {
|
||||
if (d.voiceChatId !== this.voiceChatId) return
|
||||
this.statuses.set(d.steamid, { micMuted: !!d.micMuted, outputMuted: !!d.outputMuted, noMic: !!d.noMic })
|
||||
this.hooks.onChange()
|
||||
break
|
||||
}
|
||||
case 'allStatus':
|
||||
if (d.voiceChatId !== this.voiceChatId) return
|
||||
for (const u of d.users ?? []) this.statuses.set(u.steamid, { micMuted: !!u.micMuted, outputMuted: !!u.outputMuted, noMic: !!u.noMic })
|
||||
this.hooks.onChange()
|
||||
break
|
||||
case 'ended':
|
||||
if (this.active && d.voiceChatId === this.voiceChatId) {
|
||||
this.hooks.toast('info', this.target?.kind === 'friend' ? 'The call ended' : 'Voice ended')
|
||||
this.epoch++
|
||||
this.teardown()
|
||||
}
|
||||
this.rings = this.rings.filter((r) => r.voiceChatId !== d.voiceChatId)
|
||||
this.hooks.onChange()
|
||||
break
|
||||
case 'shouldRejoin':
|
||||
// Steam moved the room's voice to another server: start again where we were.
|
||||
if (this.target?.kind === 'room' && this.target.groupId === d.groupId && this.target.roomId === d.roomId) {
|
||||
const [account, target] = [this.accountId, this.target]
|
||||
this.epoch++
|
||||
this.teardown()
|
||||
setTimeout(() => void this.start(account, target), 250)
|
||||
}
|
||||
break
|
||||
case 'callRequested':
|
||||
this.ring(event.accountId, d.steamid, d.voiceChatId)
|
||||
break
|
||||
case 'callResponse':
|
||||
if (d.voiceChatId !== this.voiceChatId || this.target?.kind !== 'friend') return
|
||||
if (d.accepted) {
|
||||
this.accepted = true
|
||||
if (this.phase === 'ringing') void this.connectData(this.epoch).catch((e: Error) => this.failed(this.epoch, e, "Couldn't join the call"))
|
||||
} else {
|
||||
this.hooks.toast('info', `${this.target.name} declined`)
|
||||
void this.stop()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private ring(accountId: string, steamid: string, voiceChatId: string): void {
|
||||
// The Steam client ignores a call from somebody who is not a friend, or while it is invisible or busy.
|
||||
if (!this.hooks.mayRing(accountId, steamid)) return
|
||||
// Already talking to them: it is them calling back to be let in again.
|
||||
if (this.target?.kind === 'friend' && this.target.steamid === steamid && this.active) {
|
||||
void this.hooks.rpc({ op: 'answerCall', accountId, steamid, voiceChatId, accept: true }).catch(() => {})
|
||||
return
|
||||
}
|
||||
this.rings = [...this.rings.filter((r) => r.steamid !== steamid), { accountId, steamid, voiceChatId }]
|
||||
this.hooks.onChange()
|
||||
}
|
||||
|
||||
/** The ring from this person, if there is one: somebody calling who is then called back is answered instead. */
|
||||
ringFrom(accountId: string, steamid: string): SteamRing | undefined {
|
||||
return this.rings.find((r) => r.accountId === accountId && r.steamid === steamid)
|
||||
}
|
||||
|
||||
/** Says yes to a call, and joins it. */
|
||||
async answer(ring: SteamRing, bufferId: string, name: string): Promise<void> {
|
||||
this.rings = this.rings.filter((r) => r !== ring)
|
||||
this.hooks.onChange()
|
||||
try {
|
||||
await this.hooks.rpc({ op: 'answerCall', accountId: ring.accountId, steamid: ring.steamid, voiceChatId: ring.voiceChatId, accept: true })
|
||||
} catch (e) {
|
||||
this.hooks.toast('error', `Couldn't answer: ${plainFailure((e as Error).message)}`)
|
||||
return
|
||||
}
|
||||
await this.start(ring.accountId, { kind: 'friend', steamid: ring.steamid, bufferId, name, voiceChatId: ring.voiceChatId })
|
||||
}
|
||||
|
||||
async decline(ring: SteamRing): Promise<void> {
|
||||
this.rings = this.rings.filter((r) => r !== ring)
|
||||
this.hooks.onChange()
|
||||
await this.hooks.rpc({ op: 'answerCall', accountId: ring.accountId, steamid: ring.steamid, voiceChatId: ring.voiceChatId, accept: false }).catch(() => {})
|
||||
}
|
||||
|
||||
/** The server's new description of the session, which is an offer: take it and answer. */
|
||||
private async renegotiate(d: Record<string, any>): Promise<void> {
|
||||
const pc = this.pc
|
||||
if (!pc) return
|
||||
this.updating = true
|
||||
this.owners = new Map((d.ssrcs ?? []).map((m: { ssrc: number; steamid: string }) => [m.ssrc, m.steamid]))
|
||||
try {
|
||||
const remote = JSON.parse(d.sdp as string) as RTCSessionDescriptionInit
|
||||
await pc.setRemoteDescription(remote)
|
||||
if (remote.type === 'offer') {
|
||||
const answer = await pc.createAnswer()
|
||||
await pc.setLocalDescription(answer)
|
||||
const s = this.session
|
||||
// The Steam client sends this and does not look at what comes back, and Steam may well refuse one that
|
||||
// reaches it before the session has joined the chat. The offer is answered either way, so a refusal is
|
||||
// not the end of the call.
|
||||
await this.hooks
|
||||
.rpc({ op: 'acknowledge', accountId: this.accountId, version: d.version, serverIp: s?.serverIp, serverPort: s?.serverPort, clientIp: s?.clientIp, clientPort: s?.clientPort })
|
||||
.catch(() => {})
|
||||
}
|
||||
} catch (e) {
|
||||
this.fail(`Voice renegotiation failed: ${plainFailure((e as Error).message)}`)
|
||||
return
|
||||
}
|
||||
this.updating = false
|
||||
const next = this.pending
|
||||
this.pending = null
|
||||
if (next) await this.renegotiate(next)
|
||||
}
|
||||
|
||||
/** Plays what the server sends, one element per stream so each can be silenced with the rest. */
|
||||
private hear(track: MediaStreamTrack, stream: MediaStream | undefined): void {
|
||||
if (track.kind !== 'audio') return
|
||||
const owned = stream ?? new MediaStream([track])
|
||||
const el = this.sinks.get(owned.id) ?? document.createElement('audio')
|
||||
el.autoplay = true
|
||||
el.srcObject = owned
|
||||
el.muted = this.hooks.muted().deafened
|
||||
el.style.display = 'none'
|
||||
document.body.appendChild(el)
|
||||
void el.play().catch(() => {})
|
||||
this.sinks.set(owned.id, el)
|
||||
}
|
||||
|
||||
/** The microphone's level and who else is audible, often; the round trip, less so. */
|
||||
private startMeters(): void {
|
||||
if (this.mic?.getAudioTracks().length) {
|
||||
try {
|
||||
this.audio = new AudioContext()
|
||||
const source = this.audio.createMediaStreamSource(this.mic)
|
||||
this.analyser = this.audio.createAnalyser()
|
||||
this.analyser.fftSize = 512
|
||||
source.connect(this.analyser)
|
||||
} catch {
|
||||
this.analyser = null
|
||||
}
|
||||
}
|
||||
const buf = new Uint8Array(512)
|
||||
this.meters = setInterval(() => {
|
||||
let level = 0
|
||||
if (this.analyser) {
|
||||
this.analyser.getByteTimeDomainData(buf)
|
||||
let peak = 0
|
||||
for (const b of buf) peak = Math.max(peak, Math.abs(b - 128) / 128)
|
||||
// The mic reads on a log-ish scale to a person; speech sits well under full.
|
||||
level = this.mic?.getAudioTracks()[0]?.enabled ? Math.min(1, peak * 2) : 0
|
||||
}
|
||||
const sources: { source: number; audioLevel?: number }[] = []
|
||||
for (const r of this.pc?.getReceivers() ?? []) {
|
||||
for (const s of r.getSynchronizationSources?.() ?? []) sources.push(s)
|
||||
}
|
||||
const speakers = speakersOf(sources, this.owners)
|
||||
const same = speakers.size === this.speakers.size && [...speakers].every((s) => this.speakers.has(s))
|
||||
this.level = level
|
||||
if (!same) {
|
||||
this.speakers = speakers
|
||||
this.hooks.onChange()
|
||||
}
|
||||
}, 120)
|
||||
this.stats = setInterval(() => {
|
||||
void this.pc?.getStats().then((report) => {
|
||||
report.forEach((s) => {
|
||||
if (s.type === 'candidate-pair' && s.nominated && typeof s.currentRoundTripTime === 'number') this.rttMs = Math.round(s.currentRoundTripTime * 1000)
|
||||
})
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import discordSvg from '../assets/discord.svg'
|
||||
import sneedchatMark from '../assets/sneedchat.png'
|
||||
import matrixSvg from '../assets/matrix-mark.svg'
|
||||
import kickSvg from '../assets/kick.svg'
|
||||
import steamSvg from '../assets/steam-mark.svg'
|
||||
|
||||
/**
|
||||
* Discord channel buffers are named "GuildName/#channel" internally - that's
|
||||
@@ -69,6 +70,8 @@ export function serviceIcon(service: string): ServiceIcon {
|
||||
return { mark: discordSvg }
|
||||
case 'matrix':
|
||||
return { mark: matrixSvg }
|
||||
case 'steam':
|
||||
return { mark: steamSvg }
|
||||
case 'irc':
|
||||
return { glyph: 'tag' }
|
||||
case 'slack':
|
||||
@@ -100,6 +103,8 @@ export function serviceLabel(service: string): string {
|
||||
return 'Sneedchat'
|
||||
case 'kick':
|
||||
return 'Kick'
|
||||
case 'steam':
|
||||
return 'Steam'
|
||||
case 'slack':
|
||||
return 'Slack'
|
||||
case 'jabber':
|
||||
@@ -137,6 +142,16 @@ export function isNotice(kind: string | undefined): boolean {
|
||||
return kind === 'notice'
|
||||
}
|
||||
|
||||
/**
|
||||
* A line the service wrote to warn about the conversation itself - Steam's
|
||||
* notice that somebody may not be who they appear to be - drawn in red so it is
|
||||
* not scrolled past. Its own kind. A warning stored before it had one is a
|
||||
* system line with the id Steam's notices are kept under.
|
||||
*/
|
||||
export function isWarning(kind: string | undefined, id?: string): boolean {
|
||||
return kind === 'warning' || (kind === 'system' && !!id?.startsWith('notice:'))
|
||||
}
|
||||
|
||||
/**
|
||||
* A message sent privately to you rather than to the room it arrived through.
|
||||
*
|
||||
@@ -166,7 +181,7 @@ export function isWhisper(kind: string | undefined): boolean {
|
||||
* does not have.
|
||||
*/
|
||||
export function isReward(kind: string | undefined): boolean {
|
||||
return kind === 'reward' || kind === 'sub' || kind === 'raid' || kind === 'poll'
|
||||
return kind === 'reward' || kind === 'sub' || kind === 'raid' || kind === 'poll' || kind === 'kicks'
|
||||
}
|
||||
|
||||
export function bufferKindGlyph(kind: string): string {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
||||
import { store, type ChatState } from './store'
|
||||
import { listInvitations, type Invitation } from '../lib/invitations'
|
||||
|
||||
/**
|
||||
* Slice subscription. The selector must return something referentially stable
|
||||
@@ -17,6 +18,29 @@ export function useStore(): typeof store {
|
||||
return store
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the log is drawn in the comfy family (comfy, and bubbles with it) as
|
||||
* against classic. The lists beside it follow: comfy has faces and a line of
|
||||
* what somebody is doing, classic is names, as on IRC.
|
||||
*/
|
||||
export function useComfy(): boolean {
|
||||
const [mode] = usePref<'classic' | 'comfy' | 'bubbles'>('display.messageMode', 'comfy')
|
||||
return mode !== 'classic'
|
||||
}
|
||||
|
||||
/** Everything waiting on a yes or a no, from every service. */
|
||||
export function useInvitations(): Invitation[] {
|
||||
const rooms = useChat((s) => s.matrixInvites)
|
||||
const friends = useChat((s) => s.friendRequests)
|
||||
const discordFriends = useChat((s) => s.discordFriends)
|
||||
const verification = useChat((s) => s.matrixVerification)
|
||||
const accounts = useChat((s) => s.accounts)
|
||||
return useMemo(
|
||||
() => listInvitations({ rooms, friends, discordFriends, verification }, accounts),
|
||||
[rooms, friends, discordFriends, verification, accounts]
|
||||
)
|
||||
}
|
||||
|
||||
const identity = (s: ChatState): ChatState => s
|
||||
|
||||
export function useChatState(): ChatState {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ChatStore } from './store'
|
||||
|
||||
/** The store, with a conversation already in its list, and the daemon's update for it applied. */
|
||||
@@ -29,3 +29,30 @@ describe('an update to a conversation the window already has', () => {
|
||||
expect([b.unread, b.mentions, b.highlight]).toEqual([4, 2, true])
|
||||
})
|
||||
})
|
||||
|
||||
describe('scrolling to the top of a conversation', () => {
|
||||
function withHistory(rows: unknown[][]): { store: ChatStore; rpc: ReturnType<typeof vi.fn> } {
|
||||
const rpc = vi.fn()
|
||||
for (const answer of rows) rpc.mockResolvedValueOnce(answer)
|
||||
;(globalThis as unknown as { window: unknown }).window = { moho: { rpc } }
|
||||
const store = new ChatStore() as unknown as { state: Record<string, unknown> }
|
||||
store.state = { ...store.state, messagesByBuffer: { b: [{ id: 'm1', ts: 100, bufferId: 'b', body: 'hello' }] }, loadingMore: {} }
|
||||
return { store: store as unknown as ChatStore, rpc }
|
||||
}
|
||||
|
||||
it('asks for what is older once and, told there is none, does not ask again at the next scroll', async () => {
|
||||
const { store, rpc } = withHistory([[], []])
|
||||
await store.loadMoreHistory('b')
|
||||
await store.loadMoreHistory('b')
|
||||
await store.loadMoreHistory('b')
|
||||
expect(rpc).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('asks again once older messages have arrived, since what is oldest has changed', async () => {
|
||||
const { store, rpc } = withHistory([[{ id: 'm0', ts: 50, bufferId: 'b', body: 'before' }], []])
|
||||
await store.loadMoreHistory('b')
|
||||
await store.loadMoreHistory('b')
|
||||
await store.loadMoreHistory('b')
|
||||
expect(rpc).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
+511
-19
@@ -8,6 +8,7 @@ import type {
|
||||
DccTransfer,
|
||||
DiscordFriend,
|
||||
MatrixInvite,
|
||||
FriendRequest,
|
||||
MatrixVerification,
|
||||
Member,
|
||||
Message,
|
||||
@@ -23,11 +24,13 @@ import type {
|
||||
} from '../../../shared/wire'
|
||||
import { addToast, type ToastAction, type ToastItem } from '../lib/toasts'
|
||||
import { ALL_STATUSES, statusesFor, supportsStatus } from '../lib/status'
|
||||
import { buildSmilieIndex, type SmilieEntry, type SmilieIndex } from '../lib/format'
|
||||
import type { SteamGroup, GroupCall } from '../lib/steamgroups'
|
||||
import { buildSmilieIndex, steamSameText, type SmilieEntry, type SmilieIndex } from '../lib/format'
|
||||
import { bufferDisplayName, isChatKind, isImageFile, resolveMediaUrl } from '../lib/util'
|
||||
import { runExport } from '../lib/exporter'
|
||||
import type { ForumPost } from '../lib/forum'
|
||||
import { DM_GROUP_ID, isDirectMessage } from '../lib/groups'
|
||||
import { DM_GROUP_ID, INVITATIONS_GROUP_ID, isDirectMessage } from '../lib/groups'
|
||||
import { listInvitations, type Invitation } from '../lib/invitations'
|
||||
import { ircNetworkFor } from '../lib/networks'
|
||||
import { parseDeepLink, type IrcLink } from '../../../shared/deeplink'
|
||||
import { MatrixCalls, type CallMember, type MatrixCallEvent, type RingingCall } from '../lib/matrixcall'
|
||||
@@ -38,6 +41,8 @@ import { cameraKey, closeFeeds, feedFrame, streamKey as feedStreamKey } from '..
|
||||
import { DEFAULT_QUALITY, encodeSettings, sendCamera, shareScreen, type VideoQuality } from '../lib/discordscreen'
|
||||
import type { PopoutState } from '../../../shared/ipc'
|
||||
import { isNewerVersion } from '../lib/version'
|
||||
import { steamRoomIds, type SteamChatGroup } from '../lib/steamchat'
|
||||
import { SteamVoice, type SteamRing, type SteamVoicePhase, type SteamVoiceTarget } from '../lib/steamvoice'
|
||||
|
||||
/**
|
||||
* The whole client-side model, held in one immutable object that is replaced
|
||||
@@ -373,6 +378,14 @@ export interface PeekTarget {
|
||||
joining?: boolean
|
||||
}
|
||||
|
||||
/** A Steam group's room that has voice, and who is in it. */
|
||||
export interface SteamVoiceRoom {
|
||||
roomId: string
|
||||
bufferId?: string | null
|
||||
name: string
|
||||
members: { userId: string; nick: string; avatarUrl?: string | null; isSelf?: boolean }[]
|
||||
}
|
||||
|
||||
export interface ChatState {
|
||||
/** Who is composing, per buffer, with when to stop believing it. */
|
||||
typingByBuffer: Record<string, { nicks: string[]; until: number }>
|
||||
@@ -438,6 +451,14 @@ export interface ChatState {
|
||||
voiceSessions: VoiceSession[]
|
||||
/** Conversations ringing right now, newest last. */
|
||||
incomingCalls: IncomingCall[]
|
||||
/** The voice rooms of each Steam group and who is in each, keyed by `account|group`. */
|
||||
steamVoiceRooms: Record<string, SteamVoiceRoom[]>
|
||||
/** Steam calls waiting to be answered. */
|
||||
steamRings: SteamRing[]
|
||||
/** The Steam voice this window holds, if any: where, and how far it has got. */
|
||||
steamVoiceNow: { accountId: string; target: SteamVoiceTarget; phase: SteamVoicePhase } | null
|
||||
/** A group somebody has asked to leave, waiting on the confirmation. */
|
||||
leaveRequest: { id: string; name: string; kind: string } | null
|
||||
/**
|
||||
* A link waiting for an account to be made for it.
|
||||
*
|
||||
@@ -676,6 +697,12 @@ export interface ChatState {
|
||||
matrixWidgets: Record<string, unknown[]>
|
||||
/** Rooms each Matrix account has been invited to and not answered. */
|
||||
matrixInvites: Record<string, MatrixInvite[]>
|
||||
/** Who has asked each account to be their friend, by account. */
|
||||
friendRequests: Record<string, FriendRequest[]>
|
||||
/** Which invitation the invitations page is showing. */
|
||||
activeInvitationKey: string
|
||||
/** A question asked about a Steam friend, waiting on an answer in a dialog. */
|
||||
steamDialog: { what: 'nickname' | 'remove' | 'block'; bufferId: string } | null
|
||||
/**
|
||||
* The profile being shown, or null.
|
||||
*
|
||||
@@ -684,6 +711,8 @@ export interface ChatState {
|
||||
* whatever draws it cannot be the thing that asked.
|
||||
*/
|
||||
profile: Profile | null
|
||||
/** Each Steam account's friend groups as Steam last said them; absent until it has. */
|
||||
steamGroups: Record<string, SteamGroup[]>
|
||||
replyingTo: { id: string; from: string; body: string } | null
|
||||
/** Whether answering pings the author (Discord's "@ ON"): on for every new reply. */
|
||||
replyPing: boolean
|
||||
@@ -708,6 +737,17 @@ export interface ChatState {
|
||||
matrixLoginStatus: string
|
||||
}
|
||||
|
||||
/** A person as the daemon reports one in a Steam friend request. */
|
||||
interface SteamPerson {
|
||||
steamid: string
|
||||
name: string | null
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
function personToRequest(p: SteamPerson): FriendRequest {
|
||||
return { id: p.steamid, name: p.name, avatarUrl: p.avatarUrl }
|
||||
}
|
||||
|
||||
export type Toast = ToastItem
|
||||
|
||||
const INITIAL: ChatState = {
|
||||
@@ -726,6 +766,10 @@ const INITIAL: ChatState = {
|
||||
voiceChannels: [],
|
||||
voiceGuildId: '',
|
||||
voiceSessions: [],
|
||||
steamVoiceRooms: {},
|
||||
steamRings: [],
|
||||
steamVoiceNow: null,
|
||||
leaveRequest: null,
|
||||
incomingCalls: [],
|
||||
transfers: [],
|
||||
minimisedTransfers: [],
|
||||
@@ -783,7 +827,11 @@ const INITIAL: ChatState = {
|
||||
ignoredByAccount: {},
|
||||
matrixWidgets: {},
|
||||
matrixInvites: {},
|
||||
friendRequests: {},
|
||||
activeInvitationKey: '',
|
||||
steamDialog: null,
|
||||
profile: null,
|
||||
steamGroups: {},
|
||||
replyingTo: null,
|
||||
replyPing: true,
|
||||
editingId: '',
|
||||
@@ -826,6 +874,7 @@ const INITIAL: ChatState = {
|
||||
*/
|
||||
function echoMatches(echo: ChatMessage, real: Message): boolean {
|
||||
if (echo.pendingBody === real.body) return true
|
||||
if (real.bufferId.startsWith('steam:') && echo.pendingBody !== undefined && steamSameText(echo.pendingBody, real.body)) return true
|
||||
// A file is posted as markup the daemon builds - a BBCode image tag on
|
||||
// Sneedchat, a bare link on IRC - so what comes back is never what was
|
||||
// typed, and usually nothing was typed at all. Matching on the whole body
|
||||
@@ -1003,6 +1052,8 @@ const MAX_MESSAGES_IDLE = 200
|
||||
/** As many mentions as the inbox will hold; the daemon's own limit matches. */
|
||||
const MAX_MENTIONS = 100
|
||||
const BACKLOG_PAGE = 200
|
||||
/** How long "nothing older" is believed before the top is allowed to ask again. */
|
||||
const HISTORY_END_MS = 10 * 60 * 1000
|
||||
const SEND_TIMEOUT_MS = 10000
|
||||
|
||||
export class ChatStore {
|
||||
@@ -1414,6 +1465,7 @@ export class ChatStore {
|
||||
try {
|
||||
this.set({ accounts: await window.moho.rpc<Account[]>('listAccounts') })
|
||||
void this.refreshInvites()
|
||||
void this.refreshFriendRequests()
|
||||
} catch (e) {
|
||||
this.toast('error', `Couldn't list accounts: ${(e as Error).message}`)
|
||||
}
|
||||
@@ -1428,9 +1480,10 @@ export class ChatStore {
|
||||
* the join panel.
|
||||
*/
|
||||
async refreshInvites(): Promise<void> {
|
||||
for (const account of this.state.accounts.filter((a) => a.service === 'matrix')) {
|
||||
for (const account of this.state.accounts.filter((a) => a.service === 'matrix' || a.service === 'steam')) {
|
||||
try {
|
||||
const invites = await window.moho.rpc<MatrixInvite[]>('listMatrixInvites', {
|
||||
// A Steam account's are the groups its friends have invited it to: the same shape, a room.
|
||||
const invites = await window.moho.rpc<MatrixInvite[]>(account.service === 'steam' ? 'steamGroupInvites' : 'listMatrixInvites', {
|
||||
accountId: account.id
|
||||
})
|
||||
this.set({ matrixInvites: { ...this.state.matrixInvites, [account.id]: invites } })
|
||||
@@ -1440,13 +1493,208 @@ export class ChatStore {
|
||||
}
|
||||
}
|
||||
|
||||
/** Accepts an invitation, or turns it down. */
|
||||
answerMatrixInvite(accountId: string, roomId: string, accept: boolean): void {
|
||||
void window.moho
|
||||
.rpc(accept ? 'acceptMatrixInvite' : 'declineMatrixInvite', { accountId, roomId })
|
||||
// Not removed here: the next sync stops listing it, which is what
|
||||
// actually confirms the server agreed.
|
||||
.catch((e: Error) => this.toast('error', e.message))
|
||||
/**
|
||||
* Who has asked each account to be their friend. Steam is the service with
|
||||
* requests today; the state is every service's so the next one only has to
|
||||
* say who asked.
|
||||
*/
|
||||
async refreshFriendRequests(): Promise<void> {
|
||||
const next: Record<string, FriendRequest[]> = {}
|
||||
for (const account of this.state.accounts.filter((a) => a.service === 'steam')) {
|
||||
try {
|
||||
const r = await window.moho.rpc<{ incoming: SteamPerson[] }>('steamFriendRequests', { accountId: account.id })
|
||||
next[account.id] = r.incoming.map(personToRequest)
|
||||
} catch {
|
||||
// Not connected yet, or an older daemon: nothing to answer.
|
||||
}
|
||||
}
|
||||
this.set({ friendRequests: next })
|
||||
// Discord keeps its requests among its friends; the daemon pushes changes,
|
||||
// and this is the first reading, so one that arrived while moho was
|
||||
// closed is waiting on the Invitations tile when it opens.
|
||||
for (const account of this.state.accounts.filter((a) => a.service === 'discord')) {
|
||||
try {
|
||||
const list = await window.moho.rpc<DiscordFriend[]>('listDiscordFriends', { accountId: account.id })
|
||||
this.set({ discordFriends: { ...this.state.discordFriends, [account.id]: list } })
|
||||
} catch {
|
||||
// Not connected yet: the gateway's own push fills it in.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything waiting on a yes or a no, as the invitations tile lists it. */
|
||||
pendingInvitations(): Invitation[] {
|
||||
const s = this.state
|
||||
return listInvitations(
|
||||
{ rooms: s.matrixInvites, friends: s.friendRequests, discordFriends: s.discordFriends, verification: s.matrixVerification },
|
||||
s.accounts
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers an invitation, from any service.
|
||||
*
|
||||
* It leaves the list the moment the service has agreed, rather than waiting
|
||||
* for the next sync or friends list to stop mentioning it: a request that
|
||||
* stays after being answered looks like it did not work.
|
||||
*
|
||||
* Accepting then opens what was accepted. A friend request has become a
|
||||
* conversation with that person, and a room invitation the room, so the pane
|
||||
* that was offering it turns into that conversation. It is not there for a
|
||||
* moment - the service has to tell moho about it - so this waits for it.
|
||||
*/
|
||||
async answerInvitation(invitation: Invitation, accept: boolean): Promise<void> {
|
||||
const { accountId, id } = invitation
|
||||
let steamGroupJoined = ''
|
||||
try {
|
||||
if (invitation.kind === 'verification') {
|
||||
await window.moho.rpc(accept ? 'respondMatrixVerification' : 'cancelMatrixVerification', {
|
||||
accountId,
|
||||
verificationId: id,
|
||||
...(accept ? { accept: true } : {})
|
||||
})
|
||||
} else if (invitation.kind === 'friend') {
|
||||
if (invitation.service === 'discord') {
|
||||
await window.moho.rpc('answerDiscordFriendRequest', { accountId, userId: id, accept })
|
||||
} else {
|
||||
await window.moho.rpc('steamAnswerFriendRequest', { accountId, steamid: id, accept })
|
||||
}
|
||||
} else if (invitation.service === 'steam') {
|
||||
const r = await window.moho.rpc<{ groupId?: string }>('steamChatGroupAction', { accountId, groupId: id, action: accept ? 'acceptInvite' : 'dismissInvite' })
|
||||
if (accept && r.groupId) steamGroupJoined = r.groupId
|
||||
} else {
|
||||
await window.moho.rpc(accept ? 'acceptMatrixInvite' : 'declineMatrixInvite', { accountId, roomId: id })
|
||||
}
|
||||
} catch (e) {
|
||||
this.toast('error', (e as Error).message)
|
||||
return
|
||||
}
|
||||
this.set(this.withoutInvitation(invitation))
|
||||
if (!accept && invitation.kind === 'verification') this.set({ matrixVerification: null })
|
||||
// A Steam group is opened at its first room, which is not found by who it is with.
|
||||
if (steamGroupJoined && (await this.openSteamGroup(steamGroupJoined))) return
|
||||
// A verification continues in its own dialog once it is accepted, and that
|
||||
// is the whole of what accepting opens.
|
||||
if (accept && invitation.kind !== 'verification' && !steamGroupJoined && (await this.openAccepted(invitation))) return
|
||||
// Nothing to open: stay on what is still waiting, or leave the page.
|
||||
if (this.pendingInvitations().length === 0 && this.state.activeGroupId === INVITATIONS_GROUP_ID) {
|
||||
this.selectGroup(`account:${accountId}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** The state with one invitation taken out of every place it is kept. */
|
||||
private withoutInvitation(invitation: Invitation): Partial<ChatState> {
|
||||
const { accountId, id } = invitation
|
||||
const v = this.state.matrixVerification
|
||||
return {
|
||||
matrixInvites: {
|
||||
...this.state.matrixInvites,
|
||||
[accountId]: (this.state.matrixInvites[accountId] ?? []).filter((i) => !(invitation.kind === 'room' && i.roomId === id))
|
||||
},
|
||||
friendRequests: {
|
||||
...this.state.friendRequests,
|
||||
[accountId]: (this.state.friendRequests[accountId] ?? []).filter((r) => !(invitation.kind === 'friend' && r.id === id))
|
||||
},
|
||||
discordFriends: {
|
||||
...this.state.discordFriends,
|
||||
[accountId]: (this.state.discordFriends[accountId] ?? []).filter(
|
||||
(f) => !(invitation.kind === 'friend' && f.userId === id && f.kind === 'incoming')
|
||||
)
|
||||
},
|
||||
// Accepted, the check carries on in its dialog; declined, it is over.
|
||||
matrixVerification: invitation.kind === 'verification' && v?.verificationId === id ? (v.state === 'requested' ? { ...v, incoming: false } : v) : v
|
||||
}
|
||||
}
|
||||
|
||||
/** Waits for a Steam group just joined to have its rooms, and opens the first. Whether it did. */
|
||||
private async openSteamGroup(railId: string): Promise<boolean> {
|
||||
for (let waited = 0; waited < 8000; waited += 200) {
|
||||
const room = this.state.buffers.find((b) => b.groupId === railId && b.kind === 'channel')
|
||||
if (room) {
|
||||
this.selectGroup(railId)
|
||||
await this.selectBuffer(room.id)
|
||||
return true
|
||||
}
|
||||
if (waited % 1000 === 0) await this.refreshBuffers()
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Waits for what an accepted invitation became, and opens it. Whether it did. */
|
||||
private async openAccepted(invitation: Invitation): Promise<boolean> {
|
||||
// Discord's friends are not conversations until one is opened: asking for
|
||||
// it is what makes the direct message exist, so it does not have to be
|
||||
// waited for.
|
||||
if (invitation.service === 'discord' && invitation.kind === 'friend') {
|
||||
try {
|
||||
const r = await window.moho.rpc<{ bufferId: string }>('openDiscordDm', { accountId: invitation.accountId, userId: invitation.id })
|
||||
await this.refreshBuffers()
|
||||
await this.selectBuffer(r.bufferId)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const found = (): BufferEntry | undefined =>
|
||||
this.state.buffers.find(
|
||||
(b) => b.accountId === invitation.accountId && b.remoteId === invitation.id && (invitation.kind === 'room' || b.kind === 'dm')
|
||||
)
|
||||
for (let waited = 0; waited < 8000; waited += 200) {
|
||||
const buffer = found()
|
||||
if (buffer) {
|
||||
await this.selectBuffer(buffer.id)
|
||||
return true
|
||||
}
|
||||
if (waited % 1000 === 0) await this.refreshBuffers()
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Asks what to do about a Steam friend, in a dialog. */
|
||||
askSteamFriend(what: 'nickname' | 'remove' | 'block', bufferId: string): void {
|
||||
this.set({ steamDialog: { what, bufferId } })
|
||||
}
|
||||
|
||||
closeSteamDialog(): void {
|
||||
this.set({ steamDialog: null })
|
||||
}
|
||||
|
||||
/** Does it: what to call them, ending the friendship, or blocking them. */
|
||||
async doSteamFriend(what: 'nickname' | 'remove' | 'block', bufferId: string, nickname = ''): Promise<void> {
|
||||
const buffer = this.state.buffers.find((b) => b.id === bufferId)
|
||||
this.set({ steamDialog: null })
|
||||
if (!buffer) return
|
||||
try {
|
||||
if (what === 'nickname') {
|
||||
await window.moho.rpc('steamSetNickname', { bufferId, nickname })
|
||||
} else if (what === 'remove') {
|
||||
await window.moho.rpc('steamRemoveFriend', { accountId: buffer.accountId, steamid: buffer.remoteId })
|
||||
this.toast('info', `Removed ${buffer.name}`)
|
||||
} else {
|
||||
await window.moho.rpc('steamBlockFriend', { bufferId, block: true })
|
||||
this.toast('info', `Blocked ${buffer.name}`)
|
||||
}
|
||||
} catch (e) {
|
||||
this.toast('error', (e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismisses the warning over a conversation: it goes now, and the service is told. */
|
||||
async dismissNotice(bufferId: string): Promise<void> {
|
||||
this.set({ buffers: this.state.buffers.map((b) => (b.id === bufferId ? { ...b, notice: undefined } : b)) })
|
||||
try {
|
||||
await window.moho.rpc('steamDismissNotice', { bufferId })
|
||||
} catch (e) {
|
||||
this.toast('error', (e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
/** Looks at one invitation, on the page that gathers them. */
|
||||
selectInvitation(key: string): void {
|
||||
this.set({ activeInvitationKey: key })
|
||||
this.selectGroup(INVITATIONS_GROUP_ID)
|
||||
}
|
||||
|
||||
private groupsTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -1552,6 +1800,8 @@ export class ChatStore {
|
||||
// Applied optimistically: a mute button that waits for a round trip feels
|
||||
// broken, and the daemon's reply corrects it either way.
|
||||
this.set({ voicePrefs: { ...this.state.voicePrefs, ...next } })
|
||||
// And onto the Steam voice this window holds, which obeys the same buttons.
|
||||
this.steamVoice.applyMute()
|
||||
try {
|
||||
this.set({ voicePrefs: await window.moho.rpc<VoicePrefs>('setVoiceMuted', next) })
|
||||
} catch (e) {
|
||||
@@ -1689,10 +1939,94 @@ export class ChatStore {
|
||||
}
|
||||
|
||||
async refreshVoiceSessions(): Promise<void> {
|
||||
// Steam's voice is held in this window and not by the daemon, so it is
|
||||
// added to what the daemon says rather than lost when that is asked for.
|
||||
const steam = this.steamVoiceSession()
|
||||
try {
|
||||
this.set({ voiceSessions: await window.moho.rpc<VoiceSession[]>('getVoiceSession') })
|
||||
this.set({ voiceSessions: [...(await window.moho.rpc<VoiceSession[]>('getVoiceSession')), ...steam] })
|
||||
} catch {
|
||||
this.set({ voiceSessions: [] })
|
||||
this.set({ voiceSessions: steam })
|
||||
}
|
||||
}
|
||||
|
||||
/** The Steam voice of this window, as the one kind of session every voice surface lists. */
|
||||
private steamVoiceSession(): VoiceSession[] {
|
||||
const v = this.steamVoice
|
||||
const t = v.target
|
||||
if (!v.active || !t) return []
|
||||
return t.kind === 'room'
|
||||
? [{ accountId: v.accountId, channelId: t.roomId, channelName: t.name, isDirect: false, bufferId: t.bufferId, railId: t.railId }]
|
||||
: [{ accountId: v.accountId, channelId: t.steamid, channelName: t.name, isDirect: true, bufferId: t.bufferId }]
|
||||
}
|
||||
|
||||
/** What Steam's voice is doing changed: show it. */
|
||||
private syncSteamVoice(): void {
|
||||
const v = this.steamVoice
|
||||
const others = this.state.voiceSessions.filter((s) => !s.accountId.startsWith('steam:'))
|
||||
this.set({
|
||||
voiceSessions: [...others, ...this.steamVoiceSession()],
|
||||
steamRings: [...v.rings],
|
||||
steamVoiceNow: v.active && v.target ? { accountId: v.accountId, target: v.target, phase: v.phase } : null
|
||||
})
|
||||
}
|
||||
|
||||
/** The window's own Steam voice, for what changes too fast to keep in state: the level and who is speaking. */
|
||||
get steamVoiceLive(): SteamVoice {
|
||||
return this.steamVoice
|
||||
}
|
||||
|
||||
private steamVoice: SteamVoice = new SteamVoice({
|
||||
rpc: (params) => window.moho.rpc('steamVoice', params),
|
||||
onChange: () => this.syncSteamVoice(),
|
||||
toast: (kind, message) => this.toast(kind, message),
|
||||
muted: () => ({ micMuted: this.state.voicePrefs.micMuted, deafened: this.state.voicePrefs.deafened }),
|
||||
nameOf: (accountId, steamid) => this.state.buffers.find((b) => b.accountId === accountId && b.remoteId === steamid)?.name ?? steamid,
|
||||
mayRing: (accountId, steamid) => {
|
||||
const account = this.state.accounts.find((a) => a.id === accountId)
|
||||
const friend = this.state.buffers.some((b) => b.accountId === accountId && b.kind === 'dm' && b.remoteId === steamid)
|
||||
return friend && account?.status !== 'invisible' && account?.status !== 'dnd'
|
||||
}
|
||||
})
|
||||
|
||||
/** Joins the voice of a room of a Steam group. */
|
||||
async joinSteamVoice(accountId: string, groupId: string, roomId: string): Promise<void> {
|
||||
const buffer = this.state.buffers.find((b) => b.accountId === accountId && b.remoteId === `${groupId}:${roomId}`)
|
||||
const group = this.state.groups.find((g) => g.id === `${accountId}|steamgroup:${groupId}`)
|
||||
if (!buffer || !group) return
|
||||
const room = (this.state.steamVoiceRooms[`${accountId}|${groupId}`] ?? []).find((r) => r.roomId === roomId)
|
||||
await this.steamVoice.start(accountId, { kind: 'room', groupId, roomId, bufferId: buffer.id, name: room?.name ?? bufferDisplayName(buffer.name), groupName: group.name, railId: group.id })
|
||||
}
|
||||
|
||||
/** Calls a Steam friend, from their conversation. */
|
||||
async callSteamFriend(bufferId: string): Promise<void> {
|
||||
const buffer = this.state.buffers.find((b) => b.id === bufferId)
|
||||
if (!buffer?.remoteId) return
|
||||
// They are already calling: answer that, rather than starting a second call to them.
|
||||
const ringing = this.steamVoice.ringFrom(buffer.accountId, buffer.remoteId)
|
||||
if (ringing) {
|
||||
await this.answerSteamCall(ringing)
|
||||
return
|
||||
}
|
||||
await this.steamVoice.start(buffer.accountId, { kind: 'friend', steamid: buffer.remoteId, bufferId, name: bufferDisplayName(buffer.name) })
|
||||
}
|
||||
|
||||
async answerSteamCall(ring: SteamRing): Promise<void> {
|
||||
const buffer = this.state.buffers.find((b) => b.accountId === ring.accountId && b.remoteId === ring.steamid)
|
||||
await this.steamVoice.answer(ring, buffer?.id ?? '', buffer ? bufferDisplayName(buffer.name) : ring.steamid)
|
||||
if (buffer) this.selectBuffer(buffer.id)
|
||||
}
|
||||
|
||||
async declineSteamCall(ring: SteamRing): Promise<void> {
|
||||
await this.steamVoice.decline(ring)
|
||||
}
|
||||
|
||||
/** Reads who is in a Steam group's voice rooms, when its channels are shown. */
|
||||
async refreshSteamVoiceRooms(accountId: string, groupId: string): Promise<void> {
|
||||
try {
|
||||
const rooms = await window.moho.rpc<SteamVoiceRoom[]>('steamVoiceRooms', { accountId, groupId })
|
||||
this.set({ steamVoiceRooms: { ...this.state.steamVoiceRooms, [`${accountId}|${groupId}`]: rooms } })
|
||||
} catch {
|
||||
/* none shown */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1722,14 +2056,24 @@ export class ChatStore {
|
||||
* nothing is removed here on optimism - leaving is the one action where
|
||||
* showing it as done before it is would be worse than a moment's delay.
|
||||
*/
|
||||
async leaveGroup(groupId: string): Promise<void> {
|
||||
async leaveGroup(groupId: string): Promise<boolean> {
|
||||
// A voice room of a Steam group is left with the group: the call would be in a place that is gone.
|
||||
const voice = this.state.steamVoiceNow
|
||||
if (voice?.target.kind === 'room' && groupId.endsWith(`|steamgroup:${voice.target.groupId}`)) await this.steamVoice.stop()
|
||||
try {
|
||||
await window.moho.rpc('leaveGroup', { groupId })
|
||||
return true
|
||||
} catch (e) {
|
||||
this.toast('error', `Couldn't leave: ${(e as Error).message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Asks to leave a group, which is confirmed first: it cannot be undone without a new invitation. */
|
||||
requestLeave(group: { id: string; name: string; kind: string } | null): void {
|
||||
this.set({ leaveRequest: group })
|
||||
}
|
||||
|
||||
/** Asks the log to scroll to a message, and to stop following the tail. */
|
||||
setJumpTarget(messageId: string): void {
|
||||
this.set({ jumpTarget: messageId })
|
||||
@@ -1787,6 +2131,10 @@ export class ChatStore {
|
||||
* resolves it to the channel the call happens in.
|
||||
*/
|
||||
async callBuffer(bufferId: string): Promise<void> {
|
||||
if (this.state.buffers.find((b) => b.id === bufferId)?.accountId.startsWith('steam:')) {
|
||||
await this.callSteamFriend(bufferId)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await window.moho.rpc('startDiscordCall', { bufferId })
|
||||
await this.refreshVoiceSessions()
|
||||
@@ -2101,6 +2449,10 @@ export class ChatStore {
|
||||
}
|
||||
|
||||
async leaveVoice(accountId: string): Promise<void> {
|
||||
if (accountId.startsWith('steam:')) {
|
||||
await this.steamVoice.stop()
|
||||
return
|
||||
}
|
||||
// What this end was sending goes with it. Left running, the camera light
|
||||
// would stay on and the encoder would go on handing frames to a call
|
||||
// that has ended.
|
||||
@@ -2313,6 +2665,58 @@ export class ChatStore {
|
||||
this.set({ profile: null })
|
||||
}
|
||||
|
||||
/** Asks what groups a Steam account has, for when it has just connected. */
|
||||
loadSteamGroups(accountId: string): void {
|
||||
void window.moho
|
||||
.rpc<SteamGroup[]>('steamFriendGroups', { accountId })
|
||||
.then((groups) => this.set({ steamGroups: { ...this.state.steamGroups, [accountId]: groups } }))
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a friend's conversation to another of the account's Steam folders, which is a change to the
|
||||
* account on Steam: out of the folder they are filed under, and into the one chosen - or out of
|
||||
* them all when none is. What comes back says where they now are, so nothing is moved here until
|
||||
* Steam has agreed.
|
||||
*/
|
||||
moveSteamFriend(bufferId: string, toGroup: number | null): void {
|
||||
const buffer = this.state.buffers.find((b) => b.id === bufferId)
|
||||
if (!buffer || !buffer.remoteId) return
|
||||
const friend = buffer.remoteId
|
||||
const groups = [...(this.state.steamGroups[buffer.accountId] ?? [])].sort((a, b) => a.id - b.id)
|
||||
// The one they are drawn under, which is the lowest-numbered of those they are in.
|
||||
const from = groups.find((g) => g.members.includes(friend))
|
||||
const to = toGroup === null ? undefined : groups.find((g) => g.id === toGroup)
|
||||
if (toGroup !== null && !to) return
|
||||
const calls: GroupCall[] = []
|
||||
if (from && from.id !== to?.id) calls.push({ accountId: buffer.accountId, action: 'remove', groupId: from.id, steamid: friend })
|
||||
if (to && !to.members.includes(friend)) calls.push({ accountId: buffer.accountId, action: 'add', groupId: to.id, steamid: friend })
|
||||
if (calls.length > 0) this.runSteamGroupCalls(calls)
|
||||
}
|
||||
|
||||
/** Makes a folder on a Steam account, which Steam then tells every client of. */
|
||||
createSteamFolder(accountId: string, name: string): void {
|
||||
this.runSteamGroupCalls([{ accountId, action: 'create', groupId: 0, name }])
|
||||
}
|
||||
|
||||
renameSteamFolder(accountId: string, groupId: number, name: string): void {
|
||||
this.runSteamGroupCalls([{ accountId, action: 'rename', groupId, name }])
|
||||
}
|
||||
|
||||
/** Deletes a folder: the friends in it stay friends, filed under nothing. */
|
||||
deleteSteamFolder(accountId: string, groupId: number): void {
|
||||
this.runSteamGroupCalls([{ accountId, action: 'delete', groupId }])
|
||||
}
|
||||
|
||||
/** Makes the changes to a Steam account's groups that moho's own headings asked for. */
|
||||
runSteamGroupCalls(calls: GroupCall[]): void {
|
||||
for (const call of calls) {
|
||||
void window.moho
|
||||
.rpc('steamGroupChange', { accountId: call.accountId, action: call.action, groupId: call.groupId, steamid: call.steamid, name: call.name })
|
||||
.catch((e: Error) => this.toast('error', `Steam did not change the group: ${e.message}`))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an IRC account rejoins this channel when it connects.
|
||||
*
|
||||
@@ -3119,6 +3523,19 @@ export class ChatStore {
|
||||
this.matrixCalls.handle(data as unknown as MatrixCallEvent)
|
||||
break
|
||||
|
||||
// Steam's voice: the server telling this window's WebRTC session what is
|
||||
// happening. The media is the window's; see lib/steamvoice.ts.
|
||||
case 'steamVoice':
|
||||
this.steamVoice.handle(data as never)
|
||||
break
|
||||
// Removed from a Steam group by somebody else.
|
||||
case 'steamGroupRemoved':
|
||||
this.toast('error', `You were ${data.how} ${data.name}`)
|
||||
break
|
||||
case 'steamVoiceRooms':
|
||||
this.set({ steamVoiceRooms: { ...this.state.steamVoiceRooms, [`${data.accountId}|${data.groupId}`]: data.rooms as SteamVoiceRoom[] } })
|
||||
break
|
||||
|
||||
// Friends, and requests either way, as they change. Discord announces
|
||||
// these rather than expecting a client to poll - which is what makes a
|
||||
// request that arrives while the panel is open appear in it.
|
||||
@@ -3418,6 +3835,12 @@ export class ChatStore {
|
||||
//
|
||||
// Ignored when it is not about whoever is on screen - two lookups in
|
||||
// quick succession would otherwise have the slower one win.
|
||||
// Steam says what groups an account has sorted its friends into, or that one changed.
|
||||
case 'steamFriendGroups': {
|
||||
this.set({ steamGroups: { ...this.state.steamGroups, [data.accountId as string]: (data.groups ?? []) as SteamGroup[] } })
|
||||
break
|
||||
}
|
||||
|
||||
case 'profile': {
|
||||
const answer = data as unknown as Profile
|
||||
const open = this.state.profile
|
||||
@@ -3426,6 +3849,12 @@ export class ChatStore {
|
||||
break
|
||||
}
|
||||
|
||||
// A Steam account's friends have invited it to a group, or an invitation has been answered. They are
|
||||
// rooms to be offered, kept with the rest of them.
|
||||
case 'steamGroupInvites':
|
||||
this.set({ matrixInvites: { ...this.state.matrixInvites, [data.accountId as string]: (data.invites as MatrixInvite[]) || [] } })
|
||||
break
|
||||
|
||||
case 'matrixInvites':
|
||||
this.set({
|
||||
matrixInvites: {
|
||||
@@ -3581,6 +4010,7 @@ export class ChatStore {
|
||||
accountId: data.accountId,
|
||||
verificationId: data.verificationId,
|
||||
state: 'requested',
|
||||
incoming: true,
|
||||
otherUser: data.fromUser,
|
||||
otherDevice: data.fromDevice,
|
||||
isSelf: data.isSelf
|
||||
@@ -3622,6 +4052,19 @@ export class ChatStore {
|
||||
void this.refreshAccounts()
|
||||
break
|
||||
|
||||
case 'steamFriendRequests': {
|
||||
const incoming = ((data.requests as { incoming?: SteamPerson[] } | undefined)?.incoming ?? []).map(personToRequest)
|
||||
this.set({ friendRequests: { ...this.state.friendRequests, [data.accountId as string]: incoming } })
|
||||
break
|
||||
}
|
||||
case 'steamFriendRequest': {
|
||||
// Said once, by the daemon, when somebody new asks. Answering is on
|
||||
// the request's own tile in the rail, so that is where the words point.
|
||||
const who = (data.request as { name?: string | null; steamid?: string } | undefined) ?? {}
|
||||
this.toast('info', `${who.name || who.steamid || 'Somebody'} wants to be your friend on Steam. It is waiting in the server list.`)
|
||||
break
|
||||
}
|
||||
|
||||
case 'matrixLoginStatus':
|
||||
this.set({ matrixLoginStatus: data.detail || '' })
|
||||
// An SSO sign-in arrives with the address of the server's own login
|
||||
@@ -3752,6 +4195,8 @@ export class ChatStore {
|
||||
...b,
|
||||
...data,
|
||||
link: data.link,
|
||||
// As sent, for the same reason: a dismissed warning is left out.
|
||||
notice: data.notice,
|
||||
syncing: data.syncing,
|
||||
topic: data.topic,
|
||||
serverMuted: data.serverMuted ?? false,
|
||||
@@ -3983,6 +4428,17 @@ export class ChatStore {
|
||||
b.id === bufferId ? { ...b, unread: 0, highlight: false, mentions: 0 } : b
|
||||
)
|
||||
}
|
||||
// The line a conversation was last opened with is that visit's, and goes
|
||||
// when it is left. Kept, it was found again on every return: a buffer read
|
||||
// to its end, left, and come back to had the old line still in it with
|
||||
// everything said since below it - so it opened far up the history at a
|
||||
// place that was no longer where anything was unread, instead of at the
|
||||
// end. A conversation opened again works out its line afresh, from what
|
||||
// is unread now.
|
||||
if (leaving && leaving !== bufferId && this.state.dividerTsByBuffer[leaving] !== undefined) {
|
||||
const { [leaving]: _gone, ...rest } = this.state.dividerTsByBuffer
|
||||
patch.dividerTsByBuffer = rest
|
||||
}
|
||||
if (dividerTs === undefined && buffer.unread > 0) {
|
||||
const list = this.state.messagesByBuffer[bufferId] || []
|
||||
const firstUnread = list.length >= buffer.unread ? list[list.length - buffer.unread] : list[0]
|
||||
@@ -3992,7 +4448,7 @@ export class ChatStore {
|
||||
// time it was last marked read.
|
||||
const stoppedAt = list.length < buffer.unread ? this.state.lastReadTs?.[bufferId] : undefined
|
||||
patch.dividerTsByBuffer = {
|
||||
...this.state.dividerTsByBuffer,
|
||||
...(patch.dividerTsByBuffer ?? this.state.dividerTsByBuffer),
|
||||
[bufferId]: stoppedAt ?? (firstUnread ? firstUnread.ts - 1 : 0)
|
||||
}
|
||||
}
|
||||
@@ -4367,11 +4823,21 @@ export class ChatStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where scrolling to the top found nothing older, by conversation: the message that was the oldest
|
||||
* and when. The top is scrolled to over and over, and without this each time asked the service
|
||||
* the same question and was told the same thing. Asked again once it has been a while, since the
|
||||
* answer may only have been "nothing, just now".
|
||||
*/
|
||||
private historyEnd = new Map<string, { id: string; at: number }>()
|
||||
|
||||
async loadMoreHistory(bufferId: string): Promise<void> {
|
||||
if (this.state.loadingMore[bufferId]) return
|
||||
const list = this.state.messagesByBuffer[bufferId] || []
|
||||
const oldest = list.find((m) => !m.pending)
|
||||
if (!oldest) return
|
||||
const end = this.historyEnd.get(bufferId)
|
||||
if (end && end.id === oldest.id && Date.now() - end.at < HISTORY_END_MS) return
|
||||
|
||||
this.set({ loadingMore: { ...this.state.loadingMore, [bufferId]: true } })
|
||||
try {
|
||||
@@ -4380,11 +4846,15 @@ export class ChatStore {
|
||||
before: oldest.ts,
|
||||
limit: BACKLOG_PAGE
|
||||
})
|
||||
if (rows.length) {
|
||||
const current = this.state.messagesByBuffer[bufferId] || []
|
||||
const seen = new Set(current.map((m) => m.id))
|
||||
const older = rows.filter((r) => !seen.has(r.id)) as ChatMessage[]
|
||||
const current = this.state.messagesByBuffer[bufferId] || []
|
||||
const seen = new Set(current.map((m) => m.id))
|
||||
const older = rows.filter((r) => !seen.has(r.id)) as ChatMessage[]
|
||||
if (older.length) {
|
||||
this.historyEnd.delete(bufferId)
|
||||
this.setMessages(bufferId, [...older, ...current])
|
||||
} else {
|
||||
// Nothing before what is here: not asked again at the next scroll.
|
||||
this.historyEnd.set(bufferId, { id: oldest.id, at: Date.now() })
|
||||
}
|
||||
} catch (e) {
|
||||
this.toast('error', `Couldn't load older messages: ${(e as Error).message}`)
|
||||
@@ -4861,6 +5331,19 @@ export class ChatStore {
|
||||
private async refreshMatrixPermissions(bufferId: string): Promise<void> {
|
||||
if (this.state.matrixPermissions[bufferId]) return
|
||||
const account = this.accountFor(bufferId)
|
||||
// A room of a Steam chat group says what the account's roles allow there.
|
||||
if (account?.service === 'steam') {
|
||||
const ids = steamRoomIds(this.state.buffers.find((b) => b.id === bufferId))
|
||||
if (!ids) return
|
||||
try {
|
||||
const groups = await window.moho.rpc<SteamChatGroup[]>('steamChatGroups', { accountId: account.id, groupId: ids.group })
|
||||
// Not kept when the group is not known yet: asked again at the next opening.
|
||||
if (groups[0]) this.set({ matrixPermissions: { ...this.state.matrixPermissions, [bufferId]: { canRedactOthers: groups[0].mayModerate } } })
|
||||
} catch {
|
||||
/* advisory only */
|
||||
}
|
||||
return
|
||||
}
|
||||
if (account?.service !== 'matrix') return
|
||||
try {
|
||||
const perms = await window.moho.rpc<RoomPermissions>('getMatrixRoomPermissions', {
|
||||
@@ -4874,6 +5357,15 @@ export class ChatStore {
|
||||
}
|
||||
|
||||
async closeBuffer(bufferId: string): Promise<void> {
|
||||
// A room of a Steam group cannot be left on its own: being in the group is what puts you in
|
||||
// its rooms. Closing one would only hide it until Steam next mentioned it, so what is offered
|
||||
// is leaving the group, asked about first.
|
||||
const room = this.state.buffers.find((b) => b.id === bufferId)
|
||||
if (room && room.kind === 'channel' && room.groupId?.includes('|steamgroup:')) {
|
||||
const group = this.state.groups.find((g) => g.id === room.groupId)
|
||||
if (group) this.requestLeave({ id: group.id, name: group.name, kind: group.kind })
|
||||
return
|
||||
}
|
||||
// Asked before the buffer goes, since afterwards there is nothing to ask.
|
||||
const sneedchat = this.accountFor(bufferId)?.service === 'sneedchat'
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useChat, usePref, usePrefsReady } from './hooks'
|
||||
import { DM_GROUP_ID } from '../lib/groups'
|
||||
import type { CustomCategory } from '../lib/categories'
|
||||
import { planDmCategories } from '../lib/dmcategories'
|
||||
import { seedSteamFolders } from '../lib/steamgroups'
|
||||
import { useStore } from './hooks'
|
||||
|
||||
// Held at module level: a fresh array as a fallback each render reads as a
|
||||
// change every time.
|
||||
const NO_CATEGORIES: Record<string, CustomCategory[]> = {}
|
||||
const NO_ASSIGNMENT: Record<string, string> = {}
|
||||
const NO_IDS: string[] = []
|
||||
|
||||
/**
|
||||
* Keeps the direct-messages page sorted by account. See lib/dmcategories.ts
|
||||
* for what it does and, as much, what it leaves alone.
|
||||
*
|
||||
* Mounted once, in the main window: it writes the same preferences the page
|
||||
* reads, and a second window doing it too would be two writers.
|
||||
*/
|
||||
export function useDmCategories(): void {
|
||||
const ready = usePrefsReady()
|
||||
const store = useStore()
|
||||
const accounts = useChat((s) => s.accounts)
|
||||
const buffers = useChat((s) => s.buffers)
|
||||
const steamGroups = useChat((s) => s.steamGroups)
|
||||
const [categories, setCategories] = usePref<Record<string, CustomCategory[]>>('customCategories', NO_CATEGORIES)
|
||||
const [assignment, setAssignment] = usePref<Record<string, string>>('channelCategory', NO_ASSIGNMENT)
|
||||
const [filed, setFiled] = usePref<string[]>('dmAutoFiled', NO_IDS)
|
||||
const [made, setMade] = usePref<string[]>('dmHeadingsMade', NO_IDS)
|
||||
const [seeded, setSeeded] = usePref<string[]>('steamFoldersSeeded', NO_IDS)
|
||||
|
||||
// A Steam account's groups, asked for when it connects: it also says them on
|
||||
// its own, but not necessarily after this window is listening.
|
||||
const connectedSteam = accounts.filter((a) => a.service === 'steam' && a.state === 'connected').map((a) => a.id).join(',')
|
||||
useEffect(() => {
|
||||
for (const id of connectedSteam.split(',').filter(Boolean)) store.loadSteamGroups(id)
|
||||
}, [connectedSteam, store])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return
|
||||
// The account headings first, then the Steam groups on top of what they
|
||||
// made: one write for both, so neither is lost to the other.
|
||||
const dm = planDmCategories(accounts, buffers, {
|
||||
categories: categories[DM_GROUP_ID] ?? [],
|
||||
assignment,
|
||||
filed,
|
||||
made
|
||||
})
|
||||
const steam = seedSteamFolders(accounts, buffers, steamGroups, {
|
||||
categories: dm?.categories ?? categories[DM_GROUP_ID] ?? [],
|
||||
assignment: dm?.assignment ?? assignment,
|
||||
seeded
|
||||
})
|
||||
if (!dm && !steam) return
|
||||
const next = steam ?? dm!
|
||||
setCategories({ ...categories, [DM_GROUP_ID]: next.categories })
|
||||
setAssignment(next.assignment)
|
||||
if (dm) {
|
||||
setFiled(dm.filed)
|
||||
setMade(dm.made)
|
||||
}
|
||||
if (steam) setSeeded(steam.seeded)
|
||||
}, [ready, accounts, buffers, steamGroups, categories, assignment, filed, made, seeded, setCategories, setAssignment, setFiled, setMade, setSeeded])
|
||||
}
|
||||
+59
-2
@@ -3,7 +3,7 @@
|
||||
|
||||
export interface Account {
|
||||
id: string
|
||||
service: 'irc' | 'discord' | 'sneedchat' | 'matrix' | 'kick' | 'jabber' | 'slack'
|
||||
service: 'irc' | 'discord' | 'sneedchat' | 'matrix' | 'kick' | 'steam' | 'jabber' | 'slack'
|
||||
displayName: string
|
||||
/**
|
||||
* How the user is presenting: "online" | "idle". Distinct from
|
||||
@@ -182,6 +182,16 @@ export interface Buffer {
|
||||
/** tor | site | chat | refused */
|
||||
cause?: string
|
||||
}
|
||||
/**
|
||||
* A warning the service has put on this conversation, drawn over it until it
|
||||
* is dismissed - Steam's "this person may not be who they appear to be".
|
||||
* Gone when dismissed, and back if the service raises it again.
|
||||
*/
|
||||
notice?: {
|
||||
/** suspicious */
|
||||
kind: string
|
||||
text: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -384,6 +394,8 @@ export interface VoiceSession {
|
||||
bufferId?: string
|
||||
/** A stage channel: speakers and an audience. */
|
||||
stage?: boolean
|
||||
/** The rail entry this belongs to, where its id is not a Discord guild's: a Steam group. */
|
||||
railId?: string
|
||||
}
|
||||
|
||||
export interface ReplyPreview {
|
||||
@@ -471,6 +483,8 @@ export interface Embed {
|
||||
provider?: string
|
||||
/** Who made it: a video's channel. */
|
||||
author?: string
|
||||
/** The colour the service draws that name in, as `#rrggbb`. */
|
||||
authorColor?: string
|
||||
/** The labelled lines under the description. */
|
||||
fields?: { name: string; value: string }[]
|
||||
/** The small line along the bottom. */
|
||||
@@ -479,7 +493,10 @@ export interface Embed {
|
||||
cta?: { label: string; url?: string }
|
||||
/**
|
||||
* "notice" for the platform's own notices to an account; "gif" for a moving
|
||||
* picture that was linked, drawn as the picture and not as a card.
|
||||
* picture that was linked, drawn as the picture and not as a card; and
|
||||
* "kicks" and "gift-sub" for what somebody did on a Kick stream - `author`
|
||||
* is who, `title` the amount, and for a gift `description` is who received
|
||||
* it, one name to a line - drawn as the card Kick draws it.
|
||||
*/
|
||||
kind?: string
|
||||
/** For a "gif": the video or image to play as it stands, which is not the page that was linked. */
|
||||
@@ -620,14 +637,26 @@ export interface Profile {
|
||||
away?: string
|
||||
/** Presence word - "online", "idle", "dnd", "offline". */
|
||||
status?: string
|
||||
/** What they are doing, where the service says: a game, a track, or words they wrote. */
|
||||
activity?: Activity | null
|
||||
/** Rooms or channels shared with them, where the service will say. */
|
||||
channels?: string[]
|
||||
/** Anything else worth showing, already phrased for a person to read. */
|
||||
extra?: { label: string; value: string }[]
|
||||
/** The names they have had, newest first, where the service keeps them; `when` is the service's own words for the moment. */
|
||||
nameHistory?: { name: string; when: string }[]
|
||||
/** Set while the service is still being asked; the card fills in. */
|
||||
pending?: boolean
|
||||
}
|
||||
|
||||
/** What somebody is doing, as a line under their name. */
|
||||
export interface Activity {
|
||||
/** Decides the little picture before it: a gamepad, a note, or none. */
|
||||
kind: 'game' | 'music' | 'custom'
|
||||
/** The game, the track, or the words. */
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
nick: string
|
||||
prefix?: string
|
||||
@@ -661,8 +690,19 @@ export interface Member {
|
||||
* split into online and offline.
|
||||
*/
|
||||
status?: string
|
||||
/**
|
||||
* What they are doing, said under their name: a game, a track, or a status
|
||||
* they wrote themselves. Only where the service says; absent when the dot is
|
||||
* the whole answer.
|
||||
*/
|
||||
activity?: Activity | null
|
||||
/** Their picture, where the service has one in its member list. */
|
||||
avatarUrl?: string
|
||||
/** Steam: which kind of client they are on, and when they were last seen (unix seconds). */
|
||||
client?: string
|
||||
lastSeen?: number
|
||||
/** Steam: what the game they are in says about what they are doing. */
|
||||
rich?: string
|
||||
}
|
||||
|
||||
export interface Protocol {
|
||||
@@ -749,6 +789,18 @@ export interface MatrixInvite {
|
||||
isDirect: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Somebody who has asked to be this account's friend and is waiting to be
|
||||
* answered. Offered in the rail beside a Matrix room's invitation, because it
|
||||
* is the same kind of thing: a request that expires when it is answered.
|
||||
*/
|
||||
export interface FriendRequest {
|
||||
/** The service's own id for the person. */
|
||||
id: string
|
||||
name: string | null
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
export interface MatrixVerification {
|
||||
accountId: string
|
||||
verificationId: string
|
||||
@@ -766,6 +818,11 @@ export interface MatrixVerification {
|
||||
isSelf?: boolean
|
||||
/** Set on an incoming request that has not been answered yet. */
|
||||
fromUser?: string
|
||||
/**
|
||||
* Somebody else asked, and it has not been answered: it is waiting on the
|
||||
* Invitations tile rather than showing as a dialog. Cleared once it is.
|
||||
*/
|
||||
incoming?: boolean
|
||||
fromDevice?: string
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user