127 Commits
Author SHA1 Message Date
SalastilandClaude Sonnet 5.5 d7c4e8767e Stoat (#32): avatars and server icons were refused by the window's media protocol and never fetched again; the probe now checks they load
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 23:43:10 -04:00
SalastilandClaude Sonnet 5.5 96ea7e7623 Stoat (#32): who is in a voice channel, listed under it; a spoilered picture is covered; probes
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 23:30:53 -04:00
SalastilandClaude Sonnet 5.5 1731674530 Stoat (#32): the window for the rest - settings page, own profile, a server's settings, search, pins, mutes, rings, blocks; probes; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 23:25:27 -04:00
SalastilandClaude Sonnet 5.5 e708e428d9 ARCHITECTURE: Stoat entry, Telegram's list brought up to date
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 22:11:26 -04:00
SalastilandClaude Sonnet 5.5 df0edf1cf8 Stoat (#32): voice calls in the window, Stoat invite links join the server; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 22:10:04 -04:00
Salastil 1d674edfe6 wip 2026-10-23 22:05:23 -04:00
SalastilandClaude Sonnet 5.5 0e1e6f38ed Stoat (#32): emoji in the box and the messages, member menu roles and moderation, an invite link on the server menu; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 19:59:01 -04:00
SalastilandClaude Sonnet 5.5 120fb10134 Stoat (#32): join panel (invite, friend), friend requests on the invitations tile, a self-contained local probe; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 19:33:52 -04:00
SalastilandClaude Sonnet 5.5 66a680167b Stoat (#32): the local-instance probe and helpers; files on the Composer; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 19:18:22 -04:00
SalastilandClaude Sonnet 5.5 f4e8136ff3 Stoat (#32): the Accounts form, the service's mark and label, and the Discord-shaped gestures for it; nobilis pointer
- Add an account: Stoat (instance optional, email and password, second factor, or a pasted token)
- edit, delete, react and the up-arrow edit on Stoat messages; Discord-style markdown (spoilers are !!..!!) in the compose box
- harness check for the form

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 18:52:23 -04:00
SalastilandClaude Sonnet 5.5 3aeae44130 README: Telegram is a supported protocol
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 17:50:55 -04:00
SalastilandClaude Sonnet 5.5 482e7bf326 1.3.3: Telegram QR sign-in, folders, stickers and links; Discord safeguards and the new-member lock; CHANGELOG
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 16:26:28 -04:00
SalastilandClaude Sonnet 5.5 5f10ae68bc nobilis pointer: Discord new-member badge lock
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 12:27:23 -04:00
SalastilandClaude Sonnet 5.5 b6b308df0b Telegram links open in moho: tg:// is moho's to handle (the View in Telegram button of a web post), and a t.me link in a message goes to the chat and the post; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 11:56:24 -04:00
SalastilandClaude Sonnet 5.5 c4b6c2a226 nobilis pointer: Telegram folder refusal wording
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 11:27:07 -04:00
SalastilandClaude Sonnet 5.5 b481fb8126 Telegram (#26): stickers, QR sign-in, folders as categories both ways, topic unread seeds; nobilis pointer
- the sticker button and tab for Telegram accounts (listTelegramStickers / sendTelegramSticker)
- "Sign in with a QR code" in the Telegram form, needing only the api_id and api_hash
- a Telegram account's page draws its chat folders as categories, in Telegram's order, and what is done to
  them is done on Telegram: filing a conversation (drag), putting headings in order, making (a category is a
  folder once it has a conversation in it), renaming, deleting; a folder changed on another device turns up
- a conversation's unreadSeed / mentionsSeed start the window's unread count where the service keeps its own
- harness checks for the folders page and the QR button

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 11:19:45 -04:00
SalastilandClaude Sonnet 5.5 5776ca8dc9 Discord safeguards from Discordo, Endcord and Concord (#338 H1-H12); nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 22:44:13 -04:00
SalastilandClaude Sonnet 5.5 d1361e080b Discord (#338): acks while the newest line is read at the bottom of a window in front; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 21:55:58 -04:00
SalastilandClaude Sonnet 5.5 ad1d5be6aa nobilis pointer: a join message is not a first word
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 20:41:08 -04:00
SalastilandClaude Sonnet 5.5 abc2c9adc2 1.3.2: Discord safeguards from Dissent's; reply quotes; CHANGELOG
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 23:36:04 -04:00
SalastilandClaude Sonnet 5.5 74f2c4750f Discord safeguards (#338): typing off by default, acks only while the window is looked at, a notice of the risk when adding an account; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 23:23:17 -04:00
SalastilandClaude Sonnet 5.5 df818a13bb A reply's quote draws what it quotes: emotes, emoji and stickers as pictures, not their codes, in the line over a reply and in the bar over the box, on every service
Twitch's parent text has no emotes, so the original on screen is quoted where it is there.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 22:07:16 -04:00
SalastilandClaude Sonnet 5.5 10f0180aab 1.3.1: Discord first-word lock; CHANGELOG
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 21:03:45 -04:00
SalastilandClaude Sonnet 5.5 b04189ad6b Discord: first-word lock, nonce, upload limit and send pacing; window banner and check; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 20:42:43 -04:00
SalastilandClaude Sonnet 5.5 5b438be371 The message box: bold, italic, underline and strikethrough are the browser's own, which toggle off from all of the box and keep its undo; the others are put back without the browser re-applying what it replaced
Select all then bold twice left the box bold: the browser keeps the formatting of the run it replaces when the selection is everything in it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 20:01:48 -04:00
SalastilandClaude Sonnet 5.5 0c331abf2c The GIF check shows IRC as a Matrix account to reach the picker (IRC has none); CHANGELOG: search and formatting
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 19:28:39 -04:00
SalastilandClaude Sonnet 5.5 4233439565 Search the web for selected text: in a message's menu and the box's, with the engine chosen in Settings > General
DuckDuckGo (default), Google, Bing, Brave, Startpage, Ecosia, Qwant, Yandex, Kagi, or an address of one's own with %s; opened in the default browser.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 19:18:28 -04:00
SalastilandClaude Sonnet 5.5 496d18f6fd The message box: a format toggles off, over a whole run, part of one or a mixed selection, and undo steps back over it with the selection kept
Worked out on a copy of the box and put back with one insertHTML (one undo step, and the browser no longer re-applies the format of what it replaced); undo and redo put back the selection that was there.

Fixes #336

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 19:18:28 -04:00
SalastilandClaude Sonnet 5.5 6bc4db8880 1.3.0: Telegram; CHANGELOG
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 19:02:55 -04:00
SalastilandClaude Sonnet 5.5 3a6c8da6ed Discord install headers; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 18:49:19 -04:00
SalastilandClaude Sonnet 5.5 ab8f40f53e Discord session resume, catch-up and throttles; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 18:19:27 -04:00
SalastilandClaude Sonnet 5.5 8f62f44517 Discord: the Connect anyway button; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 23:52:43 -04:00
SalastilandClaude Sonnet 5.5 59f978d03b Discord login limit; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 23:41:57 -04:00
SalastilandClaude Sonnet 5.5 de8674683e Telegram GIF picker; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 18:41:46 -04:00
SalastilandClaude Sonnet 5.5 7a2fda095e No GIF picker on IRC or Telegram
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 18:36:35 -04:00
SalastilandClaude Sonnet 5.5 40505af989 The GIF picker is offered only where a GIF link shows as a picture: not Steam, Twitch or Sneedchat
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 18:33:19 -04:00
SalastilandClaude Sonnet 5.5 ab1c9aa763 Telegram: nobilis pointer (made-up ids in tests)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 22:47:30 -04:00
SalastilandClaude Sonnet 5.5 4ebabb285a A left click on the tray icon puts the window away when it is showing and brings it back when it is not
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 22:42:46 -04:00
SalastilandClaude Sonnet 5.5 f1bf63a16a The hover toolbar offers ordinary emoji only, and the recent list drops the bare numbers older builds stored
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 23:53:26 -04:00
SalastilandClaude Sonnet 5.5 aab10c7fcc Full screen fills the screen: the player drops the log's shape and size limits
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 23:07:16 -04:00
SalastilandClaude Sonnet 5.5 1387907496 Full screen is allowed: the window refused the request a page makes for it, so the player's button did nothing
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 19:34:02 -04:00
SalastilandClaude Sonnet 5.5 72d5b838a8 One video player on every service: play, seek, volume that opens on hover, elapsed or remaining time, speed, full screen, save and edit
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 18:50:38 -04:00
SalastilandClaude Sonnet 5.5 bf5c33d231 Videos go full screen by themselves, on every service: no second expand button and no viewer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 18:43:14 -04:00
SalastilandClaude Sonnet 5.5 da6a2ffca4 Telegram: channel avatars on posts, no media labels in text; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 18:20:33 -04:00
SalastilandClaude Sonnet 5.5 5674fa8a05 Telegram: forum topics; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 18:13:30 -04:00
SalastilandClaude Sonnet 5.5 8f8f30a6e0 Telegram: groups on the rail; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 18:00:57 -04:00
SalastilandClaude Sonnet 5.5 510627fa40 Discord: OFFICIAL beside the platform's name in the list and on its messages; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 17:30:54 -04:00
SalastilandClaude Sonnet 5.5 9a86141cd8 Discord: an emoji picked from another server's section goes in as its token, not the number that names it
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 15:02:52 -04:00
SalastilandClaude Sonnet 5.5 789077ac32 Telegram: nobilis pointer (service_of)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 14:15:21 -04:00
SalastilandClaude Sonnet 5.5 400440b5a4 Telegram: the bot's Menu opens the list without writing into the box and sends the command; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 14:06:15 -04:00
SalastilandClaude Sonnet 5.5 a04f5186e4 Telegram: the bot's Menu button and command list; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 13:58:49 -04:00
SalastilandClaude Sonnet 5.5 51909ad819 Telegram: nobilis pointer (markup logging)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 13:53:36 -04:00
SalastilandClaude Sonnet 5.5 933763bdf8 Telegram: pressing a bot's button; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 13:40:18 -04:00
SalastilandClaude Sonnet 5.5 5d2d0fe0bb The sender's badges sit between the name and the time
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 13:32:51 -04:00
SalastilandClaude Sonnet 5.5 2f4c204d72 Discord: the APP badge; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 00:01:48 -04:00
SalastilandClaude Sonnet 5.5 c64812cc98 Discord: layout-component messages; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 23:53:47 -04:00
SalastilandClaude Sonnet 5.5 23afcb6f2a Discord: the membership-screening dialog as Discord draws it; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 20:28:44 -04:00
SalastilandClaude Sonnet 5.5 9668aa4168 Telegram: the forwarded-from line with its link to the original; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 20:07:36 -04:00
SalastilandClaude Sonnet 5.5 612198c64c Telegram: the quote above an answer, and following it to the original channel; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 19:45:56 -04:00
SalastilandClaude Sonnet 5.5 3fd8759bbd Telegram: the kind glyph goes ahead of the picture
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 19:40:08 -04:00
SalastilandClaude Sonnet 5.5 993541ea40 Telegram: name glyphs in the list (horn, robot); nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 19:36:55 -04:00
SalastilandClaude Sonnet 5.5 dc32c7dfa2 Telegram: nobilis pointer (Bots heading)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 19:32:08 -04:00
SalastilandClaude Sonnet 5.5 a2a8949d49 Discord: embed field values are markdown too
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 19:26:54 -04:00
SalastilandClaude Sonnet 5.5 eb549f7946 Discord: a backslash before a punctuation mark is an escape, in messages and in link previews
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 19:24:25 -04:00
SalastilandClaude Sonnet 5.5 e024d92e48 Telegram: nobilis pointer (article cover)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 15:27:29 -04:00
SalastilandClaude Sonnet 5.5 1d4e35351f Telegram: the link card as Telegram draws it, and Instant View in a window of its own; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 15:22:25 -04:00
SalastilandClaude Sonnet 5.5 f5b5ed52a7 Telegram: nobilis pointer (link preview cards)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 15:09:45 -04:00
SalastilandClaude Sonnet 5.5 6ffb8e5988 Telegram: nobilis pointer (files named before they are fetched), restore log lines
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 10:03:46 -04:00
SalastilandClaude Sonnet 5.5 7d1219b079 Telegram: restore attempts are recorded in the log; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 09:55:51 -04:00
SalastilandClaude Sonnet 5.5 ccceb06c82 Telegram: a picture falling back from its preview is restored too; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 09:47:31 -04:00
SalastilandClaude Sonnet 5.5 040fb01dff Telegram: videos are drawn as their preview and fetched when played; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 09:38:02 -04:00
SalastilandClaude Sonnet 5.5 19f031e2a3 Telegram: nobilis pointer (media backfill)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 09:30:16 -04:00
SalastilandClaude Sonnet 5.5 fac3ded902 Telegram: draw its pictures (cache folders allowed); nobilis pointer (incoming private messages)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 09:23:13 -04:00
SalastilandClaude Sonnet 5.5 48fb1a97f3 Telegram: Settings page, poll composer, ARCHITECTURE entry; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 00:23:50 -04:00
SalastilandClaude Sonnet 5.5 512bbd3624 Telegram: join page, member moderation, mute; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 00:17:19 -04:00
SalastilandClaude Sonnet 5.5 3346849359 Telegram: comments and thread counts in the window; nobilis pointer (polls, threads)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 00:08:10 -04:00
SalastilandClaude Sonnet 5.5 be7b1a77d1 Telegram: reactions, pins and search in the window; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 23:59:04 -04:00
Salastil 6b211a23ca Telegram: media in the window; nobilis pointer 2026-10-15 23:53:16 -04:00
SalastilandClaude Sonnet 5.5 e77d871d67 Telegram: formatting in the box and editing own messages; nobilis pointer (sending)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 23:46:35 -04:00
SalastilandClaude Sonnet 5.5 43434018e9 Telegram: nobilis pointer (read-only channels)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 23:36:22 -04:00
SalastilandClaude Sonnet 5.5 1abd887096 Telegram: nobilis pointer (conversations, history, live messages)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 23:27:30 -04:00
SalastilandClaude Sonnet 5.5 0f7cec7a98 Telegram: the account form, the mark and the service lists; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 23:17:28 -04:00
SalastilandClaude Sonnet 5.5 285fda248f 1.2.1: Steam identity; CHANGELOG
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 17:14:16 -04:00
SalastilandClaude Sonnet 5.5 fceec5d6b5 Steam: nobilis pointer, sign-in text
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 01:01:33 -04:00
SalastilandClaude Sonnet 5.5 d9f9f6a127 1.2.0: Twitch; README and CHANGELOG
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 21:54:30 -04:00
SalastilandClaude Sonnet 5.5 934dc19820 Twitch video: do not read its prefetch lines as segments (their address changes when the segment is made), which dropped the picture every ten seconds
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 21:39:30 -04:00
SalastilandClaude Sonnet 5.5 d1e39f18b0 Windows can be resized by their edges: channel list, member list, stream above the chat, and the corner panel from every edge
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 21:14:42 -04:00
SalastilandClaude Sonnet 5.5 dd4bd2e257 Twitch: the stage reports watching once a minute while playing; Settings switch
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 21:04:29 -04:00
SalastilandClaude Sonnet 5.5 b5ff4435a3 Emote picker: unique keys; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 20:44:15 -04:00
SalastilandClaude Sonnet 5.5 f103f9fc6a Emote picker: channel avatar / Twitch mark on the rail; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 19:58:36 -04:00
SalastilandClaude Sonnet 5.5 a6fd981b6f Twitch: no third-party emote services; channel emotes greyed in the picker
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 19:52:54 -04:00
SalastilandClaude Sonnet 5.5 ae98f70d95 Twitch emotes: refresh in the picker, own line draws emotes; check against the real services
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 19:42:45 -04:00
SalastilandClaude Sonnet 5.5 b656e5ee81 Twitch emote rewards: right pools for unlock and change; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 19:25:20 -04:00
SalastilandClaude Sonnet 5.5 38794e7cc5 Rewards: the emotes a reward offers are pictures to press, with a search and a second step for changes; integrity as the account
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 18:58:27 -04:00
SalastilandClaude Sonnet 5.5 aacbde768f Rewards panel: choose-an-emote and typed rewards; recent emoji only where they can be sent
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 18:47:33 -04:00
SalastilandClaude Sonnet 5.5 5518f1c534 Rewards panel: cheapest first, available above unavailable
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 18:29:06 -04:00
SalastilandClaude Sonnet 5.5 835878bbc0 Rewards panel: a font size that is defined
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 18:24:36 -04:00
SalastilandClaude Sonnet 5.5 399f521f55 One rewards panel over the chat for Kick and Twitch channel points, opened from the plaque
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 18:23:01 -04:00
SalastilandClaude Sonnet 5.5 46304f7750 Twitch: no bonus claiming; the points plaque shows the balance only
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 18:08:32 -04:00
Salastil 3cc8c14fb5 nobilis pointer 2026-10-14 18:04:00 -04:00
SalastilandClaude Sonnet 5.5 3d77002de3 Twitch: whisper badge shows (no presence dot), a script that checks the Twitch parts of the window
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 17:17:55 -04:00
SalastilandClaude Sonnet 5.5 68c6ec1581 Twitch: the points plaque, and vote and bet buttons that work
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 17:00:04 -04:00
Salastil 9dfb777e2e nobilis pointer 2026-10-14 16:46:45 -04:00
SalastilandClaude Sonnet 5.5 bb2edf573d Sneedchat: greentext in 4chan's green, and a sent greentext line is matched with the site's coloured echo
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 16:36:59 -04:00
SalastilandClaude Sonnet 5.5 34fcddf495 IRC mode explanations (nobilis pointer)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 16:20:18 -04:00
SalastilandClaude Sonnet 5.5 9ef8a67ac4 Every service notification is drawn as a card; the rule is in ARCHITECTURE.md
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 15:11:23 -04:00
SalastilandClaude Sonnet 5.5 c346b20e3d Twitch: whisper badge, moderation cards with their own glyphs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 15:03:51 -04:00
SalastilandClaude Sonnet 5.5 e4123abc05 Twitch: nobilis pointer, whispers get their own conversations
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 14:48:39 -04:00
SalastilandClaude Sonnet 5.5 f4fc379a60 Twitch: remove the Forget the video sign-in button
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 14:21:08 -04:00
SalastilandClaude Sonnet 5.5 49a09d8507 Twitch: one sign-in has every feature; the separate moderation button is gone
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 14:02:39 -04:00
SalastilandClaude Sonnet 5.5 98f7d049f0 Twitch: emotes picked from the picker show as pictures in the box; no GIF picker
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 12:05:53 -04:00
SalastilandClaude Sonnet 5.5 b2c793477b Twitch sign-in: type the code into Twitch's Activate page and press Activate; show the code in the form
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 11:53:14 -04:00
SalastilandClaude Sonnet 5.5 be013ea47c Twitch sign-in: wait out the site's own redirect after login and bring the window back to the approval page; find the session by a second cookie too
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 11:39:08 -04:00
SalastilandClaude Sonnet 5.5 5b2438bba5 Twitch: one sign-in window gives the account chat and video together
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 10:56:30 -04:00
SalastilandClaude Sonnet 5.5 0d28308ab8 Twitch: signing in also signs in on twitch.tv for video; the separate button is gone
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 10:36:46 -04:00
SalastilandClaude Sonnet 5.5 b7ee906037 Twitch: follows are opened on first connect (nobilis pointer)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 10:23:08 -04:00
SalastilandClaude Sonnet 5.5 61044b65b8 Twitch: raid cards that offer to go along, polls and predictions shown read-only, block and shoutout in the menu
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 09:56:47 -04:00
SalastilandClaude Sonnet 5.5 5356ddba55 Twitch: emotes and badges drawn, moderation and whispers in the window, README
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 09:18:31 -04:00
SalastilandClaude Sonnet 5.5 ac60123e7d Twitch: the stream plays in the window like Kick's, with its ad breaks said
Twitch prefetch segments are read as Kick's are, the ad break is said over the picture from the
playlist's own markers, mute and volume are remembered, the resolution choice is kept per service,
captions inside the video are offered, and the account's card can sign in on twitch.tv for video, open
what it follows, and forget the sign-in. A hidden page in main makes an integrity token on request.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 08:53:25 -04:00
SalastilandClaude Sonnet 5.5 d3a1efc1ef Twitch: Settings page, recent messages on opening a channel, the sign-in scripts
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 00:43:22 -04:00
SalastilandClaude Sonnet 5.5 322cbc8407 Twitch: the window signs in, joins channels and draws them
The Twitch mark, the sign-in form with the code and the page to approve it on, read-without-signing-in, an
account's own sign-in-again, the join panel, the signed-out composer, twitch emote pictures, and badge words
for a first message and a shared chat.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 00:32:29 -04:00
SalastilandClaude Sonnet 5.5 dd44e76970 Kick: the points plaque follows Kick's push, and badge pictures are drawn beside names
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 23:11:26 -04:00
SalastilandClaude Sonnet 5.5 9b29285f71 nobilis: roster test fix
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 21:54:56 -04:00
SalastilandClaude Sonnet 5.5 987644ebf1 Kick: the recently-talking count keeps rising past what is listed
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 21:51:35 -04:00
SalastilandClaude Sonnet 5.5 8e3d1100c9 Kick: threads from replies, and removed messages struck through, left out or replaced as chosen
A reply shows a button into its thread; the thread panel gathers every reply that names the same root, and
sending in it works even when the first message has left the scrollback. Messages Kick removes stay in the
log, and Settings -> Kick chooses Delete, Strikethrough (the default, in red) or a notice in place of the text.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 21:17:34 -04:00
SalastilandClaude Sonnet 5.5 a7ef798df5 Kick video: no prefetch guesses for streams whose segments differ in length, and a longer lead for long segments
A broadcaster that sends its own segments (3 to 5 s, not IVS's even 2) made the segments still being
made the wrong length, and the picture stopped in the gaps about every 8 s. Prefetch is now only taken
where the segments are alike, and the player stays a segment and a half behind where they are long.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 20:24:06 -04:00
SalastilandClaude Sonnet 5.5 3aff255375 Kick video: about 3.5 s behind instead of 20, no rebuild on every address, a resolution menu and live captions
The player was rebuilt whenever the channel payload's address changed, which was every fetch (a new token
each time): that was the stutter. hls.js counted its live edge in 6 s target durations and ignored IVS's
prefetch segments: that was the lag. Both are fixed, prefetch segments no longer feed the bandwidth
estimate, and the address now comes from the lighter playback-url endpoint with its ad opt-out kept.
Resolution and CC buttons sit in the player's controls.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 19:57:03 -04:00
120 changed files with 10871 additions and 654 deletions

No files matched your search

+66
View File
@@ -219,6 +219,72 @@ called `subscribe` for that buffer.
rather than only watched. The stream itself
plays in the window over HLS, with its VODs and clips.
- **Twitch** - a streamer's chat over Twitch's IRC service (read with no account, sent through the
official API), with emotes (Twitch's own, animated ones, and the channel's whole set in the picker with the ones the
account cannot use greyed out; BetterTTV, FrankerFaceZ and 7TV are browser extensions Twitch does not carry, so
they are not drawn), badges as pictures, cheers, replies and threads, subscriptions, gifts and
raids, raids out, polls and predictions as they happen, moderation, whispers, shoutouts and blocking.
One sign-in on twitch.tv gives the account its API token and the website session its player takes, so
the stream plays in the window as the person (their Turbo or subscriptions count) with Twitch's ad
breaks said and never skipped.
- **Telegram** - MTProto through grammers (a pure-Rust client, pinned), with each account carrying its own
`api_id` and `api_hash` from my.telegram.org (moho embeds none, so no install shares a key) and one saved
session per account in a private file that is never re-created on a hiccup. Sign-in by phone, code and cloud
password. Chats are read from the dialog list and become buffers under Direct Messages, Groups and Channels
(a channel the account cannot write in shows the lock message instead of the message box); history, live
messages, edits and deletions, formatting both ways, replies and forwards, service messages as cards, media
fetched on demand into a cache, reactions, pins, polls, search in a chat or everywhere, members and profiles,
threads and a channel's comments, join by @name or link, administrator actions, and a mute that follows the
account. Also stickers, group bot commands, forum topics with their own unread counts, sign-in by QR code, and
folders kept in step with moho's categories both ways. Not supported: secret chats, calls, stories, payments,
drafts and scheduled messages. The code is `nobilis/src/backend/telegram/`.
- **Stoat** (formerly Revolt; on the `protocol/stoat` branch, not yet released) - the REST api plus the events
websocket, for the official instance or a self-hosted one the account names. Sign-in by email and password
(with its MFA code) or a pasted token; a captcha or email check is said in words and left to Stoat's own app.
Servers, categories, channels, groups and DMs; the account's permissions worked out per channel (so a channel it
may not write in shows why); history and live messages, edits, deletions, replies, embeds, masquerade names,
system messages as cards, files both ways through Autumn, reactions, custom emoji, members with roles and
presence, profile cards, friends, blocks, invites, moderation (kick, ban, timeout, roles), searching and pins,
mutes kept in the account's own settings, the account's own profile and presence, and voice calls on LiveKit
through the same client Matrix uses (a call begun in a direct message rings the other, and one begun there rings
here). A server's owner or moderators get its settings: channels and their headings, roles and permissions by
name, who may do what in one channel, webhooks and the audit log. Limits and slowmode are kept to before a
request is made. The code is `nobilis/src/backend/stoat/`; the verification scripts run it against a
self-hosted instance in docker (`scripts/probe-stoat-local.mjs`, `probe-stoat-voice.mjs`,
`probe-stoat-manage.mjs`).
### Notifications are cards
Anything a service *says* rather than somebody *saying* - a join or part, a topic or mode change, a kick,
a ban, a timeout, a room's slow mode, a removed message, a raid, a gift, a pinned message, a warning, a
notice from the server itself - is drawn in the window as a **card**: the same plate Kick's and Twitch's
events use (`KickEventCard.tsx`), with a mark for what it was, its subject in bold, and what happened.
This holds for every protocol, and **for every protocol added later**; it is a rule of the design and not
a styling choice of any one backend.
How a backend takes part, which is the whole of its job:
- It sends the line with a kind that is not speech (`system`, `join`, `part`, `topic`, `mode`, `kick`,
`moderation`, and so on - `isChatKind` in `lib/util.ts` is the test). The window then draws a
`NotificationCard` for it on its own: a mark chosen by kind (`SYSTEM_MARKS` in `MessageRow.tsx`), the
line's own words, a warning in red. Nothing else is needed, which is why a new protocol's notifications
are cards from its first connection.
- It may say more by attaching an embed to the line (`kind: "moderation"`, `"raid"`, `"subscribe"`,
`"gift-sub"`, `"stream"`, ...) with the subject in `author`, what happened in `title`, who did it in
`description` where that is known, and a Material Symbols glyph in `icon`. A penalty (`gavel`,
`timer_off`, `delete`, `block`) draws in the danger colour, anything else in the window's accent.
- It says each thing **once**. What the service already tells the whole room (a timeout, a mode change) is
not also written by the client that caused it; only what happens to this account alone is written
locally, as a card.
- A line that is not speech is **never** left as bare grey text, and a card is never used for a person
speaking: the divide is whether somebody said it.
A new protocol is not finished until its joins, parts, topic changes, moderation, mode changes and
service notices come out of it as cards. `scripts/shot-notification-cards.mjs` draws every kind of line
beside ordinary chat and a moderation card, to check that they are the same sort of thing.
## On-disk state
| Path | Contents |
+88
View File
@@ -17,6 +17,94 @@ Each section has two parts:
that made it, including dependency updates
(`node scripts/changelog-deps.mjs <previous tag>` lists those).
## [1.3.3]
### Fixes, patches and changes
- Telegram sign-in by QR code: scan it with Telegram on a phone that is already signed in (Settings, Devices, Link Desktop Device), with no phone number or code; the code is renewed as Telegram says, an account kept in another datacenter is followed there, and a cloud password is asked for after the scan ([moho `b481fb8`](https://github.com/Moho-Chat/moho/commit/b481fb8), [nobilis `430e89f`](https://github.com/Moho-Chat/nobilis/commit/430e89f))
- Telegram chat folders are the categories of the account's page, kept in step both ways: a folder made, renamed, deleted or put in another order on Telegram turns up as the category, and the same done to a category here is done on Telegram, including filing a conversation by dragging it (a category becomes a folder when its first conversation is filed in, since Telegram keeps no empty folder) ([moho `b481fb8`](https://github.com/Moho-Chat/moho/commit/b481fb8), [nobilis `430e89f`](https://github.com/Moho-Chat/nobilis/commit/430e89f), [nobilis `c9c98de`](https://github.com/Moho-Chat/nobilis/commit/c9c98de))
- Telegram stickers can be sent, from the Stickers tab (recent, favourites and the installed packs); a group's bots offer their commands in the slash menu, written as `/command@bot` where two bots share a name; and a topic's own unread and mention counts start its badge ([moho `b481fb8`](https://github.com/Moho-Chat/moho/commit/b481fb8), [nobilis `430e89f`](https://github.com/Moho-Chat/nobilis/commit/430e89f))
- Telegram links open in moho: moho takes over `tg://` links, which is what the "View in Telegram" button on a post's web page opens, and a t.me link in a message goes to the chat and the post, joining the channel if the account is not in it ([moho `b6b308d`](https://github.com/Moho-Chat/moho/commit/b6b308d), [nobilis `64a13fa`](https://github.com/Moho-Chat/nobilis/commit/64a13fa))
- Discord is held back further from what gets an account flagged: no request after a 401 or three refusals on a route, a captcha closes its place instead of being answered, the server's own rules (verification level, onboarding, timeout, slowmode) are checked before a send, a direct message needs five of the account's own messages from Discord's own apps first and an empty channel is not written into first, history is asked once, uploads go in two steps as the official client does, one message at a time is sent to a channel, and the login is the official shape with the gateway stream compressed ([moho `5776ca8`](https://github.com/Moho-Chat/moho/commit/5776ca8), [nobilis `3557335`](https://github.com/Moho-Chat/nobilis/commit/3557335), [nobilis `2638938`](https://github.com/Moho-Chat/nobilis/commit/2638938))
- The message box of a Discord server is closed while the green new-member badge shows by your name there (taken to be the first week after joining), since what is sent from another client in that time is treated as spam; it opens again by itself ([moho `5f10ae6`](https://github.com/Moho-Chat/moho/commit/5f10ae6), [nobilis `b1862bb`](https://github.com/Moho-Chat/nobilis/commit/b1862bb))
## [1.3.2]
### Fixes, patches and changes
- Discord is asked for less and more politely, after what Dissent does to avoid being flagged as a third-party client: reads wait out a rate limit and let an empty bucket refill instead of treating a refusal as an answer; no history is fetched at login (a conversation is read the first time it is opened, 30 messages); a member list is subscribed to once a connection and no longer again at every join and leave; our own membership of each server comes from login and is not asked for; a read mark is sent only while the window is in front, never for a conversation whose last word is yours, and not again after another device sent it; text Discord would refuse (over 2,000 characters, 4,000 with Nitro, or empty) is refused before it is sent; the login declares the capabilities of a user client; the first reconnect wait is four seconds ([moho `74f2c47`](https://github.com/Moho-Chat/moho/commit/74f2c47), [nobilis `7943767`](https://github.com/Moho-Chat/nobilis/commit/7943767))
- The typing notice to Discord is off unless you turn it on in Settings > Discord, and adding a Discord account says plainly that third-party clients are against Discord's terms and what moho does about that ([moho `74f2c47`](https://github.com/Moho-Chat/moho/commit/74f2c47))
- A reply's quote, in the line over a reply and in the bar over the box, draws what it quotes - emotes, emoji and stickers as pictures instead of their codes - on every service ([moho `df818a1`](https://github.com/Moho-Chat/moho/commit/df818a1))
## [1.3.1]
### Fixes, patches and changes
- Discord is not spoken to first from here: a server where your account has not said anything since it joined, and a conversation with nothing in it, are locked with a note to send the first message from Discord's own app or website, after Discord flagged accounts for exactly that. Whether you have spoken is looked for in what moho keeps and then in Discord's search (one request a server, remembered), the lock lifts by itself once there is a message of yours there, and a message sent from moho does not count ([moho `b04189a`](https://github.com/Moho-Chat/moho/commit/b04189a), [nobilis `b9a79e4`](https://github.com/Moho-Chat/nobilis/commit/b9a79e4))
- Discord messages carry a nonce and an explicit list of who may be mentioned, as Discord's own clients send them; a file is checked against what the account may upload before any of it is sent, and waits a few seconds after a message instead of following straight on ([moho `b04189a`](https://github.com/Moho-Chat/moho/commit/b04189a), [nobilis `b9a79e4`](https://github.com/Moho-Chat/nobilis/commit/b9a79e4))
- Taking bold, italic, underline or strikethrough off all of the message box (select all, then the same button or key) works, and undo and redo step over formatting and leave the selection where it was ([moho `5b438be`](https://github.com/Moho-Chat/moho/commit/5b438be))
## [1.3.0]
### Major features
- **Telegram, ported:** your chats, groups, channels and bots in the same window as everything else. Sign in with your phone number, the code Telegram sends and your two-step password if you have one, using your own API id and hash from my.telegram.org (one is enough for every client you use); the session is kept, so a restart does not sign in again.
- **Telegram conversations:** private chats and bots as direct messages, groups and supergroups on the server rail like Discord's servers, channels with their posts, and a group's forum topics as conversations of their own. A horn marks a broadcast channel and a robot a bot. History loads as you scroll, new messages, edits and deletions arrive live, and messages you send, edit and delete go out the same way.
- **Telegram messages:** formatting, replies (with the quote shown above and a click to the original, even in another channel), forwards with a link back to where they came from, pictures, videos, files and voice messages drawn as they arrive and fetched when played, link cards, and Instant View articles opened in a window of their own.
- **Telegram on top of the chat:** reactions, pinned messages, polls (voting and making them), search, comments and thread counts on channel posts, a member list with moderation where you have the rights, muting, joining by link, and the GIF picker with your saved GIFs and Telegram's own search.
- **Telegram bots:** a bot's inline buttons are drawn under its message and pressed from the window, and the Menu button opens the bot's own command list.
- **Discord stays signed in:** a restart within a couple of minutes resumes the session Discord is already holding instead of logging in again, only the conversations that have something new are asked about, and a limit on fresh logins (3 an hour, 8 a day) holds the connection and says why, with a Connect anyway button when you mean it.
- **One video player on every service:** play, seek, a volume slider that opens on hover, elapsed or remaining time, speed, full screen, save, and edit on your own messages.
### Fixes, patches and changes
- Select text in a message or in the message box, right-click it and search the web for it, in your default browser, with the engine chosen in Settings > General: DuckDuckGo, Google, Bing, Brave Search, Startpage, Ecosia, Qwant, Yandex, Kagi, or an address of your own ([moho `4233439`](https://github.com/Moho-Chat/moho/commit/4233439))
- Formatting in the message box can be taken off again with the same button or key, over a whole run, part of one or a mixed selection, and undo and redo step over each change and leave the selection where it was ([moho `496d18f`](https://github.com/Moho-Chat/moho/commit/496d18f))
- Discord sends this install's id, language and timezone with its requests, as its own clients do; it still says it is moho ([moho `3a6c8da`](https://github.com/Moho-Chat/moho/commit/3a6c8da), [nobilis `3bf68e7`](https://github.com/Moho-Chat/nobilis/commit/3bf68e7))
- Discord resumes its session after a restart, asks only about conversations that moved on, posts typing at most every eight seconds and does not repeat a read mark ([moho `ab8f40f`](https://github.com/Moho-Chat/moho/commit/ab8f40f), [nobilis `e8b25d9`](https://github.com/Moho-Chat/nobilis/commit/e8b25d9))
- Discord limits fresh logins per account, holds the connection when it is over, and lets you through with Connect anyway ([moho `59f978d`](https://github.com/Moho-Chat/moho/commit/59f978d), [nobilis `8a7d733`](https://github.com/Moho-Chat/nobilis/commit/8a7d733), [moho `8f62f44`](https://github.com/Moho-Chat/moho/commit/8f62f44), [nobilis `58bf5ac`](https://github.com/Moho-Chat/nobilis/commit/58bf5ac))
- Discord's membership-screening dialog is drawn as Discord draws it, with the rules, the questions and the answers sent ([moho `23afcb6`](https://github.com/Moho-Chat/moho/commit/23afcb6), [nobilis `63093e1`](https://github.com/Moho-Chat/nobilis/commit/63093e1))
- Discord messages made of layout components (containers, sections, text, pictures) are drawn as cards with their buttons ([moho `c64812c`](https://github.com/Moho-Chat/moho/commit/c64812c), [nobilis `41c2780`](https://github.com/Moho-Chat/nobilis/commit/41c2780))
- Discord's APP mark on bots and applications, a check on verified ones, and OFFICIAL on the platform's own account and its messages; badges sit between the name and the time ([moho `2f4c204`](https://github.com/Moho-Chat/moho/commit/2f4c204), [nobilis `dba7731`](https://github.com/Moho-Chat/nobilis/commit/dba7731), [moho `510627f`](https://github.com/Moho-Chat/moho/commit/510627f), [nobilis `5501675`](https://github.com/Moho-Chat/nobilis/commit/5501675), [moho `5d2d0fe`](https://github.com/Moho-Chat/moho/commit/5d2d0fe))
- Discord: an emoji picked from another server's section goes in as its token and not the number that names it, and the hover toolbar and recent list no longer show bare numbers ([moho `9a86141`](https://github.com/Moho-Chat/moho/commit/9a86141), [moho `f1bf63a`](https://github.com/Moho-Chat/moho/commit/f1bf63a))
- Discord reads a backslash before punctuation as an escape in messages, link previews and embed field values ([moho `eb549f7`](https://github.com/Moho-Chat/moho/commit/eb549f7), [moho `a2a8949`](https://github.com/Moho-Chat/moho/commit/a2a8949))
- Videos go full screen by themselves on every service, filling the screen, with no second expand button and no viewer ([moho `bf5c33d`](https://github.com/Moho-Chat/moho/commit/bf5c33d), [moho `1387907`](https://github.com/Moho-Chat/moho/commit/1387907), [moho `aab10c7`](https://github.com/Moho-Chat/moho/commit/aab10c7), [moho `72d5b83`](https://github.com/Moho-Chat/moho/commit/72d5b83))
- A left click on the tray icon puts the window away when it is showing and brings it back when it is not ([moho `4ebabb2`](https://github.com/Moho-Chat/moho/commit/4ebabb2))
- The GIF picker is offered only where a GIF link shows as a picture: not on Steam, Twitch, Sneedchat or IRC ([moho `40505af`](https://github.com/Moho-Chat/moho/commit/40505af), [moho `7a2fda0`](https://github.com/Moho-Chat/moho/commit/7a2fda0))
## [1.2.1]
### Fixes, patches and changes
- Steam signs in as a desktop client of its own, with a machine id and a logon id that belong to the install, instead of as the Steam mobile app (a "phone" reporting a Linux desktop, with no machine id, which Steam raised an account alert about), and waits longer before reconnecting after Steam closes a connection. Sign in to Steam again from Accounts to take on the new identity; the old one stays in Steam's list of devices until you remove it there ([moho `fceec5d`](https://github.com/Moho-Chat/moho/commit/fceec5d), [nobilis `f3de870`](https://github.com/Moho-Chat/nobilis/commit/f3de870))
## [1.2.0]
### Major features
- **Twitch, fully ported:** a streamer's chat beside the stream, in the same window as everything else. Read with no account at all, or sign in once and have chat, video, channel points, moderation and whispers from that single sign-in.
- **Twitch chat:** messages with name colours, badges, cheers with their pictures, replies and threads, Twitch's own emotes (animated ones move), first-time and returning chatters, and subscriptions, gifts, raids and announcements drawn as cards. The channels you follow are opened the first time you sign in, and the member list counts who is talking.
- **Twitch video:** the stream plays in the window the way Kick's does, in the conversation, in the corner or in a window of its own, with a resolution menu, captions where the streamer sends them, and Twitch's ad breaks said instead of left to look like a fault. A subscriber's or Turbo account plays without them.
- **Twitch emotes:** the picker shows the selected channel's whole set with the ones you cannot use greyed out and the usable ones first, then Twitch's own, then a section for each other channel that gives you emotes, each with the channel's picture. What you send leaves as its word and shows as its picture in your own line; an emote that lapses leaves the picker, and an emote that cannot be sent in a conversation is not offered as a recent one on any service.
- **Twitch channel points:** the balance on a plaque, one panel over the chat that lists a channel's rewards cheapest first and redeems them (Highlight My Message, Choose an Emote to Unlock, Unlock a Random Sub Emote, Modify a Single Emote, and the streamer's own, with the text a reward asks for), and watching a stream in moho now earns points because the player reports that it is being watched, once a minute and only while the picture is playing, with a switch in Settings.
- **Twitch polls, predictions and raids** as they happen, as cards: vote in a poll and bet on a prediction from the card, and go along with a raid.
- **Twitch moderation and whispers** from one sign-in: removing messages, timeouts, bans, the room's modes, announcements, shoutouts, blocking, and whispers as conversations of their own, from menus and from slash commands, for the channels where the account has the rights.
- **Twitch's own sections in the list:** Channels and Direct Messages are fixed headings, and a whisper is a conversation named for the person, with a badge to tell it from a channel.
- **Kick and Twitch share one rewards panel** for channel points, opened from the plaque over the chat like the quick switcher.
### Fixes, patches and changes
- Every notification a service sends is drawn as a card in the same style, and that is the rule for protocols to come ([moho `9ef8a67`](https://github.com/Moho-Chat/moho/commit/9ef8a67))
- IRC explains a channel's modes in words when you join and when one changes, so `+Cnrt` is not left for a new user to decode ([moho `34fcddf`](https://github.com/Moho-Chat/moho/commit/34fcddf), [nobilis `28481f3`](https://github.com/Moho-Chat/nobilis/commit/28481f3))
- Sneedchat greentext is drawn in 4chan's green, and a sent greentext line is matched with the site's coloured echo ([moho `bb2edf5`](https://github.com/Moho-Chat/moho/commit/bb2edf5))
- Recent emoji are offered only where they can be sent, in every conversation of every service ([moho `aacbde7`](https://github.com/Moho-Chat/moho/commit/aacbde7))
- The channel list, the member list, the stream above the conversation and the corner panel can be resized by dragging their edges ([moho `d1e39f1`](https://github.com/Moho-Chat/moho/commit/d1e39f1))
- Kick video plays about 3.5 seconds behind instead of 20, without rebuilding the picture on every new address, and has a resolution menu and live captions ([moho `3aff255`](https://github.com/Moho-Chat/moho/commit/3aff255), [nobilis `9f0ca86`](https://github.com/Moho-Chat/nobilis/commit/9f0ca86))
- Kick video no longer guesses prefetched segments for streams whose segments differ in length ([moho `a7ef798`](https://github.com/Moho-Chat/moho/commit/a7ef798))
- Kick shows threads from replies and removed messages, struck through, left out or replaced as chosen ([moho `8e3d110`](https://github.com/Moho-Chat/moho/commit/8e3d110), [nobilis `bb318cf`](https://github.com/Moho-Chat/nobilis/commit/bb318cf))
- Kick's recently-talking count keeps rising past the length of what is listed ([moho `987644e`](https://github.com/Moho-Chat/moho/commit/987644e), [nobilis `41e93b8`](https://github.com/Moho-Chat/nobilis/commit/41e93b8))
- Kick's points plaque follows its push, and badge pictures are drawn beside names ([moho `dd44e76`](https://github.com/Moho-Chat/moho/commit/dd44e76), [nobilis `4c17be0`](https://github.com/Moho-Chat/nobilis/commit/4c17be0))
## [1.1.0]
### Major features
+5 -3
View File
@@ -3,13 +3,13 @@
**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, Sneedchat, Steam and many more protocols at the
moho is a desktop chat client that speaks IRC, Discord, Matrix, Kick, Twitch, Sneedchat, Steam, Telegram 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
- **Many networks, one place.** IRC, Discord, Matrix, Kick, Sneedchat, Steam and many more protocols soon, side by side, with
- **Many networks, one place.** IRC, Discord, Matrix, Kick, Twitch, Sneedchat, Steam, Telegram 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.
- **Calls.** Voice, camera and screen sharing on Discord and on Matrix, voice rooms and calls on Steam, including the group
@@ -18,7 +18,7 @@ stream's chat going past at forty lines a minute you're able to be part of the c
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.
- **Kick streams in the window.** Watch the stream beside its chat, in the corner or in a
- **Kick and Twitch streams in the window.** Watch the stream beside its chat, in the corner or in a
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.
@@ -31,8 +31,10 @@ stream's chat going past at forty lines a minute you're able to be part of the c
| **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 |
| **Twitch** | Supported, fully ported | A streamer's chat beside the stream, which plays in the window with a resolution menu and its ad breaks said; emotes (animated, and the channel's whole set with the ones you cannot use greyed out), badges, cheers, replies and threads, its events drawn as cards, channel points with a rewards panel and watch time that earns them, polls, predictions and raids you can answer, and moderation and whispers, all from one sign-in |
| **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 |
| **Telegram** | Supported | Private chats, bots, groups with their forum topics, and channels, with replies and forwards, formatting, pictures, video, files and voice messages, reactions, polls, pinned messages, search, comments on channel posts, the GIF picker and stickers, bots' buttons and command menus, your chat folders kept in step with moho's categories both ways, links to chats and posts that open in moho, and signing in by phone number and code 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
+1 -1
Submodule nobilis updated: 289b5db54a...152b832961.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "moho",
"version": "1.1.0",
"version": "1.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "moho",
"version": "1.1.0",
"version": "1.3.3",
"license": "GPL-3.0-or-later",
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "moho",
"version": "1.1.0",
"version": "1.3.3",
"description": "Unified desktop chat client for IRC, Discord, Sneedchat and Matrix",
"author": "Salastil <[email protected]>",
"license": "GPL-3.0-or-later",
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env node
/**
* Formatting in the message box, in a scratch moho with injected state (nothing is contacted): a format applied
* to a selection can be taken off again - by the keyboard shortcut or the strip's button, over the whole run,
* over part of it, or over a selection reaching past it - and each step can be undone with the box's own undo.
*
* node scripts/check-composer-format.mjs (SERVICE=sneedchat to try another service's box)
*/
import { launchApp, sleep } from './lib/harness.mjs'
const SERVICE = process.env.SERVICE ?? 'discord'
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
const results = []
const check = (what, ok, detail = '') => {
results.push(ok)
console.log(ok ? ' ok ' : ' FAIL', what, ok ? '' : detail)
}
const BOX = '.composer-input'
const html = () => ev(`document.querySelector(${JSON.stringify(BOX)}).innerHTML.replace(/ data-[a-z-]+="[^"]*"/g, (m) => m.startsWith(' data-fmt') ? m : '')`)
/** The text the box would send, with the markers of what is bold, italic, and so on in it. */
const marked = () =>
ev(`(() => {
const mark = { bold: '**', italic: '_', underline: '__', strike: '~~', code: '\`', spoiler: '||', quote: '> ' }
const native = { B: 'bold', STRONG: 'bold', I: 'italic', EM: 'italic', U: 'underline', S: 'strike', STRIKE: 'strike' }
const walk = (n) => {
if (n.nodeType === 3) return n.nodeValue
const kind = n instanceof HTMLElement ? (n.dataset.fmt ?? native[n.nodeName]) : null
const inner = [...n.childNodes].map(walk).join('')
return kind ? (mark[kind] ?? '') + inner + (kind === 'quote' ? '' : mark[kind] ?? '') : inner
}
return walk(document.querySelector(${JSON.stringify(BOX)}))
})()`)
const type = async (text) => {
await ev(`(() => { const b = document.querySelector(${JSON.stringify(BOX)}); b.focus(); b.innerHTML = '' })()`)
await page.call('Input.insertText', { text })
}
const select = (needle, nth = 0) =>
ev(`(() => {
const box = document.querySelector(${JSON.stringify(BOX)})
const text = []
const w = document.createTreeWalker(box, NodeFilter.SHOW_TEXT)
for (let n; (n = w.nextNode()); ) text.push(n)
const all = text.map((t) => t.nodeValue).join('')
let at = -1
for (let i = 0; i <= ${nth}; i++) at = all.indexOf(${JSON.stringify(needle)}, at + 1)
if (at < 0) return false
const locate = (offset) => { let seen = 0; for (const t of text) { if (offset <= seen + t.nodeValue.length) return [t, offset - seen]; seen += t.nodeValue.length } }
const [a, ao] = locate(at)
const [b, bo] = locate(at + ${JSON.stringify(needle)}.length)
const r = document.createRange()
r.setStart(a, ao); r.setEnd(b, bo)
const s = getSelection(); s.removeAllRanges(); s.addRange(r)
box.focus()
return true
})()`)
const key = (k, { ctrl = false, commands } = {}) =>
page.call('Input.dispatchKeyEvent', { type: 'keyDown', key: k, code: 'Key' + k.toUpperCase(), windowsVirtualKeyCode: k.toUpperCase().charCodeAt(0), modifiers: ctrl ? 2 : 0, commands })
const bold = () => key('b', { ctrl: true })
const undo = () => key('z', { ctrl: true, commands: ['undo'] })
const redo = () => key('z', { ctrl: true, commands: ['redo'] })
const strip = async (title) => {
await sleep(150)
return ev(`(() => { const b = [...document.querySelectorAll('.format-bar button')].find((x) => x.title === ${JSON.stringify(title)}); if (!b) return false; b.click(); return true })()`)
}
try {
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
await ev(`(() => {
const s = window.__mohoShots.store
const acct = { id: '${SERVICE}:me', service: '${SERVICE}', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, nick: 'me' }
const buf = { id: '${SERVICE}:me|chat', accountId: '${SERVICE}:me', kind: 'channel', name: 'chat', lastActivityTs: 5, category: 'Channels', groupId: 'account:${SERVICE}:me' }
s.set({ accounts: [acct], groups: [{ id: 'account:${SERVICE}:me', accountId: '${SERVICE}:me', service: '${SERVICE}', kind: 'account', name: 'me', position: 0 }], buffers: [buf], messagesByBuffer: { '${SERVICE}:me|chat': [] }, activeBufferId: '${SERVICE}:me|chat' })
s.selectGroup('account:${SERVICE}:me')
})()`)
await sleep(1200)
console.log('Applying and taking off')
await type('one two three four')
await select('two')
await bold()
check('bold goes on', (await marked()) === 'one **two** three four', await marked())
await bold()
check('the same key again takes it off', (await marked()) === 'one two three four', await marked())
await bold()
check('and again puts it back', (await marked()) === 'one **two** three four', await marked())
console.log('Undo')
await undo()
check('undo takes the bold back off', (await marked()) === 'one two three four', await marked())
await redo()
check('redo puts it on again', (await marked()) === 'one **two** three four', await marked())
check('redo leaves the word selected, not the whole message', (await ev(`String(getSelection())`)) === 'two', await ev(`String(getSelection())`))
await bold()
await undo()
check('undo puts it back after it was taken off', (await marked()) === 'one **two** three four', await marked())
check('and leaves the word selected', (await ev(`String(getSelection())`)) === 'two', await ev(`String(getSelection())`))
console.log('All of the box')
await type('one two three')
await ev(`(() => { const b = document.querySelector(${JSON.stringify(BOX)}); b.focus(); getSelection().selectAllChildren(b) })()`)
await bold()
check('everything goes bold (select all, then the key)', (await marked()) === '**one two three**', await marked())
await bold()
check('and the key again takes it all off', (await marked()) === 'one two three', await marked())
await bold()
await undo()
check('and undo puts the box back as it was', (await marked()) === 'one two three', await marked())
await ev(`(() => { const b = document.querySelector(${JSON.stringify(BOX)}); b.focus(); getSelection().selectAllChildren(b) })()`)
await sleep(250)
await strip('Bold')
await sleep(250)
check('everything goes bold from the strip', (await marked()) === '**one two three**', await marked())
await strip('Bold')
check('and the strip again takes it all off', (await marked()) === 'one two three', await marked())
for (const [title, m] of [['Code', '`'], ['Spoiler', '||'], ['Italic', '_']]) {
await ev(`(() => { const b = document.querySelector(${JSON.stringify(BOX)}); b.focus(); getSelection().selectAllChildren(b) })()`)
await sleep(250)
await strip(title)
const on = await marked()
await sleep(250)
await strip(title)
check(`${title}: all of the box goes on and comes off again`, on === `${m}one two three${m}` && (await marked()) === 'one two three', `${on} -> ${await marked()}`)
}
console.log('Formats the window makes itself (code, spoiler) undo as well')
for (const [title, m] of [['Code', '`'], ['Spoiler', '||']]) {
await type('one two three four')
await select('two')
await sleep(250)
await strip(title)
const on = await marked()
await undo()
const off = await marked()
const kept = await ev(`String(getSelection())`)
await redo()
check(`${title}: on, undo takes it off with the word still selected, redo puts it on`, on === `one ${m}two${m} three four` && off === 'one two three four' && kept === 'two' && (await marked()) === on, `${on} | ${off} | ${kept} | ${await marked()}`)
await ev(`(() => { const b = document.querySelector(${JSON.stringify(BOX)}); b.focus(); getSelection().selectAllChildren(b) })()`)
await sleep(250)
await strip(title)
await strip(title)
await undo()
check(`${title}: over all of the box, taken off and undone`, (await marked()).replace(/[\`|]/g, '') === 'one two three four' && (await marked()).includes(m), await marked())
}
console.log('Undo leaves a selection that is safe to type over')
await type('one two three four')
await select('two')
await bold()
await undo()
check('after undo the word is selected, not the whole message', (await ev(`String(getSelection())`)) === 'two', await ev(`String(getSelection())`))
console.log('The ends of the box')
await type('one two')
await select('one')
await bold()
check('the first word goes bold', (await marked()) === '**one** two', await marked())
await bold()
check('and comes off', (await marked()) === 'one two', await marked())
await select('two')
await bold()
check('the last word goes bold', (await marked()) === 'one **two**', await marked())
await bold()
check('and comes off', (await marked()) === 'one two', await marked())
console.log('Other formats')
await type('one two three')
await select('two')
await key('i', { ctrl: true })
check('italic goes on', (await marked()) === 'one _two_ three', await marked())
await select('one two thr')
await bold()
check('bold over italic keeps both', (await marked()) === '**one _two_ thr**ee', await marked())
await key('i', { ctrl: true })
check('italic over text that is only partly italic makes all of it italic', (await marked()) === '**_one two thr_**ee', await marked())
await key('i', { ctrl: true })
check('and again takes italic off all of it, leaving the bold', (await marked()) === '**one two thr**ee', await marked())
console.log('Lines')
await type('first line')
await page.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, modifiers: 8 })
await page.call('Input.insertText', { text: 'second line' })
if (!(await select('line\nsecond'))) await select('linesecond')
await bold()
const lines = await ev(`document.querySelector(${JSON.stringify(BOX)}).innerHTML`)
check('a selection over two lines is bold, and the break is still there', /<b>|data-fmt="bold"/.test(lines) && (/<br/.test(lines) || /\n/.test(lines)) && (await marked()).replace(/\n/g, '').includes('**'), lines)
await bold()
check('and comes off whole', !/data-fmt|<b>/.test(await html()), await html())
console.log('Part of a run')
await type('one two three four')
await select('two three')
await bold()
await select('wo th')
await bold()
check('part of a run is taken off, the rest stays', (await marked()) === 'one **t**wo th**ree** four', await marked())
console.log('Reaching past a run')
await type('one two three four')
await select('two')
await bold()
await select('one two thr')
await bold()
check('a selection partly bold is made all bold, not doubled', (await marked()) === '**one two thr**ee four', await marked())
await bold()
check('and the next press takes it all off', (await marked()) === 'one two three four', await marked())
console.log('The strip')
await type('one two three four')
await select('three')
await sleep(300)
check('the strip offers bold', await strip('Bold'))
check('and it goes on', (await marked()) === 'one two **three** four', await marked())
await sleep(300)
check('the strip button again takes it off', (await strip('Bold')) && (await marked()) === 'one two three four', await marked())
await undo()
check('and undo puts it back', (await marked()) === 'one two **three** four', await marked())
} catch (e) {
console.log(' FAIL', e.message)
results.push(false)
} finally {
await stop()
}
process.exit(results.every(Boolean) ? 0 : 1)
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env node
/**
* The message box in a Discord server (or conversation) the account has not spoken in: replaced by the reason, in a
* scratch moho with injected state (nothing is contacted). The daemon decides the lock; this checks what the window
* makes of the `readOnly` it is given.
*
* node scripts/check-first-word-lock.mjs [output directory]
*/
import fs from 'node:fs'
import path from 'node:path'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2]
if (out) fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
const results = []
const check = (what, ok, detail = '') => {
results.push(ok)
console.log(ok ? ' ok ' : ' FAIL', what, ok ? '' : detail)
}
const REASON = "Send your first message in this server from Discord's own app or website. Discord flags accounts that start talking in a server from another client."
try {
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
await ev(`(() => {
const s = window.__mohoShots.store
const acct = { id: 'discord:me', service: 'discord', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, nick: 'me' }
const mk = (name, readOnly) => ({ id: 'discord:me|' + name, accountId: 'discord:me', kind: 'channel', name, lastActivityTs: 5, category: 'Channels', groupId: 'account:discord:me', ...(readOnly ? { readOnly } : {}) })
s.set({ accounts: [acct], groups: [{ id: 'account:discord:me', accountId: 'discord:me', service: 'discord', kind: 'account', name: 'me', position: 0 }], buffers: [mk('chasm/#general', ${JSON.stringify(REASON)}), mk('chasm/#open')], messagesByBuffer: { 'discord:me|chasm/#general': [], 'discord:me|chasm/#open': [] }, activeBufferId: 'discord:me|chasm/#general' })
s.selectGroup('account:discord:me')
})()`)
await sleep(1000)
check('a locked server shows the reason where the box would be', (await ev(`document.querySelector('.composer-readonly')?.innerText ?? ''`)).includes('first message in this server'))
check('with a line saying moho opens it by itself', (await ev(`document.querySelector('.composer-readonly')?.innerText ?? ''`)).includes('opens this up by itself'))
check('and no message box to type in', !(await ev(`!!document.querySelector('.composer-input')`)))
if (out) {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, 'first-word-lock.png'), Buffer.from(data, 'base64'))
}
await ev(`(() => { const s = window.__mohoShots.state(); window.__mohoShots.patch({ buffers: s.buffers.map((b) => ({ ...b, readOnly: undefined })) }) })()`)
await sleep(500)
check('once the lock is lifted the box is back', await ev(`!!document.querySelector('.composer-input')`))
} catch (e) {
console.log(' FAIL', e.message)
results.push(false)
} finally {
await stop()
}
process.exit(results.every(Boolean) ? 0 : 1)
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env node
/**
* The line above a reply, and the bar over the box while one is written, in a scratch moho with injected state
* (nothing is contacted): what the reply quotes is drawn as the original is - an emote or emoji as its picture, not
* its code - on every service that has such codes, and text that has none is untouched.
*
* node scripts/check-reply-emotes.mjs [output directory]
*/
import fs from 'node:fs'
import path from 'node:path'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2]
if (out) fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
const results = []
const check = (what, ok, detail = '') => {
results.push(ok)
console.log(ok ? ' ok ' : ' FAIL', what, ok ? '' : detail)
}
// What each service's stored text carries for a picture, and what must not be left on screen.
const CASES = [
{ service: 'kick', body: '[emote:5756632:SUSSY][emote:5756632:SUSSY] lol', code: '[emote:', pictures: 2 },
{ service: 'discord', body: 'nice <:pog:123456789012345678> yes', code: '<:pog', pictures: 1 },
{ service: 'twitch', body: '[twitch:emote:25:Kappa] hello', code: '[twitch:', pictures: 1 },
// Twitch quotes the parent as plain text, with no word of which are emotes: the original on screen has them.
{ service: 'twitch', body: '[twitch:emote:25:Kappa] hello', quotedAs: 'Kappa hello', code: '[twitch:', pictures: 1 },
{ service: 'steam', body: '[steam:emote:steamhappy] hi', code: '[steam:', pictures: 1 },
{ service: 'sneedchat', body: 'hello :smile: there', code: null, pictures: 0 },
{ service: 'matrix', body: 'plain 😀 words', code: null, pictures: 0 },
{ service: 'irc', body: 'plain 😀 words', code: null, pictures: 0 },
{ service: 'telegram', body: 'plain 😀 words', code: null, pictures: 0 }
]
try {
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
for (const c of CASES) {
console.log(c.service)
await ev(`(() => {
const s = window.__mohoShots.store
const id = ${JSON.stringify(c.service + ':me')}
const acct = { id, service: ${JSON.stringify(c.service)}, name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, nick: 'me' }
const buf = { id: id + '|room', accountId: id, kind: 'channel', name: 'room', lastActivityTs: 5, category: 'Channels', groupId: 'account:' + id }
const t = Math.floor(Date.now() / 1000) - 600
const base = { bufferId: buf.id, ts: t, kind: 'chat', isAction: false, isHighlight: false, isOwn: false, edited: false }
const messages = [
{ ...base, id: 'o1', from: 'TAYL31GH', senderId: 't', body: ${JSON.stringify(c.body)} },
{ ...base, id: 'r1', from: 'RildWandy', senderId: 'r', ts: t + 30, body: 'I shit you not lol', replyTo: { id: 'o1', from: 'TAYL31GH', body: ${JSON.stringify(c.quotedAs ?? c.body)}${c.service === 'telegram' ? ", chat: ''" : ''} } }
]
s.set({ accounts: [acct], groups: [{ id: 'account:' + id, accountId: id, service: ${JSON.stringify(c.service)}, kind: 'account', name: 'me', position: 0 }], buffers: [buf], messagesByBuffer: { [buf.id]: messages }, activeBufferId: buf.id })
s.selectGroup('account:' + id)
})()`)
await sleep(900)
const preview = await ev(`(() => {
const el = document.querySelector('.reply-preview, .tg-quote-body')
return el ? { text: el.innerText, pictures: el.querySelectorAll('img.custom-emoji, img.smilie, .custom-emoji-gone').length, html: el.innerHTML.slice(0, 200) } : null
})()`)
check(`${c.service}: the reply shows what it answers`, !!preview)
if (!preview) continue
if (c.code) check(`${c.service}: and not the emote's code`, !preview.text.includes(c.code) && !preview.html.includes(c.code), JSON.stringify(preview))
if (c.pictures) check(`${c.service}: as ${c.pictures} picture(s)`, preview.pictures === c.pictures, JSON.stringify(preview))
else check(`${c.service}: its words are kept`, /plain|hello/.test(preview.text), JSON.stringify(preview))
if (c.service === 'kick') {
console.log('The bar over the box')
await ev(`window.__mohoShots.store.startReply('o1', 'TAYL31GH', '[emote:5756632:SUSSY] lol')`)
await sleep(600)
const bar = await ev(`(() => { const el = document.querySelector('.composer-reply'); return el ? { text: el.innerText, pictures: el.querySelectorAll('img.custom-emoji, .custom-emoji-gone').length } : null })()`)
check('the bar over the box draws the emote too', !!bar && bar.pictures === 1 && !bar.text.includes('[emote:'), JSON.stringify(bar))
await ev(`window.__mohoShots.store.set({ replyingTo: null })`)
}
if (c.service === 'kick' && out) {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, 'reply-emotes-kick.png'), Buffer.from(data, 'base64'))
}
}
} catch (e) {
console.log(' FAIL', e.message)
results.push(false)
} finally {
await stop()
}
process.exit(results.every(Boolean) ? 0 : 1)
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env node
/**
* The edges that can be dragged, in a scratch moho with injected state (nothing is contacted): the channel list
* and the member list change width and keep it, double-click puts them back, a stream above the conversation
* changes height by its bottom edge, and the corner panel changes size from every edge and corner.
*
* node scripts/check-resize.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
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'))
}
const results = []
const check = (what, ok, detail = '') => {
results.push(ok)
console.log(ok ? ' ok ' : ' FAIL', what, detail)
}
const mouse = (type, x, y) => page.call('Input.dispatchMouseEvent', { type, x, y, button: 'left', buttons: type === 'mouseReleased' ? 0 : 1, clickCount: 1 })
const drag = async (from, to) => {
await mouse('mouseMoved', from.x, from.y)
await mouse('mousePressed', from.x, from.y)
for (let i = 1; i <= 6; i++) await mouse('mouseMoved', from.x + ((to.x - from.x) * i) / 6, from.y + ((to.y - from.y) * i) / 6)
await mouse('mouseReleased', to.x, to.y)
await sleep(300)
}
const rect = (sel) => ev(`(() => { const r = document.querySelector(${JSON.stringify(sel)})?.getBoundingClientRect(); return r ? { x: r.left, y: r.top, w: r.width, h: r.height, r: r.right, b: r.bottom } : null })()`)
const centre = (r) => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 })
try {
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
await ev(`(() => {
const s = window.__mohoShots.store
const acct = { id: 'twitch:me', service: 'twitch', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, nick: 'me' }
const mk = (name) => ({ id: 'twitch:me|' + name, accountId: 'twitch:me', kind: 'channel', name, lastActivityTs: 5, category: 'Channels', groupId: 'account:twitch:me' })
const t = Math.floor(Date.now() / 1000) - 600
const messages = Array.from({ length: 8 }, (_, i) => ({ id: 'm' + i, bufferId: 'twitch:me|xqc', from: 'user' + i, body: 'hello ' + i, ts: t + i * 20, kind: 'chat', isAction: false, isHighlight: false, isOwn: false, edited: false }))
s.set({ accounts: [acct], groups: [{ id: 'account:twitch:me', accountId: 'twitch:me', service: 'twitch', kind: 'account', name: 'me', position: 0 }], buffers: [mk('xqc'), mk('other')], messagesByBuffer: { 'twitch:me|xqc': messages }, activeBufferId: 'twitch:me|xqc' })
s.selectGroup('account:twitch:me')
})()`)
await sleep(1200)
console.log('Channel list')
let side = await rect('.sidebar')
const divider = await rect('.divider-v.resizer')
check('the divider beside the channel list can be taken', !!divider, JSON.stringify(divider))
const before = side.w
await drag(centre(divider), { x: centre(divider).x + 90, y: centre(divider).y })
side = await rect('.sidebar')
check('dragging it wider widens the channel list', Math.abs(side.w - (before + 90)) <= 2, `${before} -> ${side.w}`)
await drag(centre(await rect('.divider-v.resizer')), { x: 5, y: 300 })
side = await rect('.sidebar')
check('it stops at its narrowest', side.w >= 158 && side.w <= 162, String(side.w))
await ev(`document.querySelector('.divider-v.resizer').dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))`)
await sleep(300)
side = await rect('.sidebar')
check('double-click puts it back to its usual width', Math.abs(side.w - 220) <= 2, String(side.w))
console.log('Member list')
await ev(`window.__mohoShots.store.set({ roster: {} })`)
const nick = await rect('.nicklist-pane')
if (nick) {
const handles = await ev(`[...document.querySelectorAll('.divider-v.resizer')].map((d) => { const r = d.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 } })`)
const right = handles[handles.length - 1]
const w0 = nick.w
await drag(right, { x: right.x - 70, y: right.y })
const w1 = (await rect('.nicklist-pane')).w
check('dragging the member list divider left widens the member list', Math.abs(w1 - (w0 + 70)) <= 2, `${w0} -> ${w1}`)
} else check('the member list is shown for a channel', false)
await shot('columns.png')
console.log('Stream above the conversation')
await ev(`(() => {
const s = window.__mohoShots.store
s.set({ watching: { bufferId: 'twitch:me|xqc', accountId: 'twitch:me', title: 'xqc' }, kickStreams: { 'twitch:me|xqc': { bufferId: 'twitch:me|xqc', live: true, title: 'a stream', viewers: 5, playbackUrl: '' } } })
})()`)
await sleep(800)
const edge = await rect('.call-stage:not(.pip) .stage-resize')
check('the stream has a bottom edge to take', !!edge, JSON.stringify(edge))
if (edge) {
const h0 = (await rect('.video-stage')).h
await drag(centre(edge), { x: centre(edge).x, y: centre(edge).y - 60 })
const h1 = (await rect('.video-stage')).h
check('dragging it up makes the picture shorter', h1 < h0 - 40, `${h0} -> ${h1}`)
await shot('stream-inline.png')
}
console.log('Corner panel')
await ev(`window.__mohoShots.store.selectBuffer('twitch:me|other')`)
await sleep(800)
let pip = await rect('.call-stage.pip')
check('the stream goes to a corner when another channel is open', !!pip, JSON.stringify(pip))
if (pip) {
for (const [dir, dx, dy, bigger] of [['l', -80, 0, true], ['t', 0, -60, true], ['tl', -60, -40, true], ['r', 60, 0, true], ['b', 0, 50, true]]) {
const was = (await rect('.call-stage.pip')).w
const handle = await rect(`.call-stage.pip .edge-${dir}`)
if (!handle) { check(`the ${dir} edge can be taken`, false); continue }
const start = centre(handle)
await drag(start, { x: start.x + dx, y: start.y + dy })
const now = await rect('.call-stage.pip')
check(`dragging the ${dir} edge changes the corner panel's size`, bigger ? now.w > was + 20 : now.w < was - 20, `${Math.round(was)} -> ${Math.round(now.w)}`)
check(`...and it stays inside the window (${dir})`, now.x >= 0 && now.r <= 1280 && now.y >= 0 && now.b <= 800, JSON.stringify(now))
}
await shot('pip.png')
}
} finally {
await stop()
console.log(results.every(Boolean) ? 'ALL OK' : 'SOME FAILED')
process.exitCode = results.every(Boolean) ? 0 : 1
}
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env node
/**
* The channel points panel over the chat, for Twitch and for Kick, in a scratch moho with the daemon's answers
* stood in for (nothing is contacted, nothing spent): it opens from the plaque, lists the rewards with their
* costs, says why one cannot be pressed, needs a second press to spend, asks for the text a reward wants, and
* closes on Escape.
*
* node scripts/check-rewards-panel.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
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'))
}
const results = []
const check = (what, ok, detail = '') => {
results.push(ok)
console.log(ok ? ' ok ' : ' FAIL', what, detail)
}
const key = (k) => page.call('Input.dispatchKeyEvent', { type: 'keyDown', key: k, code: k }).then(() => page.call('Input.dispatchKeyEvent', { type: 'keyUp', key: k, code: k }))
try {
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1200, height: 800, deviceScaleFactor: 1, mobile: false })
await ev(`(() => {
window.__redeemed = []
window.__rpcFixture = {
listTwitchRewards: () => ({ points: 600, name: 'Sloths', rewards: [
{ id: 'r1', title: 'Drink water', cost: 500, description: null, enabled: true, paused: false, needsInput: false },
{ id: 'r2', title: 'Say a name', cost: 100, description: 'Who?', enabled: true, paused: false, needsInput: true },
{ id: 'r3', title: 'Dance', cost: 50, description: null, enabled: true, paused: true, needsInput: false },
{ id: 'r4', title: 'Costly', cost: 9000, description: null, enabled: true, paused: false, needsInput: false },
{ id: 'a1', title: 'Choose an Emote to Unlock', cost: 480, description: 'Pick a subscriber emote to unlock for 24 hours', enabled: true, paused: false, needsInput: false, choices: [{ id: 'e1', label: 'binxbWut' }, { id: 'e2', label: 'binxbYAP' }] },
{ id: 'a3', title: 'Modify a Single Emote', cost: 550, description: null, enabled: true, paused: false, needsInput: false, choices: [{ id: 'e1_BW', label: 'binxbWut, black and white', group: 'binxbWut', groupImage: null }, { id: 'e1_SG', label: 'binxbWut, with sunglasses', group: 'binxbWut', groupImage: null }, { id: 'e2_SG', label: 'binxbYAP, with sunglasses', group: 'binxbYAP', groupImage: null }] },
{ id: 'a2', title: 'Sub-only', cost: 350, description: null, enabled: true, paused: false, needsInput: false, unavailable: 'Send this one from twitch.tv chat box' } ] }),
redeemTwitchReward: (p) => { window.__redeemed.push(p); return { points: 600 } },
listKickRewards: () => ({ points: 1200, rewards: [ { id: 'k1', title: 'Hydrate', cost: 300, enabled: true, paused: false, needsInput: false }, { id: 'k2', title: 'Type something', cost: 100, enabled: true, paused: false, needsInput: true } ] }),
redeemKickReward: (p) => { window.__redeemed.push(p); return { points: 900 } },
twitchPoints: () => ({ points: 600, name: 'Sloths' }),
kickPoints: () => ({ points: 1200 })
}
const s = window.__mohoShots.store
const pic = 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#7b5cd6"/></svg>')
const accts = [
{ id: 'twitch:me', service: 'twitch', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, webSession: true, nick: 'me' },
{ id: 'kick:me', service: 'kick', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, nick: 'me' }
]
const bufs = [
{ id: 'twitch:me|xqc', accountId: 'twitch:me', kind: 'channel', name: 'xqc', lastActivityTs: 5, avatarUrl: pic, groupId: 'account:twitch:me' },
{ id: 'kick:me|spreen', accountId: 'kick:me', kind: 'channel', name: 'spreen', lastActivityTs: 5, avatarUrl: pic, groupId: 'account:kick:me' }
]
s.set({ accounts: accts, groups: accts.map((a, i) => ({ id: 'account:' + a.id, accountId: a.id, service: a.service, kind: 'account', name: 'me', position: i })), buffers: bufs, activeBufferId: 'twitch:me|xqc', twitchPoints: { 'twitch:me|xqc': { points: 600, name: 'Sloths' } }, kickPointsByBuffer: { 'kick:me|spreen': 1200 } })
s.selectGroup('account:twitch:me')
})()`)
await sleep(1500)
await ev(`window.__errs = []; window.addEventListener('error', (e) => window.__errs.push(String(e.message))); window.addEventListener('unhandledrejection', (e) => window.__errs.push('rej ' + String(e.reason)))`)
console.log('Twitch')
const plaque = `document.querySelector('.kick-points-plaque')`
check('the plaque shows the balance with the channel\'s name for its points', await ev(`${plaque}?.innerText.includes('600 Sloths')`))
await ev(`${plaque}.click()`)
await sleep(600)
check('a panel opens over the chat', await ev(`!!document.querySelector('.switcher.rewards')`))
check('it lists the four rewards, cheapest first within the ones that can be had, then the ones that cannot', await ev(`JSON.stringify([...document.querySelectorAll('.reward-row')].map((r) => r.querySelector('.reward-cost').innerText)) === JSON.stringify(['100', '480', '500', '550', '50', '350', '9,000'])`))
check('under two headings', await ev(`JSON.stringify([...document.querySelectorAll('.reward-group')].map((h) => h.innerText.toLowerCase())) === JSON.stringify(['available', 'unavailable'])`))
check('a paused reward and an unaffordable one are disabled with the reason', await ev(`(() => { const r = [...document.querySelectorAll('.reward-row')]; return r.slice(0, 4).every((x) => !x.disabled) && r[4].disabled && r[4].innerText.includes('Paused') && r[5].disabled && r[5].innerText.includes('twitch.tv chat box') && r[6].disabled && r[6].innerText.includes('You have 600 of 9,000') })()`))
await shot('rewards-twitch.png')
await ev(`document.querySelectorAll('.reward-row')[2].click()`)
await sleep(300)
check('the first press arms it and spends nothing', await ev(`!!document.querySelector('.reward-confirm') && window.__redeemed.length === 0`))
await ev(`document.querySelector('.reward-confirm .button.primary').click()`)
await sleep(500)
check('Redeem spends it', await ev(`window.__redeemed.length === 1 && window.__redeemed[0].rewardId === 'r1'`))
await ev(`document.querySelectorAll('.reward-row')[0].click()`)
await sleep(300)
check('a reward that asks for text shows its field, and Redeem waits for it', await ev(`!!document.querySelector('.reward-confirm input') && document.querySelector('.reward-confirm .button.primary').disabled`))
await page.call('Input.insertText', { text: 'Greg' })
await sleep(200)
await ev(`document.querySelector('.reward-confirm .button.primary').click()`)
await sleep(500)
check('what was typed goes with it', await ev(`window.__redeemed[1]?.input === 'Greg'`))
await ev(`document.querySelectorAll('.reward-row')[1].click()`)
await sleep(300)
check('an emote unlock shows the emotes to press, with a search, and Redeem waits for one', await ev(`!document.querySelector('.reward-confirm select') && document.querySelectorAll('.reward-confirm .reward-emote').length === 2 && !!document.querySelector('.reward-confirm input[placeholder="Search emotes"]') && document.querySelector('.reward-confirm .button.primary').disabled`))
await ev(`document.querySelector('.reward-confirm .reward-emote[title="binxbYAP"]').click()`)
await sleep(200)
check('pressing one picks it', await ev(`!document.querySelector('.reward-confirm .button.primary').disabled && document.querySelector('.reward-emote.selected')?.title === 'binxbYAP'`))
await ev(`document.querySelector('.reward-confirm .button.primary').click()`)
await sleep(500)
check('the emote picked goes with it', await ev(`window.__redeemed[2]?.rewardId === 'a1' && window.__redeemed[2]?.choice === 'e2'`))
await ev(`[...document.querySelectorAll('.reward-row')].find((r) => r.innerText.includes('Modify a Single Emote')).click()`)
await sleep(300)
check('a change to an emote shows the emotes first', await ev(`[...document.querySelectorAll('.reward-confirm .reward-emote')].map((b) => b.title).join() === 'binxbWut,binxbYAP'`))
await ev(`document.querySelector('.reward-confirm .reward-emote[title="binxbWut"]').click()`)
await sleep(200)
check('then what can be done to the one picked', await ev(`[...document.querySelectorAll('.reward-confirm .reward-emote')].map((b) => b.title).join() === 'binxbWut, black and white,binxbWut, with sunglasses'`))
await ev(`document.querySelector('.reward-confirm .reward-emote[title="binxbWut, with sunglasses"]').click()`)
await sleep(200)
await ev(`document.querySelector('.reward-confirm .button.primary').click()`)
await sleep(500)
check('the change picked goes with it', await ev(`window.__redeemed[3]?.rewardId === 'a3' && window.__redeemed[3]?.choice === 'e1_SG'`))
await key('Escape')
await sleep(300)
check('Escape closes the panel', await ev(`!document.querySelector('.switcher.rewards')`))
console.log('Kick')
await ev(`window.__mohoShots.store.selectGroup('account:kick:me'); window.__mohoShots.store.selectBuffer('kick:me|spreen', true)`)
await sleep(1200)
await ev(`document.querySelector('.kick-points-plaque')?.click()`)
await sleep(600)
check('Kick opens the same panel', await ev(`!!document.querySelector('.switcher.rewards') && document.querySelectorAll('.reward-row').length === 2`))
check('a Kick reward that asks for text is disabled with a reason', await ev(`(() => { const r = document.querySelectorAll('.reward-row')[1]; return r.disabled && r.innerText.includes('cannot do') })()`))
await shot('rewards-kick.png')
} finally {
await stop()
}
console.log(results.every(Boolean) ? 'ALL OK' : `${results.filter((r) => !r).length} FAILED`)
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env node
/**
* Searching the web for selected text, in a scratch moho with injected state (nothing is contacted): the message
* menu offers it for a selection only, names the engine chosen in Settings > General, follows a change of engine,
* and Settings shows the drop-down (and the address field for an engine of one's own).
*
* node scripts/check-search-menu.mjs [output directory]
*/
import fs from 'node:fs'
import path from 'node:path'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2]
if (out) fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
const results = []
const check = (what, ok, detail = '') => {
results.push(ok)
console.log(ok ? ' ok ' : ' FAIL', what, ok ? '' : detail)
}
const shot = async (name) => {
if (!out) return
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
}
const mouse = (type, x, y, button = 'left') => page.call('Input.dispatchMouseEvent', { type, x, y, button, buttons: type === 'mouseReleased' ? 0 : button === 'right' ? 2 : 1, clickCount: 1 })
const menuLabels = () => ev(`[...document.querySelectorAll('.context-menu .context-menu-item')].map((e) => e.innerText.trim().split('\\n').pop())`)
/** Sets a field the way a person's edit does, so the window's own handlers run. */
const setField = (selector, value, how) => ev(`(async () => {
const el = document.querySelector(${JSON.stringify(selector)})
if (!el) return false
if (${JSON.stringify(how)} === 'input') el.focus()
const proto = el instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLInputElement.prototype
Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, ${JSON.stringify(value)})
el.dispatchEvent(new Event(${JSON.stringify(how)}, { bubbles: true }))
// Time for the window to take the edit before the field is left, as it has when a person types.
if (${JSON.stringify(how)} === 'input') { await new Promise((r) => setTimeout(r, 200)); el.blur() }
return true
})()`)
const openGeneral = async () => {
await ev(`window.__mohoShots.store.setActivePanel('settings')`)
await sleep(500)
await ev(`(() => { const b = [...document.querySelectorAll('button, [role=tab], a')].find((e) => e.innerText.trim() === 'General'); b?.click() })()`)
await sleep(500)
}
const closeMenu = async () => {
await page.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 })
await sleep(200)
}
/** Selects `needle` in the first message and right-clicks on it. */
const rightClickOn = async (needle) => {
const at = await ev(`(() => {
const body = document.querySelector('.message-body') ?? document.querySelector('[data-msg-id]')
const w = document.createTreeWalker(body, NodeFilter.SHOW_TEXT)
for (let n; (n = w.nextNode()); ) {
const i = n.nodeValue.indexOf(${JSON.stringify(needle)})
if (i < 0) continue
const r = document.createRange()
r.setStart(n, i); r.setEnd(n, i + ${JSON.stringify(needle)}.length)
const s = getSelection(); s.removeAllRanges(); s.addRange(r)
const b = r.getBoundingClientRect()
return { x: b.left + b.width / 2, y: b.top + b.height / 2 }
}
return null
})()`)
if (!at) return false
await mouse('mouseMoved', at.x, at.y)
await mouse('mousePressed', at.x, at.y, 'right')
await mouse('mouseReleased', at.x, at.y, 'right')
await sleep(400)
return true
}
try {
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
await ev(`(() => {
const s = window.__mohoShots.store
const acct = { id: 'discord:me', service: 'discord', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, nick: 'me' }
const buf = { id: 'discord:me|chat', accountId: 'discord:me', kind: 'channel', name: 'chat', lastActivityTs: 5, category: 'Channels', groupId: 'account:discord:me' }
const t = Math.floor(Date.now() / 1000) - 600
const messages = [{ id: 'm1', bufferId: 'discord:me|chat', from: 'ada', body: 'the Antikythera mechanism is older than anyone expected', ts: t, kind: 'chat', isAction: false, isHighlight: false, isOwn: false, edited: false }]
s.set({ accounts: [acct], groups: [{ id: 'account:discord:me', accountId: 'discord:me', service: 'discord', kind: 'account', name: 'me', position: 0 }], buffers: [buf], messagesByBuffer: { 'discord:me|chat': messages }, activeBufferId: 'discord:me|chat' })
s.selectGroup('account:discord:me')
})()`)
await sleep(1200)
console.log('Message menu')
check('a message with a word selected', await rightClickOn('Antikythera mechanism'))
let labels = await menuLabels()
check('offers a search for it, by the default engine', labels.some((l) => l === 'Search DuckDuckGo for “Antikythera mechanism”'), JSON.stringify(labels))
await shot('search-menu.png')
await closeMenu()
await openGeneral()
check('Settings > General has the engine drop-down', await setField('select[aria-label="Search engine"]', 'google', 'change'))
await sleep(300)
await ev(`window.__mohoShots.store.setActivePanel('')`)
await sleep(500)
await rightClickOn('Antikythera mechanism is older')
labels = await menuLabels()
check('names the engine chosen in Settings, and shortens a long selection', labels.some((l) => /^Search Google for “Antikythera mechanism is ol…”$/.test(l)), JSON.stringify(labels))
await closeMenu()
await openGeneral()
await setField('select[aria-label="Search engine"]', 'custom', 'change')
await sleep(300)
check('an own engine asks for an address', await setField('input[placeholder="https://example.org/search?q=%s"]', 'https://www.example.org/find?q=%s', 'input'))
await shot('search-settings.png')
await sleep(300)
await ev(`window.__mohoShots.store.setActivePanel('')`)
await sleep(500)
await rightClickOn('Antikythera')
labels = await menuLabels()
check('an engine of one’s own is named by its host', labels.some((l) => l === 'Search example.org for “Antikythera”'), JSON.stringify(labels))
await closeMenu()
await openGeneral()
await setField('input[placeholder="https://example.org/search?q=%s"]', 'nonsense', 'input')
await sleep(300)
await ev(`window.__mohoShots.store.setActivePanel('')`)
await sleep(500)
await rightClickOn('Antikythera')
labels = await menuLabels()
check('an address that cannot be searched is not offered', !labels.some((l) => l.startsWith('Search ')), JSON.stringify(labels))
await closeMenu()
await openGeneral()
await setField('select[aria-label="Search engine"]', 'duckduckgo', 'change')
await sleep(300)
await ev(`window.__mohoShots.store.setActivePanel('')`)
await sleep(500)
await ev(`getSelection().removeAllRanges()`)
const row = await ev(`(() => { const r = document.querySelector('[data-msg-id]').getBoundingClientRect(); return { x: r.right - 20, y: r.top + r.height / 2 } })()`)
await mouse('mouseMoved', row.x, row.y)
await mouse('mousePressed', row.x, row.y, 'right')
await mouse('mouseReleased', row.x, row.y, 'right')
await sleep(400)
labels = await menuLabels()
check('without a selection there is no search entry', labels.length > 0 && !labels.some((l) => l.startsWith('Search ')), JSON.stringify(labels))
await closeMenu()
} catch (e) {
console.log(' FAIL', e.message)
results.push(false)
} finally {
await stop()
}
process.exit(results.every(Boolean) ? 0 : 1)
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* The Stoat parts of the window, in a scratch moho (nothing is typed into a form that would be sent): the account
* picker offers Stoat with its mark, the form asks for an instance (optional), an email and a password, keeps the
* button off until the last two are given, and offers a pasted session token as the other way in.
*
* node scripts/check-stoat-ui.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
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'))
}
const results = []
const check = (what, ok, detail = '') => {
results.push(ok)
console.log(ok ? ' ok ' : ' FAIL', what, detail)
}
try {
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1100, height: 800, deviceScaleFactor: 1, mobile: false })
await ev(`window.__mohoShots.store.setActivePanel('accounts')`)
await sleep(1200)
const chip = await ev(`(() => { const c = [...document.querySelectorAll('.service-chip')].find((x) => x.textContent.includes('Stoat')); return c ? { text: c.textContent.trim(), mark: !!c.querySelector('.mask-icon, img, span[style*="mask"]') } : null })()`)
check('the picker offers Stoat', !!chip, JSON.stringify(chip))
await ev(`[...document.querySelectorAll('.service-chip')].find((x) => x.textContent.includes('Stoat')).click()`)
await sleep(500)
const form = await ev(`(() => { const f = document.querySelector('.add-form'); return f ? f.innerText : '' })()`)
check('the form says the instance can be left empty and where accounts are made', /official one/.test(form) && /own app/.test(form), form.slice(0, 160))
check('it asks for an email and a password', /Email/.test(form) && /Password/.test(form))
const signIn = () => ev(`[...document.querySelectorAll('.add-form button.primary')].find((b) => /Sign in/.test(b.textContent))?.disabled`)
check('nothing can be sent while the form is empty', (await signIn()) === true)
const fill = (label, value) => ev(`(() => {
const field = [...document.querySelectorAll('.add-form label.field')].find((l) => l.textContent.includes(${JSON.stringify(label)}))
const input = field.querySelector('input')
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set
set.call(input, ${JSON.stringify(value)})
input.dispatchEvent(new Event('input', { bubbles: true }))
})()`)
await fill('Email', '[email protected]')
check('an email alone is not enough', (await signIn()) === true)
await fill('Password', 'hunter2')
await sleep(200)
check('an email and a password turn the button on', (await signIn()) === false)
await shot('stoat-form.png')
await ev(`[...document.querySelectorAll('.add-form .segmented button')].find((b) => /token/i.test(b.textContent)).click()`)
await sleep(300)
check('a session token can be pasted instead', /Session token/.test(await ev(`document.querySelector('.add-form').innerText`)))
check('which needs only the token', (await signIn()) === true)
await fill('Session token', 'abc')
await sleep(200)
check('and then it can be sent', (await signIn()) === false)
} finally {
await stop()
console.log(results.every(Boolean) ? 'ALL OK' : 'SOME FAILED')
process.exitCode = results.every(Boolean) ? 0 : 1
}
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env node
/**
* A Telegram account's page with its conversations under the account's chat folders, and the changes the window asks
* Telegram for: filing a conversation by dragging it to another folder, putting the folders in another order, making a
* category (which is a folder once it has a conversation in it), renaming and deleting one - and that what Telegram
* says next is what is drawn. The daemon's answers are stood in for here, in a scratch home on a fake IRC server.
*
* node scripts/check-telegram-folders-ui.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 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'))
}
const results = []
const check = (what, ok, detail = '') => {
results.push(ok)
console.log(ok ? ' ok ' : ' FAIL', what, detail)
}
const headings = () => ev(`[...document.querySelectorAll('.bufferlist .category')].map((c) => ({ head: (c.querySelector('.category-head')?.textContent || '').replace('expand_more', '').replace('chevron_right', '').trim(), rows: [...c.querySelectorAll('[draggable=true]')].filter((r) => !r.classList.contains('category-head')).map((r) => r.textContent.trim()) }))`)
/** Holds a conversation (or a heading) over a heading and lets go. */
const drag = (from, to) =>
ev(`(async () => {
const row = [...document.querySelectorAll('.bufferlist [draggable=true]')].find((e) => e.textContent.replace(/[a-z_]+(?=[A-Z])/, '').trim().endsWith(${JSON.stringify(from)}) && e.textContent.trim().length < 40)
const head = [...document.querySelectorAll('.bufferlist .category-head')].find((e) => e.textContent.includes(${JSON.stringify(to)}))
if (!row || !head) return { row: !!row, head: !!head }
const dt = new DataTransfer()
const wait = () => new Promise((r) => setTimeout(r, 120))
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()
head.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt }))
row.dispatchEvent(new DragEvent('dragend', { bubbles: true, dataTransfer: dt }))
await wait()
return { dragged: true }
})()`)
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 })
// A window that is not focused takes a field's focus away again as soon as it is given.
await page.call('Emulation.setFocusEmulationEnabled', { enabled: true })
await ev(`(() => {
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: 'telegram:7', service: 'telegram', displayName: 'me', state: 'connected' }
const group = { ...ircGroup, id: 'account:telegram:7', accountId: 'telegram:7', service: 'telegram', name: 'Telegram - me' }
const chat = (name, id, category) => ({ ...channel, id: 'telegram:7|' + name, accountId: 'telegram:7', name, kind: 'dm', remoteId: id, groupId: group.id, category, position: 0, unread: 0, highlight: false, mentions: 0 })
const buffers = [
chat('Alice', '101', 'Direct Messages'), chat('Bob', '102', 'Direct Messages'), chat('Carol', '103', 'Direct Messages'),
chat('HelperBot', '201', 'Bots'), chat('News', '-1001', 'Channels')
]
window.__calls = []
const store = s.store
for (const name of ['fileTelegramChat', 'renameTelegramFolder', 'deleteTelegramFolder', 'reorderTelegramFolders']) {
store[name] = (...a) => { window.__calls.push([name, ...a]) }
}
store.createTelegramFolder = async (...a) => { window.__calls.push(['createTelegramFolder', ...a]); return true }
s.patch({
accounts: [...st.accounts, account],
groups: [...st.groups, group],
buffers: [...st.buffers, ...buffers],
telegramFolders: { 'telegram:7': [
{ id: 3, name: 'Work', members: ['101', '102'] },
{ id: 2, name: 'Bots', members: ['201'] }
] },
activeGroupId: group.id, activeBufferId: buffers[0].id
})
})()`)
await sleep(1200)
await ev(`window.__mohoShots.patch({ telegramFolders: { 'telegram:7': [{ id: 3, name: 'Work', members: ['101', '102'] }, { id: 2, name: 'Bots', members: ['201'] }] } })`)
await sleep(500)
let h = await headings()
console.log(JSON.stringify(h))
check('the account page has a category for each folder, in Telegram’s order', h.findIndex((x) => x.head === 'Work') >= 0 && h.findIndex((x) => x.head === 'Work') < h.findIndex((x) => x.head === 'Bots'))
check('conversations sit under the folder that holds them', h.find((x) => x.head === 'Work')?.rows.length === 2 && h.find((x) => x.head === 'Bots')?.rows.length === 1)
await shot('telegram-folders.png')
await drag('Alice', 'Bots')
const filed = await ev(`window.__calls.filter((c) => c[0] === 'fileTelegramChat')`)
check('dragging a conversation to another folder asks Telegram to move it there', JSON.stringify(filed) === JSON.stringify([['fileTelegramChat', 'telegram:7|Alice', 2]]), JSON.stringify(filed))
h = await headings()
check('and nothing moves here until Telegram says so', h.find((x) => x.head === 'Work')?.rows.length === 2)
// Telegram says it did.
await ev(`window.__mohoShots.patch({ telegramFolders: { 'telegram:7': [{ id: 3, name: 'Work', members: ['102'] }, { id: 2, name: 'Bots', members: ['201', '101'] }] } })`)
await sleep(400)
h = await headings()
check('what Telegram says is what is drawn', h.find((x) => x.head === 'Work')?.rows.length === 1 && h.find((x) => x.head === 'Bots')?.rows.length === 2)
// A folder added on another of the account's apps turns up.
await ev(`window.__mohoShots.patch({ telegramFolders: { 'telegram:7': [{ id: 3, name: 'Work', members: ['102'] }, { id: 2, name: 'Bots', members: ['201', '101'] }, { id: 4, name: 'Family', members: ['103'] }] } })`)
await sleep(400)
h = await headings()
check('a folder made on another device turns up as a category', h.some((x) => x.head === 'Family' && x.rows.length === 1))
// The order, by dragging a heading.
await ev(`(async () => {
const heads = [...document.querySelectorAll('.bufferlist .category-head')]
const from = heads.find((e) => e.textContent.includes('Family'))
const to = heads.find((e) => e.textContent.includes('Work'))
const dt = new DataTransfer()
const wait = () => new Promise((r) => setTimeout(r, 120))
from.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer: dt }))
await wait()
to.dispatchEvent(new DragEvent('dragenter', { bubbles: true, cancelable: true, dataTransfer: dt }))
to.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt }))
await wait()
to.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt }))
from.dispatchEvent(new DragEvent('dragend', { bubbles: true, dataTransfer: dt }))
})()`)
await sleep(400)
const reordered = await ev(`window.__calls.filter((c) => c[0] === 'reorderTelegramFolders')`)
check('dragging a heading puts the folders in that order on Telegram', reordered.length >= 1 && JSON.stringify(reordered.at(-1)) === JSON.stringify(['reorderTelegramFolders', 'telegram:7', [4, 3, 2]]), JSON.stringify(reordered))
// A new category is only drawn until a conversation is put in it. (A click with a mouse takes focus off the message
// box; these do not, and the menu would give it back to the box as it closed, taking it from the name field.)
await ev(`document.activeElement?.blur()`)
await ev(`(() => { const t = [...document.querySelectorAll('.bufferlist button')].find((b) => b.textContent.includes('Telegram - me')); t && t.click() })()`)
await sleep(300)
await ev(`(() => { const i = [...document.querySelectorAll('.context-menu button, .context-menu [role=menuitem]')].find((e) => e.textContent.includes('New category')); i && i.click() })()`)
await sleep(100)
h = await headings()
const naming = await ev(`!!document.querySelector('.category-name-field')`)
console.log('naming field right after:', naming, JSON.stringify(h.map((x) => x.head)))
check('a new category is drawn where it was asked for, empty', naming || h.some((x) => x.head === 'New category' && x.rows.length === 0))
check('and nothing has been asked of Telegram', !(await ev(`window.__calls.some((c) => c[0] === 'createTelegramFolder')`)))
// Named by typing over the name it opened with.
if (naming) {
await ev(`(() => { const inp = document.querySelector('.category-name-field'); const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; set.call(inp, 'Raiders'); inp.dispatchEvent(new Event('input', { bubbles: true })); inp.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) })()`)
await sleep(300)
}
h = await headings()
console.log(JSON.stringify(h.map((x) => x.head)))
await shot('telegram-folders-new.png')
const pendingName = h.find((x) => x.head === 'Raiders') ? 'Raiders' : 'New category'
await drag('Carol', pendingName)
const made = await ev(`window.__calls.filter((c) => c[0] === 'createTelegramFolder')`)
check('the first conversation put in it makes it a folder', made.length === 1 && made[0][1] === 'telegram:7|Carol' && made[0][2] === pendingName, JSON.stringify(made))
// Rename and delete, from the heading's menu.
const menu = async (head, label) => {
await ev(`(() => { const e = [...document.querySelectorAll('.bufferlist .category-head')].find((x) => x.textContent.includes(${JSON.stringify(head)})); e.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 100, clientY: 100 })) })()`)
await sleep(300)
await ev(`(() => { const i = [...document.querySelectorAll('.context-menu button, .context-menu [role=menuitem]')].find((e) => e.textContent.includes(${JSON.stringify(label)})); i && i.click() })()`)
await sleep(300)
}
await menu('Bots', 'Remove')
check('removing a category deletes the folder on Telegram', JSON.stringify(await ev(`window.__calls.filter((c) => c[0] === 'deleteTelegramFolder')`)) === JSON.stringify([['deleteTelegramFolder', 'telegram:7', 2]]))
await ev(`document.activeElement?.blur()`)
// Renamed: typed over as soon as the field is there (a window that is not focused takes it away again).
await ev(`(async () => {
const e = [...document.querySelectorAll('.bufferlist .category-head')].find((x) => x.textContent.includes('Work'))
e.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 100, clientY: 100 }))
await new Promise((r) => setTimeout(r, 200))
const i = [...document.querySelectorAll('.context-menu button, .context-menu [role=menuitem]')].find((x) => x.textContent.includes('Rename'))
i.click()
await new Promise((r) => setTimeout(r, 30))
const inp = document.querySelector('.category-name-field')
if (!inp) return
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set
set.call(inp, 'Job')
inp.dispatchEvent(new Event('input', { bubbles: true }))
inp.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
})()`)
await sleep(300)
const renamed = await ev(`window.__calls.filter((c) => c[0] === 'renameTelegramFolder')`)
check('renaming one renames the folder on Telegram', renamed.some((c) => c[1] === 'telegram:7' && c[2] === 3 && c[3] === 'Job'), JSON.stringify(renamed))
} finally {
await stop()
irc.close()
console.log(results.every(Boolean) ? 'ALL OK' : 'SOME FAILED')
process.exitCode = results.every(Boolean) ? 0 : 1
}
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env node
/**
* The Telegram parts of the window, in a scratch moho (nothing is typed into a form that would be sent, so nothing
* reaches Telegram): the account picker offers Telegram with its mark, its form asks for the api_id, the api_hash and
* the phone number, explains where the first two come from, keeps the button off until all three are given, and
* warns about Tor.
*
* node scripts/check-telegram-ui.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
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'))
}
const results = []
const check = (what, ok, detail = '') => {
results.push(ok)
console.log(ok ? ' ok ' : ' FAIL', what, detail)
}
try {
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1100, height: 800, deviceScaleFactor: 1, mobile: false })
await ev(`window.__mohoShots.store.setActivePanel('accounts')`)
await sleep(1200)
const chip = await ev(`(() => { const c = [...document.querySelectorAll('.service-chip')].find((x) => x.textContent.includes('Telegram')); return c ? { text: c.textContent.trim(), mark: !!c.querySelector('.mask-icon, img, span[style*="mask"]') } : null })()`)
check('the picker offers Telegram', !!chip, JSON.stringify(chip))
await ev(`[...document.querySelectorAll('.service-chip')].find((x) => x.textContent.includes('Telegram')).click()`)
await sleep(500)
const form = await ev(`(() => { const f = document.querySelector('.add-form'); return f ? f.innerText : '' })()`)
check('the form says where the api_id comes from', /my\.telegram\.org\/apps/.test(form), form.slice(0, 120))
check('it asks for the api_id, the api_hash and the phone number', /api_id/.test(form) && /api_hash/.test(form) && /Phone number/.test(form))
const qrDisabled = () => ev(`[...document.querySelectorAll('.add-form button')].find((b) => /QR code/.test(b.textContent))?.disabled`)
check('a QR code is offered as the other way in', (await qrDisabled()) !== undefined)
check('and waits for the api_id and the api_hash, which it needs, but not for a phone number', (await qrDisabled()) === true)
const disabled = () => ev(`[...document.querySelectorAll('.add-form button.primary')].find((b) => /Send the code/.test(b.textContent))?.disabled`)
check('nothing can be sent while the form is empty', (await disabled()) === true)
const fill = (placeholderOrLabel, value) => ev(`(() => {
const label = [...document.querySelectorAll('.add-form label.field')].find((l) => l.textContent.includes(${JSON.stringify(placeholderOrLabel)}))
const input = label.querySelector('input')
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set
set.call(input, ${JSON.stringify(value)})
input.dispatchEvent(new Event('input', { bubbles: true }))
})()`)
await fill('api_id', '12345')
await fill('api_hash', 'abcdef')
check('two of three are not enough', (await disabled()) === true)
check('the QR code needs only those two', (await qrDisabled()) === false)
await fill('Phone number', '+1 555 123 4567')
await sleep(200)
check('all three turn the button on', (await disabled()) === false)
await ev(`document.querySelector('.add-form .switch')?.click()`)
await sleep(300)
const warning = await ev(`document.querySelector('.add-form .warning-note')?.innerText || ''`)
check('routing it says Telegram may challenge a Tor sign-in', /Telegram/.test(warning), warning.slice(0, 80))
await shot('telegram-form.png')
} finally {
await stop()
console.log(results.every(Boolean) ? 'ALL OK' : 'SOME FAILED')
process.exitCode = results.every(Boolean) ? 0 : 1
}
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env node
/**
* Makes a Twitch integrity token the way the app does when Twitch asks for one - in a hidden page on
* twitch.tv, from main - and then uses it for a stream's playback token from outside the page, to show the
* token is the kind Twitch's GraphQL takes.
*
* node scripts/check-twitch-integrity.mjs
*/
import { cdp, freePort, launchApp, sleep } from './lib/harness.mjs'
const inspectPort = await freePort()
const { stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' }, args: [`--inspect=${inspectPort}`] })
try {
let main = null
for (let i = 0; i < 60 && !main; i++) {
try {
const list = await (await fetch(`http://127.0.0.1:${inspectPort}/json/list`)).json()
const target = list.find((t) => t.webSocketDebuggerUrl)
if (target) main = cdp(target.webSocketDebuggerUrl)
} catch {
/* not up yet */
}
await sleep(500)
}
if (!main) throw new Error('main never offered its inspector')
for (let i = 0; i < 40 && !(await main.evaluate('!!globalThis.__mohoTwitchIntegrity').catch(() => false)); i++) await sleep(500)
const device = `check${Math.random().toString(16).slice(2, 14)}`
const t0 = Date.now()
const made = await main.evaluate(`globalThis.__mohoTwitchIntegrity.mint(${JSON.stringify(device)})`)
console.log(`made in ${((Date.now() - t0) / 1000).toFixed(1)}s:`, { prefix: made.token.slice(0, 9), length: made.token.length, goodForHours: +((made.expiration - Date.now()) / 3600000).toFixed(1) })
// From outside any page: the player's own token query, with the integrity token and the device it was made for.
const res = await fetch('https://gql.twitch.tv/gql', {
method: 'POST',
headers: { 'Client-ID': 'kimne78kx3ncx6brgo4mv6wki5h1ko', 'Content-Type': 'text/plain;charset=UTF-8', 'X-Device-Id': device, 'Client-Integrity': made.token },
body: JSON.stringify({ operationName: 'PlaybackAccessToken', variables: { isLive: true, login: 'xqc', isVod: false, vodID: '', playerType: 'site', platform: 'web' }, extensions: { persistedQuery: { version: 1, sha256Hash: 'ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9' } } })
})
const body = await res.json()
const token = body?.data?.streamPlaybackAccessToken
console.log('playback token call', res.status, token ? `token for device ${JSON.parse(token.value).device_id}` : JSON.stringify(body).slice(0, 200))
} finally {
await stop()
}
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env node
/**
* Drives the sign-in window of a scratch moho without anybody signing in: after the window opens, a made-up
* `auth-token` cookie stands in for the one a real sign-in leaves, and the window is watched to see that it is
* taken to Twitch's approval page and kept there when something sends it back to the front page. Nothing real
* is typed or approved; the sign-in is cancelled at the end.
*
* node scripts/check-twitch-signin-window.mjs
*/
import { cdp, launchApp, sleep } from './lib/harness.mjs'
const { page, stop, port } = 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 targets = async () => (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()).filter((t) => t.type === 'page' && /twitch\.tv/.test(t.url))
const clickText = (selector, text) => ev(`(() => { const b = [...document.querySelectorAll(${JSON.stringify(selector)})].find((x) => x.textContent.trim().startsWith(${JSON.stringify(text)})); if (!b) return false; b.click(); return true })()`)
try {
await sleep(2500)
await ev(`window.__mohoShots.store.setActivePanel('accounts')`)
await sleep(600)
await clickText('.service-chip', 'Twitch')
await sleep(500)
say('sign in', await clickText('button', 'Sign in with Twitch'))
let t
for (let i = 0; i < 40 && !t; i++) {
await sleep(500)
t = (await targets())[0]
}
say('window at', t?.url)
const win = cdp(t.webSocketDebuggerUrl)
await sleep(3000)
await win.call('Network.enable')
await win.call('Network.setCookie', { name: 'auth-token', value: 'madeupmadeupmadeup', domain: '.twitch.tv', path: '/', secure: true })
say('cookie set')
let at = ''
for (let i = 0; i < 40 && !/activate/.test(at); i++) {
await sleep(500)
at = (await targets())[0]?.url ?? ''
}
say('window now at', at)
// Something sends it back to the front page, as a late redirect would.
await win.evaluate(`location.href = 'https://www.twitch.tv/'`).catch(() => {})
const seen = []
for (let i = 0; i < 24; i++) {
await sleep(250)
const u = (await targets())[0]?.url ?? ''
const where = new URL(u).pathname
if (seen[seen.length - 1] !== where) seen.push(where)
}
say('the window went', seen.join(' -> '))
await sleep(6000)
say('page text', JSON.stringify(await win.evaluate(`document.body.innerText.slice(0, 300)`)))
say('inputs', JSON.stringify(await win.evaluate(`[...document.querySelectorAll('input')].map((i) => ({ type: i.type, name: i.name, id: i.id, ph: i.placeholder, max: i.maxLength, value: i.value, shown: i.getClientRects().length > 0 }))`)))
win.close()
} finally {
await stop()
}
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* The Twitch parts of the window, in a scratch moho with injected state (nothing is contacted): the list's fixed
* headings, whispers' badge, Settings -> Twitch, Ctrl+K finding a Twitch channel, readable name colours, emotes
* and cheers drawn as pictures, links, and a signed-in account's composer with no GIF button.
*
* node scripts/check-twitch-ui.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
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'))
}
const results = []
const check = (what, ok, detail = '') => {
results.push(ok)
console.log(ok ? ' ok ' : ' FAIL', what, detail)
}
const key = (k, mods = 0) => page.call('Input.dispatchKeyEvent', { type: 'keyDown', key: k, code: k.length === 1 ? 'Key' + k.toUpperCase() : k, modifiers: mods, windowsVirtualKeyCode: k.length === 1 ? k.toUpperCase().charCodeAt(0) : 0 }).then(() => page.call('Input.dispatchKeyEvent', { type: 'keyUp', key: k, modifiers: mods }))
try {
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1200, height: 800, deviceScaleFactor: 1, mobile: false })
await ev(`(() => {
const s = window.__mohoShots.store
const acct = { id: 'twitch:me', service: 'twitch', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, webSession: true, moderation: true, nick: 'me' }
const pic = 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#7b5cd6"/></svg>')
const mk = (name, kind, t, cat) => ({ id: 'twitch:me|' + name, accountId: 'twitch:me', kind, name, lastActivityTs: t, avatarUrl: pic, category: cat, groupId: 'account:twitch:me' })
const buffers = [mk('xqc', 'channel', 5, 'Channels'), mk('skitzy_va', 'channel', 4, 'Channels'), mk('@friend', 'dm', 6, 'Direct Messages')]
const t = Math.floor(Date.now() / 1000) - 600
const m = (i, from, body, extra = {}) => ({ id: 'm' + i, bufferId: 'twitch:me|xqc', from, body, ts: t + i * 20, kind: 'chat', isAction: false, isHighlight: false, isOwn: false, edited: false, ...extra })
const messages = [
m(1, 'darkblue', 'a very dark colour name', { senderColor: '#0000FF' }),
m(2, 'pinkie', 'with an emote [twitch:emote:25:Kappa] and a moving [twitch:aemote:emotesv2_abc:Dance] one'),
m(3, 'whale', 'cheered [twitch:cheer:cheer:100] for the stream', {}),
m(4, 'linker', 'see https://example.com/page for details'),
m(5, 'newbie', 'first message here', { firstMessage: true })
]
s.set({ accounts: [acct], groups: [{ id: 'account:twitch:me', accountId: 'twitch:me', service: 'twitch', kind: 'account', name: 'me', position: 0 }], buffers, messagesByBuffer: { 'twitch:me|xqc': messages }, activeBufferId: 'twitch:me|xqc' })
s.selectGroup('account:twitch:me')
})()`)
await sleep(1500)
console.log('Channel list')
const rows = await ev(`[...document.querySelectorAll('.category, .buffer-row')].map((r) => r.innerText.replace(/\\n/g, ' ').trim())`)
check('fixed headings with the whisper under Direct Messages', JSON.stringify(rows).includes('DIRECT MESSAGES') && JSON.stringify(rows).includes('CHANNELS'), JSON.stringify(rows))
check('the whisper row has the mail badge', await ev(`!!document.querySelector('.avatar-badge')`), await ev(`[...document.querySelectorAll('.buffer-row')].map((r) => r.innerHTML.slice(0, 200)).join(' || ')`))
console.log('Messages')
check('a very dark name colour is made readable', await ev(`(() => { const e = [...document.querySelectorAll('.message-sender, .message-from, .sender')].find((x) => x.textContent.includes('darkblue')); if (!e) return false; const m = getComputedStyle(e).color.match(/\\d+/g).map(Number); return (0.2126 * m[0] + 0.7152 * m[1] + 0.0722 * m[2]) > 90 })()`))
check('emotes are pictures', (await ev(`document.querySelectorAll('.message-row img[src*="emoticons"], .message-row img.custom-emoji').length`)) >= 2, String(await ev(`[...document.querySelectorAll('.message-row img')].map((i) => i.src.slice(0, 60)).join(' | ')`)))
check('a cheer is drawn with its amount', await ev(`document.body.innerText.includes('100')`))
check('a link is a link', await ev(`!!document.querySelector('.message-row a[href^="https://example.com"], .message-row .link')`))
await shot('twitch-messages.png')
console.log('Composer')
check('the signed-in composer has an emoji button and no GIF button', await ev(`!!document.querySelector('button[title="Emoji"]') && !document.querySelector('button[title="GIFs"]')`))
console.log('Ctrl+K')
await key('k', 2)
await sleep(600)
const opened = await ev(`!!document.querySelector('input[placeholder*="Where"], .switcher, [role="dialog"] input')`)
check('the quick switcher opens', opened)
if (opened) {
await ev(`(() => { const i = document.querySelector('.switcher input, [role="dialog"] input, input[placeholder*="Where"]'); i.focus() })()`)
await page.call('Input.insertText', { text: 'skitz' })
await sleep(500)
check('it finds a Twitch channel by name', await ev(`document.body.innerText.includes('skitzy_va')`))
await key('Escape')
await sleep(300)
}
console.log('Settings')
await ev(`window.__mohoShots.store.setActivePanel('settings')`)
await sleep(800)
const clicked = await ev(`(() => { const b = [...document.querySelectorAll('button, [role=tab], a')].find((x) => x.textContent.trim() === 'Twitch'); if (!b) return false; b.click(); return true })()`)
check('Settings has a Twitch page', clicked)
await sleep(600)
const text = await ev(`document.body.innerText`)
for (const want of ['What to show besides chat', 'Badges', 'Watching', 'Polls, predictions and raids', 'Recent messages']) check(`Settings -> Twitch has "${want}"`, text.includes(want))
await shot('twitch-settings.png')
} finally {
await stop()
}
console.log(results.every(Boolean) ? 'ALL OK' : `${results.filter((r) => !r).length} FAILED`)
+13 -3
View File
@@ -101,8 +101,18 @@ try {
const klipy = rows.find((r) => r.text.includes('klipy.com'))
check(!!klipy && klipy.videos.length === 0, 'a Klipy page nothing could read stays a link', JSON.stringify(klipy))
// The picker.
check(await page.evaluate('!!document.querySelector(\'button[title="GIFs"]\')'), 'a GIF button is offered where there is a Discord account')
// The picker. It is offered only where a GIF link shows as a picture - Discord, Matrix, Kick - and none of those can be
// stood up here, so the IRC account is shown to the window as a Matrix one: the picker appears, and what it sends still
// goes through the daemon to the fake IRC server.
check(!(await page.evaluate('!!document.querySelector(\'button[title="GIFs"]\')')), 'no GIF button on an IRC conversation, which would show a GIF link as a link')
await page.evaluate(`(() => {
const shots = window.__mohoShots
const s = shots.state()
const account = s.accounts.find((a) => a.id === s.buffers.find((b) => b.id === s.activeBufferId)?.accountId)
shots.patch({ accounts: s.accounts.map((a) => (a === account ? { ...a, service: 'matrix' } : a)) })
})()`)
await sleep(500)
check(await page.evaluate('!!document.querySelector(\'button[title="GIFs"]\')'), 'a GIF button is offered where a GIF link shows as a picture')
await page.evaluate(click('button[title="GIFs"]'))
await until(page, 'document.querySelector(".gif-picker")', 'the picker')
check((await page.evaluate('document.querySelectorAll(".gif-cell").length')) === 3, 'it lists what it was given')
@@ -117,7 +127,7 @@ try {
// Choosing one sends it - to IRC, the file, which anything can show as a link.
await page.evaluate(`document.querySelector('.gif-cell[title="cat dancing"]').click()`)
await sleep(1200)
check(sent.some((l) => /PRIVMSG #lobby :?http:\/\/127\.0\.0\.1:9\/a\.gif/.test(l)), 'choosing one sends the file to a service that is not Discord', JSON.stringify(sent))
check(sent.some((l) => /PRIVMSG #lobby :?http:\/\/127\.0\.0\.1:9\/a\.gif/.test(l)), 'choosing one sends the file to a service that is not Discord (here IRC\'s wire, dressed as Matrix)', JSON.stringify(sent))
check(!(await page.evaluate('!!document.querySelector(".gif-picker")')), 'and the picker closes')
} catch (e) {
console.log('FAIL', e.message)
+2 -2
View File
@@ -71,9 +71,9 @@ Exec=$BIN_DIR/$APP_ID.AppImage %U
Icon=$APP_ID
Terminal=false
Categories=Network;InstantMessaging;Chat;
# What makes an irc:// link in a browser offer moho at all. Exec already takes
# What makes an irc:// or tg:// (Telegram) link in a browser offer moho at all. Exec already takes
# %U, so the desktop has somewhere to put the URL once it decides to.
MimeType=x-scheme-handler/irc;x-scheme-handler/ircs;
MimeType=x-scheme-handler/irc;x-scheme-handler/ircs;x-scheme-handler/tg;
Keywords=chat;irc;discord;matrix;sneedchat;messaging;
StartupWMClass=moho
StartupNotify=true
+99
View File
@@ -0,0 +1,99 @@
// The page the Kick video probe plays in: one hls.js player per configuration, measured while it plays.
import Hls from 'hls.js'
import { ivsAbr, ivsLoader } from '../../src/renderer/src/lib/ivsplaylist'
interface Config {
name: string
url: string
seconds: number
prefetch: number
hls: Record<string, unknown>
cap?: number
}
declare global {
interface Window {
runProbe: (config: Config) => Promise<Record<string, unknown>>
}
}
window.runProbe = async (cfg) => {
// How far this clock is from the server's, from the time the playlist says it was made at.
const sent = Date.now()
const text = await (await fetch(cfg.url)).text()
const received = Date.now()
const server = Number(/SERVER-TIME",VALUE="([0-9.]+)"/.exec(text)?.[1] ?? NaN)
const offset = server * 1000 - (sent + received) / 2
return new Promise((resolve) => {
const video = document.getElementById('v') as HTMLVideoElement
const hls = new Hls({ ...cfg.hls, ...(cfg.prefetch > 0 ? { loader: ivsLoader(cfg.prefetch), abrController: ivsAbr() } : {}) } as never)
const latency: number[] = []
const levels = new Set<string>()
const fatal: string[] = []
let stalls = 0
let waits = 0
let switches = 0
let waitStart = 0
let waited = 0
let firstFrame = 0
const began = Date.now()
video.muted = true
video.onwaiting = (): void => {
waits++
if (!waitStart && firstFrame) waitStart = Date.now()
}
video.onplaying = (): void => {
if (!firstFrame) firstFrame = Date.now() - began
if (waitStart) {
waited += Date.now() - waitStart
waitStart = 0
}
}
hls.on(Hls.Events.ERROR, (_e, d) => {
if (d.details === 'bufferStalledError') stalls++
if (d.fatal) fatal.push(d.details)
})
hls.on(Hls.Events.LEVEL_SWITCHED, (_e, d) => {
switches++
levels.add(`${hls.levels[d.level]?.height}p`)
})
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (cfg.cap) {
const best = hls.levels.reduce((b, l, i) => (l.height <= (cfg.cap ?? 720) && l.height >= (hls.levels[b]?.height ?? 0) ? i : b), -1)
if (best >= 0) hls.autoLevelCapping = best
}
void video.play().catch(() => {})
})
hls.attachMedia(video)
hls.loadSource(cfg.url)
const timer = setInterval(() => {
const at = hls.playingDate
if (at && !video.paused && firstFrame) latency.push((Date.now() + offset - at.getTime()) / 1000)
if (Date.now() - began < cfg.seconds * 1000) return
clearInterval(timer)
const q = video.getVideoPlaybackQuality()
const sorted = [...latency].sort((a, b) => a - b)
const pick = (p: number): number => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] ?? NaN
const settled = sorted.slice(Math.floor(sorted.length * 0.2))
const mean = settled.length ? settled.reduce((a, b) => a + b, 0) / settled.length : NaN
hls.destroy()
resolve({
name: cfg.name,
startedInMs: firstFrame,
samples: latency.length,
latencyMin: +Math.min(...latency).toFixed(2),
latencyMax: +Math.max(...latency).toFixed(2),
latencyMedian: +pick(0.5).toFixed(2),
latencyP95: +pick(0.95).toFixed(2),
latencyMeanAfterSettling: +mean.toFixed(2),
rebufferSeconds: +(waited / 1000).toFixed(2),
waits,
stallErrors: stalls,
dropped: `${q.droppedVideoFrames}/${q.totalVideoFrames}`,
levelSwitches: switches,
levels: [...levels].join(','),
fatal: fatal.join(',')
})
}, 1000)
})
}
+60
View File
@@ -0,0 +1,60 @@
/**
* The local self-hosted Stoat the Stoat probes talk to (http://localhost:8880, started with
* ~/.local/share/moho-dev/stoat-docker.sh and set up with stoat-local.py): its two test accounts, and calls made as
* either of them - the "other person" in a conversation moho is one side of.
*/
import fs from 'node:fs'
export const API = 'http://localhost:8880/api'
export const accounts = () => JSON.parse(fs.readFileSync('/1tb/stoat-test/accounts.json', 'utf8'))
/** One call as `who` ('alice' or 'bob'). */
export async function as(who, method, path, body) {
return withToken(accounts()[who].token, method, path, body)
}
/** A call with a token, waiting out the instance's rate limit as a client has to (a 429 says how long). */
async function withToken(token, method, path, body) {
for (let attempt = 0; ; attempt++) {
const r = await fetch(API + path, {
method,
headers: { 'X-Session-Token': token, ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
body: body !== undefined ? JSON.stringify(body) : undefined
})
const text = await r.text()
if (r.status === 429 && attempt < 4) {
await new Promise((resolve) => setTimeout(resolve, (JSON.parse(text).retry_after ?? 1000) + 50))
continue
}
if (!r.ok) throw new Error(`${method} ${path}: ${r.status} ${text.slice(0, 200)}`)
return text ? JSON.parse(text) : null
}
}
/** A file put on the file server as `who`, answering its id. `tag` is attachments, emojis, avatars, icons... */
export async function upload(who, name, bytes, type, tag = 'attachments') {
const a = accounts()[who]
const form = new FormData()
form.append('file', new Blob([bytes], { type }), name)
const r = await fetch(`http://localhost:8880/autumn/${tag}`, { method: 'POST', headers: { 'X-Session-Token': a.token }, body: form })
if (!r.ok) throw new Error(`upload: ${r.status} ${await r.text()}`)
return (await r.json()).id
}
/** A new account made on the local instance (never the official one): answers its token and id, for a probe that needs a stranger. */
export async function makeAccount(name) {
const email = `${name}@test.local`
const password = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)
const post = async (path, body, token) => {
const r = await fetch(API + path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { 'X-Session-Token': token } : {}) }, body: JSON.stringify(body) })
const text = await r.text()
if (!r.ok) throw new Error(`POST ${path}: ${r.status} ${text.slice(0, 200)}`)
return text ? JSON.parse(text) : null
}
await post('/auth/account/create', { email, password })
const session = await post('/auth/session/login', { email, password, friendly_name: 'probe' })
await post('/onboard/complete', { username: name }, session.token)
const me = await (await fetch(API + '/users/@me', { headers: { 'X-Session-Token': session.token } })).json()
const call = (method, path, body) => withToken(session.token, method, path, body)
return { id: me._id, username: me.username, token: session.token, call }
}
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* Plays a live Kick stream with several hls.js configurations, one after the other, in the app's own
* Chromium, and says for each how far behind the broadcaster it ran, how often it stopped to wait,
* and how many frames it dropped. Nothing is signed in: the playlist is public.
*
* node scripts/probe-kick-video.mjs [channel] [seconds] (defaults: spreen, 75)
*/
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 channel = process.argv[2] ?? 'spreen'
const seconds = Number(process.argv[3] ?? 75)
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..')
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'kick-video-'))
execFileSync(path.join(root, 'node_modules/.bin/esbuild'), [path.join(root, 'scripts/lib/hlsprobe.ts'), '--bundle', '--format=iife', `--outfile=${path.join(work, 'probe.js')}`, '--log-level=error'])
fs.writeFileSync(path.join(work, 'probe.html'), '<!doctype html><body style="margin:0"><video id="v" width="1280" height="720" autoplay muted playsinline></video><script src="probe.js"></script></body>')
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
const playlist = async () => {
const r = await fetch(`https://kick.com/api/v2/channels/${channel}/playback-url`, { headers: { 'User-Agent': UA, Accept: 'application/json' } })
return (await r.json()).data
}
const base = { capLevelToPlayerSize: true }
const configs = [
{ name: 'as it is now (3 target durations, no prefetch)', prefetch: 0, hls: { ...base, liveSyncDurationCount: 3 } },
{ name: 'seconds: sync 4s, no prefetch', prefetch: 0, hls: { ...base, liveSyncDuration: 4, liveMaxLatencyDuration: 10 } },
{ name: 'prefetch 2, sync 4s', prefetch: 2, hls: { ...base, liveSyncDuration: 4, liveMaxLatencyDuration: 10 } },
{ name: 'prefetch 2, sync 3s, catch-up 1.1', prefetch: 2, hls: { ...base, liveSyncDuration: 3, liveMaxLatencyDuration: 8, maxLiveSyncPlaybackRate: 1.1 } },
{ name: 'prefetch 1, sync 3s, catch-up 1.1', prefetch: 1, hls: { ...base, liveSyncDuration: 3, liveMaxLatencyDuration: 8, maxLiveSyncPlaybackRate: 1.1 } },
{ name: 'prefetch 2, sync 5s, catch-up 1.1', prefetch: 2, hls: { ...base, liveSyncDuration: 5, liveMaxLatencyDuration: 10, maxLiveSyncPlaybackRate: 1.1 } }
]
if (process.env.LIST === 'steady') {
configs.length = 0
configs.push(
{ name: 'prefetch 2, sync 3s', prefetch: 2, hls: { ...base, liveSyncDuration: 3, liveMaxLatencyDuration: 10 } },
{ name: 'prefetch 2, sync 4s', prefetch: 2, hls: { ...base, liveSyncDuration: 4, liveMaxLatencyDuration: 10 } },
{ name: 'prefetch 2, sync 5s', prefetch: 2, hls: { ...base, liveSyncDuration: 5, liveMaxLatencyDuration: 10 } }
)
}
const only = process.env.ONLY ? process.env.ONLY.split(',').map(Number) : null
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
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('Page.navigate', { url: `file://${path.join(work, 'probe.html')}` })
await sleep(1500)
for (const [i, cfg] of configs.entries()) {
if (only && !only.includes(i)) continue
const url = await playlist()
const out = await page.evaluate(`window.runProbe(${JSON.stringify({ ...cfg, url, seconds, cap: 720 })})`)
console.log(JSON.stringify(out))
}
} finally {
await stop()
}
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env node
/**
* moho against the local self-hosted Stoat, signed in as alice, with bob as the other person: what bob does is seen,
* and what alice does in moho is seen from bob's side.
*
* ~/.local/share/moho-dev/stoat-docker.sh start && cd /1tb/stoat-test/selfhosted && DOCKER_HOST=unix:///run/docker-stoat.sock docker compose up -d
* node scripts/probe-stoat-local.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { launchApp, sleep } from './lib/harness.mjs'
import { accounts, as, upload, makeAccount, API } from './lib/stoat-local.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const acc = accounts()
// A stranger for this run: asks to be a friend, and has a server to be invited to.
const stranger = await makeAccount(`probe${Date.now().toString(36)}`)
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
const rpc = (method, params = {}) => ev(`window.moho.rpc(${JSON.stringify(method)}, ${JSON.stringify(params)})`)
const shot = async (name) => {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
}
let crashed = false
const results = []
const check = (what, ok, detail = '') => {
results.push(ok)
console.log(ok ? ' ok ' : ' FAIL', what, detail)
}
const until = async (what, fn, ms = 15000) => {
const t = Date.now()
while (Date.now() - t < ms) {
try {
const v = await fn()
if (v) return v
} catch {
/* not yet */
}
await sleep(250)
}
return null
}
try {
await sleep(2500)
// The two are friends to begin with (a block ends a friendship, and an earlier run may have made one).
await as('alice', 'PUT', `/users/${acc.bob.id}/friend`).catch(() => {})
await as('bob', 'PUT', `/users/${acc.alice.id}/friend`).catch(() => {})
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
await rpc('addStoatAccountToken', { instance: API, token: acc.alice.token })
const account = `stoat:${acc.alice.id}`
const connected = await until('connected', async () => (await rpc('listAccounts')).find((a) => a.id === account && a.state === 'connected'))
check('alice connects to the local instance', !!connected)
await ev(`window.__mohoShots?.store.refreshAccounts?.(); window.__mohoShots?.store.refreshBuffers?.()`)
const general = `${account}|Test Hall/#General`
const buffers = await until('buffers', async () => {
const b = (await rpc('listBuffers')).filter((x) => x.accountId === account)
return b.some((x) => x.id === general) ? b : null
})
check('the server and its channels are made', !!buffers, JSON.stringify(buffers?.map((b) => b.name)))
check('a voice room is a conversation too, marked as one', buffers?.find((b) => /Voice Room/.test(b.name))?.nameIcon === 'volume_up')
const notes = await rpc('openDirectMessage', { accountId: account, userId: acc.alice.id })
check('the account’s own notes open as a conversation', (notes.bufferId ?? notes).endsWith('|Saved Notes'), JSON.stringify(notes))
const channel = acc.server.channels.General
// bob speaks.
await rpc('subscribe', { bufferId: general }).catch(() => {})
await as('bob', 'POST', `/channels/${channel}/messages`, { content: 'hello from bob, **bold** and !!a secret!!' })
const heard = await until('bob heard', async () => (await rpc('getBacklog', { bufferId: general })).find((m) => /hello from bob/.test(m.body)))
check('what bob says is heard', !!heard, heard && JSON.stringify([heard.from, heard.body]))
check('with its spoiler in the window’s marks', /\|\|a secret\|\|/.test(heard?.body ?? ''))
// alice speaks, in moho.
await rpc('sendMessage', { bufferId: general, body: 'hello from moho' })
const seen = await until('alice seen', async () => (await as('bob', 'GET', `/channels/${channel}/messages?limit=10`)).find((m) => m.content === 'hello from moho'))
check('what alice says in moho is seen by bob', !!seen)
// a reply, an edit, a reaction, a delete.
const mine = (await rpc('getBacklog', { bufferId: general })).find((m) => m.body === 'hello from moho')
await rpc('editMessage', { bufferId: general, messageId: mine.id, body: 'hello from moho (edited)' })
const edited = await until('edited', async () => (await as('bob', 'GET', `/channels/${channel}/messages/${mine.id}`)).content === 'hello from moho (edited)')
check('an edit reaches bob', !!edited)
await rpc('toggleReaction', { bufferId: general, messageId: mine.id, emoji: '👍', add: true }).catch((e) => console.log('react:', e.message))
const reacted = await until('reacted', async () => Object.keys((await as('bob', 'GET', `/channels/${channel}/messages/${mine.id}`)).reactions ?? {}).includes('👍'))
check('a reaction reaches bob', !!reacted)
await as('bob', 'PUT', `/channels/${channel}/messages/${mine.id}/reactions/${encodeURIComponent('🔥')}`)
const flame = await until('flame', async () => (await rpc('getBacklog', { bufferId: general })).find((m) => m.id === mine.id)?.reactions?.some((r) => r.emoji === '🔥'))
check('and bob’s reaches moho', !!flame)
await rpc('deleteMessage', { bufferId: general, messageId: mine.id })
const gone = await until('gone', async () => !(await as('bob', 'GET', `/channels/${channel}/messages?limit=10`)).some((m) => m.id === mine.id))
check('a delete reaches bob', !!gone)
// The people here, and who one of them is.
await rpc('requestMemberList', { bufferId: general })
const roster = await until('roster', () => ev(`window.__mohoShots.state().presenceByBuffer[${JSON.stringify(general)}]`))
check('the member list names alice and bob', !!roster?.some((m) => m.nick === 'alice') && !!roster?.some((m) => m.nick === 'bob'), JSON.stringify(roster?.map((m) => [m.nick, m.status])))
check('alice, who is connected, is online, and bob, who is not, is offline', roster?.find((m) => m.nick === 'alice')?.status === 'online' && roster?.find((m) => m.nick === 'bob')?.status === 'offline')
await rpc('requestProfile', { bufferId: general, nick: 'bob', userId: acc.bob.id })
const card = await until('profile', () => ev(`(() => { const p = window.__mohoShots.state().profile; return p && p.id === ${JSON.stringify(acc.bob.id)} && !p.pending ? p : null })()`))
check('bob’s card says who he is, when the account was made and when he joined', !!card && /^bob#/.test(card.handle ?? '') && !!card.createdTs && !!card.joinedTs, JSON.stringify(card && [card.name, card.handle, card.createdTs, card.joinedTs]))
// Files, both ways.
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64')
const fileId = await upload('bob', 'dot.png', png, 'image/png')
await as('bob', 'POST', `/channels/${channel}/messages`, { content: 'a picture', attachments: [fileId] })
const pic = await until('picture heard', async () => (await rpc('getBacklog', { bufferId: general })).find((m) => m.body === 'a picture' && m.attachments?.length))
if (!pic) console.log('backlog:', JSON.stringify((await rpc('getBacklog', { bufferId: general })).slice(-4).map((m) => [m.body, m.attachments])), 'bob’s view:', JSON.stringify((await as('bob', 'GET', `/channels/${channel}/messages?limit=2`)).map((m) => m.attachments)))
check('a picture bob sends arrives as an attachment', !!pic && pic.attachments[0].kind === 'image' && pic.attachments[0].filename === 'dot.png', JSON.stringify(pic?.attachments?.[0]))
const fetched = await rpc('restoreMedia', { path: pic.attachments[0].path }).catch((e) => ({ error: e.message }))
const local = (fetched.path ?? '').replace('file://', '')
check('and is fetched from the file server when looked at', !!local && fs.existsSync(local) && fs.statSync(local).size > 0, JSON.stringify(fetched))
const mine2 = path.join(out, 'from-moho.png')
fs.writeFileSync(mine2, png)
await rpc('sendMessage', { bufferId: general, body: 'a picture from moho', attachmentPaths: [mine2] })
const theirs = await until('picture seen', async () => (await as('bob', 'GET', `/channels/${channel}/messages?limit=10`)).find((m) => m.content === 'a picture from moho' && m.attachments?.length))
check('a picture alice sends is seen by bob', !!theirs && theirs.attachments[0].filename === 'from-moho.png')
await rpc('sendMessage', { bufferId: general, body: 'hidden', attachmentPaths: [mine2], spoilerPaths: [mine2] })
const spoiled = await until('spoiler seen', async () => (await as('bob', 'GET', `/channels/${channel}/messages?limit=10`)).find((m) => m.content === 'hidden' && m.attachments?.length))
check('and one marked as a spoiler goes under the name Stoat reads as one', !!spoiled && /^SPOILER_/.test(spoiled.attachments[0].filename))
// Custom emoji: one made on the server is offered, drawn in what others say, and sent by name.
const emojiFile = await upload('alice', 'party.png', png, 'image/png', 'emojis')
const emojiName = `party${Date.now().toString(36).slice(-4)}`
await as('alice', 'PUT', `/custom/emoji/${emojiFile}`, { name: emojiName, parent: { type: 'Server', id: acc.server.id } })
const offered = await until('emoji offered', async () => {
const sources = await rpc('listAllEmoji', { bufferId: general })
return sources.find((s) => s.service === 'stoat' && s.emoji.some((e) => e.name === emojiName))
})
check('a custom emoji made on the server is offered in the picker under the server’s name', !!offered && offered.name === 'Test Hall', JSON.stringify(offered && [offered.name, offered.emoji.length, offered.usableHere]))
await as('bob', 'POST', `/channels/${channel}/messages`, { content: `yay :${emojiFile}:` })
const drawn = await until('emoji heard', async () => (await rpc('getBacklog', { bufferId: general })).find((m) => m.body.startsWith(`yay [stoat:emoji:${emojiName}:`)))
check('and one in what bob says is a picture token naming the cache', !!drawn && new RegExp(`^yay \\[stoat:emoji:${emojiName}:file://.*/stoat-media/[0-9a-f]+\\.png\\]$`).test(drawn.body), drawn?.body)
await rpc('sendMessage', { bufferId: general, body: `back :${emojiName}: !!and a secret!!` })
const said = await until('emoji seen', async () => (await as('bob', 'GET', `/channels/${channel}/messages?limit=10`)).find((m) => (m.content ?? '').startsWith('back :')))
check('what alice types by name goes out as the emoji’s id', said?.content === `back :${emojiFile}: !!and a secret!!`, said?.content)
// Moderation: roles, a timeout, removing and barring, and an invite to the server.
const powers = await rpc('getStoatPowers', { bufferId: general })
check('the owner may kick, ban, silence and hand out roles', powers.canKick && powers.canBan && powers.canMute && powers.canAssignRoles, JSON.stringify(powers))
const role = await as('alice', 'POST', `/servers/${acc.server.id}/roles`, { name: `Helpers ${Date.now().toString(36).slice(-3)}` })
await until('role heard', async () => (await rpc('getStoatPowers', { bufferId: general })).roles.some((r) => r.id === role.id))
check('a role made on the server is one the menu can give', true)
await rpc('setStoatMemberRole', { bufferId: general, userId: acc.bob.id, roleId: role.id, give: true })
check('giving it reaches bob', (await as('alice', 'GET', `/servers/${acc.server.id}/members/${acc.bob.id}`)).roles.includes(role.id))
await rpc('setStoatMemberRole', { bufferId: general, userId: acc.bob.id, roleId: role.id, give: false })
check('and taking it away does too', !(await as('alice', 'GET', `/servers/${acc.server.id}/members/${acc.bob.id}`)).roles.includes(role.id))
await rpc('moderateStoatMember', { bufferId: general, userId: acc.bob.id, action: 'mute', minutes: 5 })
check('a timeout is put on bob', !!(await as('alice', 'GET', `/servers/${acc.server.id}/members/${acc.bob.id}`)).timeout)
await rpc('moderateStoatMember', { bufferId: general, userId: acc.bob.id, action: 'unmute' })
check('and lifted', !(await as('alice', 'GET', `/servers/${acc.server.id}/members/${acc.bob.id}`)).timeout)
const link = await rpc('createStoatInvite', { bufferId: general })
check('an invite link is made, on the instance’s own web app', /^http:\/\/localhost:8880\/invite\/[A-Za-z0-9]+$/.test(link.link), link.link)
await stranger.call('POST', `/invites/${link.link.split('/').pop()}`, {})
check('and the stranger joins by it', (await stranger.call('GET', `/servers/${acc.server.id}/members/${stranger.id}`))._id.user === stranger.id)
await rpc('moderateStoatMember', { bufferId: general, userId: stranger.id, action: 'kick' })
check('kicking removes the stranger', await stranger.call('GET', `/servers/${acc.server.id}/members/${stranger.id}`).then(() => false, () => true))
await stranger.call('POST', `/invites/${link.link.split('/').pop()}`, {})
await rpc('moderateStoatMember', { bufferId: general, userId: stranger.id, action: 'ban', reason: 'probe' })
check('barring does too, and the stranger cannot come back', await stranger.call('POST', `/invites/${link.link.split('/').pop()}`, {}).then(() => false, () => true))
await rpc('moderateStoatMember', { bufferId: general, userId: stranger.id, action: 'unban' })
// A conversation with bob, both ways.
const opened = await rpc('openDirectMessage', { accountId: account, userId: acc.bob.id, nick: 'bob' })
const dm = opened.bufferId ?? opened
check('a direct message with bob opens', typeof dm === 'string' && dm.includes('|bob'), JSON.stringify(opened))
const dmChannel = (await as('bob', 'GET', `/users/${acc.alice.id}/dm`))._id
await as('bob', 'POST', `/channels/${dmChannel}/messages`, { content: 'psst, it is bob' })
const whisper = await until('dm heard', async () => (await rpc('getBacklog', { bufferId: dm })).find((m) => m.body === 'psst, it is bob'))
check('what bob says in it is heard', !!whisper)
await rpc('sendMessage', { bufferId: dm, body: 'psst back' })
const back = await until('dm seen', async () => (await as('bob', 'GET', `/channels/${dmChannel}/messages?limit=5`)).find((m) => m.content === 'psst back'))
check('and what alice says reaches bob', !!back)
// Blocking, and being let through again.
await rpc('stoatBlock', { accountId: account, userId: acc.bob.id, block: true })
const locked = await until('locked', async () => (await rpc('listBuffers')).find((b) => b.id === dm)?.readOnly)
check('blocking bob closes the conversation to writing', !!locked, locked)
await rpc('stoatBlock', { accountId: account, userId: acc.bob.id, block: false })
const stillShut = await until('shut', async () => (await rpc('listBuffers')).find((b) => b.id === dm)?.readOnly)
check('and unblocking does not make them friends again: it stays shut, with the reason', /friends/.test(stillShut ?? ''), stillShut)
await as('alice', 'PUT', `/users/${acc.bob.id}/friend`)
await as('bob', 'PUT', `/users/${acc.alice.id}/friend`)
const open = await until('open again', async () => !(await rpc('listBuffers')).find((b) => b.id === dm)?.readOnly)
check('and being friends again opens it', !!open)
// Somebody asks to be a friend.
await stranger.call('PUT', `/users/${acc.alice.id}/friend`)
const asked = await until('request', async () => (await rpc('stoatFriendRequests', { accountId: account })).incoming.find((r) => r.id === stranger.id))
check('a friend request is listed with the name of who asked', !!asked && asked.name === stranger.username, JSON.stringify(asked))
await rpc('answerStoatFriendRequest', { accountId: account, userId: stranger.id, accept: true })
const friends = await until('friends', async () => (await stranger.call('GET', `/users/${acc.alice.id}`)).relationship === 'Friend')
check('accepting makes them friends', !!friends)
check('and they are no longer waiting', !(await rpc('stoatFriendRequests', { accountId: account })).incoming.some((r) => r.id === stranger.id))
// Another server, joined by its invite and left.
const hall = await stranger.call('POST', '/servers/create', { name: 'Stranger Den' })
const invite = await stranger.call('POST', `/channels/${hall.channels[0]._id}/invites`, {})
await rpc('joinBuffer', { accountId: account, name: `http://localhost:8880/invite/${invite._id}` })
const joined = await until('joined', async () => (await rpc('listBuffers')).find((b) => b.accountId === account && /^Stranger Den\//.test(b.name)))
check('a server is joined by its invite link and its channels are made', !!joined, joined?.name)
await rpc('leaveGroup', { groupId: `${account}|server:${hall.server._id}` })
const left = await until('left', async () => !(await rpc('listBuffers')).some((b) => /^Stranger Den\//.test(b.name)))
check('and leaving it takes it away', !!left)
const ownLeft = await rpc('leaveGroup', { groupId: `${account}|server:${acc.server.id}` }).then(() => 'left', (e) => e.message)
check('a server the account owns is not left, since that deletes it', /own this server/.test(ownLeft), ownLeft)
await ev(`window.__mohoShots.store.refreshBuffers(); window.__mohoShots.store.selectBuffer(${JSON.stringify(general)})`)
await sleep(800)
await shot('stoat-local.png')
} catch (e) {
crashed = true
console.log('CRASHED:', e.message)
} finally {
await stop()
console.log(results.every(Boolean) && !crashed ? 'ALL OK' : 'SOME FAILED')
process.exitCode = results.every(Boolean) && !crashed ? 0 : 1
}
+264
View File
@@ -0,0 +1,264 @@
#!/usr/bin/env node
/**
* moho against the local self-hosted Stoat, signed in as alice (who owns the test server), with bob as the other person:
* mutes kept on the account, searching and pins, the account's own profile and status, looking after a server (channels,
* headings, roles, permissions, webhooks, the audit log), blocking, a bridge's name marked, and a custom emoji reaction.
*
* ~/.local/share/moho-dev/stoat-docker.sh start && cd /1tb/stoat-test/selfhosted && DOCKER_HOST=unix:///run/docker-stoat.sock docker compose up -d
* npx electron-vite build && node scripts/probe-stoat-manage.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { launchApp, sleep } from './lib/harness.mjs'
import { accounts, as, upload, API } from './lib/stoat-local.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const acc = accounts()
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
const rpc = (method, params = {}) => ev(`window.moho.rpc(${JSON.stringify(method)}, ${JSON.stringify(params)})`)
const shot = async (name) => {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
}
let crashed = false
const results = []
const check = (what, ok, detail = '') => {
results.push(ok)
console.log(ok ? ' ok ' : ' FAIL', what, detail)
}
const until = async (fn, ms = 15000) => {
const t = Date.now()
while (Date.now() - t < ms) {
try {
const v = await fn()
if (v) return v
} catch {
/* not yet */
}
await sleep(250)
}
return null
}
const S = acc.server.id
const channelId = acc.server.channels
try {
await sleep(2500)
// Whatever an earlier run left behind.
const left = await as('alice', 'GET', `/servers/${S}?include_channels=true`)
for (const c of left.channels ?? []) if (/^probe-/.test(c.name)) await as('alice', 'DELETE', `/channels/${c._id}`).catch(() => {})
for (const [id, r] of Object.entries(left.roles ?? {})) if (/^Role /.test(r.name)) await as('alice', 'DELETE', `/servers/${S}/roles/${id}`).catch(() => {})
await as('alice', 'PATCH', `/servers/${S}`, { categories: (left.categories ?? []).filter((c) => !/^Probe /.test(c.title)) })
await as('alice', 'PUT', `/users/${acc.bob.id}/friend`).catch(() => {})
await as('bob', 'PUT', `/users/${acc.alice.id}/friend`).catch(() => {})
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
await rpc('addStoatAccountToken', { instance: API, token: acc.alice.token })
const account = `stoat:${acc.alice.id}`
check('alice connects', !!(await until(async () => (await rpc('listAccounts')).find((a) => a.id === account && a.state === 'connected'))))
await ev(`window.__mohoShots.store.refreshAccounts(); window.__mohoShots.store.refreshBuffers()`)
const general = `${account}|Test Hall/#General`
const random = `${account}|Test Hall/#random`
await until(async () => (await rpc('listBuffers')).some((b) => b.id === general))
const muted = async (id) => !!(await rpc("listBuffers")).find((b) => b.id === id)?.serverMuted
// --- Mutes, kept on the account.
await as('alice', 'POST', '/sync/settings/set', { notifications: JSON.stringify({}) })
await rpc('setStoatChannelMuted', { bufferId: random, muted: true })
check('muting a conversation silences it', !!(await until(async () => (await muted(random)) === true)))
const saved = JSON.parse((await as('alice', 'POST', '/sync/settings/fetch', { keys: ['notifications'] })).notifications[1])
check('and is kept in the account’s own settings', Object.keys(saved.channel_mutes ?? {}).includes(channelId.random), JSON.stringify(saved))
await as('alice', 'POST', '/sync/settings/set', { notifications: JSON.stringify({}) })
check('a mute taken off in another app is heard here', !!(await until(async () => (await muted(random)) === false)))
// --- Search and pins.
const sent = await as('bob', 'POST', `/channels/${channelId.General}/messages`, { content: 'the quokka crosses the zebra needle' })
await sleep(800)
const found = await until(async () => {
const r = await rpc('searchStoatMessages', { bufferId: general, query: 'quokka' })
return r.results?.find((m) => m.id === sent._id)
})
check('a word searched for is found on Stoat’s side', !!found, JSON.stringify(found && [found.from, found.body]))
await rpc('setPinned', { bufferId: general, messageId: sent._id, pinned: true })
const pins = await until(async () => {
const r = await rpc('listPinned', { bufferId: general })
return r.pinned?.some((m) => m.id === sent._id) ? r.pinned : null
})
check('a message pinned from here is in the channel’s pins', !!pins)
const ctx = await rpc('loadMessageContext', { bufferId: general, messageId: sent._id })
check('and the part of the channel around it can be read', ctx.ts > 1_700_000_000 && ctx.messages?.some((m) => m.id === sent._id), JSON.stringify(ctx.ts))
await rpc('setPinned', { bufferId: general, messageId: sent._id, pinned: false })
check('the pin comes off', !!(await until(async () => (await rpc('listPinned', { bufferId: general })).pinned?.length === 0)))
// The window is told of a pin made in another app, and does not ask for ever.
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(general)})`)
await as('bob', 'POST', `/channels/${channelId.General}/messages/${sent._id}/pin`, {}).catch(async () => {})
await as('alice', 'POST', `/channels/${channelId.General}/messages/${sent._id}/pin`, {})
const told = await until(async () => (await ev(`window.__mohoShots.state().pinnedMessages[${JSON.stringify(general)}]`))?.includes(sent._id))
check('a pin made elsewhere reaches the window', !!told)
await as('alice', 'DELETE', `/channels/${channelId.General}/messages/${sent._id}/pin`)
// --- The account’s own profile and presence.
const before = await rpc('stoatOwnProfile', { accountId: account })
await rpc('setStoatProfile', { accountId: account, displayName: 'Alice A', pronouns: 'she, her', bio: 'hello from moho', statusText: 'probing' })
const mine = await as('bob', 'GET', `/users/${acc.alice.id}`)
check('a changed profile is what others see', mine.display_name === 'Alice A' && mine.status?.text === 'probing' && JSON.stringify(mine.pronouns).includes('she'), JSON.stringify([mine.display_name, mine.status, mine.pronouns]))
const bio = await as('bob', 'GET', `/users/${acc.alice.id}/profile`)
check('and its about text', bio.content === 'hello from moho')
await rpc('setAccountStatus', { accountId: account, status: 'dnd' })
check('do not disturb is Stoat’s Busy', !!(await until(async () => (await as('bob', 'GET', `/users/${acc.alice.id}`)).status?.presence === 'Busy')))
await rpc('setAccountStatus', { accountId: account, status: 'invisible' })
check('and invisible is what Stoat shows as offline, though connected', !!(await until(async () => (await as('bob', 'GET', `/users/${acc.alice.id}`)).online === false)))
await rpc('setAccountStatus', { accountId: account, status: 'online' })
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64')
const pic = path.join(out, 'me.png')
fs.writeFileSync(pic, png)
const answer = await rpc('setStoatProfileAvatar', { accountId: account, path: pic })
const withPic = await as('bob', 'GET', `/users/${acc.alice.id}`)
check('a picture chosen is put on the account', !!answer.avatarUrl && !!withPic.avatar, JSON.stringify([answer, withPic.avatar]))
await rpc('setStoatProfileAvatar', { accountId: account, path: '' })
check('and taken off', !!(await until(async () => !(await as('bob', 'GET', `/users/${acc.alice.id}`)).avatar)))
await rpc('setStoatProfile', { accountId: account, displayName: before.displayName, pronouns: before.pronouns, bio: before.bio, statusText: before.statusText })
// --- Looking after the server.
const o = await rpc('stoatManage', { bufferId: general, action: 'overview' })
check('the server is described to its owner with everything they may do', o.name === 'Test Hall' && o.can.owner && o.can.channels && o.can.roles && o.can.audit && o.channels.length >= 4 && o.permissions.length > 20, JSON.stringify(o.can))
const tag = Date.now().toString(36).slice(-4)
await rpc('stoatManage', { bufferId: general, action: 'createChannel', name: `probe-${tag}`, voice: false })
const made = await until(async () => (await rpc('stoatManage', { bufferId: general, action: 'overview' })).channels.find((c) => c.name === `probe-${tag}`))
check('a channel can be made', !!made)
await rpc('stoatManage', { bufferId: general, action: 'editChannel', channelId: made.id, description: 'a topic', nsfw: true, slowmode: 5 })
const edited = (await rpc('stoatManage', { bufferId: general, action: 'overview' })).channels.find((c) => c.id === made.id)
check('and edited: topic, age restriction, slowmode', edited.description === 'a topic' && edited.nsfw && edited.slowmode === 5, JSON.stringify([edited.description, edited.nsfw, edited.slowmode]))
const after = (await rpc('listBuffers')).find((b) => b.remoteId === made.id)
check('the new channel is a conversation in moho, with its topic', !!after, JSON.stringify(after && [after.name, after.topic]))
const heading = `H${tag}`.toUpperCase()
const current = (await rpc('stoatManage', { bufferId: general, action: 'overview' })).categories
await rpc('stoatManage', { bufferId: general, action: 'setCategories', categories: [...current.map((c) => ({ ...c, channels: c.channels.filter((x) => x !== made.id) })), { id: heading, title: `Probe ${tag}`, channels: [made.id] }] })
const filed = (await rpc('stoatManage', { bufferId: general, action: 'overview' })).channels.find((c) => c.id === made.id)
check('a heading can be made with the channel under it', filed.category === heading)
await rpc('stoatManage', { bufferId: general, action: 'createRole', name: `Role ${tag}` })
const role = await until(async () => (await rpc('stoatManage', { bufferId: general, action: 'overview' })).roles.find((r) => r.name === `Role ${tag}`))
check('a role can be made', !!role)
await rpc('stoatManage', { bufferId: general, action: 'editRole', roleId: role.id, colour: '#ff8800', hoist: true })
await rpc('stoatManage', { bufferId: general, action: 'setRolePermissions', roleId: role.id, allow: ['SendMessage', 'ViewAuditLogs'], deny: ['React'] })
const r2 = (await rpc('stoatManage', { bufferId: general, action: 'overview' })).roles.find((r) => r.id === role.id)
check('with a colour, listed apart, and what it allows and denies (a permission high above 32 bits too)', r2.colour === '#ff8800' && r2.hoist && r2.allow.includes('SendMessage') && r2.allow.includes('ViewAuditLogs') && r2.deny.includes('React'), JSON.stringify(r2))
await rpc('stoatManage', { bufferId: general, action: 'setChannelPermissions', channelId: made.id, roleId: role.id, allow: ['SendMessage'], deny: [] })
await rpc('stoatManage', { bufferId: general, action: 'setChannelPermissions', channelId: made.id, roleId: '', allow: [], deny: ['UploadFiles'] })
const c2 = (await rpc('stoatManage', { bufferId: general, action: 'overview' })).channels.find((c) => c.id === made.id)
check('who may do what in one channel can be set, for a role and for everybody', c2.roles.some((x) => x.roleId === role.id && x.allow.includes('SendMessage')) && c2.default.deny.includes('UploadFiles'), JSON.stringify([c2.roles, c2.default]))
await rpc('stoatManage', { bufferId: general, action: 'reorderRoles', order: (await rpc('stoatManage', { bufferId: general, action: 'overview' })).roles.map((r) => r.id).reverse() })
const hooked = await rpc('stoatManage', { bufferId: general, action: 'createWebhook', channelId: made.id, name: 'probe hook' })
const hooks = await rpc('stoatManage', { bufferId: general, action: 'webhooks', channelId: made.id })
check('a webhook can be made and listed with its address', hooks.webhooks.length === 1 && /\/webhooks\/[A-Z0-9]{26}\/.+/.test(hooks.webhooks[0].url ?? ''), JSON.stringify(hooks.webhooks.map((w) => w.name)))
// This version of the server (0.15) has no route to delete one; they go with their channel. The answer is Stoat’s own.
const gone = await rpc('stoatManage', { bufferId: general, action: 'deleteWebhook', webhookId: hooks.webhooks[0].id, token: hooks.webhooks[0].token }).then(() => 'deleted', (e) => e.message)
console.log(' note deleting a webhook:', String(gone).slice(0, 80))
const log = await rpc('stoatManage', { bufferId: general, action: 'auditLog' })
check('the audit log says what was done, in words', log.entries.some((e) => /made the channel probe-/.test(e.text)) && log.entries.some((e) => /made the role Role /.test(e.text)), JSON.stringify(log.entries.slice(0, 4).map((e) => e.text)))
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(general)})`)
await rpc('stoatManage', { bufferId: general, action: 'deleteChannel', channelId: made.id })
await rpc('stoatManage', { bufferId: general, action: 'deleteRole', roleId: role.id })
await rpc('stoatManage', { bufferId: general, action: 'setCategories', categories: current })
check('the channel and role are deleted again, and the conversation goes', !!(await until(async () => !(await rpc('listBuffers')).some((b) => b.remoteId === made.id))))
// A refusal is Stoat’s, in words: bob cannot manage anything.
const bobsView = await as('bob', 'GET', `/servers/${S}`)
check('the server is one bob is in', bobsView._id === S)
// --- Blocking, through the ignore the member menu has.
await rpc('setIgnored', { accountId: account, target: acc.bob.id, ignored: true })
check('ignoring a person on Stoat blocks them on the account', !!(await until(async () => (await as('alice', 'GET', `/users/${acc.bob.id}`)).relationship === 'Blocked')))
await rpc('setIgnored', { accountId: account, target: acc.bob.id, ignored: false })
await as('alice', 'PUT', `/users/${acc.bob.id}/friend`).catch(() => {})
await as('bob', 'PUT', `/users/${acc.alice.id}/friend`).catch(() => {})
// --- A bridge’s name is marked, and a custom emoji is a picture in a reaction.
const relayed = await as('alice', 'POST', `/channels/${channelId.General}/messages`, { content: 'from the bridge', masquerade: { name: 'Relay Ada' } })
const marked = await until(async () => (await rpc('getBacklog', { bufferId: general })).find((m) => m.id === relayed._id && m.badges?.some((b) => b.type === 'via')))
check('a message sent under another name is marked as such', !!marked && marked.from === 'Relay Ada', JSON.stringify(marked && [marked.from, marked.badges]))
const emojis = await as('alice', 'GET', `/servers/${S}/emojis`)
if (emojis.length) {
await as('bob', 'PUT', `/channels/${channelId.General}/messages/${relayed._id}/reactions/${emojis[0]._id}`)
const pill = await until(async () => (await rpc('getBacklog', { bufferId: general })).find((m) => m.id === relayed._id)?.reactions?.find((r) => r.emoji.startsWith('[stoat:emoji:')))
check('a custom emoji reaction is held as its picture', !!pill, JSON.stringify(pill))
await ev(`window.__mohoShots.store.refreshBuffers(); window.__mohoShots.store.selectBuffer(${JSON.stringify(general)})`)
await sleep(1200)
const drawn = await ev(`!!document.querySelector('.reaction-pill img.reaction-emoji')`)
check('and drawn in the pill', drawn)
await shot('stoat-reactions.png')
if (pill) {
await rpc('toggleReaction', { bufferId: general, messageId: relayed._id, emoji: pill.emoji, add: true })
check('and reacting with it from its pill reaches Stoat', !!(await until(async () => (await as('bob', 'GET', `/channels/${channelId.General}/messages/${relayed._id}`)).reactions?.[emojis[0]._id]?.includes(acc.alice.id))))
}
}
await as('alice', 'DELETE', `/channels/${channelId.General}/messages/${relayed._id}`).catch(() => {})
// --- A picture sent as a spoiler is covered until it is clicked.
const hiddenFile = await upload('bob', 'SPOILER_dot.png', png, 'image/png')
const covered = await as('bob', 'POST', `/channels/${channelId.General}/messages`, { content: 'a hidden one', attachments: [hiddenFile] })
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(general)})`)
const cover = await until(async () => ev(`!!document.querySelector('.spoiler-cover')`), 20000)
check('a spoilered picture from somebody else is covered in the log', !!cover)
await shot('stoat-spoiler.png')
await as('bob', 'DELETE', `/channels/${channelId.General}/messages/${covered._id}`).catch(() => {})
// --- Pictures actually load in the window: a person's avatar in the member list, and the server's icon on the rail.
const loaded = (selector) => ev(`[...document.querySelectorAll(${JSON.stringify(selector)})].some((i) => i.complete && i.naturalWidth > 0)`)
const avatarId = await upload('bob', 'bob.png', png, 'image/png', 'avatars')
await as('bob', 'PATCH', '/users/@me', { avatar: avatarId })
await rpc('requestMemberList', { bufferId: general })
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(general)})`)
check('a person’s avatar loads in the member list', !!(await until(async () => loaded('.nick-row img'), 20000)))
await as('bob', 'PATCH', '/users/@me', { remove: ['Avatar'] })
const iconPath = path.join(out, 'icon.png')
fs.writeFileSync(iconPath, png)
await rpc('stoatManage', { bufferId: general, action: 'editServer', icon: iconPath })
check('and the server’s icon loads on the rail', !!(await until(async () => loaded('.rail-tile[aria-label="Test Hall"] img.rail-icon'), 20000)))
await shot('stoat-pictures.png')
await rpc('stoatManage', { bufferId: general, action: 'editServer', icon: '' })
// --- The window: the server’s settings opened from its tile on the rail, against the live local server.
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(general)})`)
await sleep(800)
await ev(`document.querySelector('.rail-tile[aria-label="Test Hall"]').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 40, clientY: 200 }))`)
await sleep(400)
const opened = await ev(`(() => { const b = [...document.querySelectorAll('.context-menu-item')].find((x) => /Server settings/.test(x.textContent)); if (b) b.click(); return !!b })()`)
check('the rail tile’s menu offers the server’s settings', opened)
const modal = (await until(async () => { const t = await ev(`document.querySelector('.stoat-server')?.innerText ?? ''`); return /Overview/.test(t) ? t : null }, 30000)) ?? ''
check('which open on the server’s name, with what the owner may do', /Test Hall/.test(modal) && /Overview/.test(modal) && /Roles/.test(modal) && /Audit log/.test(modal), modal.slice(0, 80).replace(/\n/g, ' | '))
await shot('stoat-settings-overview.png')
const tab = async (name) => { await ev(`[...document.querySelectorAll('.room-settings-tab')].find((t) => t.textContent === ${JSON.stringify(name)}).click()`); await sleep(900) }
await tab('Channels')
const channelsText = await ev(`document.querySelector('.stoat-server .room-settings-body').innerText`)
check('the channels tab lists them', /General/.test(channelsText) && /Voice Room/.test(channelsText), channelsText.slice(0, 80).replace(/\n/g, ' | '))
await ev(`[...document.querySelectorAll('.stoat-server button')].find((b) => b.textContent === 'Who may').click()`)
await sleep(600)
await shot('stoat-settings-channels.png')
await tab('Roles')
await ev(`[...document.querySelectorAll('.stoat-server button')].filter((b) => b.textContent === 'Edit')[0]?.click()`)
await sleep(600)
await shot('stoat-settings-roles.png')
await tab('Webhooks')
await shot('stoat-settings-webhooks.png')
await tab('Audit log')
await sleep(600)
const audit = await ev(`document.querySelector('.stoat-server .room-settings-body').innerText`)
check('the audit log tab reads in words', /Alice|alice/.test(audit) && /made|changed|deleted/.test(audit), audit.slice(0, 100).replace(/\n/g, ' | '))
await shot('stoat-settings-audit.png')
await ev(`document.querySelector('.modal-scrim').click()`)
// The account’s own profile, under its card in the Accounts pane.
await ev(`window.__mohoShots.store.setActivePanel('accounts')`)
await sleep(1200)
await ev(`[...document.querySelectorAll('span, i')].filter((e) => e.children.length === 0 && e.textContent.trim() === 'expand_more').at(-1)?.click()`)
await sleep(1500)
const hasTools = await ev(`!!document.querySelector('.account-tools input[type=text]') || /Your profile on Stoat/.test(document.body.innerText)`)
check('the Accounts pane shows the account’s own profile to edit', hasTools)
await shot('stoat-account.png')
} catch (e) {
crashed = true
console.log('CRASHED:', e.message)
} finally {
await stop()
console.log(results.every(Boolean) && !crashed ? 'ALL OK' : 'SOME FAILED')
process.exitCode = results.every(Boolean) && !crashed ? 0 : 1
}
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env node
/**
* A call between two moho windows on the local self-hosted Stoat: alice and bob each sign in, join the voice room with a
* (fake) camera and microphone, and see each other - tiles, video arriving, who the daemon says is in the call - and
* then one shares a screen.
*
* node scripts/probe-stoat-voice.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { launchApp, sleep } from './lib/harness.mjs'
import { accounts, API } from './lib/stoat-local.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const acc = accounts()
const FAKE = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream']
let crashed = false
const results = []
const check = (what, ok, detail = '') => {
results.push(ok)
console.log(ok ? ' ok ' : ' FAIL', what, detail)
}
const until = async (fn, ms = 20000) => {
const t = Date.now()
while (Date.now() - t < ms) {
try {
const v = await fn()
if (v) return v
} catch {
/* not yet */
}
await sleep(300)
}
return null
}
async function person(who) {
const app = await launchApp({ env: { MOHO_UI_SHOTS: '1' }, args: FAKE })
const ev = (code) => app.page.evaluate(code)
const rpc = (method, params = {}) => ev(`window.moho.rpc(${JSON.stringify(method)}, ${JSON.stringify(params)})`)
await sleep(2500)
await app.page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
await rpc('addStoatAccountToken', { instance: API, token: acc[who].token })
const account = `stoat:${acc[who].id}`
await until(async () => (await rpc('listAccounts')).find((a) => a.id === account && a.state === 'connected'))
await ev(`window.__mohoShots.store.refreshAccounts(); window.__mohoShots.store.refreshBuffers()`)
const room = `${account}|Test Hall/#Voice Room`
await until(async () => (await rpc('listBuffers')).some((b) => b.id === room))
await ev(`window.__mohoShots.store.refreshBuffers(); window.__mohoShots.store.selectBuffer(${JSON.stringify(room)})`)
const shot = async (name) => {
const { data } = await app.page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
}
return { ...app, ev, rpc, account, room, shot }
}
const apps = []
try {
const a = await person('alice')
apps.push(a)
const b = await person('bob')
apps.push(b)
check('both are in the room', !!a.room && !!b.room)
await a.ev(`window.__mohoShots.store.joinStoatCall(${JSON.stringify(a.room)}, true)`)
const aUp = await until(async () => (await a.ev(`window.__mohoShots.state().activeCall?.phase`)) === 'connected')
check('alice joins the voice room', !!aUp)
await b.ev(`window.__mohoShots.store.joinStoatCall(${JSON.stringify(b.room)}, true)`)
const bUp = await until(async () => (await b.ev(`window.__mohoShots.state().activeCall?.phase`)) === 'connected')
check('bob joins it', !!bUp)
const named = await until(async () => {
const who = await a.ev(`(window.__mohoShots.state().stoatVoice[${JSON.stringify(a.room)}] ?? []).map((m) => m.name).sort().join(',')`)
return who === 'alice,bob' ? who : null
})
check('the daemon says both are in the call', !!named)
const tiles = await until(async () => {
const t = await a.ev(`window.__mohoShots.store.callTiles().map((t) => ({ id: t.id, label: t.label, hasVideo: t.hasVideo }))`)
return t.length === 2 && t.some((x) => x.label === 'bob') ? t : null
})
check('alice has a tile for bob', !!tiles, JSON.stringify(tiles))
const arriving = await until(async () => {
const r = await a.ev(`window.__mohoShots.store.callArrivals()`)
return r.length > 0 && r.some((x) => x.packets > 0) ? r : null
})
check('and what bob sends arrives', !!arriving, JSON.stringify(arriving))
const arrivingB = await until(async () => {
const r = await b.ev(`window.__mohoShots.store.callArrivals()`)
return r.some((x) => x.packets > 0) ? r : null
})
check('and what alice sends reaches bob', !!arrivingB)
const video = await until(async () => (await a.ev(`window.__mohoShots.store.callTiles().find((t) => t.label === 'bob')?.hasVideo`)) === true)
check('bob’s picture is video', !!video)
await a.ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(a.room)})`)
const listed = await until(async () => (await a.ev(`[...document.querySelectorAll('.bufferlist .voice-member, .voice-member')].map((e) => e.textContent.trim()).join(',')`)).includes('bob'))
check('the channel list names who is in the voice room under it', !!listed)
await a.shot('stoat-call-alice.png')
// The camera and the microphone, from the buttons' own methods.
await a.ev(`window.__mohoShots.store.toggleCallMute()`)
check('alice mutes', await until(async () => (await a.ev(`window.__mohoShots.state().activeCall?.muted`)) === true))
const mutedForBob = await until(async () => (await b.ev(`(window.__mohoShots.state().stoatVoice[${JSON.stringify(b.room)}] ?? []).find((m) => m.name === 'alice')?.muted`)) === true)
check('and the daemon tells bob she is muted', !!mutedForBob)
await a.ev(`window.__mohoShots.store.toggleCamera()`)
check('alice turns her camera off', await until(async () => (await a.ev(`window.__mohoShots.state().activeCall?.cameraOn`)) === false))
const camOff = await until(async () => (await b.ev(`(window.__mohoShots.state().stoatVoice[${JSON.stringify(b.room)}] ?? []).find((m) => m.name === 'alice')?.camera`)) === false)
check('and bob is told', !!camOff)
// A screen.
await a.ev(`window.__mohoShots.store.toggleScreenShare().catch(() => {})`)
const sources = await until(async () => a.ev(`window.__mohoShots.state().screenSources`), 8000)
if (sources?.length) await a.ev(`window.__mohoShots.store.chooseScreenSource(${JSON.stringify({ id: sources[0].id, name: sources[0].name })})`)
const sharing = await until(async () => (await a.ev(`window.__mohoShots.state().activeCall?.sharingScreen`)) === true, 12000)
check('alice shares a screen', !!sharing, sources ? `picked ${sources[0]?.name}` : 'no picker offered')
if (sharing) {
const told = await until(async () => (await b.ev(`(window.__mohoShots.state().stoatVoice[${JSON.stringify(b.room)}] ?? []).find((m) => m.name === 'alice')?.screen`)) === true)
check('and bob is told it is a screen', !!told)
}
await b.shot('stoat-call-bob.png')
await a.ev(`window.__mohoShots.store.hangUpMatrixCall()`)
const left = await until(async () => (await b.ev(`(window.__mohoShots.state().stoatVoice[${JSON.stringify(b.room)}] ?? []).map((m) => m.name).join(',')`)) === 'bob')
check('when alice hangs up, bob is alone in it', !!left)
await b.ev(`window.__mohoShots.store.hangUpMatrixCall()`)
// A call begun in a direct message rings the other person's window, who answers it.
const dmA = (await a.rpc('openDirectMessage', { accountId: a.account, userId: acc.bob.id })).bufferId
const dmB = (await b.rpc('openDirectMessage', { accountId: b.account, userId: acc.alice.id })).bufferId
await a.ev(`window.__mohoShots.store.joinStoatCall(${JSON.stringify(dmA)}, false)`)
const rung = await until(async () => (await b.ev(`window.__mohoShots.state().incomingCalls.some((c) => c.bufferId === ${JSON.stringify(dmB)} && c.ringing)`)) === true)
check('a call alice begins in her direct message with bob rings bob’s window', !!rung)
await b.shot('stoat-ring-bob.png')
await b.ev(`window.__mohoShots.store.acceptCall(${JSON.stringify(dmB)})`)
check('and bob answers it', !!(await until(async () => (await b.ev(`window.__mohoShots.state().activeCall?.phase`)) === 'connected')))
check('and it stops ringing', (await b.ev(`window.__mohoShots.state().incomingCalls.some((c) => c.ringing)`)) === false)
const together = await until(async () => (await a.ev(`(window.__mohoShots.state().stoatVoice[${JSON.stringify(dmA)}] ?? []).map((m) => m.name).sort().join(',')`)) === 'alice,bob')
check('with both in it', !!together)
await b.ev(`window.__mohoShots.store.hangUpMatrixCall()`)
await a.ev(`window.__mohoShots.store.hangUpMatrixCall()`)
// A ring declined stops, and the call carries on without them.
await a.ev(`window.__mohoShots.store.joinStoatCall(${JSON.stringify(dmA)}, false)`)
await until(async () => (await b.ev(`window.__mohoShots.state().incomingCalls.some((c) => c.ringing)`)) === true)
await b.ev(`window.__mohoShots.store.declineCall(${JSON.stringify(dmB)})`)
check('a ring turned down stops', (await b.ev(`window.__mohoShots.state().incomingCalls.some((c) => c.ringing)`)) === false)
await a.ev(`window.__mohoShots.store.hangUpMatrixCall()`)
const quiet = await until(async () => (await b.ev(`window.__mohoShots.state().incomingCalls.length`)) === 0)
check('and nothing rings once the caller has gone', !!quiet)
} catch (e) {
crashed = true
console.log('CRASHED:', e.message)
} finally {
for (const app of apps) await app.stop()
console.log(results.every(Boolean) && !crashed ? 'ALL OK' : 'SOME FAILED')
process.exitCode = results.every(Boolean) && !crashed ? 0 : 1
}
+19
View File
@@ -0,0 +1,19 @@
import fs from 'node:fs'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (c) => page.evaluate(c)
try {
try { const cl = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString()); for (const c of cl.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: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
const a = await rpc('addKickAccount', {})
await rpc('joinBuffer', { accountId: a.accountId, name: 'spreen' })
const id = `${a.accountId}|spreen`
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
await sleep(20000)
console.log(await ev(`JSON.stringify({ images: document.querySelectorAll('.sender-badge-image').length, loaded: [...document.querySelectorAll('.sender-badge-image')].filter((i) => i.complete && i.naturalWidth > 0).length, words: [...document.querySelectorAll('.sender-badge')].map((b) => b.textContent).slice(0, 8), titles: [...new Set([...document.querySelectorAll('.sender-badge-image')].map((i) => i.title))].slice(0, 8) })`))
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(process.env.HOME + '/.local/share/moho-dev/shots/kick-badges.png', Buffer.from(data, 'base64'))
} finally { await stop() }
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* Watches a real live Kick stream in a scratch moho with no account signed in, and checks what the
* player does: that it plays, how far behind it runs, that the resolution menu changes the picture, that
* the CC button shows the captions, and that the player is built once and not again as the address is
* renewed. Photographs the stream, each menu and the captions.
*
* node scripts/shot-kick-stream.mjs <output directory> [channel] [idle seconds]
*/
import fs from 'node:fs'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
const channel = process.argv[3] ?? 'spreen'
const idle = Number(process.argv[4] ?? 60)
fs.mkdirSync(out, { recursive: true })
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'))
}
const click = (selector) => ev(`(() => { const b = document.querySelector(${JSON.stringify(selector)}); if (!b || b.disabled) return false; b.click(); return true })()`)
const video = (expr) => ev(`(() => { const v = document.querySelector('video.video-tile-picture'); return v ? (${expr}) : null })()`)
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: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
const added = await rpc('addKickAccount', {})
say('account', JSON.stringify(added))
say('join', JSON.stringify(await rpc('joinBuffer', { accountId: added.accountId, name: channel })))
const id = `${added.accountId}|${channel}`
let live = null
for (let i = 0; i < 40 && !live?.playbackUrl; i++) {
await sleep(1000)
live = await ev(`window.__mohoShots.state().kickStreams[${JSON.stringify(id)}] || null`)
}
say('stream', JSON.stringify({ live: live?.live, hasUrl: !!live?.playbackUrl, expires: live?.playbackExpires, livestreamId: live?.livestreamId }))
await ev(`(async () => { await window.__mohoShots.store.selectBuffer(${JSON.stringify(id)}); window.__mohoShots.store.watchStream(${JSON.stringify(id)}) })()`)
for (let i = 0; i < 40; i++) {
if ((await video('v.readyState >= 3 && v.currentTime > 1')) === true) break
await sleep(500)
}
say('playing', await video('({ t: v.currentTime, w: v.videoWidth, h: v.videoHeight, paused: v.paused })').then(JSON.stringify))
await sleep(4000)
await shot('stream-playing.png')
// Resolution.
say('quality menu', await click('button[title^="Resolution"]'))
await sleep(400)
say('quality choices', JSON.stringify(await ev(`[...document.querySelectorAll('.stream-menu .context-menu-item')].map((b) => b.textContent.trim())`)))
await shot('stream-quality-menu.png')
for (const want of ['480p', 'Auto']) {
await ev(`(() => { const b = [...document.querySelectorAll('.stream-menu .context-menu-item')].find((x) => x.textContent.trim().startsWith(${JSON.stringify(want)})); b && b.click() })()`)
await sleep(8000)
say(`after choosing ${want}`, JSON.stringify(await video('({ h: v.videoHeight, t: +v.currentTime.toFixed(1) })')), JSON.stringify(await ev(`document.querySelector('button[title^="Resolution"]')?.title`)))
if (want === '480p') await click('button[title^="Resolution"]')
}
// Captions.
say('captions button', await ev(`JSON.stringify({ title: document.querySelector('button[title*="aptions"]')?.title, disabled: document.querySelector('button[title*="aptions"]')?.disabled })`))
say('captions menu', await click('button[title*="aptions"]'))
await sleep(400)
say('caption choices', JSON.stringify(await ev(`[...document.querySelectorAll('.stream-menu .context-menu-item')].map((b) => b.textContent.trim())`)))
await shot('stream-captions-menu.png')
await ev(`(() => { const b = [...document.querySelectorAll('.stream-menu .context-menu-item')].find((x) => x.textContent.trim() === 'English'); b && b.click() })()`)
await sleep(15000)
say('text tracks', await video(`JSON.stringify([...v.textTracks].map((t) => ({ lang: t.language, mode: t.mode, cues: t.cues ? t.cues.length : 0 })))`))
await shot('stream-captions-on.png')
say('captions button now', await ev(`document.querySelector('button[title*="aptions"]')?.title`))
// Built once.
const sources = new Set()
const began = Date.now()
while (Date.now() - began < idle * 1000) {
sources.add(await video('v.src'))
await sleep(1000)
}
const later = await ev(`window.__mohoShots.state().kickStreams[${JSON.stringify(id)}]`)
say(`over ${idle}s: ${sources.size} player source(s); address expires`, later?.playbackExpires, 'changed:', later?.playbackUrl !== live?.playbackUrl)
say('quality', JSON.stringify(await video('(() => { const q = v.getVideoPlaybackQuality(); return { dropped: q.droppedVideoFrames, total: q.totalVideoFrames } })()')))
} finally {
await stop()
}
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* Reads a busy live Kick channel in a scratch moho and checks what replies and removals look like: that a
* reply carries its thread, that the thread opens from it, and that a removed message is struck through in
* red, left out or replaced by a notice according to Settings -> Kick. Removal is the one thing it cannot
* wait for, so one is injected through the store the way the daemon's event would arrive.
*
* node scripts/shot-kick-threads.mjs <output directory> [channel]
*/
import fs from 'node:fs'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
const channel = process.argv[3] ?? 'xqc'
fs.mkdirSync(out, { recursive: true })
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: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
const added = await rpc('addKickAccount', {})
say('join', JSON.stringify(await rpc('joinBuffer', { accountId: added.accountId, name: channel })))
const id = `${added.accountId}|${channel}`
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
// Replies, as the daemon read them: each should name the thread it belongs to.
let replies = []
for (let i = 0; i < 120 && replies.length < 2; i++) {
await sleep(1000)
replies = JSON.parse(await ev(`JSON.stringify((window.__mohoShots.state().messagesByBuffer[${JSON.stringify(id)}] || []).filter((m) => m.replyTo).map((m) => ({ id: m.id, to: m.replyTo.id, root: m.replyTo.threadRoot, thread: m.replyTo.thread })))`))
}
say('replies seen', replies.length, JSON.stringify(replies.slice(0, 4)))
say('every reply has a thread root', replies.every((r) => r.root))
await sleep(1500)
await shot('kick-replies.png')
await ev(`document.querySelector('.reply-preview')?.scrollIntoView({ block: 'center' })`)
await sleep(500)
await shot('kick-reply-row.png')
// Open a thread from a reply.
say('thread button', await ev(`(() => { const b = document.querySelector('.reply-thread-open'); if (!b) return false; b.click(); return true })()`))
await sleep(1200)
say('thread panel', await ev(`JSON.stringify({ open: !!document.querySelector('.thread-pane'), rows: document.querySelectorAll('.thread-pane .message-row').length, title: document.querySelector('.thread-header')?.textContent })`))
await shot('kick-thread.png')
await ev(`window.__mohoShots.store.closeThreadPanel()`)
await sleep(300)
// A removal, as the daemon would announce it, in each of the three ways.
const victim = await ev(`(() => { const ms = window.__mohoShots.state().messagesByBuffer[${JSON.stringify(id)}] || []; const m = [...ms].reverse().find((x) => x.kind === 'chat' && x.body && !x.replyTo); return m && m.id })()`)
await ev(`window.__mohoShots.store.handleEvent({ event: 'messageModerated', data: { bufferId: ${JSON.stringify(id)}, id: ${JSON.stringify(victim)}, moderated: { ai: true, rules: ['hate'] } } })`)
await sleep(500)
await ev(`document.querySelector('.message-row.removed')?.scrollIntoView({ block: 'center' })`)
await sleep(500)
say('removed row (default: strike)', await ev(`(() => { const r = document.querySelector('.message-row.removed .message-body'); if (!r) return null; const s = getComputedStyle(r); return JSON.stringify({ text: r.textContent.slice(0, 40), color: s.color, line: s.textDecorationLine, title: r.title }) })()`))
await shot('kick-removed-strike.png')
for (const [label, name] of [['Replace with notice', 'replace'], ['Delete', 'remove'], ['Strikethrough', 'strike']]) {
await ev(`window.__mohoShots.store.setActivePanel('settings')`)
await sleep(500)
await ev(`[...document.querySelectorAll('.settings-rail-item, .settings-rail button')].find((b) => b.textContent.trim().startsWith('Kick'))?.click()`)
await sleep(500)
say('setting', label, await ev(`(() => { const b = [...document.querySelectorAll('.setting-segmented button')].find((x) => x.textContent.trim() === ${JSON.stringify(label)}); if (!b) return false; b.click(); return true })()`))
await sleep(300)
if (name === 'replace') await shot('kick-settings.png')
await ev(`window.__mohoShots.store.setActivePanel('')`)
await sleep(500)
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
await sleep(700)
say(name, await ev(`JSON.stringify({ removedRows: document.querySelectorAll('.message-row.removed').length, text: document.querySelector('.message-row.removed .message-body')?.textContent.slice(0, 50) })`))
await ev(`document.querySelector('.message-row.removed')?.scrollIntoView({ block: 'center' })`)
await sleep(300)
if (name !== 'strike') await shot(`kick-removed-${name}.png`)
}
} finally {
await stop()
}
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
/**
* Every kind of line a service writes rather than somebody saying it, beside ordinary chat and a moderation
* card, in each layout: they must all be the same sort of card. Messages are injected; nothing is contacted.
*
* node scripts/shot-notification-cards.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
try {
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1000, height: 760, deviceScaleFactor: 2, mobile: false })
await ev(`(() => {
const s = window.__mohoShots.store
const acct = { id: 'irc:test', service: 'irc', name: 'test', host: 'irc.example', port: 6667, state: 'connected', hasPassword: false, nick: 'me' }
const b = { id: 'irc:test|#room', accountId: 'irc:test', kind: 'channel', name: '#room', lastActivityTs: 5, groupId: 'account:irc:test' }
const t = Math.floor(Date.now() / 1000) - 600
const m = (i, kind, from, body, extra = {}) => ({ id: 'm' + i, bufferId: b.id, from, body, ts: t + i * 20, kind, isAction: false, isHighlight: false, isOwn: false, edited: false, ...extra })
const messages = [
m(1, 'join', 'alice', 'alice joined #room'),
m(2, 'chat', 'alice', 'hello everyone'),
m(3, 'topic', 'bob', 'bob changed the topic to: Release day'),
m(4, 'mode', 'ChanServ', 'ChanServ sets mode +o alice'),
m(5, 'kick', 'bob', 'bob kicked carol (spamming)'),
m(6, 'part', 'carol', 'carol left #room'),
m(7, 'system', 'moderation', 'This room is now in slow mode.'),
m(8, 'moderation', 'room', 'Slow mode is on: one message every 10 seconds', { embeds: [{ kind: 'moderation', author: 'Slow mode', title: 'is on: one message every 10 seconds', icon: 'timer' }] }),
m(9, 'chat', 'bob', 'back to normal chat')
]
s.set({ accounts: [acct], groups: [{ id: 'account:irc:test', accountId: 'irc:test', service: 'irc', kind: 'account', name: 'test', position: 0 }], buffers: [b], messagesByBuffer: { [b.id]: messages }, activeBufferId: b.id })
s.selectGroup('account:irc:test')
})()`)
await sleep(1200)
for (const mode of ['comfy', 'classic', 'bubbles']) {
await ev(`window.moho.rpc && 0; window.__mohoShots.store.setPref && window.__mohoShots.store.setPref('ui.messageLayout', ${JSON.stringify(mode)})`).catch(() => {})
await sleep(500)
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, `notification-cards-${mode}.png`), Buffer.from(data, 'base64'))
}
console.log('cards', await ev(`document.querySelectorAll('.kick-event.notification').length`), 'moderation', await ev(`document.querySelectorAll('.kick-event.moderation').length`))
} finally {
await stop()
}
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env node
/**
* Sneedchat lines that start with ">" in a scratch moho: the site's own coloured echo and a plain one must both
* draw as 4chan's greentext (#789922) and not as a quote block. Messages are injected; nothing is contacted.
*
* node scripts/shot-sneedchat-greentext.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
try {
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 900, height: 600, deviceScaleFactor: 2, mobile: false })
await ev(`(() => {
const s = window.__mohoShots.store
const acct = { id: 'sneedchat:me', service: 'sneedchat', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, nick: 'me' }
const b = { id: 'sneedchat:me|#room', accountId: 'sneedchat:me', kind: 'channel', name: '#room', lastActivityTs: 5, groupId: 'account:sneedchat:me' }
const t = Math.floor(Date.now() / 1000) - 300
const m = (i, from, body) => ({ id: 'm' + i, bufferId: b.id, from, body, ts: t + i * 20, kind: 'chat', isAction: false, isHighlight: false, isOwn: false, edited: false })
const messages = [
m(1, 'alice', 'an ordinary line of chat'),
m(2, 'bob', '[color=#72ff72]>archlinuxwoman'),
m(3, 'carol', '>be me\\nwalk into a room\\nnobody notices'),
m(4, 'bob', 'back to normal')
]
s.set({ accounts: [acct], groups: [{ id: 'account:sneedchat:me', accountId: 'sneedchat:me', service: 'sneedchat', kind: 'account', name: 'me', position: 0 }], buffers: [b], messagesByBuffer: { [b.id]: messages }, activeBufferId: b.id })
s.selectGroup('account:sneedchat:me')
})()`)
await sleep(1200)
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, 'sneedchat-greentext.png'), Buffer.from(data, 'base64'))
console.log('green lines', await ev(`[...document.querySelectorAll('.greentext')].map((e) => e.textContent + ' => ' + getComputedStyle(e).color)`))
console.log('quote blocks', await ev(`document.querySelectorAll('.quote-block').length`))
} finally {
await stop()
}
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env node
/**
* Reads a busy live Twitch channel in a scratch moho with no sign-in, and checks what the window shows:
* that the channel joins, messages arrive with their colours, badges and emote pictures, replies carry
* their quote, the room's modes are in the header and the chatters are counted.
*
* node scripts/shot-twitch-chat.mjs <output directory> [channel] [seconds]
*/
import fs from 'node:fs'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
const channel = process.argv[3] ?? 'xqc'
const seconds = Number(process.argv[4] ?? 40)
fs.mkdirSync(out, { recursive: true })
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)
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: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
// HISTORY=1 turns on the third-party recent messages, which is off until a person says so.
if (process.env.HISTORY) await ev(`window.moho.prefs.set('twitch.recentHistory', true)`)
const added = await rpc('addTwitchAccount', {})
say('account', JSON.stringify(added))
await sleep(2500)
say('accounts', await ev(`JSON.stringify(window.__mohoShots.state().accounts.map((a) => ({ id: a.id, service: a.service, state: a.state, name: a.displayName, hasPassword: a.hasPassword })))`))
say('join', JSON.stringify(await rpc('joinBuffer', { accountId: added.accountId, name: channel })))
const id = `${added.accountId}|${channel}`
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
say('a channel that does not exist', JSON.stringify(await rpc('joinBuffer', { accountId: added.accountId, name: 'zzzz_no_such_channel_zzzz' })))
if (process.env.HISTORY) {
await sleep(6000)
say('rows six seconds after opening, with the past', await ev(`(window.__mohoShots.state().messagesByBuffer[${JSON.stringify(id)}] || []).length`))
}
await sleep(seconds * 1000)
say('buffers', await ev(`JSON.stringify(window.__mohoShots.state().buffers.filter((b) => b.accountId === ${JSON.stringify(added.accountId)}).map((b) => ({ id: b.id, modes: b.channelModes })))`))
say('page', await ev(`JSON.stringify({
rows: document.querySelectorAll('.message-row').length,
emotes: document.querySelectorAll('.message-row img.custom-emoji').length,
emotesLoaded: [...document.querySelectorAll('.message-row img.custom-emoji')].filter((i) => i.complete && i.naturalWidth > 0).length,
badges: [...new Set([...document.querySelectorAll('.message-row .sender-badge')].map((b) => b.textContent))],
badgePictures: document.querySelectorAll('.message-row .sender-badge-image').length,
badgePicturesLoaded: [...document.querySelectorAll('.message-row .sender-badge-image')].filter((i) => i.complete && i.naturalWidth > 0).length,
badgeTitles: [...new Set([...document.querySelectorAll('.message-row .sender-badge-image')].map((i) => i.title))].slice(0, 6),
emoteSources: [...new Set([...document.querySelectorAll('.message-row img.custom-emoji')].map((i) => new URL(i.src).hostname))],
replies: document.querySelectorAll('.message-row .reply-preview').length,
header: document.querySelector('.nicklist-header span')?.textContent,
coloured: document.querySelectorAll('.message-row .message-from[style*="color"]').length
})`))
await shot('twitch-chat.png')
async function shot(name) {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
}
} finally {
await stop()
}
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env node
/**
* Opens the Accounts pane in a scratch moho and starts a Twitch sign-in: checks that one window of twitch.tv's
* login opens (nothing is typed into it), then closes that window as a person giving up would, and checks the
* form comes back with nothing saved. No account is signed in.
*
* node scripts/shot-twitch-signin.mjs <output directory>
*/
import fs from 'node:fs'
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 { page, stop, port } = 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'))
}
const clickText = (selector, text) => ev(`(() => { const b = [...document.querySelectorAll(${JSON.stringify(selector)})].find((x) => x.textContent.trim().startsWith(${JSON.stringify(text)})); if (!b) return false; b.click(); return true })()`)
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: 1100, height: 800, deviceScaleFactor: 1, mobile: false })
await ev(`window.__mohoShots.store.setActivePanel('accounts')`)
await sleep(600)
say('twitch chip', await clickText('.service-chip', 'Twitch'))
await sleep(500)
await shot('twitch-signin-form.png')
say('sign in', await clickText('button', 'Sign in with Twitch'))
// The sign-in window is another page of the same app, so the debugging port lists it.
const targets = async () => (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()).filter((t) => t.type === 'page' && /twitch\.tv/.test(t.url))
let found = []
for (let i = 0; i < 40 && found.length === 0; i++) {
await sleep(500)
found = await targets()
}
say('sign-in window', JSON.stringify(found.map((t) => t.url)))
say('waiting text', await ev(`document.querySelector('.add-form')?.textContent.slice(0, 120)`))
await shot('twitch-signin-waiting.png')
// Giving up, as a person does by closing it.
for (const t of found) await fetch(`http://127.0.0.1:${port}/json/close/${t.id}`)
await sleep(2000)
say('window gone', (await targets()).length === 0)
say('back to the form', await ev(`!!([...document.querySelectorAll('button')].find((b) => b.textContent.trim() === 'Sign in with Twitch'))`))
say('accounts saved', await ev(`window.__mohoShots.state().accounts.filter((a) => a.service === 'twitch').length`))
} finally {
await stop()
}
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env node
/**
* A Twitch channel and a whisper conversation side by side in the channel list, to see that they can be told
* apart. Injects the two buffers; nothing is contacted.
*
* node scripts/shot-twitch-whisper.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
try {
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1100, height: 700, deviceScaleFactor: 2, mobile: false })
await ev(`(() => {
const s = window.__mohoShots.store
const acct = { id: 'twitch:me', service: 'twitch', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, nick: 'me' }
const pic = 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#7b5cd6"/><circle cx="32" cy="26" r="12" fill="#fff"/><rect x="14" y="42" width="36" height="16" rx="8" fill="#fff"/></svg>')
const buffers = [
{ id: 'twitch:me|skitzy_va', accountId: 'twitch:me', kind: 'channel', name: 'skitzy_va', lastActivityTs: 5, avatarUrl: pic, category: 'Channels', groupId: 'account:twitch:me' },
{ id: 'twitch:me|kotbarsik_twt', accountId: 'twitch:me', kind: 'channel', name: 'kotbarsik_twt', lastActivityTs: 4, avatarUrl: pic, category: 'Channels', groupId: 'account:twitch:me' },
{ id: 'twitch:me|@anon7of10', accountId: 'twitch:me', kind: 'dm', name: '@anon7of10', lastActivityTs: 6, avatarUrl: pic, category: 'Direct Messages', groupId: 'account:twitch:me' }
]
s.set({ accounts: [acct], groups: [{ id: 'account:twitch:me', accountId: 'twitch:me', service: 'twitch', kind: 'account', name: 'me', position: 0 }], buffers, activeBufferId: 'twitch:me|skitzy_va' })
s.selectGroup('account:twitch:me')
})()`)
await sleep(800)
await sleep(400)
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, 'twitch-whisper-vs-channel.png'), Buffer.from(data, 'base64'))
console.log('rows', await ev(`[...document.querySelectorAll('.buffer-row')].map((r) => r.innerText.replace(/\\n/g, ' ') + (r.querySelector('.avatar-badge') ? ' [badge]' : ''))`))
} finally {
await stop()
}
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env node
/**
* Reads a busy live Kick channel in a scratch moho and reports how the chatters count grows against the
* names listed, and whether drawing a long list holds the window up.
*
* node scripts/watch-kick-roster.mjs [channel] [seconds]
*/
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const channel = process.argv[2] ?? 'spreen'
const seconds = Number(process.argv[3] ?? 150)
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
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)
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
const added = await rpc('addKickAccount', {})
await rpc('joinBuffer', { accountId: added.accountId, name: channel })
const id = `${added.accountId}|${channel}`
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
await ev(`(() => { window.__long = 0; window.__longMs = 0; new PerformanceObserver((l) => { for (const e of l.getEntries()) { window.__long++; window.__longMs += e.duration } }).observe({ entryTypes: ['longtask'] }) })()`)
const began = Date.now()
while (Date.now() - began < seconds * 1000) {
await sleep(15000)
console.log(await ev(`JSON.stringify({ header: document.querySelector('.nicklist-header span')?.textContent, rows: document.querySelectorAll('.nicklist .member-row, .nicklist-scroll > div > div:not(.nicklist-group)').length, state: (() => { const s = window.__mohoShots.state(); return { listed: (s.presenceByBuffer[${JSON.stringify(id)}] || []).length, total: s.presenceTotalByBuffer[${JSON.stringify(id)}] } })(), longTasks: window.__long, longMs: Math.round(window.__longMs) })`))
}
} finally {
await stop()
}
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env node
/**
* Watches a live Kick stream in a scratch moho and reports every time the picture waits for data: when,
* for how long, and how much was buffered ahead of the playhead just before.
*
* node scripts/watch-kick-stalls.mjs [channel] [seconds]
*/
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const channel = process.argv[2] ?? 'spreen'
const seconds = Number(process.argv[3] ?? 90)
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
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)
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
const added = await rpc('addKickAccount', {})
await rpc('joinBuffer', { accountId: added.accountId, name: channel })
const id = `${added.accountId}|${channel}`
for (let i = 0; i < 40; i++) {
await sleep(1000)
if (await ev(`!!window.__mohoShots.state().kickStreams[${JSON.stringify(id)}]?.playbackUrl`)) break
}
await ev(`(async () => { await window.__mohoShots.store.selectBuffer(${JSON.stringify(id)}); window.__mohoShots.store.watchStream(${JSON.stringify(id)}) })()`)
for (let i = 0; i < 40; i++) {
if (await ev(`!!document.querySelector('video.video-tile-picture')`)) break
await sleep(500)
}
await ev(`(() => {
const v = document.querySelector('video.video-tile-picture')
const log = (window.__stalls = { events: [], samples: [], t0: performance.now() })
const at = () => +((performance.now() - log.t0) / 1000).toFixed(1)
const ahead = () => { const b = v.buffered; return b.length ? +(b.end(b.length - 1) - v.currentTime).toFixed(2) : 0 }
for (const n of ['waiting', 'stalled', 'playing', 'seeking', 'seeked', 'ratechange']) v.addEventListener(n, () => log.events.push({ t: at(), n, ahead: ahead(), rate: v.playbackRate, ct: +v.currentTime.toFixed(2) }))
setInterval(() => log.samples.push({ t: at(), ahead: ahead(), ct: +v.currentTime.toFixed(2), rate: v.playbackRate }), 500)
})()`)
await sleep(seconds * 1000)
const r = JSON.parse(await ev('JSON.stringify(window.__stalls)'))
const waits = r.events.filter((e) => e.n === 'waiting')
console.log(`${waits.length} waiting events in ${seconds}s`)
for (const e of r.events) console.log(JSON.stringify(e))
const a = r.samples.map((s) => s.ahead)
console.log('buffer ahead: min', Math.min(...a), 'max', Math.max(...a), 'mean', +(a.reduce((x, y) => x + y, 0) / a.length).toFixed(2))
console.log('rates seen', [...new Set(r.samples.map((s) => s.rate))])
console.log('quality', await ev(`JSON.stringify((() => { const q = document.querySelector('video.video-tile-picture').getVideoPlaybackQuality(); return { dropped: q.droppedVideoFrames, total: q.totalVideoFrames } })())`))
} finally {
await stop()
}
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env node
/**
* Watches a live stream of either service in a scratch moho and reports how it plays: every time the
* picture waits for data, how much was buffered ahead of the playhead, how far behind the stream's own
* clock it runs, what rendition it settled on, and whether an ad break was marked.
*
* node scripts/watch-stream-stalls.mjs <kick|twitch> <channel> [seconds]
*/
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const service = process.argv[2] ?? 'twitch'
const channel = process.argv[3] ?? 'xqc'
const seconds = Number(process.argv[4] ?? 90)
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
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)
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
const added = await rpc(service === 'twitch' ? 'addTwitchAccount' : 'addKickAccount', {})
const accountId = added.accountId
console.log('join', JSON.stringify(await rpc('joinBuffer', { accountId, name: channel })))
const id = `${accountId}|${channel}`
for (let i = 0; i < 60; i++) {
await sleep(1000)
if (await ev(`!!window.__mohoShots.state().kickStreams[${JSON.stringify(id)}]?.playbackUrl`)) break
}
const stream = JSON.parse(await ev(`JSON.stringify((({ live, title, category, viewers, playbackExpires, adFree, playbackError }) => ({ live, title, category, viewers, playbackExpires, adFree, playbackError }))(window.__mohoShots.state().kickStreams[${JSON.stringify(id)}] || {}))`))
console.log('stream', JSON.stringify(stream))
if (!stream.live) { console.log('not live; nothing to watch'); process.exit(0) }
await ev(`(async () => { await window.__mohoShots.store.selectBuffer(${JSON.stringify(id)}); window.__mohoShots.store.watchStream(${JSON.stringify(id)}) })()`)
for (let i = 0; i < 40; i++) {
if (await ev(`!!document.querySelector('video.video-tile-picture') && window.__mohoHls?.levels?.length > 0`)) break
await sleep(500)
}
// The stream's own clock against ours, from the playlist's session data.
const offset = await ev(`(async () => { const u = window.__mohoShots.state().kickStreams[${JSON.stringify(id)}].playbackUrl; const t0 = Date.now(); const r = await fetch(u); const m = /SERVER-TIME",VALUE="([0-9.]+)"/.exec(await r.text()); return m ? Number(m[1]) * 1000 - (t0 + Date.now()) / 2 : null })().catch(() => null)`)
console.log('clock offset ms', offset)
await ev(`(() => {
const v = document.querySelector('video.video-tile-picture')
const log = (window.__stalls = { events: [], samples: [], t0: performance.now() })
const at = () => +((performance.now() - log.t0) / 1000).toFixed(1)
const ahead = () => { const b = v.buffered; return b.length ? +(b.end(b.length - 1) - v.currentTime).toFixed(2) : 0 }
for (const n of ['waiting', 'stalled', 'seeking', 'ratechange']) v.addEventListener(n, () => log.events.push({ t: at(), n, ahead: ahead(), ct: +v.currentTime.toFixed(2) }))
setInterval(() => {
const h = window.__mohoHls
log.samples.push({ t: at(), ahead: ahead(), latency: h?.playingDate && ${offset === null ? 'null' : offset} !== null ? +((Date.now() + ${offset ?? 0} - h.playingDate.getTime()) / 1000).toFixed(1) : null, level: h?.currentLevel, height: v.videoHeight, ad: !!document.querySelector('.stream-ad') })
}, 500)
})()`)
if (process.env.SHOT_DIR) {
// Photographs the stage, then its resolution menu, part-way through.
const fs = await import('node:fs')
const shot = async (name) => {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(`${process.env.SHOT_DIR}/${name}`, Buffer.from(data, 'base64'))
}
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
await sleep(Math.min(20, seconds / 2) * 1000)
await shot(`${service}-stream.png`)
await ev(`document.querySelector('button[title^="Resolution"]')?.click()`)
await sleep(500)
console.log('quality choices', await ev(`JSON.stringify([...document.querySelectorAll('.stream-menu .context-menu-item')].map((b) => b.textContent.trim()))`))
await shot(`${service}-stream-quality.png`)
await ev(`document.querySelector('.stream-menu-scrim')?.click()`)
await sleep((seconds - Math.min(20, seconds / 2)) * 1000 - 500)
} else {
await sleep(seconds * 1000)
}
const r = JSON.parse(await ev('JSON.stringify(window.__stalls)'))
const waits = r.events.filter((e) => e.n === 'waiting' && e.t > 6)
console.log(`${waits.length} waiting events after the first six seconds, over ${seconds}s`)
for (const e of r.events.filter((e) => e.t > 6)) console.log(JSON.stringify(e))
const a = r.samples.map((s) => s.ahead)
const lat = r.samples.map((s) => s.latency).filter((x) => x !== null)
console.log('buffer ahead: min', Math.min(...a), 'max', Math.max(...a), 'mean', +(a.reduce((x, y) => x + y, 0) / a.length).toFixed(2))
if (lat.length) console.log('behind the stream: min', Math.min(...lat), 'max', Math.max(...lat), 'mean', +(lat.reduce((x, y) => x + y, 0) / lat.length).toFixed(1))
console.log('heights', [...new Set(r.samples.map((s) => s.height))], 'ad breaks seen', r.samples.some((s) => s.ad))
console.log('quality', await ev(`JSON.stringify((() => { const q = document.querySelector('video.video-tile-picture').getVideoPlaybackQuality(); return { dropped: q.droppedVideoFrames, total: q.totalVideoFrames } })())`))
} finally {
await stop()
}
+78 -5
View File
@@ -30,7 +30,16 @@ export type Capture =
/** A request header the page starts sending - Discord's Authorization. */
| { kind: 'header'; name: string }
/** A cookie the service sets - the usual shape for forum software. */
| { kind: 'cookie'; name: string; url: string }
| {
kind: 'cookie'
name: string
url: string
/**
* Where the same value is to be had if the named cookie is not there: another cookie holding JSON with
* the value under a key. Twitch's page-readable `twilight-user` carries the token its `auth-token` cookie does.
*/
fallback?: { name: string; key: string }
}
/** A parameter on a redirect the service lands on - SSO, mostly. */
| { kind: 'query'; param: string }
/**
@@ -126,6 +135,17 @@ export const LOGIN_FLOWS: Record<string, LoginFlow> = {
"(document.querySelector('.p-navgroup-user-linkText') || document.querySelector('.p-navgroup-link--user .p-navgroup-linkText'))?.textContent?.trim() || null"
}
},
twitch: {
label: 'Twitch',
url: 'https://www.twitch.tv/login',
// Nothing: the credential is a cookie the site sets, not a request to read.
watch: [],
// The website's own session token, which is what Twitch's player calls accept for playing a stream as the
// person who signed in - their Turbo, their subscriptions - and which a token from moho's own sign-in
// (made for chat and the official API) is not. It goes straight to the daemon, for the account named.
capture: { kind: 'cookie', name: 'auth-token', url: 'https://www.twitch.tv', fallback: { name: 'twilight-user', key: 'authToken' } },
finish: { method: 'setTwitchWebToken', param: 'token' }
},
kick: {
label: 'Kick',
url: 'https://kick.com/',
@@ -165,7 +185,20 @@ let openWindow: BrowserWindow | null = null
* be signing in, and the second would inherit the first's half-finished
* session.
*/
export async function browserLogin(service: string): Promise<LoginOutcome> {
export async function browserLogin(
service: string,
options?: {
/**
* Keeps the window open once the credential is captured, for a flow that goes on in it: called with the
* credential and the window, and the window closes when what it returns settles. Twitch's: signing in on
* twitch.tv and then, in the same window and the same session, approving the code that gives the chat
* connection its token.
*/
hold?: (value: string, win: BrowserWindow) => Promise<void>
/** Said at each step, without any credential in it, for the log. */
trace?: (message: string) => void
}
): Promise<LoginOutcome> {
const flow = LOGIN_FLOWS[service]
if (!flow) return { ok: false, error: `No browser sign-in is defined for ${service}` }
if (openWindow && !openWindow.isDestroyed()) {
@@ -219,6 +252,17 @@ export async function browserLogin(service: string): Promise<LoginOutcome> {
e.preventDefault()
retitle()
})
if (options?.trace) {
const trace = options.trace
win.webContents.on('did-navigate', (_e, url) => {
try {
const u = new URL(url)
trace(`the window is at ${u.host}${u.pathname}`)
} catch {
/* not a URL */
}
})
}
win.webContents.on('did-navigate', retitle)
win.webContents.on('did-navigate-in-page', retitle)
retitle()
@@ -244,7 +288,19 @@ export async function browserLogin(service: string): Promise<LoginOutcome> {
TIMEOUT_MS
)
let holding = false
watch(ses, win, flow, (value) => {
if (options?.hold) {
// The credential shows up more than once as cookies change; the flow is told once.
if (holding) return
holding = true
options.trace?.('signed in on twitch.tv; the session was found')
void options
.hold(value, win)
.then(() => finish({ ok: true, value }))
.catch((e: Error) => finish({ ok: false, error: e.message }))
return
}
// Read before finishing, because finishing destroys the window - and
// best-effort, because this is a nicety and the credential is not.
const also = flow.alsoRead
@@ -320,14 +376,31 @@ function watch(ses: Session, win: BrowserWindow, flow: LoginFlow, found: (value:
}
if (flow.capture.kind === 'cookie') {
const { name, url } = flow.capture
const { name, url, fallback } = flow.capture
const check = (): void => {
void ses.cookies.get({ url, name }).then((cookies) => {
const value = cookies[0]?.value
void ses.cookies.get({ url }).then((cookies) => {
let value = cookies.find((c) => c.name === name)?.value
if (!value && fallback) {
const holder = cookies.find((c) => c.name === fallback.name)?.value
if (holder) {
try {
const parsed = JSON.parse(decodeURIComponent(holder)) as Record<string, unknown>
const token = parsed[fallback.key]
if (typeof token === 'string') value = token
} catch {
/* not JSON yet; the next change will be */
}
}
}
if (value) found(value)
})
}
ses.cookies.on('changed', check)
// Also after each page, in case the sign-in ended in a redirect that the cookie jar's own events did not announce.
if (!win.isDestroyed()) {
win.webContents.on('did-navigate', check)
win.webContents.on('did-finish-load', check)
}
check()
return
}
+78 -4
View File
@@ -29,6 +29,8 @@ import { Notifier } from './notifications'
import { IdleWatcher } from './idle'
import { NOTHING_WAITING, trayIconFile, trayMenu, trayTooltip, type TrayState, type TrayStatus } from './tray'
import { browserLogin, LOGIN_FLOWS } from './browser-login'
import { feedTwitchSignIn, twitchSignIn } from './twitch-signin'
import { answerIntegrity, mintIntegrity } from './twitch-integrity'
import { EDIT_ACTIONS, IPC, POPOUT_FLAG, UI_SHOTS_FLAG, type EditAction, type EditMenuRequest, type PopoutState } from '../shared/ipc'
import { clearnetLinks } from '../shared/clearnet'
import { readCapped, pictureNamedIn } from './imagepage'
@@ -354,7 +356,8 @@ function wireEditMenu(win: BrowserWindow): void {
delete: f.canDelete,
selectAll: f.canSelectAll
},
hasSelection: params.selectionText.length > 0
hasSelection: params.selectionText.length > 0,
selection: params.selectionText.slice(0, 1000)
} satisfies EditMenuRequest)
})
}
@@ -617,6 +620,28 @@ function toggleWindow(): void {
}
}
/**
* What a left click on the tray icon does: put the window away if it is showing, bring it back if it is not.
*
* By whether it is showing, and not by whether it has focus. Clicking the tray takes the focus off the window first
* on most desktops, so asking whether it is focused always said no, and a click could only ever show the window -
* never put it away. The hotkey keeps the focus test (see `toggleWindow`): pressed while typing somewhere else it
* means "bring moho here", not "hide it".
*/
function toggleFromTray(): void {
if (!mainWindow) {
createWindow()
return
}
if (mainWindow.isVisible() && !mainWindow.isMinimized()) {
mainWindow.hide()
return
}
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.show()
mainWindow.focus()
}
function trayIcon(unread: number): Electron.NativeImage {
lastTrayIcon = trayIconFile(nativeTheme.shouldUseDarkColors, unread)
const img = nativeImage.createFromPath(resourcePath('icons', lastTrayIcon))
@@ -698,7 +723,7 @@ function createTray(): void {
tray = new Tray(trayIcon(0))
tray.setToolTip('moho')
refreshTrayMenu()
tray.on('click', toggleWindow)
tray.on('click', toggleFromTray)
// The icon is drawn for the panel it sits on, so it is drawn again when the
// system changes colour scheme.
nativeTheme.on('updated', () => updateTray(lastTray))
@@ -891,6 +916,38 @@ function wireIpc(): void {
if (/^(https?|mailto):/i.test(url)) shell.openExternal(url)
})
// An article, drawn from a document the daemon built (see nobilis's backend/telegram/instant.rs). It gets a window
// of its own that can do nothing but show it: no script, no preload, no way to navigate away - a link opens in the
// browser - and the only pictures it may draw are the daemon's cached ones, through moho-media.
ipcMain.handle(IPC.openArticle, (_e, title: unknown, html: unknown) => {
if (typeof html !== 'string' || html.length > 8_000_000) return
const win = new BrowserWindow({
width: 900,
height: 800,
minWidth: 360,
minHeight: 300,
autoHideMenuBar: true,
backgroundColor: '#0e1621',
icon: resourcePath('icons', 'moho.png'),
title: typeof title === 'string' && title ? title : 'Article',
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, javascript: false }
})
const external = (url: string): void => {
if (/^(https?|mailto):/i.test(url)) shell.openExternal(url)
}
win.webContents.setWindowOpenHandler(({ url }) => {
external(url)
return { action: 'deny' }
})
win.webContents.on('will-navigate', (event, url) => {
// A place in the same page is fine; anything else is somewhere else.
if (url.startsWith('data:') && url.includes('#')) return
event.preventDefault()
external(url)
})
void win.loadURL(`data:text/html;charset=utf-8;base64,${Buffer.from(html, 'utf8').toString('base64')}`)
})
// Where to write something the app is about to produce - a key export, so
// far. Separate from pickFile because the two dialogs ask opposite
// questions, and a save dialog that cannot name a default file is a save
@@ -1110,6 +1167,13 @@ function wireIpc(): void {
}
})
// Twitch's sign-in, both halves in one window (see twitch-signin.ts). Only whether it worked comes back.
ipcMain.handle(IPC.twitchSignIn, (_e, options?: { useTor?: boolean }) =>
twitchSignIn((method, params) => client.request(method, params), {
useTor: typeof options?.useTor === 'boolean' ? options.useTor : undefined
})
)
ipcMain.handle(IPC.smiliesDir, () => smiliesPath())
// Chromium's HTTP cache, which Settings reports beside the daemon's.
@@ -1143,7 +1207,7 @@ app.whenReady().then(() => {
applyPolicy(contents)
contents.on('console-message', (event) => {
const message = (event as unknown as { message?: string }).message
if (typeof message === 'string' && message.startsWith('[call] ')) log.info(message)
if (typeof message === 'string' && (message.startsWith('[call] ') || message.startsWith('[restore] '))) log.info(message)
})
})
@@ -1179,7 +1243,9 @@ app.whenReady().then(() => {
// Granted only to this application's own windows, on the default session.
// A sign-in window loads somebody else's page and runs in a partition of
// its own, which this handler never sees.
const allowed = new Set(['media', 'display-capture', 'audioCapture', 'videoCapture'])
// And full screen, which a page asks for with requestFullscreen: a handler that does not name it refuses it, and the
// video player's button then did nothing.
const allowed = new Set(['media', 'display-capture', 'audioCapture', 'videoCapture', 'fullscreen'])
session.defaultSession.setPermissionRequestHandler((_contents, permission, callback) => {
callback(allowed.has(permission))
})
@@ -1320,6 +1386,13 @@ app.whenReady().then(() => {
} else if (frame.event === 'netSettings') {
void applyTunnel((method, params) => client.request(method, params))
resetRoute()
} else if (frame.event.startsWith('twitchLogin')) {
feedTwitchSignIn(frame as { event: string; data?: Record<string, unknown> })
} else if (frame.event === 'twitchIntegrityNeeded') {
// Twitch wants proof that a browser is asking for a stream; only a page of its own can make it.
void answerIntegrity(frame.data as { requestId?: string; deviceId?: string; authToken?: string }, (method, params) => client.request(method, params))
// Not forwarded: it carries the session, which only main and the daemon are to hold.
return
}
send(IPC.event, frame)
})
@@ -1334,6 +1407,7 @@ app.whenReady().then(() => {
// For the UI harness, which has no way to look inside a tray: what it shows
// and offers, and the entries themselves to be clicked. Only when it asks.
if (process.env.MOHO_UI_SHOTS === '1') {
;(globalThis as Record<string, unknown>).__mohoTwitchIntegrity = { mint: mintIntegrity }
;(globalThis as Record<string, unknown>).__mohoTray = {
update: updateTray,
idle: (v: boolean | null) => idleWatcher.forceForTesting(v),
+4
View File
@@ -122,6 +122,10 @@ const DAEMON_MEDIA_DIRS = [
'kick-emotes',
'discord-stickers',
'discord-sounds',
'telegram-media',
'telegram-avatars',
'stoat-media',
'stoat-avatars',
'transient'
]
+81
View File
@@ -0,0 +1,81 @@
import { BrowserWindow, session } from 'electron'
import { routeSession } from './tunnel'
/**
* An integrity token for Twitch's GraphQL, which only a real page on twitch.tv can make.
*
* Twitch asks, now and then, for proof that the one asking for a stream is a browser and not a program: a
* token its own page computes with bot-detection script that nobody else can run. Not asked for today, and
* for a week in May 2023 it was. When it is asked for again the daemon cannot make one, so it says so (the
* `twitchIntegrityNeeded` event) and this makes it: a window nobody sees, on a throwaway session, that loads
* a twitch.tv page, waits for that script to be ready, and calls the same endpoint the page does. The token
* comes back valid for about sixteen hours and is for the device id the daemon names.
*
* Nothing is signed in to, nothing is kept (the session is memory-only and wiped), and it goes through the
* route like every other window does.
*/
/** The Client ID twitch.tv's own pages send; the integrity endpoint answers only to it. */
const SITE_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'
export interface Integrity {
token: string
/** When it stops being good, in Unix milliseconds. */
expiration: number
}
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
export async function mintIntegrity(deviceId: string, authToken = '', timeoutMs = 45_000): Promise<Integrity> {
const partition = `moho-twitch-integrity-${Date.now()}`
const ses = session.fromPartition(partition)
await routeSession(ses)
const win = new BrowserWindow({
show: false,
width: 900,
height: 700,
webPreferences: { partition, contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true }
})
try {
// A page with Twitch's own scripts on it: the bot check is one of them, and it patches fetch.
await win.loadURL('https://www.twitch.tv/directory')
const script = `(async () => {
const r = await fetch('https://gql.twitch.tv/integrity', { method: 'POST', headers: { 'Client-ID': ${JSON.stringify(SITE_CLIENT_ID)}, 'X-Device-Id': ${JSON.stringify(deviceId)}${authToken ? `, Authorization: ${JSON.stringify('OAuth ' + authToken)}` : ''} }, credentials: 'omit' })
if (r.status !== 200) throw new Error('Twitch answered ' + r.status)
const j = await r.json()
if (!j.token) throw new Error('Twitch sent no token')
return { token: j.token, expiration: j.expiration }
})()`
const deadline = Date.now() + timeoutMs
let last = ''
// The script that makes the call valid loads after the page does, so the first tries may be refused.
while (Date.now() < deadline) {
try {
const made = (await win.webContents.executeJavaScript(script, true)) as Integrity
if (made?.token) return made
} catch (e) {
last = (e as Error).message
}
await sleep(2000)
}
throw new Error(last || 'the page never became ready')
} finally {
if (!win.isDestroyed()) win.destroy()
void ses.clearStorageData()
}
}
/** Answers the daemon's request: makes the token and hands it over, or says why it could not. */
export async function answerIntegrity(
data: { requestId?: string; deviceId?: string; authToken?: string },
request: (method: string, params: Record<string, unknown>) => Promise<unknown>
): Promise<void> {
if (!data.requestId || !data.deviceId) return
try {
// As the account when it names one: Twitch takes for an account's call only a token made as that account.
const made = await mintIntegrity(data.deviceId, data.authToken ?? '')
await request('provideTwitchIntegrity', { requestId: data.requestId, token: made.token, expiration: made.expiration })
} catch (e) {
await request('provideTwitchIntegrity', { requestId: data.requestId, error: (e as Error).message }).catch(() => {})
}
}
+64
View File
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// The window itself is Electron's; what is under test is the order of things around it.
const login = vi.hoisted(() => ({ browserLogin: vi.fn() }))
vi.mock('./browser-login', () => login)
vi.mock('./log', () => ({ log: { info: () => undefined } }))
import { feedTwitchSignIn, twitchSignIn } from './twitch-signin'
describe('Twitch sign-in as one action', () => {
beforeEach(() => {
login.browserLogin.mockReset()
})
it('hands the session to the daemon, takes the same window to the code, and finishes when the code is approved', async () => {
const calls: string[] = []
const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
calls.push(method)
if (method === 'startTwitchLogin') {
// The code is heard in the time it takes the person to sign in.
setTimeout(() => feedTwitchSignIn({ event: 'twitchLoginCode', data: { loginId: 'L1', verificationUri: 'https://www.twitch.tv/activate?device-code=ABCD' } }), 0)
return { loginId: 'L1' }
}
if (method === 'setTwitchLoginWebToken') expect(params).toEqual({ loginId: 'L1', token: 'web-session' })
return {}
})
const loaded: string[] = []
login.browserLogin.mockImplementation(async (_service: string, options: { hold: (v: string, w: unknown) => Promise<void> }) => {
const win = { loadURL: async (u: string) => void loaded.push(u), webContents: { isLoading: () => false, on: () => undefined } }
const held = options.hold('web-session', win)
// Approving it on the page.
setTimeout(() => feedTwitchSignIn({ event: 'twitchLoginResult', data: { loginId: 'L1', success: true } }), 20)
await held
return { ok: true }
})
const outcome = await twitchSignIn(request, {})
expect(outcome).toEqual({ ok: true })
expect(calls).toEqual(['startTwitchLogin', 'setTwitchLoginWebToken'])
expect(loaded).toEqual(['https://www.twitch.tv/activate?device-code=ABCD'])
expect(request).toHaveBeenCalledWith('startTwitchLogin', {})
})
it('ends the daemon\'s wait when the window is closed before the code was approved', async () => {
const calls: string[] = []
const request = vi.fn(async (method: string) => {
calls.push(method)
return method === 'startTwitchLogin' ? { loginId: 'L2' } : {}
})
login.browserLogin.mockResolvedValue({ ok: false, error: 'cancelled' })
expect(await twitchSignIn(request, {})).toEqual({ ok: false, error: 'cancelled' })
expect(calls).toEqual(['startTwitchLogin', 'cancelTwitchLogin'])
})
it('does not run two at once, and says a refused sign-in', async () => {
let release: (v: { ok: boolean }) => void = () => {}
login.browserLogin.mockImplementation(() => new Promise((r) => (release = r)))
const request = vi.fn(async () => ({ loginId: 'L3' }))
const first = twitchSignIn(request, {})
await new Promise((r) => setTimeout(r, 5))
expect(await twitchSignIn(request, {})).toEqual({ ok: false, error: 'A Twitch sign-in is already under way' })
release({ ok: true })
await first
})
})
+189
View File
@@ -0,0 +1,189 @@
import { browserLogin } from './browser-login'
import { log } from './log'
/**
* Signing in to Twitch: one action that gives the account both things it needs.
*
* Twitch has two sign-ins that cannot be had from each other. Its official API (chat, sending, follows,
* moderation) takes a token made by approving a short code on twitch.tv; its player takes only the session
* of someone signed in to twitch.tv itself. Asking a person to do those as two flows was asking them to sign
* in twice. Here it is one window: the person signs in to twitch.tv (typed there, never seen by moho), and
* the same window, with the session it now holds, is taken to the page that approves the code. Approving it
* is Twitch's own consent screen - what moho will be allowed to do is listed there - and is left to the
* person; it is the one click that is theirs to make.
*
* The session found on the way goes straight to the daemon, to be saved with the account the code becomes,
* and is never returned to the page that asked.
*/
type Request = (method: string, params: Record<string, unknown>) => Promise<unknown>
interface Waiting {
loginId: string
userCode: string
gotCode: ((uri: string) => void) | null
codePromise: Promise<string>
result: ((r: { success: boolean; error?: string }) => void) | null
resultPromise: Promise<{ success: boolean; error?: string }>
/** The code was approved or the sign-in is over: the window may go where it likes. */
finished: boolean
}
let active: Waiting | null = null
/** Daemon events, offered here by main for the sign-in that is waiting on them. */
export function feedTwitchSignIn(frame: { event: string; data?: Record<string, unknown> }): void {
if (!active || !frame.event.startsWith('twitchLogin')) return
const d = frame.data ?? {}
// The first events can be heard before the daemon's answer naming the sign-in; nothing else is signing in.
if (active.loginId && d.loginId !== active.loginId) return
if (frame.event === 'twitchLoginCode' && typeof d.verificationUri === 'string') {
if (typeof d.userCode === 'string') active.userCode = d.userCode
active.gotCode?.(d.verificationUri)
} else if (frame.event === 'twitchLoginResult') {
active.result?.({ success: d.success === true, error: typeof d.error === 'string' ? d.error : undefined })
}
}
export interface SignInOutcome {
ok: boolean
error?: string
}
export async function twitchSignIn(request: Request, options: { useTor?: boolean }): Promise<SignInOutcome> {
if (active) return { ok: false, error: 'A Twitch sign-in is already under way' }
let gotCode: (uri: string) => void = () => {}
let result: (r: { success: boolean; error?: string }) => void = () => {}
const w: Waiting = {
loginId: '',
userCode: '',
gotCode: null,
codePromise: new Promise<string>((r) => (gotCode = r)),
result: null,
finished: false,
resultPromise: new Promise((r) => (result = r))
}
w.gotCode = gotCode
w.result = result
active = w
try {
const started = (await request('startTwitchLogin', {
...(typeof options.useTor === 'boolean' ? { useTor: options.useTor } : {})
})) as { loginId: string }
w.loginId = started.loginId
const outcome = await browserLogin('twitch', {
trace: (m) => log.info(`twitch sign-in: ${m}`),
hold: async (token, win) => {
// To the daemon, for the account the code is about to become. Never anywhere else.
await request('setTwitchLoginWebToken', { loginId: w.loginId, token })
let giveUp: NodeJS.Timeout | undefined
const uri = await Promise.race([
w.codePromise,
new Promise<string>((_, no) => (giveUp = setTimeout(() => no(new Error('Twitch sent no code')), 30_000)))
]).finally(() => clearTimeout(giveUp))
// Signed in. The page is still on its way somewhere (twitch.tv sends a new login to its front page), so
// that is let finish before the same window is taken to the page that approves the code - and brought
// back to it if the site's own redirect lands after, which would leave the person on the front page.
await settled(win)
const go = (): void => {
log.info('twitch sign-in: taking the window to the approval page')
win.loadURL(uri).catch(() => undefined)
}
go()
let tries = 0
const wander = (_e: unknown, url: string): void => {
let here = ''
try {
here = new URL(url).pathname
} catch {
return
}
if (!here.startsWith('/activate') && !w.finished && tries < 4) {
tries++
log.info(`twitch sign-in: the window went to ${here}; back to the approval page (${tries})`)
setTimeout(go, 600)
}
}
// Twitch's page does not take the code from the address, so it is typed into the page's field for the
// person, and nothing more: pressing Activate and then Authorize on Twitch's own screens is theirs.
const filled = (): void => {
if (w.finished || !w.userCode || !win.webContents.getURL().includes('/activate')) return
void win.webContents
.executeJavaScript(fillCode(w.userCode), true)
.then((how: unknown) => log.info(`twitch sign-in: the code field: ${String(how)}`))
.catch(() => undefined)
}
win.webContents.on('did-finish-load', filled)
win.webContents.on('did-navigate', wander)
win.webContents.on('did-navigate-in-page', wander)
const done = await w.resultPromise
w.finished = true
if (!done.success) throw new Error(done.error || 'Twitch sign-in failed')
}
})
if (!outcome.ok) {
// The window was closed or timed out before the code was approved: nobody is waiting for it any more.
await request('cancelTwitchLogin', { loginId: w.loginId }).catch(() => undefined)
return { ok: false, error: outcome.error }
}
return { ok: true }
} catch (e) {
if (w.loginId) await request('cancelTwitchLogin', { loginId: w.loginId }).catch(() => undefined)
return { ok: false, error: (e as Error).message }
} finally {
active = null
}
}
/** Waits for the window's page to finish loading and stay quiet for a moment. */
async function settled(win: { webContents: { isLoading(): boolean } }): Promise<void> {
const quiet = 1500
let quietFor = 0
for (let waited = 0; waited < 15_000 && quietFor < quiet; waited += 250) {
await new Promise((r) => setTimeout(r, 250))
quietFor = win.webContents.isLoading() ? 0 : quietFor + 250
}
}
/**
* A script that finds the code field on Twitch's "Activate your device" page and types the code in, the way a
* person would (so the page's own scripts see it). Waits for the page, which draws it after it loads; says what it
* found. It presses the page's Activate button, which only submits the code, and nothing after it.
*/
export function fillCode(code: string): string {
return `(async (code) => {
const type = (el, v) => {
const set = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value').set
el.focus()
set.call(el, v)
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
const shown = (el) => el.getClientRects().length > 0 && !el.disabled && !el.readOnly
const deadline = Date.now() + 20000
while (Date.now() < deadline) {
const inputs = [...document.querySelectorAll('input')].filter((i) => ['text', 'tel', 'search', ''].includes(i.type) && shown(i))
const boxes = inputs.filter((i) => i.maxLength === 1)
if (boxes.length >= code.length) {
code.split('').forEach((c, i) => type(boxes[i], c))
return 'filled ' + code.length + ' boxes'
}
const named = inputs.find((i) => /code/i.test([i.name, i.id, i.placeholder, i.getAttribute('aria-label')].join(' ')))
const field = named || (inputs.length === 1 ? inputs[0] : null)
if (field) {
if (field.value !== code) type(field, code)
// Submitting the code is the page's own "Activate" button; approving what moho may do, next, is not.
const go = field.form && [...field.form.querySelectorAll('button, input[type=submit]')].find((b) => /^activate$/i.test((b.innerText || b.value || '').trim()))
if (go && !window.__mohoSubmitted) {
window.__mohoSubmitted = true
go.click()
return 'filled one field and pressed Activate'
}
return 'filled one field'
}
await new Promise((r) => setTimeout(r, 400))
}
return 'no code field found'
})(${JSON.stringify(code)})`
}
+8
View File
@@ -44,6 +44,11 @@ const api = {
return ipcRenderer.invoke(IPC.browserLogin, service, accountId, options)
},
/** Signs in to Twitch for chat and video at once, in one window. Resolves when it is done or given up. */
twitchSignIn(options: { useTor?: boolean }): Promise<{ ok: boolean; error?: string }> {
return ipcRenderer.invoke(IPC.twitchSignIn, options)
},
onEvent(cb: (frame: NobilisEvent) => void): () => void {
const handler = (_e: unknown, frame: NobilisEvent): void => cb(frame)
ipcRenderer.on(IPC.event, handler)
@@ -130,6 +135,9 @@ const api = {
openExternal: (url: string): Promise<void> => ipcRenderer.invoke(IPC.openExternal, url),
/** A finished document (a Telegram Instant View) in a window of its own. */
openArticle: (title: string, html: string): Promise<void> => ipcRenderer.invoke(IPC.openArticle, title, html),
/**
* Which picture a page is showing, read from its own OpenGraph tag.
*
+7 -3
View File
@@ -12,6 +12,7 @@ import { trayCount } from './lib/traycount'
import { Composer } from './components/Composer'
import { MembershipGate } from './components/MembershipGate'
import { NickList } from './components/NickList'
import { ColumnResizer, useColumnWidth } from './components/ColumnResizer'
import { ThreadPanel } from './components/ThreadPanel'
import { InvitePanel } from './components/InvitePanel'
import { SteamFriendDialogs } from './components/SteamFriendDialogs'
@@ -85,6 +86,9 @@ export default function App(): JSX.Element {
const [zoom] = usePref<number>('appearance.zoom', 1)
const [density] = usePref<string>('appearance.density', 'cozy')
const [reduceMotion] = usePref<boolean>('appearance.reduceMotion', false)
// The two side columns, as they were last dragged.
const [sidebarWidth, setSidebarWidth] = useColumnWidth('ui.sidebarWidth', 220)
const [nicklistWidth, setNicklistWidth] = useColumnWidth('ui.nicklistWidth', 180)
const [hourFormat] = usePref<string>('display.hourFormat', '24')
setHourFormat(hourFormat)
useEffect(() => window.moho.setZoom(zoom), [zoom])
@@ -196,7 +200,7 @@ export default function App(): JSX.Element {
: ''
return (
<div className="app">
<div className="app" style={{ '--sidebar-width': `${sidebarWidth}px`, '--nicklist-width': `${nicklistWidth}px` } as React.CSSProperties}>
<TitleBar />
<div className="app-body">
@@ -210,7 +214,7 @@ export default function App(): JSX.Element {
<div className="sidebar">
<BufferList />
</div>
<div className="divider-v" />
<ColumnResizer width={sidebarWidth} onWidth={setSidebarWidth} min={160} max={520} grows="right" label="Channel list width" />
</>
)}
@@ -308,7 +312,7 @@ export default function App(): JSX.Element {
{!openThread && showNickList && (
<>
<div className="divider-v" />
<ColumnResizer width={nicklistWidth} onWidth={setNicklistWidth} min={140} max={420} grows="left" label="Member list width" />
<div className="nicklist-pane">
<NickList />
</div>
+820 -74
View File
@@ -71,6 +71,37 @@
/* --- shell ------------------------------------------------------------ */
/* A divider between columns that can be dragged. Its line stays one pixel; the part that takes the pointer is wider. */
.divider-v.resizer {
position: relative;
cursor: col-resize;
touch-action: none;
z-index: var(--z-raised);
}
.divider-v.resizer::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: -4px;
right: -4px;
}
.divider-v.resizer:hover,
.divider-v.resizer.dragging,
.divider-v.resizer:focus-visible {
background: var(--primary);
box-shadow: 0 0 0 1px var(--primary);
outline: none;
}
/* While a column is being dragged: the cursor stays, and nothing underneath is selected. */
body.resizing-column {
cursor: col-resize;
user-select: none;
}
.app-body {
display: flex;
flex: 1;
@@ -949,6 +980,7 @@
* conversation and people type into a channel while talking in it.
*/
.call-stage {
position: relative;
display: flex;
flex-direction: column;
flex: none;
@@ -1115,6 +1147,35 @@ button.video-tile {
object-fit: cover;
}
/* The live captions of a stream, drawn by the video element itself. */
.video-tile-picture::cue {
background: rgba(0, 0, 0, 0.72);
color: #fff;
font-size: clamp(13px, 2.2vw, 20px);
line-height: 1.3;
}
/* A list over the picture, in place of a menu in the window: a stream popped out is another document. */
.stream-menu-scrim {
position: absolute;
inset: 0;
z-index: 2;
}
.context-menu.stream-menu {
position: absolute;
top: var(--spacing-s);
right: var(--spacing-s);
z-index: 3;
max-height: calc(100% - 2 * var(--spacing-s));
overflow-y: auto;
transform-origin: top right;
}
.stream-menu-title {
padding: 2px var(--spacing-s) var(--spacing-xs);
}
/* Nothing to look at: the name and a face, which is what a call without
cameras actually is. */
.video-tile-picture.audio-only {
@@ -2921,8 +2982,8 @@ img.custom-emoji.steam-sticker {
}
.membership-rules {
max-width: 480px;
max-height: 70vh;
width: min(860px, 94vw);
max-height: 80vh;
overflow-y: auto;
padding: var(--spacing-l);
border-radius: var(--corner-radius);
@@ -2931,6 +2992,69 @@ img.custom-emoji.steam-sticker {
box-shadow: var(--shadow-2);
}
/* The server on the left and its form on the right, as Discord draws it; one above the other when narrow. */
.membership-body {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: var(--spacing-xl, 24px);
align-items: start;
}
@media (max-width: 720px) {
.membership-body {
grid-template-columns: minmax(0, 1fr);
}
}
.membership-card {
display: flex;
flex-direction: column;
gap: 4px;
padding: var(--spacing-m);
border-radius: var(--corner-radius);
background: var(--surface-container);
border: 1px solid var(--outline-medium);
}
.membership-card-icon {
width: 64px;
height: 64px;
border-radius: 16px;
object-fit: cover;
}
.membership-card-name {
font-weight: var(--weight-semibold);
}
.membership-title {
margin: 0 0 4px;
font-size: var(--font-size-large);
font-weight: var(--weight-semibold);
}
.membership-field {
margin-top: var(--spacing-m);
}
.membership-field-label {
display: block;
margin-bottom: 4px;
font-weight: var(--weight-semibold);
}
.membership-rules-list {
margin: 0 0 var(--spacing-s);
padding: var(--spacing-s) var(--spacing-m) var(--spacing-s) 2em;
border-radius: var(--corner-radius-small);
background: var(--surface-container);
}
.membership-rules-list li {
padding: 6px 0;
white-space: pre-wrap;
}
.membership-rules-list li + li {
border-top: 1px solid var(--outline-medium);
}
.membership-agree {
display: flex;
align-items: center;
gap: var(--spacing-s);
margin: 4px 0;
}
.membership-rules-head {
font-size: var(--font-size-large);
font-weight: var(--weight-semibold);
@@ -3447,6 +3571,155 @@ img.smilie {
margin-top: var(--spacing-s);
}
/* A link's preview as Telegram draws it: an accent bar, the site in the accent, the title in bold, the picture, and
Instant View as a bar along the bottom. */
.link-card {
margin: var(--spacing-xxs) 0;
padding: 2px 0 0 var(--spacing-m);
border-left: 3px solid var(--primary);
max-width: 420px;
}
.link-card-site {
color: var(--primary);
font-weight: var(--weight-semibold);
font-size: 0.92em;
}
.link-card-title {
display: block;
font-weight: var(--weight-semibold);
color: inherit;
text-decoration: none;
}
.link-card-desc {
color: var(--surface-variant-text);
white-space: pre-wrap;
}
.link-card-image {
display: block;
width: 100%;
max-height: 320px;
margin-top: var(--spacing-xs);
border-radius: var(--corner-radius-small);
object-fit: cover;
cursor: pointer;
}
.link-card-instant {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
margin-top: var(--spacing-xs);
padding: 6px 0;
border: 0;
border-radius: var(--corner-radius-small);
background: var(--primary-hover);
color: var(--primary);
font-weight: var(--weight-semibold);
letter-spacing: 0.04em;
cursor: pointer;
}
.link-card-instant:hover {
background: var(--primary-selected);
}
/* The glyph for what kind of conversation it is (a horn for a broadcast channel, a robot for a bot), ahead of its picture. */
.buffer-kind-icon {
flex: none;
opacity: 0.85;
}
/* What a Telegram message answers: an accent bar, who said it, what they said, and the quote mark. */
.tg-quote {
position: relative;
display: block;
margin: 2px 0 4px;
padding: 4px 28px 4px var(--spacing-m);
border-left: 3px solid var(--primary);
border-radius: 0 var(--corner-radius-small) var(--corner-radius-small) 0;
background: var(--primary-hover-light);
max-width: 520px;
cursor: pointer;
}
.tg-quote:hover {
background: var(--primary-hover);
}
.tg-quote-from {
display: flex;
align-items: center;
gap: 4px;
color: var(--primary);
font-weight: var(--weight-semibold);
font-size: 0.92em;
}
.tg-quote-body {
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: pre-wrap;
color: var(--surface-variant-text);
}
.tg-quote-mark {
position: absolute;
top: 4px;
right: 6px;
opacity: 0.7;
color: var(--primary);
}
/* Where a forwarded Telegram message came from. */
.tg-forward {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 2px;
margin: 2px 0 2px;
padding-left: var(--spacing-m);
border-left: 3px solid var(--primary);
}
.tg-forward-source {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 0;
color: var(--primary);
font-weight: var(--weight-semibold);
text-decoration: none;
}
/* Discord's own conversation in the list: the mark beside its name and what it is underneath. */
.buffer-official-mark {
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: 6px;
padding: 1px 5px;
border-radius: 4px;
background: #5865f2;
color: #fff;
font-size: 10px;
font-weight: var(--weight-bold);
letter-spacing: 0.04em;
vertical-align: middle;
}
/* A Telegram bot's Menu button, ahead of the message box. */
.composer-menu-button {
flex: none;
margin-right: var(--spacing-xs);
padding: 4px 14px;
border: 0;
border-radius: 16px;
background: var(--primary-container);
color: #fff;
font-weight: var(--weight-semibold);
cursor: pointer;
}
.composer-menu-button:hover {
filter: brightness(1.15);
}
/* --- media embeds ------------------------------------------------------ */
.media-row {
@@ -5587,6 +5860,195 @@ video.lightbox-media {
opacity: 0;
}
/* The player: the controls are drawn over the picture and rest out of sight while it plays. */
.vp {
position: relative;
display: block;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: var(--corner-radius-small);
background: #000;
}
.vp.full,
.vp:fullscreen {
/* Full screen is the whole screen, not the shape the video had in the log (which its inline aspect ratio and the
embed's size limits would otherwise keep). */
width: 100vw !important;
height: 100vh !important;
max-width: none !important;
max-height: none !important;
aspect-ratio: auto !important;
border-radius: 0;
}
.vp.full .vp-video,
.vp:fullscreen .vp-video {
width: 100% !important;
height: 100% !important;
max-width: none !important;
max-height: none !important;
aspect-ratio: auto !important;
object-fit: contain;
}
.vp-video {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
cursor: pointer;
}
.vp-top {
position: absolute;
top: var(--spacing-xs);
right: var(--spacing-xs);
display: flex;
gap: 2px;
padding: 2px;
border-radius: var(--corner-radius-small);
background: rgba(0, 0, 0, 0.6);
opacity: 0;
transition: opacity 0.12s;
}
.vp-bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 18px 10px 6px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
opacity: 1;
transition: opacity 0.12s;
}
/* Out of the way while it plays, back when the pointer is on it or it stops. */
.vp.playing .vp-bar {
opacity: 0;
}
.vp:hover .vp-top,
.vp:hover .vp-bar,
.vp:focus-within .vp-top,
.vp:focus-within .vp-bar,
.vp-bar:has(.vp-speeds) {
opacity: 1;
}
.vp-controls {
display: flex;
align-items: center;
gap: 2px;
margin-top: 2px;
}
.vp-fill {
flex: 1;
}
.vp-button {
position: relative;
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
padding: 0 4px;
border: 0;
border-radius: var(--corner-radius-small);
background: none;
color: #fff;
cursor: pointer;
}
.vp-button:hover {
background: rgba(255, 255, 255, 0.18);
}
.vp-button:disabled {
opacity: 0.6;
cursor: default;
}
.vp-time {
flex: none;
white-space: nowrap;
padding: 0 8px;
border: 0;
background: none;
color: #fff;
font-size: 13px;
font-variant-numeric: tabular-nums;
cursor: pointer;
}
.vp-rate {
margin-left: 2px;
font-size: 11px;
}
/* The volume: the icon, and the slider that opens out beside it when the pointer is on either. */
.vp-volume {
display: flex;
align-items: center;
}
.vp-level {
width: 0;
opacity: 0;
margin: 0;
transition: width 0.15s, opacity 0.15s, margin 0.15s;
}
.vp-volume:hover .vp-level,
.vp-volume:focus-within .vp-level {
width: 70px;
margin: 0 6px 0 2px;
opacity: 1;
}
.vp-seek,
.vp-level {
-webkit-appearance: none;
appearance: none;
height: 4px;
border-radius: 2px;
background: linear-gradient(to right, #fff var(--vp-progress, 0%), rgba(255, 255, 255, 0.35) var(--vp-progress, 0%));
cursor: pointer;
}
.vp-seek {
display: block;
width: 100%;
margin: 0;
}
.vp-seek::-webkit-slider-thumb,
.vp-level::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff;
}
.vp-speed {
position: relative;
}
.vp-speeds {
position: absolute;
right: 0;
bottom: 34px;
display: flex;
flex-direction: column;
min-width: 96px;
padding: 4px;
border-radius: var(--corner-radius-small);
background: rgba(20, 20, 24, 0.96);
box-shadow: var(--shadow-2);
}
.vp-speed-option {
padding: 5px 10px;
border: 0;
border-radius: var(--corner-radius-small);
background: none;
color: #fff;
text-align: left;
cursor: pointer;
}
.vp-speed-option:hover {
background: rgba(255, 255, 255, 0.14);
}
.vp-speed-option.on {
color: var(--primary);
font-weight: var(--weight-semibold);
}
.media-embed-wrap.video:hover .video-expand {
opacity: 1;
}
@@ -5802,6 +6264,11 @@ video.lightbox-media {
overflow: hidden;
white-space: normal;
}
/* A held login is explained in full: the time it will connect is at the end. */
.account-detail.held {
-webkit-line-clamp: unset;
display: block;
}
/* --- presence on avatars ---------------------------------------------- */
@@ -6119,6 +6586,19 @@ video.lightbox-media {
letter-spacing: 0.04em;
}
/* Discord's own pill for a bot or application, in its blurple, whoever's name it sits beside. */
.sender-badge.app,
.sender-badge.verified-app,
.sender-badge.official {
margin-left: 6px;
padding: 1px 5px;
border-radius: 4px;
background: #5865f2;
color: #fff;
font-size: 10px;
line-height: 1.3;
}
/* The one badge that is a warning rather than a title: this message came
from a device nobody has vouched for. Coloured like an error and left
uncoloured nowhere else, because a doubt about who sent something is the
@@ -7056,67 +7536,6 @@ video.lightbox-media {
text-align: left;
}
/* Capped and scrolling: a channel may offer more rewards than the window is
tall, and a list that runs past the top edge is a list with no top. */
.kick-rewards {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
display: flex;
flex-direction: column;
max-height: min(60vh, 420px);
border: 1px solid var(--outline-medium);
border-radius: var(--corner-radius) var(--corner-radius) 0 0;
background: var(--surface-container-high);
box-shadow: var(--shadow-up);
overflow: hidden;
}
.kick-rewards-head {
padding: 8px 10px 4px;
flex: none;
}
.kick-rewards-list {
overflow-y: auto;
padding-bottom: 4px;
}
.kick-rewards-empty {
padding: 6px 10px 10px;
}
.kick-reward {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.kick-reward:hover:not(:disabled) {
background: var(--surface-container-highest);
}
/* Shown rather than hidden, because the list is also how somebody learns what
a channel does - but plainly not pressable. */
.kick-reward:disabled {
cursor: default;
opacity: 0.45;
}
.kick-reward-cost {
flex: none;
font-variant-numeric: tabular-nums;
color: var(--primary);
}
/* --- Exporting a conversation ----------------------------------------- */
@@ -8544,14 +8963,30 @@ video.lightbox-media {
position: absolute;
left: 0;
right: 0;
bottom: 0;
bottom: -3px;
z-index: 3;
height: 6px;
height: 10px;
cursor: ns-resize;
touch-action: none;
}
.stage-resize:hover {
background: var(--overlay-control);
/* A short bar in the middle of the edge, so it can be seen to be a handle. */
.stage-resize::after {
content: '';
position: absolute;
left: 50%;
top: 3px;
width: 36px;
height: 3px;
margin-left: -18px;
border-radius: 2px;
background: var(--outline-strong);
opacity: 0.6;
}
.stage-resize:hover::after {
opacity: 1;
background: var(--primary);
}
/* The line left above the conversation while the call is in its own window. */
@@ -8807,6 +9242,12 @@ video.lightbox-media {
object-fit: contain;
}
/* A channel's own picture, as the server rail draws one. */
.emoji-rail-item img.emoji-rail-avatar {
border-radius: 50%;
object-fit: cover;
}
.emoji-footer {
display: flex;
align-items: center;
@@ -8919,19 +9360,49 @@ video.lightbox-media {
line-height: 1;
}
/* The corner panel's resize grip, in its inside (top-left) corner. */
/* The corner panel is resized from any edge or corner. The inside (top-left) corner keeps its visible grip. */
.call-stage.pip .call-stage-resize {
position: absolute;
top: -4px;
left: -4px;
z-index: 2;
width: 16px;
height: 16px;
cursor: nwse-resize;
touch-action: none;
}
.call-stage.pip .call-stage-resize::after {
.call-stage.pip .edge-t,
.call-stage.pip .edge-b {
left: 12px;
right: 12px;
height: 8px;
cursor: ns-resize;
}
.call-stage.pip .edge-t { top: -4px; }
.call-stage.pip .edge-b { bottom: -4px; }
.call-stage.pip .edge-l,
.call-stage.pip .edge-r {
top: 12px;
bottom: 12px;
width: 8px;
cursor: ew-resize;
}
.call-stage.pip .edge-l { left: -4px; }
.call-stage.pip .edge-r { right: -4px; }
.call-stage.pip .edge-tl,
.call-stage.pip .edge-tr,
.call-stage.pip .edge-bl,
.call-stage.pip .edge-br {
width: 16px;
height: 16px;
}
.call-stage.pip .edge-tl { top: -4px; left: -4px; cursor: nwse-resize; }
.call-stage.pip .edge-br { bottom: -4px; right: -4px; cursor: nwse-resize; }
.call-stage.pip .edge-tr { top: -4px; right: -4px; cursor: nesw-resize; }
.call-stage.pip .edge-bl { bottom: -4px; left: -4px; cursor: nesw-resize; }
.call-stage.pip .edge-tl::after {
content: '';
position: absolute;
top: 5px;
@@ -10885,6 +11356,22 @@ html[data-reduce-motion] *::after {
box-shadow: var(--shadow-3);
}
.stoat-server {
width: 680px;
}
/* A choice sits at the row's right-hand end and leaves the words their room; a row's buttons are the small kind. */
.stoat-server .field-row > select.text-field {
flex: 0 0 9rem;
width: 9rem;
}
.stoat-server .steam-group-row .button,
.stoat-server .steam-group-row .confirm-button .button {
padding: 2px 10px;
font-size: var(--font-size-small);
}
.steam-group-body {
padding-top: var(--spacing-s);
}
@@ -10913,3 +11400,262 @@ html[data-reduce-motion] *::after {
color: inherit;
cursor: pointer;
}
/* A message the service took down, kept so that what it said can still be read. */
.message-row.removed .message-body,
.message-row.removed .message-body * {
color: var(--danger-text);
text-decoration: line-through;
}
/* The notice that stands where the words were: nothing to strike through. */
.message-row.removed.replaced .message-body,
.message-row.removed.replaced .message-body * {
color: var(--text-muted);
font-style: italic;
text-decoration: none;
}
/* The way into a thread from a reply, at the end of the line it quotes. */
.reply-preview .reply-thread-open {
flex: none;
margin-left: auto;
padding: 0 4px;
background: none;
border: 0;
color: inherit;
cursor: pointer;
border-radius: var(--radius-xs);
}
.reply-preview .reply-thread-open:hover {
color: var(--surface-variant-text);
background: var(--surface-hover);
}
/* A badge Kick sends as a picture, at the height of the line's small text. */
.sender-badge-image {
display: inline-block;
width: 16px;
height: 16px;
margin-left: var(--spacing-xxs);
object-fit: contain;
vertical-align: middle;
}
/* Over the picture while the stream is playing one of Twitch's ads: said, because a picture that
changes to something else for half a minute with nothing to say why looks like a fault. */
.stream-ad {
position: absolute;
top: 8px;
left: 8px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border-radius: var(--radius-pill);
background: rgba(0, 0, 0, 0.7);
color: var(--on-accent);
pointer-events: auto;
}
.stream-volume {
width: 72px;
accent-color: var(--primary, currentColor);
}
/* The number beside a cheermote, in the colour Twitch draws it. */
.cheer-amount {
margin: 0 4px 0 2px;
font-weight: var(--weight-bold);
color: var(--mention-edge);
}
/* The code Twitch's "Activate your device" page asks for, shown in case the page's field has to be filled by hand. */
.twitch-code {
padding: 6px 14px;
border: 1px solid var(--surface-variant-text);
border-radius: var(--radius-xs);
font-family: var(--font-mono);
font-size: 22px;
letter-spacing: 0.24em;
user-select: all;
}
/* The corner mark on a conversation with a person where the service's channels have pictures too (Twitch's
whispers): it says "direct message" where there is no presence dot to say it. */
.avatar-badge {
position: absolute;
right: -3px;
bottom: -3px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--primary);
color: var(--on-primary, #fff);
border: 2px solid var(--ring, var(--surface-container));
box-sizing: content-box;
}
.header-face .avatar-badge,
.header-nameplate .avatar-badge {
border-color: var(--surface);
}
/* A notification the service wrote (a join, a topic, a notice): the same card the events are, quieter - a
neutral bar and less room - so a long run of them does not outweigh the conversation. */
.kick-event.notification {
--kick-accent: var(--outline-strong);
justify-content: flex-start;
min-width: 0;
padding-top: var(--spacing-xs);
padding-bottom: var(--spacing-xs);
gap: var(--spacing-m);
}
.kick-event.notification .kick-event-line {
color: var(--text-muted, var(--surface-variant-text));
font-style: italic;
}
.kick-event.notification.warning {
--kick-accent: var(--danger);
}
.kick-event.notification.warning .kick-event-line {
color: var(--danger);
font-style: normal;
}
/* A moderation card that is not a penalty - a room's mode, someone let back, a shoutout - takes the accent of
the window rather than the red of a ban. */
.kick-event.moderation.soft {
--kick-accent: var(--primary);
}
/* Sneedchat's greentext: a line that starts with ">" in the green 4chan has always used for it. */
.greentext {
color: #789922;
}
/* The rewards panel: the quick switcher's card, with a row per reward and what it costs at the right. */
.rewards .switcher-input {
font-size: var(--font-size-medium);
}
.rewards-balance {
flex: none;
color: var(--surface-text);
white-space: nowrap;
}
.reward-item.armed {
background: var(--surface-container);
border-radius: var(--corner-radius-small);
}
.reward-row.locked {
opacity: 0.55;
}
.reward-image {
width: 22px;
height: 22px;
border-radius: 4px;
object-fit: cover;
}
.reward-cost {
flex: none;
padding: 1px 8px;
border-radius: 999px;
background: var(--surface-container-highest, var(--surface-container));
color: var(--surface-text);
font-variant-numeric: tabular-nums;
}
.reward-confirm {
display: flex;
gap: var(--spacing-s);
align-items: center;
padding: var(--spacing-xs) var(--spacing-s) var(--spacing-s) calc(22px + var(--spacing-s) * 2);
}
.reward-confirm .text-field {
flex: 1;
min-width: 0;
}
/* The two groups of the rewards panel, each under its own small heading. */
.reward-group {
padding: var(--spacing-s) var(--spacing-s) 2px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
/* The emotes a reward offers, as pictures to press: a scrolling grid under a search, inside the armed reward. */
.reward-confirm {
flex-wrap: wrap;
}
.reward-chooser {
flex: 1 1 100%;
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.reward-chooser-head {
display: flex;
align-items: center;
gap: var(--spacing-s);
}
.reward-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(44px, 1fr));
gap: 4px;
max-height: 200px;
overflow-y: auto;
padding: 2px;
}
.reward-emote {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-height: 44px;
padding: 4px;
border-radius: var(--corner-radius-small);
background: transparent;
color: var(--surface-text);
}
.reward-emote:hover {
background: var(--primary-hover);
}
.reward-emote.selected {
background: var(--primary-hover);
box-shadow: inset 0 0 0 2px var(--primary);
}
.reward-emote img {
width: 32px;
height: 32px;
object-fit: contain;
}
.reward-emote-caption {
font-size: var(--font-size-xxs);
line-height: 1.1;
text-align: center;
}
.reward-picked {
padding: 0 2px;
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<!-- A small animal's head as a plain silhouette: a stand-in until Stoat's own mark is put here. -->
<path fill="#fff" d="M5 3c-1.7 0-3 1.3-3 3 0 1.3.8 2.4 1.9 2.8C3.3 9.9 3 11 3 12.2 3 16.5 7 20 12 20s9-3.5 9-7.8c0-1.2-.3-2.3-.9-3.4C21.2 8.4 22 7.3 22 6c0-1.7-1.3-3-3-3-1.5 0-2.7 1-3 2.4A9.9 9.9 0 0 0 12 5c-1.3 0-2.6.2-4 .4C7.700 4 6.500 3 5 3zm3.300 8.200a1.200 1.200 0 1 1 0 2.400 1.200 1.200 0 0 1 0-2.400zm7.400 0a1.200 1.200 0 1 1 0 2.400 1.200 1.200 0 0 1 0-2.400zM12 14.500c.8 0 1.500.5 1.500 1.100 0 .5-.7 1-1.500 1s-1.500-.5-1.500-1c0-.6.700-1.100 1.500-1.100z"/>
</svg>

After

Width:  |  Height:  |  Size: 670 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<!-- Telegram's paper plane as a plain silhouette (Simple Icons, CC0). -->
<path fill="#fff" d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
</svg>

After

Width:  |  Height:  |  Size: 825 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<!-- Twitch's mark as a plain silhouette: the speech bubble with its two bars. -->
<path fill="#fff" d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0 1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143-3.428 3.428h-3.43l-3 3v-3H6.857V1.714h13.714z"/>
</svg>

After

Width:  |  Height:  |  Size: 383 B

+638 -5
View File
@@ -3,7 +3,9 @@ import { SwitchRow } from './Switch'
import { useEffect, useRef, useState } from 'react'
import { Icon, IconButton, ServiceMark } from './Icon'
import { MatrixAccountTools } from './MatrixAccountTools'
import { StoatAccountTools } from './StoatAccountTools'
import { SteamAccountTools } from './SteamAccountTools'
import { TwitchSignIn } from './TwitchSignIn'
import { IgnoreList } from './IgnoreList'
import { TunnelAllSwitch } from './settings/Tunnel'
import { useChat, useConnectionDetail, useMapPref, usePref, useStore } from '../state/hooks'
@@ -24,7 +26,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', 'steam'] as const
const FORMS = ['irc', 'discord', 'sneedchat', 'matrix', 'kick', 'steam', 'twitch', 'telegram', 'stoat'] as const
type AddableService = (typeof FORMS)[number]
/** What `listProtocols` answers with. */
@@ -136,6 +138,9 @@ export function AccountsPanel(): JSX.Element {
{adding === 'matrix' && <MatrixForm />}
{adding === 'kick' && <KickForm onDone={() => setAdding(null)} />}
{adding === 'steam' && <SteamForm onDone={() => setAdding(null)} />}
{adding === 'twitch' && <TwitchForm onDone={() => setAdding(null)} />}
{adding === 'telegram' && <TelegramForm onDone={() => setAdding(null)} />}
{adding === 'stoat' && <StoatForm onDone={() => setAdding(null)} />}
{adding && (
<button type="button" className="button subtle small" onClick={() => setAdding(null)}>
Cancel
@@ -329,6 +334,7 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
// What the daemon last said about why. Only meaningful while something is
// wrong, which is exactly when the state line alone explains nothing.
const detail = useConnectionDetail(account.id)
const held = account.service === 'discord' && !!detail?.startsWith('Holding the Discord connection')
const call = (method: string, params: Record<string, unknown>): void => {
void window.moho
@@ -350,7 +356,20 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
<div className="small muted">
{serviceLabel(account.service)} · {account.state}
</div>
{detail && <div className="small muted ellipsis account-detail" title={detail}>{detail}</div>}
{detail && <div className={classes('small muted account-detail', held && 'held')} title={detail}>{detail}</div>}
{/* Held for having logged in too often (see nobilis's identify_budget): said in full, with the way past it. */}
{held && (
<div>
<button
type="button"
className="button subtle"
title="Many fresh logins can get an account flagged as spam by Discord. This one is counted."
onClick={() => call('connectDiscordAnyway', { accountId: account.id })}
>
Connect anyway
</button>
</div>
)}
</div>
<button
type="button"
@@ -459,6 +478,8 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
{account.service === 'matrix' && <MatrixAccountTools account={account} />}
{account.service === 'steam' && <SteamAccountTools account={account} />}
{account.service === 'stoat' && <StoatAccountTools account={account} />}
{account.service === 'twitch' && <TwitchAccountTools account={account} />}
{account.service === 'sneedchat' && (
<>
@@ -977,6 +998,16 @@ function DiscordForm({ accountId }: { accountId?: string }): JSX.Element {
return (
<div className="add-form">
{/* Said before anything is entered, the way Dissent's README says it: this is the risk, and what moho does and does
not do about it. */}
{!accountId && (
<p className="small muted discord-risk" role="note">
Discord does not allow third-party clients, and an account using one can be flagged and logged out, or banned.
moho is careful - it says what it is, makes few requests, and will not send the first message to a server or a
person - but nothing it does can make that risk nothing. Use an account you can afford to lose, and see the
first message in each new place from Discord&apos;s own app.
</p>
)}
{!accountId && <TorSwitch service="discord" on={useTor} onChange={setUseTor} />}
<div className="setting-segmented">
<button
@@ -1412,9 +1443,10 @@ function SteamForm({ onDone }: { onDone: () => void }): JSX.Element {
<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.
display name your friends see. moho signs in as a desktop client of its own, the way other
third-party Steam clients do, and shows up in your Steam devices as "moho on" your
computer's name. 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">
@@ -1472,6 +1504,604 @@ function SteamForm({ onDone }: { onDone: () => void }): JSX.Element {
)
}
/**
* Adding a Telegram account: an api_id and api_hash, a phone number, then the code Telegram sends and, if the account
* has one, its cloud password.
*
* Telegram gives one api_id and api_hash to a phone number's owner, and every client sends them. moho has none of its
* own, so the ones typed here are kept with the account, as private as the session is. Nothing typed here is sent
* anywhere but Telegram.
*/
function TelegramForm({ onDone }: { onDone: () => void }): JSX.Element {
const store = useStore()
const [apiId, setApiId] = useState('')
const [apiHash, setApiHash] = useState('')
const [phone, setPhone] = useState('')
const [useTor, setUseTor] = useState(false)
const [busy, setBusy] = useState(false)
const [loginId, setLoginId] = useState('')
const [detail, setDetail] = useState('')
const [needs, setNeeds] = useState<{ step: 'code' | 'password'; hint?: string; error?: string } | null>(null)
const [typed, setTyped] = useState('')
/** A QR code to scan, and when it was drawn (Telegram renews it about every half minute). */
const [qr, setQr] = useState<{ path: string; at: number } | null>(null)
// Held in a ref as well: the first event can be handled before the render that follows the answer that started the
// sign-in. Nothing else is signing in from this form, so an event with no id known yet is 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; text?: string; step?: 'code' | 'password'; hint?: string; error?: string; success?: boolean }
if (!e.event.startsWith('telegramLogin')) return
if (current.current && d.loginId !== current.current) return
if (e.event === 'telegramLoginStatus') {
setDetail(d.text || '')
} else if (e.event === 'telegramLoginQr') {
const q = e.data as { path?: string }
if (q.path) setQr({ path: q.path, at: Date.now() })
setDetail('')
} else if (e.event === 'telegramLoginNeeds') {
setQr(null)
setNeeds({ step: d.step ?? 'code', hint: d.hint ?? undefined, error: d.error ?? undefined })
setTyped('')
setDetail('')
} else if (e.event === 'telegramLoginResult') {
setNeeds(null)
setQr(null)
setId('')
setBusy(false)
setDetail('')
if (d.success) {
setApiHash('')
void store.refreshAccounts()
onDone()
} else {
store.toast('error', d.error || 'Telegram sign-in failed')
}
}
})
return off
}, [onDone, store])
// Closing the form on a sign-in that is still waiting tells the daemon nobody is.
useEffect(
() => () => {
if (current.current) void window.moho.rpc('cancelTelegramLogin', { loginId: current.current }).catch(() => {})
},
[]
)
const begin = (): void => {
setBusy(true)
setDetail('')
setNeeds(null)
current.current = ''
void window.moho
.rpc<{ loginId: string }>('addTelegramAccount', { apiId: apiId.trim(), apiHash: apiHash.trim(), phone, useTor })
.then((r) => setId(r.loginId))
.catch((e: Error) => {
setBusy(false)
store.toast('error', e.message)
})
}
const beginQr = (): void => {
setBusy(true)
setDetail('')
setNeeds(null)
setQr(null)
current.current = ''
void window.moho
.rpc<{ loginId: string }>('addTelegramAccountQr', { apiId: apiId.trim(), apiHash: apiHash.trim(), useTor })
.then((r) => setId(r.loginId))
.catch((e: Error) => {
setBusy(false)
store.toast('error', e.message)
})
}
const send = (): void => {
const value = needs?.step === 'password' ? typed : typed.trim()
if (!value || !loginId || !needs) return
void window.moho
.rpc(needs.step === 'password' ? 'submitTelegramPassword' : 'submitTelegramCode', { loginId, value })
.then(() => setDetail('checking'))
.catch((e: Error) => store.toast('error', e.message))
}
const cancel = (): void => {
if (current.current) void window.moho.rpc('cancelTelegramLogin', { loginId: current.current }).catch(() => {})
setId('')
setNeeds(null)
setQr(null)
setDetail('')
setBusy(false)
}
if (qr && !needs) {
return (
<div className="add-form">
<p>Scan this with Telegram on your phone.</p>
<p className="small muted">
In the app, open Settings, then Devices, then Link Desktop Device, and point the phone at the code. The code
changes about every half minute, and the picture here changes with it. If the account has a cloud password,
it is asked for next.
</p>
<img className="qr-image" src={resolveMediaUrl(`file://${qr.path}#${qr.at}`)} alt="Telegram 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>
)
}
if (needs) {
const password = needs.step === 'password'
return (
<div className="add-form">
<p>
{password
? 'This account has a cloud password.'
: 'Telegram sent a code to your other Telegram app, or by SMS.'}
</p>
<label className="field">
<span className="small muted">{password ? `Password${needs.hint ? ` (hint: ${needs.hint})` : ''}` : 'Code'}</span>
<input
className="text-field"
type={password ? 'password' : 'text'}
autoFocus
autoComplete="off"
spellCheck={false}
inputMode={password ? undefined : 'numeric'}
value={typed}
onChange={(e) => setTyped(e.target.value)}
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">
<button type="button" className="button primary" disabled={!typed.trim()} onClick={send}>
{password ? 'Submit password' : 'Submit code'}
</button>
<button type="button" className="button" onClick={cancel}>
Cancel sign-in
</button>
</div>
</div>
)
}
const ready = apiId.trim() !== '' && apiHash.trim() !== '' && phone.trim() !== ''
return (
<div className="add-form">
<p className="small muted">
Telegram gives one <strong>api_id</strong> and <strong>api_hash</strong> to the owner of a phone number, at{' '}
<a href="https://my.telegram.org/apps" target="_blank" rel="noreferrer">
my.telegram.org/apps
</a>
, and every Telegram app a person uses can share them. moho has none of its own: the ones you type here are kept
with the account, as private as the session. Telegram then sends a code, and asks for the account's cloud
password if it has one.
</p>
<TorSwitch service="telegram" on={useTor} onChange={setUseTor} />
<div className="field-row">
<label className="field">
<span className="small muted">api_id</span>
<input
className="text-field"
autoComplete="off"
spellCheck={false}
inputMode="numeric"
value={apiId}
disabled={busy}
onChange={(e) => setApiId(e.target.value)}
/>
</label>
<label className="field">
<span className="small muted">api_hash</span>
<input
className="text-field"
type="password"
autoComplete="off"
spellCheck={false}
value={apiHash}
disabled={busy}
onChange={(e) => setApiHash(e.target.value)}
/>
</label>
</div>
<label className="field">
<span className="small muted">Phone number, with its country code (not needed for a QR code)</span>
<input
className="text-field"
autoComplete="off"
placeholder="+1 555 123 4567"
value={phone}
disabled={busy}
onChange={(e) => setPhone(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && ready && !busy) begin()
}}
/>
</label>
<div className="field-row">
<button type="button" className="button primary" disabled={busy || !ready} onClick={begin}>
{busy ? 'Signing in…' : 'Send the code'}
</button>
<button type="button" className="button" disabled={busy || apiId.trim() === '' || apiHash.trim() === ''} 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>
)
}
/**
* Adding a Stoat account (formerly Revolt): an email and password, then the code of its authenticator app or a recovery
* code if the account has a second factor; or a session token pasted from another app.
*
* Stoat can be hosted by anybody, so the instance can be named; left empty it is the official one. Nothing typed here
* is kept but the session Stoat issues, and moho does not make accounts: that is done in Stoat's own app, where its
* captcha and email check are.
*/
function StoatForm({ onDone }: { onDone: () => void }): JSX.Element {
const store = useStore()
const [instance, setInstance] = useState('')
const [method, setMethod] = useState<'password' | 'token'>('password')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [token, setToken] = useState('')
const [useTor, setUseTor] = useState(false)
const [busy, setBusy] = useState(false)
const [loginId, setLoginId] = useState('')
const [detail, setDetail] = useState('')
const [needs, setNeeds] = useState<{ methods: string[]; error?: string } | null>(null)
const [typed, setTyped] = useState('')
// Held in a ref as well: the first event can be handled before the render that follows the answer that started the
// sign-in. Nothing else is signing in from this form, so an event with no id known yet is 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; text?: string; methods?: string[]; error?: string; success?: boolean }
if (!e.event.startsWith('stoatLogin')) return
if (current.current && d.loginId !== current.current) return
if (e.event === 'stoatLoginStatus') {
setDetail(d.text || '')
} else if (e.event === 'stoatLoginNeeds') {
setNeeds({ methods: d.methods ?? [], error: d.error ?? undefined })
setTyped('')
setDetail('')
} else if (e.event === 'stoatLoginResult') {
setNeeds(null)
setId('')
setBusy(false)
setDetail('')
if (d.success) {
setPassword('')
setToken('')
void store.refreshAccounts()
onDone()
} else {
store.toast('error', d.error || 'Stoat sign-in failed')
}
}
})
return off
}, [onDone, store])
// Closing the form on a sign-in that is still waiting tells the daemon nobody is.
useEffect(
() => () => {
if (current.current) void window.moho.rpc('cancelStoatLogin', { loginId: current.current }).catch(() => {})
},
[]
)
const begin = (): void => {
setBusy(true)
setDetail('')
setNeeds(null)
current.current = ''
const call =
method === 'password'
? window.moho.rpc<{ loginId: string }>('addStoatAccount', { instance: instance.trim(), email: email.trim(), password, useTor })
: window.moho.rpc<{ loginId: string }>('addStoatAccountToken', { instance: instance.trim(), token: token.trim(), useTor })
void call
.then((r) => setId(r.loginId))
.catch((e: Error) => {
setBusy(false)
store.toast('error', e.message)
})
}
const send = (): void => {
const value = typed.trim()
if (!value || !loginId || !needs) return
void window.moho
.rpc('submitStoatCode', { loginId, value })
.then(() => setDetail('checking'))
.catch((e: Error) => store.toast('error', e.message))
}
const cancel = (): void => {
if (current.current) void window.moho.rpc('cancelStoatLogin', { loginId: current.current }).catch(() => {})
setId('')
setNeeds(null)
setDetail('')
setBusy(false)
}
if (needs) {
return (
<div className="add-form">
<p>This account has a second factor. Type the six-digit code from your authenticator app, or a recovery code.</p>
<label className="field">
<span className="small muted">Code</span>
<input
className="text-field"
autoFocus
autoComplete="off"
spellCheck={false}
value={typed}
onChange={(e) => setTyped(e.target.value)}
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">
<button type="button" className="button primary" disabled={!typed.trim()} onClick={send}>
Submit code
</button>
<button type="button" className="button" onClick={cancel}>
Cancel sign-in
</button>
</div>
</div>
)
}
const ready = method === 'password' ? email.trim() !== '' && password !== '' : token.trim() !== ''
return (
<div className="add-form">
<p className="small muted">
Sign in to your Stoat (formerly Revolt) account. Making an account, and answering its captcha or email check, is
done in Stoat&apos;s own app first. moho keeps only the session Stoat issues, never the password.
</p>
<p className="small muted">
What a third-party client does here: it signs in once and keeps that session, shown in Stoat&apos;s own list of sessions
as &ldquo;moho (your system)&rdquo;, and it never signs in again to get over a dropped connection. Ending the session in Stoat signs moho out.
It keeps to the instance&apos;s limits, tells you when one applies, and does not make accounts, run bots or answer captchas.
</p>
<TorSwitch service="stoat" on={useTor} onChange={setUseTor} />
<label className="field">
<span className="small muted">Instance (leave empty for the official one, stoat.chat)</span>
<input
className="text-field"
autoComplete="off"
spellCheck={false}
placeholder="https://api.stoat.chat"
value={instance}
disabled={busy}
onChange={(e) => setInstance(e.target.value)}
/>
</label>
<div className="segmented">
<button type="button" className={method === 'password' ? 'active' : undefined} disabled={busy} onClick={() => setMethod('password')}>
Email and password
</button>
<button type="button" className={method === 'token' ? 'active' : undefined} disabled={busy} onClick={() => setMethod('token')}>
Session token
</button>
</div>
{method === 'password' ? (
<div className="field-row">
<label className="field">
<span className="small muted">Email</span>
<input className="text-field" type="email" autoComplete="off" spellCheck={false} value={email} disabled={busy} onChange={(e) => setEmail(e.target.value)} />
</label>
<label className="field">
<span className="small muted">Password</span>
<input
className="text-field"
type="password"
autoComplete="off"
value={password}
disabled={busy}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && ready && !busy) begin()
}}
/>
</label>
</div>
) : (
<label className="field">
<span className="small muted">Session token</span>
<input
className="text-field"
type="password"
autoComplete="off"
spellCheck={false}
value={token}
disabled={busy}
onChange={(e) => setToken(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && ready && !busy) begin()
}}
/>
</label>
)}
<div className="field-row">
<button type="button" className="button primary" disabled={busy || !ready} onClick={begin}>
{busy ? 'Signing in…' : 'Sign in'}
</button>
{busy && (
<button type="button" className="button" onClick={cancel}>
Cancel
</button>
)}
</div>
{busy && detail && <p className="small muted">{detail}…</p>}
</div>
)
}
/**
* Adding a Twitch account: sign in, or only read.
*
* Twitch's chat is public, so an account that never signs in still reads every
* channel it is pointed at - the same choice Kick's form offers. Signing in is
* what buys sending, the channels you follow and the emotes you can use.
*/
function TwitchForm({ onDone }: { onDone: () => void }): JSX.Element {
const store = useStore()
const [useTor, setUseTor] = useState(false)
const [busy, setBusy] = useState(false)
const readOnly = (): void => {
setBusy(true)
void window.moho
.rpc('addTwitchAccount', { useTor })
.then(() => {
void store.refreshAccounts()
onDone()
})
.catch((e: Error) => store.toast('error', e.message))
.finally(() => setBusy(false))
}
return (
<div className="add-form">
<TorSwitch service="twitch" on={useTor} onChange={setUseTor} />
<TwitchSignIn useTor={useTor} onDone={onDone}>
<button
type="button"
className="button subtle"
disabled={busy}
title="Read channels without an account. Twitch's chat is public; sending needs a sign-in."
onClick={readOnly}
>
Read without signing in
</button>
</TwitchSignIn>
</div>
)
}
/**
* What a Twitch account can be asked to do from its own card.
*
* Signing in (for one that only reads, or whose sign-in Twitch has stopped accepting; the new sign-in is a
* Twitch account of its own under the login that approved it, so an anonymous reader is left where it
* was), opening the channels the account follows.
*
* Video needs the website's own session, because Twitch's player takes only a token its own website made: the one
* moho gets for chat and the official API is refused there. It is not a second sign-in - one window signs in on
* twitch.tv and approves the code, and the session it saw on the way is kept with the account (see TwitchSignIn).
* It is the person's own website session, and says so - what it can do is what being signed in there can do - and
* it is what makes their Turbo or their subscriptions count, so ads they are not meant to see are not shown.
*/
function TwitchAccountTools({ account }: { account: Account }): JSX.Element {
const store = useStore()
const refused = account.state === 'auth_failed'
const [busy, setBusy] = useState('')
const run = (what: string, work: () => Promise<void>): void => {
setBusy(what)
void work()
.catch((e: Error) => store.toast('error', e.message))
.finally(() => setBusy(''))
}
const follows = (liveOnly: boolean): void =>
run('follows', async () => {
const r = await window.moho.rpc<{ count: number }>('syncTwitchFollows', { accountId: account.id, liveOnly })
store.toast('info', r.count === 0 ? 'You follow no channels that are on air right now' : `Opening ${r.count} channel${r.count === 1 ? '' : 's'}`)
})
return (
<div className="account-tools">
{(!account.hasPassword || refused) && (
<>
<p className="small muted">
{refused
? 'Twitch no longer accepts this sign-in. Signing in again keeps its channels and settings.'
: 'This account only reads. Sign in to send messages and to see the channels you follow.'}
</p>
<TwitchSignIn useTor={account.useTor} onDone={() => undefined} label={refused ? 'Sign in again' : 'Sign in'} />
</>
)}
{account.hasPassword && !refused && (
<div className="field-row">
<button type="button" className="button subtle" disabled={busy !== ''} onClick={() => follows(true)} title="Open the channels you follow that are streaming now">
Open followed channels that are live
</button>
<button type="button" className="button subtle" disabled={busy !== ''} onClick={() => follows(false)} title="Open everyone you follow, up to 200">
Open everyone I follow
</button>
</div>
)}
{account.hasPassword && !refused && (!account.moderation || !account.webSession) && (
<>
<p className="small muted">
This sign-in was made before {account.moderation ? 'video' : account.webSession ? 'moderation, whispers, shoutouts and blocking' : 'video, moderation, whispers, shoutouts and blocking'}{' '}
{account.moderation || account.webSession ? 'was' : 'were'} part of signing in. Signing in again adds {account.moderation || account.webSession ? 'it' : 'them'}, in the same one
window; nothing else changes.
</p>
<TwitchSignIn useTor={account.useTor} onDone={() => undefined} label="Sign in again" />
</>
)}
<p className="small muted">
{account.webSession
? 'Video plays as you: signed in on twitch.tv, so your Turbo or your subscriptions count and their ads are not shown.'
: 'Video plays without a twitch.tv sign-in, so Twitch puts ads in streams. Signing in again signs in on twitch.tv too, which lets your Turbo or your subscriptions count.'}
</p>
{account.webSession && (
<p className="small muted">
It is your twitch.tv login session, so what it can do is what being signed in there can do. Removing this account
forgets it here; to end it on Twitch, use Sign out everywhere in twitch.tv's security settings.
</p>
)}
</div>
)
}
/**
* Whether an account's connections go through Tor or a SOCKS5 proxy.
*
@@ -1590,6 +2220,9 @@ function TorWarning({ service }: { service: Account['service'] }): JSX.Element |
} else if (service === 'kick') {
text =
"Kick blocks Tor's exit points, so a connection through moho's own Tor will most likely be refused. A SOCKS5 proxy you host yourself may work."
} else if (service === 'telegram') {
text =
"Telegram often challenges or limits a sign-in that comes from Tor or a VPN, and signing in from a new address can put the account's other sessions on alert. Sign in once from your usual connection, then route it if you need to."
} else if (service === 'irc' || service === 'matrix') {
text = `Each ${service === 'irc' ? 'network' : 'homeserver'} has its own policy on Tor, and many block its exit points. A SOCKS5 proxy you host yourself may be accepted where Tor is not.`
} else {
+13
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useMediaUrl } from '../lib/route'
import { nickColor } from '../lib/util'
import { presenceClass, presenceLabel } from '../lib/presence'
import { Icon } from './Icon'
/**
* Somebody's face, with a fallback for the many people who have no picture.
@@ -21,12 +22,19 @@ export function Avatar({
url,
size = 24,
status,
badge,
accountId
}: {
name: string
url?: string
size?: number
status?: string
/**
* A glyph in the corner that says what kind of thing this is, for a face that would otherwise look like
* a channel's: a conversation with a person on a service whose channels have pictures too, and which
* has no presence dot to tell them apart by.
*/
badge?: string
/** Whose picture: a strictly routed account's is fetched through its route
* (#257). Taken from the surrounding conversation when not given. */
accountId?: string
@@ -60,6 +68,11 @@ export function Avatar({
{initial(name)}
</span>
)}
{badge && !status && (
<span className="avatar-badge" title="Direct message" aria-label="Direct message">
<Icon name={badge} size={Math.max(8, Math.round(size * 0.4))} />
</span>
)}
{status && (
<span className={`presence-dot ${presenceClass(status)}`} title={presenceLabel(status)} />
)}
+96 -15
View File
@@ -1,17 +1,26 @@
import { useMediaUrl } from '../lib/route'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
import { bufferLink } from '../lib/bufferlink'
import { Icon, IconButton, ServiceMark } from './Icon'
import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu'
import { bufferMenuEntries } from '../lib/buffermenu'
import { UserFooter } from './UserFooter'
import { VoiceChannels } from './VoiceChannels'
import { StoatVoiceWho } from './StoatVoiceWho'
import { SteamVoiceRooms } from './SteamVoiceRooms'
import { VoicePanel } from './VoicePanel'
import { presenceClass, presenceLabel } from '../lib/presence'
import { activityLabel, dmActivity } from '../lib/activity'
import { ActivityLine } from './ActivityLine'
import { steamCategoryId, parseSteamCategory } from '../lib/steamgroups'
import {
folderAssignment,
folderCategories,
folderOrder,
isPendingCategory,
parseTelegramCategory,
pendingCategoryId
} from '../lib/telegramfolders'
import { NamePrompt } from './NamePrompt'
import { useChat, useComfy, useIdSetPref, useInvitations, useMapPref, usePref, useStore } from '../state/hooks'
import {
@@ -170,6 +179,9 @@ export function BufferList(): JSX.Element {
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'
// A Telegram account's own page: its conversations sit under the chat folders the account keeps, which are kept in step
// with the categories both ways.
const telegramPage = activeGroup?.kind === 'account' && accounts.find((a) => a.id === activeGroup.accountId)?.service === 'telegram'
/**
* Whose identity the plaque shows.
*
@@ -264,8 +276,23 @@ export function BufferList(): JSX.Element {
: [],
[steamPage, activeGroup, steamGroups]
)
const myCategories = steamPage ? steamFolders : activeGroup ? (customCats[activeGroup.id] ?? []) : []
const telegramFolders = useChat((s) => s.telegramFolders)
/** Categories made on this page that are not Telegram folders yet: Telegram keeps no empty one. */
const [pendingFolders, setPendingFolders] = useState<CustomCategory[]>([])
const tgFolders = useMemo(() => (telegramPage && activeGroup ? (telegramFolders[activeGroup.accountId] ?? []) : []), [telegramPage, activeGroup, telegramFolders])
const myCategories = useMemo(
() =>
steamPage
? steamFolders
: telegramPage && activeGroup
? [...folderCategories(activeGroup.accountId, tgFolders), ...pendingFolders.filter((p) => p.id.startsWith(`tfnew:${activeGroup.accountId}:`))]
: activeGroup
? (customCats[activeGroup.id] ?? [])
: [],
[steamPage, steamFolders, telegramPage, activeGroup, tgFolders, pendingFolders, customCats]
)
const viewAssignment = useMemo(() => {
if (telegramPage && activeGroup) return folderAssignment(activeGroup.accountId, tgFolders, groupBuffers)
if (!steamPage || !activeGroup) return assignment
const folders = [...(steamGroups[activeGroup.accountId] ?? [])].sort((a, b) => a.id - b.id)
const next = { ...assignment }
@@ -276,16 +303,30 @@ export function BufferList(): JSX.Element {
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. */
}, [steamPage, telegramPage, tgFolders, activeGroup, steamGroups, assignment, groupBuffers])
/** Files a conversation under a heading: on Steam for a Steam page, on Telegram for a Telegram one, 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)
if (steamPage) {
store.moveSteamFriend(bufferId, parseSteamCategory(categoryId)?.groupId ?? null)
} else if (telegramPage) {
const pending = pendingFolders.find((p) => p.id === categoryId)
if (pending) {
// The first conversation in it is what makes it a folder on Telegram.
void store.createTelegramFolder(bufferId, pending.name).then((made) => {
if (made) setPendingFolders((list) => list.filter((p) => p.id !== categoryId))
})
} else {
store.fileTelegramChat(bufferId, parseTelegramCategory(categoryId)?.folderId ?? null)
}
} else {
setAssignment(bufferId, categoryId)
}
}
const grouped = useMemo(() => {
if (!activeGroup) return null
const list = sections(groupBuffers, myCategories, viewAssignment, categoryOrder[activeGroup.id] ?? [])
// Telegram's own order stands on its page: a heading dragged here is moved there, and comes back in that order.
const list = sections(groupBuffers, myCategories, viewAssignment, telegramPage ? [] : (categoryOrder[activeGroup.id] ?? []))
if (!isPinnedPage && !isDmPage) {
for (const s of list) {
s.buffers.sort((a, b) => (a.position || 0) - (b.position || 0))
@@ -295,7 +336,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, steamPage, groupBuffers, myCategories, viewAssignment, categoryOrder])
}, [activeGroup, isPinnedPage, isDmPage, steamPage, telegramPage, 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
@@ -303,7 +344,7 @@ 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' : service === 'steam' ? 'steamSetFriendMuted' : null
return service === 'matrix' ? 'setMatrixRoomMuted' : service === 'discord' ? 'setDiscordChannelMuted' : service === 'steam' ? 'steamSetFriendMuted' : service === 'telegram' ? 'setTelegramChatMuted' : service === 'stoat' ? 'setStoatChannelMuted' : null
}
const isEffectivelyMuted = (buffer: BufferEntry): boolean =>
isMutedBuffer(buffer, muted, mutedGroups)
@@ -341,8 +382,8 @@ export function BufferList(): JSX.Element {
}
const renderRow = (b: BufferEntry): JSX.Element => (
<Fragment key={b.id}>
<BufferRow
key={b.id}
buffer={b}
active={b.id === activeBufferId}
muted={isEffectivelyMuted(b)}
@@ -416,8 +457,11 @@ export function BufferList(): JSX.Element {
lifted={dragging?.kind === 'buffer' && dragging.id === b.id}
onDragStart={() => beginDrag('buffer', b.id)}
onDragEnd={endDrag}
filed={!!assignment[b.id] && myCategories.some((c) => c.id === assignment[b.id])}
filed={telegramPage ? !!viewAssignment[b.id] : !!assignment[b.id] && myCategories.some((c) => c.id === assignment[b.id])}
/>
{/* Who is in a Stoat voice channel, under it. */}
{b.accountId.startsWith('stoat:') && b.nameIcon === 'volume_up' && <StoatVoiceWho bufferId={b.id} accountId={b.accountId} />}
</Fragment>
)
// What is in flight lives in a ref as well as in state, for the reason the
@@ -463,7 +507,9 @@ export function BufferList(): JSX.Element {
if (!held || !grouped || !activeGroup) return
if (held.kind === 'category') {
if (held.id === section.key) return
setCategoryOrder(activeGroup.id, reorderCategories(grouped, held.id, section.key))
const order = reorderCategories(grouped, held.id, section.key)
if (telegramPage) store.reorderTelegramFolders(activeGroup.accountId, folderOrder(order))
else setCategoryOrder(activeGroup.id, order)
return
}
if (!section.custom) return
@@ -619,6 +665,14 @@ export function BufferList(): JSX.Element {
if (name && steamPage && activeGroup) {
const group = parseSteamCategory(naming.id)?.groupId
if (group !== undefined) store.renameSteamFolder(activeGroup.accountId, group, name)
} else if (name && telegramPage && activeGroup) {
// A category not yet a folder is only named; a folder is renamed on Telegram.
if (isPendingCategory(naming.id)) setPendingFolders((list) => list.map((p) => (p.id === naming.id ? { ...p, name } : p)))
else {
const folder = parseTelegramCategory(naming.id)?.folderId
// The same name is not a change: nothing is asked of Telegram.
if (folder !== undefined && name !== section.name) store.renameTelegramFolder(activeGroup.accountId, folder, name)
}
} else if (name) {
writeCategories(
myCategories.map((c) => (c.id === naming.id ? { ...c, name } : c))
@@ -750,6 +804,13 @@ export function BufferList(): JSX.Element {
setMakingFolder(true)
return
}
// On Telegram it is made there with its first conversation, so until then it is only drawn.
if (telegramPage) {
const id = pendingCategoryId(activeGroup.accountId)
setPendingFolders((list) => [...list, { id, name: 'New category' }])
setNaming({ id, name: 'New category' })
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
@@ -800,7 +861,10 @@ export function BufferList(): JSX.Element {
// to wherever the service filed them.
onClick: () => {
const group = parseSteamCategory(catMenu.section.key)
const folder = parseTelegramCategory(catMenu.section.key)
if (steamPage && activeGroup && group) store.deleteSteamFolder(activeGroup.accountId, group.groupId)
else if (telegramPage && activeGroup && isPendingCategory(catMenu.section.key)) setPendingFolders((list) => list.filter((p) => p.id !== catMenu.section.key))
else if (telegramPage && activeGroup && folder) store.deleteTelegramFolder(activeGroup.accountId, folder.folderId)
else writeCategories(myCategories.filter((c) => c.id !== catMenu.section.key))
}
}
@@ -1045,6 +1109,8 @@ function BufferRow({
// redraws when it changes and not on every presence update in the window.
const activity = useChat((s) => dmActivity(buffer, s.presenceByBuffer))
const comfy = useComfy()
// The one conversation Discord itself writes to, which the daemon marks read-only for that reason.
const isOfficial = account?.service === 'discord' && !!buffer.readOnly?.includes('official Discord')
const doing = activity ? activityLabel(activity) : undefined
// Under an account header the row's own kind is what's worth showing (a
@@ -1059,7 +1125,7 @@ function BufferRow({
// A conversation with a person is headed by that person, whatever
// protocol they are on: their picture where there is one, their initial
// where there is not.
<Avatar name={buffer.name} url={buffer.avatarUrl} size={22} status={status} accountId={buffer.accountId} />
<Avatar name={buffer.name} url={buffer.avatarUrl} size={22} status={account?.service === 'twitch' ? undefined : status} badge="mail" accountId={buffer.accountId} />
) : buffer.avatarUrl ? (
<img className="buffer-avatar" src={media(buffer.avatarUrl)} alt="" />
) : buffer.serviceRoom ? (
@@ -1089,7 +1155,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' || account?.service === 'steam') && buffer.kind === 'dm'
const canCall = (account?.service === 'discord' || account?.service === 'steam' || account?.service === 'stoat') && buffer.kind === 'dm'
// The same entries the header's nameplate opens - see lib/buffermenu.ts for
// why they live there rather than here.
@@ -1156,8 +1222,23 @@ function BufferRow({
onDragEnd={onDragEnd}
title={link ? `${buffer.name} — ${link.detail}` : draggable ? `${buffer.name} — drag onto a heading to file it` : doing ? `${buffer.name} — ${doing}` : buffer.name}
>
{/* What kind of conversation it is, ahead of its picture: a horn for a broadcast channel, a robot for a bot. */}
{buffer.nameIcon && <Icon name={buffer.nameIcon} size={15} className="buffer-kind-icon" />}
{leading}
{comfy && activity ? (
{isOfficial ? (
// Discord's own conversation: marked so a notice that arrives here can be trusted as Discord's, and a message
// that merely claims to be Discord's cannot borrow it - the mark belongs to this conversation alone.
<span className="buffer-text">
<span className="ellipsis buffer-name">
{rowName}
<span className="buffer-official-mark">
<Icon name="check" size={11} />
OFFICIAL
</span>
</span>
<span className="small ellipsis">Official Discord Message</span>
</span>
) : comfy && activity ? (
<span className="buffer-text">
<span className="ellipsis buffer-name">{rowName}</span>
<ActivityLine activity={activity} />
+43
View File
@@ -355,6 +355,49 @@ export function RoomCallBar({ bufferId }: { bufferId: string }): JSX.Element | n
)
}
/**
* A Stoat conversation's call, and the way into it.
*
* A voice room, a direct message and a group can each be called into; who is in the call is said by the daemon as
* they come and go, so a room says who is talking before anybody joins. The call itself is held on a media server and
* drawn by the same stage every other call is.
*/
export function StoatCallBar({ bufferId }: { bufferId: string }): JSX.Element | null {
const store = useStore()
const buffer = useChat((s) => s.buffers).find((b) => b.id === bufferId)
const members = useChat((s) => s.stoatVoice[bufferId]) ?? []
const activeCall = useChat((s) => s.activeCall)
if (!buffer?.accountId.startsWith('stoat:')) return null
const callable = buffer.nameIcon === 'volume_up' || buffer.kind === 'dm' || buffer.category === 'Groups'
if (!callable) return null
const inIt = activeCall?.bufferId === bufferId
// A conversation nobody is calling in has no bar: a direct message is called from its header, and a voice room has
// its own entry beside the box.
if (members.length === 0 && buffer.nameIcon !== 'volume_up') return null
const who =
members.length === 0
? 'Nobody is in the voice room yet'
: members.length === 1
? `${members[0].name} is in the call`
: `${members.length} people are in the call: ${members.map((m) => m.name).join(', ')}`
return (
<div className="room-call-bar">
<Icon name={members.some((m) => m.camera || m.screen) ? 'videocam' : 'volume_up'} size={16} />
<span className="small ellipsis">{who}</span>
{!inIt && (
<>
<button type="button" className="button" onClick={() => void store.joinStoatCall(bufferId, false)}>
Join voice
</button>
<button type="button" className="button" onClick={() => void store.joinStoatCall(bufferId, true)}>
Join with video
</button>
</>
)}
</div>
)
}
/**
* Which screen or window to show the room.
*
@@ -0,0 +1,92 @@
import { useState, type KeyboardEvent, type PointerEvent } from 'react'
import { usePref } from '../state/hooks'
/**
* A column's width as somebody left it: followed while a drag is under way and written down once, when it ends,
* because a preference goes to disk over IPC and a drag is a hundred moves.
*/
export function useColumnWidth(key: string, fallback: number): [number, (width: number | null, commit: boolean) => void] {
const [saved, setSaved] = usePref<number>(key, fallback)
const [live, setLive] = useState<number | null>(null)
const apply = (width: number | null, commit: boolean): void => {
if (!commit) return setLive(width)
setLive(null)
setSaved(width ?? fallback)
}
return [live ?? saved, apply]
}
/**
* The line between two columns, which can be dragged to change the width of one of them.
*
* `grows` says which way a drag makes the column wider: `right` for a column on the left of the line (the channel
* list), `left` for one on its right (the member list). Double-click puts it back to its usual width, and the
* arrow keys move it for somebody who is not using a pointer.
*/
export function ColumnResizer({
width,
onWidth,
min,
max,
grows,
label
}: {
width: number
onWidth: (width: number | null, commit: boolean) => void
min: number
max: number
grows: 'left' | 'right'
label: string
}): JSX.Element {
const [dragging, setDragging] = useState(false)
const [from, setFrom] = useState<{ x: number; w: number } | null>(null)
const fit = (w: number): number => Math.round(Math.min(Math.max(w, min), Math.min(max, window.innerWidth * 0.45)))
const at = (x: number, start: { x: number; w: number }): number => fit(start.w + (grows === 'right' ? x - start.x : start.x - x))
const down = (e: PointerEvent<HTMLDivElement>): void => {
setFrom({ x: e.clientX, w: width })
setDragging(true)
e.currentTarget.setPointerCapture(e.pointerId)
document.body.classList.add('resizing-column')
e.preventDefault()
}
const move = (e: PointerEvent<HTMLDivElement>): void => {
if (from) onWidth(at(e.clientX, from), false)
}
const up = (e: PointerEvent<HTMLDivElement>): void => {
if (!from) return
const done = at(e.clientX, from)
setFrom(null)
setDragging(false)
document.body.classList.remove('resizing-column')
e.currentTarget.releasePointerCapture(e.pointerId)
onWidth(done, true)
}
const key = (e: KeyboardEvent<HTMLDivElement>): void => {
const step = e.shiftKey ? 40 : 10
const toward = (e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0) * (grows === 'right' ? 1 : -1)
if (!toward) return
e.preventDefault()
onWidth(fit(width + toward * step), true)
}
return (
<div
className={`divider-v resizer${dragging ? ' dragging' : ''}`}
role="separator"
aria-orientation="vertical"
aria-label={label}
aria-valuenow={width}
aria-valuemin={min}
aria-valuemax={max}
tabIndex={0}
title="Drag to resize · double-click to reset"
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
onDoubleClick={() => onWidth(null, true)}
onKeyDown={key}
/>
)
}
+100 -23
View File
@@ -1,6 +1,6 @@
import { FormatBar } from './FormatBar'
import { formatsFor, serialize, type FormatKind } from '../lib/composeFormat'
import { applyFormat } from '../lib/composeFormatDom'
import { applyFormat, restoreSelectionAfterHistory } from '../lib/composeFormatDom'
import { useMediaUrl } from '../lib/route'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Icon, IconButton, MaskIcon } from './Icon'
@@ -10,6 +10,7 @@ import { PollComposer } from './PollComposer'
import { Avatar } from './Avatar'
import { COMMON_EMOJI, emojiToken, EmojiPicker, type StickerEntry } from './EmojiPicker'
import { GifPicker } from './GifPicker'
import { ReplyBody } from './ReplyBody'
import { PlaceField } from './PlaceField'
import { VoiceRecorder } from './VoiceRecorder'
import { useActiveBuffer, useChat, useStore } from '../state/hooks'
@@ -51,6 +52,9 @@ interface StagedAttachment {
*/
const TYPING_REPEAT_MS = 4000
/** The services whose conversations can take a GIF: the picker sends a link, and these show it as the picture. */
const GIF_SERVICES = new Set(['discord', 'matrix', 'kick'])
/**
* What the box holds, as the text that will be sent.
*
@@ -104,7 +108,7 @@ interface TypedCommand {
description?: string
/** "Built-in", or the bot that answers it. */
source?: string
kind: 'builtin' | 'application'
kind: 'builtin' | 'application' | 'bot'
/** Discord's own description of the command, handed back to run it. */
command?: { options?: { name: string; type?: number; required?: boolean }[] }
}
@@ -136,6 +140,8 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
const [stickers, setStickers] = useState<StickerEntry[]>([])
const [stickerPicker, setStickerPicker] = useState(false)
const [gifPicker, setGifPicker] = useState(false)
const [discordTyping] = usePref<boolean>('discord.sendTyping', false)
const [stoatTyping] = usePref<boolean>('stoat.sendTyping', true)
/**
* What to do with files dropped on the window: they land in the tray, to be
* looked over and sent with the next message rather than going out on the
@@ -208,6 +214,8 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
*/
const [commands, setCommands] = useState<TypedCommand[]>([])
const [commandIndex, setCommandIndex] = useState(0)
/** A Telegram bot's Menu, open over the box: the bot's commands, asked for without anything being typed. */
const [botMenu, setBotMenu] = useState(false)
/**
* The application command whose name has been completed into the box.
*
@@ -240,7 +248,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' || service === 'steam'
const hasStickers = service === 'matrix' || service === 'discord' || service === 'steam' || service === 'telegram'
const formats = formatsFor(service)
/**
* The account's stickers, asked for when the picker opens - from either
@@ -250,7 +258,16 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
const loadStickers = (): void => {
if (!hasStickers || !buffer) return
void window.moho
.rpc<StickerEntry[]>(service === 'discord' ? 'listDiscordStickers' : service === 'steam' ? 'listSteamStickers' : 'listMatrixStickers', { bufferId: buffer.id })
.rpc<StickerEntry[]>(
service === 'discord'
? 'listDiscordStickers'
: service === 'steam'
? 'listSteamStickers'
: service === 'telegram'
? 'listTelegramStickers'
: 'listMatrixStickers',
{ bufferId: buffer.id }
)
.then(setStickers)
.catch(() => setStickers([]))
}
@@ -260,7 +277,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
// 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 === 'steam'
service === 'discord' || service === 'sneedchat' || service === 'matrix' || service === 'irc' || service === 'steam' || service === 'telegram' || service === 'stoat'
// Starting a reply puts the cursor in the box: the next thing anybody does
// is write the answer.
@@ -394,7 +411,9 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
const typedCommand = /^\/\S*$/.test(text) ? text.slice(1) : null
useEffect(() => {
if (typedCommand === null || !buffer) {
// The bot's Menu is open: its whole list, whatever is (or is not) in the box.
const asking = botMenu ? '' : typedCommand
if (asking === null || !buffer) {
setCommands([])
return
}
@@ -402,7 +421,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
void window.moho
.rpc<{ commands: TypedCommand[] }>('listCommands', {
bufferId: buffer.id,
query: typedCommand
query: asking
})
.then((answer) => {
setCommands(answer.commands || [])
@@ -414,10 +433,21 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
// complaint about typing a slash.
setCommands([])
})
}, 200)
}, botMenu ? 0 : 200)
return () => clearTimeout(timer)
}, [typedCommand, buffer?.id])
}, [typedCommand, buffer?.id, botMenu])
// The Menu belongs to the conversation it was opened in, and goes when anything else is clicked.
useEffect(() => setBotMenu(false), [buffer?.id])
useEffect(() => {
if (!botMenu) return
const away = (e: MouseEvent): void => {
const target = e.target as HTMLElement | null
if (!target?.closest('.command-picker, .composer-menu-button')) setBotMenu(false)
}
document.addEventListener('mousedown', away)
return () => document.removeEventListener('mousedown', away)
}, [botMenu])
if (!buffer) return null
@@ -430,6 +460,10 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
* it on services with no such notion, so there is nothing to check here.
*/
const noteTyping = (): void => {
// Not on Discord unless asked for: its own clients send it, but a third-party client that does is one more thing
// Discord can tell it apart by, and Dissent does without it altogether. A setting in Settings > Discord.
if (service === 'discord' && !discordTyping) return
if (service === 'stoat' && !stoatTyping) return
const now = Date.now()
if (now - typingSentAt.current < TYPING_REPEAT_MS) return
typingSentAt.current = now
@@ -487,6 +521,9 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
disabled: !supportsAttachments,
onClick: pickAndStage
},
...(service === 'telegram' && !thread
? ([{ label: 'Start a poll', icon: 'ballot', onClick: () => setPolling(true) }] as MenuEntry[])
: []),
...(service === 'matrix' && !thread
? ([
{
@@ -643,6 +680,15 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
const takeCommand = (command: TypedCommand): void => {
const wants = (command.usage ?? '').trim().length > 0
setCommands([])
// A Telegram bot's command is sent the moment it is chosen, as Telegram's own apps do: it is a message to the bot,
// and the menu is the bot's own list of what it understands.
if (command.kind === 'bot' && buffer) {
setBotMenu(false)
setText('')
if (inputRef.current) inputRef.current.textContent = ''
void store.sendMessage(buffer.id, `/${command.name}`)
return
}
if (!wants && command.kind === 'application') {
setPendingCommand(command)
runCommand(command, `/${command.name}`)
@@ -844,8 +890,11 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
// Somewhere that can be read and not written in: a channel this account has
// no permission to send to, or Discord's own notices. A box that every send
// is refused from is a worse answer than saying so where it would be.
if (buffer.readOnly) {
// A channel's comments are written to its discussion group, which the channel's own lock says nothing about.
if (buffer.readOnly && !(thread && service === 'telegram')) {
const official = buffer.readOnly.includes('official Discord')
// Discord's first-word lock: the daemon opens it by itself once the account has spoken there.
const firstWord = buffer.readOnly.includes("Discord's own app")
return (
<div className="composer">
<div className="divider-h" />
@@ -856,6 +905,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
{official && (
<span className="small">Discord will never ask you for your password or account token.</span>
)}
{firstWord && <span className="small">moho notices when you have, and opens this up by itself.</span>}
</div>
</div>
</div>
@@ -864,13 +914,13 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
// A Kick account with no sign-in watches; it cannot talk. Offering the
// box anyway meant typing a message the service was always going to refuse.
if (account && account.service === 'kick' && !account.hasPassword) {
if (account && (account.service === 'kick' || account.service === 'twitch') && !account.hasPassword) {
return (
<div className="composer">
<div className="divider-h" />
<div className="composer-watching small muted">
<Icon name="visibility" size={16} />
<span>Watching signed out. Sign in to Kick to talk here.</span>
<span>Watching signed out. Sign in to {account.service === 'twitch' ? 'Twitch' : 'Kick'} to talk here.</span>
<button type="button" className="button" onClick={() => store.setActivePanel('accounts')}>
Sign in
</button>
@@ -897,7 +947,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
{commands.length > 0 && (
<div className="command-picker" role="listbox" aria-label="Run a command">
<div className="picker-head small muted">
Commands matching /{typedCommand}
{botMenu ? 'Commands' : <>Commands matching /{typedCommand}</>}
</div>
{commands.map((c, i) => (
<button
@@ -1008,7 +1058,14 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
{repliedTo?.avatarUrl && <img className="reply-avatar" src={media(repliedTo.avatarUrl)} alt="" />}
<span className="ellipsis">
Replying to <strong>{replyingTo.from}</strong>
{replyingTo.body ? <span className="muted">{`: ${replyingTo.body}`}</span> : ''}
{replyingTo.body ? (
<span className="muted">
{': '}
<ReplyBody text={replyingTo.body} service={service} />
</span>
) : (
''
)}
</span>
{/* Whether the one answered is told, as Discord's own bar has it.
Only there: nothing else here can say it. */}
@@ -1048,7 +1105,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 === 'steam') && (
{(service === 'discord' || service === 'steam' || service === 'telegram') && (
<button
type="button"
className={classes('staged-action', att.spoiler && 'on')}
@@ -1075,6 +1132,19 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
<div className="composer-row">
<div className="composer-box">
{/* The bot's own menu, where Telegram puts it: ahead of the box. Opens the list of what the bot understands. */}
{service === 'telegram' && buffer.nameIcon === 'smart_toy' && !thread && (
<button
type="button"
className="composer-menu-button"
title="Show this bot's commands"
// Opens the list and nothing else: nothing is written into the box, and choosing a command sends it.
onMouseDown={(e) => e.preventDefault()}
onClick={() => setBotMenu((open) => !open)}
>
Menu
</button>
)}
{/* Everything that is not typed goes through here, inside the box on
the left as in Discord. Where there is only the one thing - a
file - it is that thing, without a menu in between. */}
@@ -1137,6 +1207,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
aria-multiline="true"
aria-label={thread ? 'Reply in thread' : `Message ${bufferDisplayName(buffer.name)}`}
onInput={(e) => {
restoreSelectionAfterHistory(e.currentTarget, (e.nativeEvent as InputEvent).inputType)
setText(composerText(e.currentTarget))
noteTyping()
noteMention()
@@ -1168,6 +1239,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
if (e.key === 'Escape') {
e.preventDefault()
setCommands([])
setBotMenu(false)
return
}
}
@@ -1232,7 +1304,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
!e.altKey &&
!e.metaKey &&
!text &&
(service === 'discord' || service === 'sneedchat' || service === 'matrix')
(service === 'discord' || service === 'sneedchat' || service === 'matrix' || service === 'telegram' || service === 'stoat')
) {
const last = store.lastEditableOwn(buffer.id)
if (last) {
@@ -1301,10 +1373,12 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
<Icon name="mood" size={18} />
</button>
{/* GIFs, wherever there is a Discord account to ask for them: the
picker borrows that account's way of looking, and what it finds can
be sent in any conversation. */}
{!thread && service !== 'steam' && accounts.some((a) => a.service === 'discord') && (
{/* GIFs, wherever there is a Discord account to ask for them: the picker borrows that account's way of looking.
What it finds is sent as a link, so it is offered only on the services that show a link to one as a picture
(GIF_SERVICES): not IRC, Steam, Twitch or Sneedchat, which take none. Telegram has GIFs of its own - the account's
saved ones and Telegram's GIF search - and gets the picker with those, asking Telegram rather than Discord. A service not on the list is
one nobody has checked, and gets no button. */}
{!thread && ((GIF_SERVICES.has(service ?? '') && accounts.some((a) => a.service === 'discord')) || service === 'telegram') && (
<button
ref={gifButtonRef}
type="button"
@@ -1324,7 +1398,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' || service === 'steam') && (
{!thread && hasStickers && (
<button
ref={stickerButtonRef}
type="button"
@@ -1346,7 +1420,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
messages at all: Discord's flag and base64 waveform, and Matrix's
MSC3245 marker with integers. The daemon speaks both from one
recording, so this is the same button either way. */}
{!thread && (service === 'discord' || service === 'matrix') && <VoiceRecorder bufferId={buffer.id} />}
{!thread && (service === 'discord' || service === 'matrix' || service === 'telegram') && <VoiceRecorder bufferId={buffer.id} />}
{/* Sending is the one thing the box does that its own Enter does not
say, so it is only drawn when there is something to send. */}
@@ -1384,6 +1458,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
bufferId={buffer.id}
accountId={buffer.accountId}
discord={service === 'discord'}
telegram={service === 'telegram'}
onClose={() => setGifPicker(false)}
/>
)}
@@ -1408,7 +1483,9 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
const sent =
service === 'steam'
? window.moho.rpc('sendSteamSticker', { bufferId: buffer.id, name: sticker.name })
: sticker.id
: service === 'telegram'
? window.moho.rpc('sendTelegramSticker', { bufferId: buffer.id, id: sticker.id })
: 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))
@@ -64,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' || account?.service === 'steam') && buffer.kind === 'dm',
canCall: (account?.service === 'discord' || account?.service === 'steam' || account?.service === 'stoat') && buffer.kind === 'dm',
live,
watched,
autojoins: store.autojoins(buffer.accountId, name),
@@ -72,17 +72,17 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
onTogglePin: () => togglePin(buffer.id),
onToggleMute: () => {
toggleMute(buffer.id)
if (account?.service === 'matrix' || account?.service === 'steam') {
if (account?.service === 'matrix' || account?.service === 'steam' || account?.service === 'telegram' || account?.service === 'stoat') {
void window.moho
.rpc(account.service === 'steam' ? 'steamSetFriendMuted' : 'setMatrixRoomMuted', { bufferId: buffer.id, muted: !isMuted(buffer.id) })
.rpc(account.service === 'steam' ? 'steamSetFriendMuted' : account.service === 'telegram' ? 'setTelegramChatMuted' : account.service === 'stoat' ? 'setStoatChannelMuted' : 'setMatrixRoomMuted', { bufferId: buffer.id, muted: !isMuted(buffer.id) })
.catch((e: Error) => store.toast('error', e.message))
}
},
onUnmuteAccount:
account?.service === 'matrix' || account?.service === 'steam'
account?.service === 'matrix' || account?.service === 'steam' || account?.service === 'telegram' || account?.service === 'stoat'
? () =>
void window.moho
.rpc(account.service === 'steam' ? 'steamSetFriendMuted' : 'setMatrixRoomMuted', { bufferId: buffer.id, muted: false })
.rpc(account.service === 'steam' ? 'steamSetFriendMuted' : account.service === 'telegram' ? 'setTelegramChatMuted' : account.service === 'stoat' ? 'setStoatChannelMuted' : 'setMatrixRoomMuted', { bufferId: buffer.id, muted: false })
.catch((e: Error) => store.toast('error', e.message))
: undefined,
onToggleAutojoin:
@@ -167,13 +167,17 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
name={buffer.name}
url={buffer.avatarUrl}
size={24}
status={dmStatus(buffer, presence, buffers)}
// Twitch has no presence for a whisper's other end; the badge says what it is instead.
status={buffer.accountId.startsWith('twitch:') ? undefined : dmStatus(buffer, presence, buffers)}
badge={buffer.kind === 'dm' ? 'mail' : undefined}
/>
) : 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>}
{/* A whisper on Twitch is headed like a channel is - a picture and a name - so it says what it is. */}
{buffer.kind === 'dm' && buffer.accountId.startsWith('twitch:') && <span className="header-status small muted">Whisper</span>}
</button>
{buffer.topic && <HeaderTopic topic={buffer.topic} />}
@@ -615,6 +615,8 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
// to a name half written.
const filterMenu = suggestions(query)
const isMatrix = buffer.accountId.startsWith('matrix:')
const isTelegram = buffer.accountId.startsWith('telegram:')
const isStoat = buffer.accountId.startsWith('stoat:')
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
@@ -682,11 +684,11 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
if (filtered) setResults([])
})
}
if (!isMatrix) return
// Then the homeserver, which has what this client never downloaded
if (!isMatrix && !isTelegram && !isStoat) return
// Then the server, which has what this client never downloaded
// - including everything said before this account joined.
return window.moho
.rpc<MatrixSearch>('searchMatrixMessages', {
.rpc<MatrixSearch>(isTelegram ? 'searchTelegramMessages' : isStoat ? 'searchStoatMessages' : 'searchMatrixMessages', {
bufferId: buffer.id,
query: text,
scope,
@@ -711,15 +713,17 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
.finally(() => setSearching(false))
}, 250)
return () => clearTimeout(timer)
}, [query, buffer.id, store, isMatrix, isDiscord, scope])
}, [query, buffer.id, store, isMatrix, isTelegram, isStoat, isDiscord, scope])
// A viewer count that never moves is worse than none, and Kick pushes a
// stream starting and stopping but never the count. The followed channels
// are polled as a group by the daemon; this is the one channel on screen,
// which may not be followed at all, asked about on its own minute.
const isKick = buffer.accountId.startsWith('kick:')
const isTwitch = buffer.accountId.startsWith('twitch:')
const hasStream = isKick || buffer.accountId.startsWith('twitch:')
useEffect(() => {
if (!isKick || buffer.kind !== 'channel') return
if (!hasStream || buffer.kind !== 'channel') return
const tick = (): void => {
void window.moho.rpc('refreshKickStream', { bufferId: buffer.id }).catch(() => {
// Nothing to say: the header keeps the last count it had, which is
@@ -728,7 +732,7 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
}
const timer = setInterval(tick, 60_000)
return () => clearInterval(timer)
}, [isKick, buffer.id, buffer.kind])
}, [hasStream, buffer.id, buffer.kind])
const rerun = async (): Promise<void> => {
setSearching(true)
@@ -950,6 +954,18 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
/>
)}
{/* Stoat's calls are held on a media server and drawn by the same stage as Matrix's: a direct message, a group or a
voice room is joined, with the camera as a second choice. */}
{buffer.accountId.startsWith('stoat:') && (buffer.kind === 'dm' || buffer.category === 'Groups' || buffer.nameIcon === 'volume_up') && matrixCall?.bufferId !== buffer.id && !matrixCall && (
<>
<IconButton name="call" title="Join the call" onClick={() => void store.joinStoatCall(buffer.id, false)} />
<IconButton name="videocam" title="Join the call with video" onClick={() => void store.joinStoatCall(buffer.id, true)} />
</>
)}
{buffer.accountId.startsWith('stoat:') && matrixCall?.bufferId === buffer.id && (
<IconButton name="call_end" title="Hang up" className="calling" onClick={() => store.hangUpMatrixCall()} />
)}
{(isDiscord || isSteam) && isDm && (
<IconButton
name={inCall ? 'call_end' : 'call'}
@@ -978,7 +994,7 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
{isMatrix && buffer.kind !== 'server' && <ThreadList buffer={buffer} />}
{isDiscord && buffer.kind === 'channel' && <DiscordThreads buffer={buffer} />}
{(isMatrix || isDiscord) && buffer.kind !== 'server' && <PinnedMessages buffer={buffer} />}
{(isMatrix || isDiscord || buffer.accountId.startsWith('telegram:') || isStoat) && buffer.kind !== 'server' && <PinnedMessages buffer={buffer} />}
{/* Beside the pins, because it is the same kind of fact about a room:
something the room keeps, rather than something said in it. */}
@@ -998,8 +1014,8 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
have polls and no predictions, IRC has neither, and an icon that
opens an empty list on a service that could never fill it is an
icon that says the feature is missing rather than absent. */}
{(isKick || isMatrix) && <CardHistory buffer={buffer} kind="poll" icon="help" label="Past polls" />}
{isKick && <CardHistory buffer={buffer} kind="prediction" icon="casino" label="Past predictions" />}
{(isKick || isMatrix || isTwitch) && <CardHistory buffer={buffer} kind="poll" icon="help" label="Past polls" />}
{(isKick || isTwitch) && <CardHistory buffer={buffer} kind="prediction" icon="casino" label="Past predictions" />}
{/* An icon until it is being used. A box wide enough to type into is
the single widest thing in this row, and a header that always
@@ -1083,7 +1099,7 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
{/* Where to look. Only on Matrix, because it is the only protocol
here whose server will answer the question - and the room is
often exactly what somebody searching has forgotten. */}
{isMatrix && (
{(isMatrix || isTelegram) && (
<div className="search-scope small">
<button
type="button"
+16
View File
@@ -1,6 +1,8 @@
import { useEffect, useRef, useState } from 'react'
import { ContextMenu, type MenuEntry } from './ContextMenu'
import type { EditAction, EditMenuRequest } from '../../../shared/ipc'
import { usePref } from '../state/hooks'
import { DEFAULT_SEARCH_ENGINE, engineName, searchUrl, shortened } from '../lib/searchengines'
/**
* The right-click menu for anywhere text is typed: the message box, and every
@@ -14,6 +16,8 @@ import type { EditAction, EditMenuRequest } from '../../../shared/ipc'
export function EditMenu(): JSX.Element | null {
const [menu, setMenu] = useState<EditMenuRequest | null>(null)
const focus = useRef<{ element: HTMLElement | null; range: Range | null }>({ element: null, range: null })
const [searchEngine] = usePref<string>('search.engine', DEFAULT_SEARCH_ENGINE)
const [searchCustom] = usePref<string>('search.customUrl', '')
useEffect(
() =>
@@ -79,6 +83,18 @@ export function EditMenu(): JSX.Element | null {
{ separator: true },
{ label: 'Select all', icon: 'select_all', disabled: !menu.can.selectAll, onClick: act('selectAll') }
)
// Looking up what is selected, in the browser the person already uses.
const address = searchUrl(menu.selection ?? '', searchEngine, searchCustom)
if (address) {
entries.push(
{ separator: true },
{
label: `Search ${engineName(searchEngine, searchCustom)} for “${shortened(menu.selection)}”`,
icon: 'search',
onClick: () => void window.moho.openExternal(address)
}
)
}
return <ContextMenu x={menu.x} y={menu.y} entries={entries} onClose={() => setMenu(null)} />
}
+42 -9
View File
@@ -1,8 +1,10 @@
import React, { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useMediaUrl } from '../lib/route'
import { Icon } from './Icon'
import { Icon, ServiceMark } from './Icon'
import { serviceIcon } from '../lib/util'
import { discordEmojiUrl, emojiPreview, sourcePick, type SmilieEntry } from '../lib/format'
import { recentsHere } from '../lib/recents'
import { drawableEmoteUrl, onLocalEmotes } from '../lib/emotecache'
import { LottieSticker } from './LottieSticker'
import type { CustomEmoji } from '../../../shared/wire'
@@ -74,6 +76,8 @@ export const COMMON_EMOJI: { emoji: string; name: string }[] = [
export function emojiToken(e: CustomEmoji): string {
// Steam's, named rather than numbered: the id carries the service.
if (e.id.startsWith('steam:')) return `[steam:emote:${e.id.slice(6)}]`
// Twitch's, which the daemon names by the token its messages carry: the box draws the picture and Twitch is sent the word.
if (e.id.startsWith('[twitch:')) return e.id
if (e.url) return `[emote:${e.id}:${e.name}]`
return `<${e.animated ? 'a' : ''}:${e.name}:${e.id}>`
}
@@ -123,7 +127,9 @@ function stickerKey(s: StickerEntry): string {
export function readRecent(accountId?: string): string[] {
try {
const stored = JSON.parse(localStorage.getItem(recentKey(accountId)) || '[]')
return Array.isArray(stored) ? stored : []
// Without the bare numbers older builds kept for a custom emoji picked from another server: they were never a
// message, and are not worth showing.
return Array.isArray(stored) ? stored.filter((r) => !/^\d+$/.test(String(r))) : []
} catch {
return []
}
@@ -262,6 +268,8 @@ export function EmojiPicker({
// daemon rather than assembled here, because the answer depends on things
// only it knows - a Nitro subscription, a Kick channel's standing.
const [sources, setSources] = useState<EmojiSource[]>([])
/** Whether the daemon has said yet what can be sent here, which decides what the recent picks may offer. */
const [sourcesLoaded, setSourcesLoaded] = useState(false)
const scroll = useRef<HTMLDivElement>(null)
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({})
@@ -272,7 +280,10 @@ export function EmojiPicker({
// An older daemon has no such method; the buffer's own emoji below are
// what this showed before it asked, so that is what it falls back to.
.catch(() => [])
.then(setSources)
.then((list) => {
setSources(list)
setSourcesLoaded(true)
})
}, [bufferId, stickersOnly])
// The picker is normally opened fresh, but it stays up across a buffer
@@ -407,6 +418,26 @@ export function EmojiPicker({
return groups
}, [custom, sources])
// What can be sent in this conversation, as the text each is picked as. A recent pick is shown only if it is in
// here (or is an ordinary Unicode emoji): one from another server, another channel, an unlock that has lapsed or
// an account without Nitro is not something this conversation can take, so it is not offered - on every service.
// The stored list is left as it is, so a pick comes back where it works again.
const onOffer = useMemo(() => {
const set = new Set<string>()
for (const src of sources) {
if (!src.usableHere) continue
for (const e of src.emoji) {
if (e.locked) continue
set.add(sourcePick(src.service, e))
set.add(emojiToken({ id: e.id, name: e.name, url: e.url, animated: e.animated } as CustomEmoji))
}
}
for (const e of customEmoji) set.add(emojiToken(e))
for (const s of smilies) for (const a of s.aliases ?? []) set.add(a)
return set
}, [sources, customEmoji, smilies])
const recentHere = useMemo(() => recentsHere(recent, onOffer, sourcesLoaded, fixedSet), [recent, onOffer, sourcesLoaded, fixedSet])
const jumpTo = (id: string): void => {
sectionRefs.current[id]?.scrollIntoView({ block: 'start', behavior: 'smooth' })
}
@@ -527,12 +558,14 @@ export function EmojiPicker({
}))
]
: [
...(!q && recent.length > 0 ? [{ id: 'recent', name: 'Recent', icon: <Icon name="schedule" size={20} /> }] : []),
...(!q && recentHere.length > 0 ? [{ id: 'recent', name: 'Recent', icon: <Icon name="schedule" size={20} /> }] : []),
...sourceSections.map((src) => ({
id: src.id,
name: src.name,
icon: src.iconUrl ? (
<img src={media(src.iconUrl)} alt="" />
<img src={media(src.iconUrl)} alt="" className="emoji-rail-avatar" />
) : src.service === 'twitch' && src.name === 'Twitch' ? (
<ServiceMark icon={serviceIcon('twitch')} size={20} />
) : (
<span className="emoji-jump-initial">{src.name.slice(0, 1).toUpperCase()}</span>
)
@@ -742,9 +775,9 @@ export function EmojiPicker({
}}
>
<PickerScroll.Provider value={scroll}>
{!stickersOnly && !q && recent.length > 0 && (
{!stickersOnly && !q && recentHere.length > 0 && (
<Section title="Recent" anchorRef={(el) => (sectionRefs.current.recent = el)}>
{recent.map((r) => {
{recentHere.map((r) => {
// A recent is stored as the text that gets sent, which for a
// custom emoji or a shortcode is a stand-in rather than the
// thing. Resolved back to a picture here, so the row of recents
@@ -782,7 +815,7 @@ export function EmojiPicker({
title={src.name}
anchorRef={(el) => (sectionRefs.current[src.id] = el)}
>
{src.emoji.map((e) => {
{src.emoji.map((e, at) => {
// Only one reason left: a source that could not be used here
// at all was dropped above, so a cell that cannot be pressed is
// one this account has not paid for - in a room it is reading.
@@ -802,7 +835,7 @@ export function EmojiPicker({
: emojiPreview(e.id, smilies)?.src
return (
<button
key={`${src.id}:${e.id}`}
key={`${src.id}:${e.id}:${at}`}
type="button"
// A cell with no picture shows its name, and the styling
// that keeps a name inside its square is `.emoji-cell.
+10 -2
View File
@@ -43,6 +43,7 @@ export function GifPicker({
bufferId,
accountId,
discord,
telegram,
onClose
}: {
anchor: HTMLElement | null
@@ -50,6 +51,8 @@ export function GifPicker({
accountId: string
/** Whether the conversation is on Discord, which decides what is sent. */
discord: boolean
/** Whether the conversation is on Telegram, which has GIFs of its own: the account's saved ones, and its GIF search. */
telegram?: boolean
onClose: () => void
}): JSX.Element {
const store = useStore()
@@ -116,7 +119,7 @@ export function GifPicker({
const wait = setTimeout(
() => {
window.moho
.rpc<{ gifs: GifEntry[]; categories: GifCategory[] }>('listGifs', { query: query.trim() })
.rpc<{ gifs: GifEntry[]; categories: GifCategory[] }>(telegram ? 'listTelegramGifs' : 'listGifs', telegram ? { bufferId, query: query.trim() } : { query: query.trim() })
.then((answer) => {
if (!current) return
setGifs(answer.gifs)
@@ -135,10 +138,15 @@ export function GifPicker({
current = false
clearTimeout(wait)
}
}, [query])
}, [query, telegram, bufferId])
const pick = (gif: GifEntry): void => {
onClose()
// Telegram's are files on Telegram: sending one is sending that file, not a link to it.
if (telegram) {
void window.moho.rpc('sendTelegramGif', { bufferId, id: gif.id }).catch((e: Error) => store.toast('error', e.message))
return
}
// Discord's own client sends the link, which Discord turns into the GIF.
// Everything else is sent the file, since a page address would be only
// that.
+94 -1
View File
@@ -74,6 +74,12 @@ export function JoinPanel(): JSX.Element {
return <DiscordJoin account={account} />
case 'kick':
return <KickJoin account={account} />
case 'twitch':
return <TwitchJoin account={account} />
case 'telegram':
return <TelegramJoin account={account} />
case 'stoat':
return <StoatJoin account={account} />
case 'sneedchat':
return <SneedchatRooms account={account} />
case 'steam':
@@ -82,7 +88,7 @@ export function JoinPanel(): JSX.Element {
return <div className="panel muted">Joining isn&apos;t supported for this service yet.</div>
}
})()
const joinable = all.filter((a) => ['irc', 'matrix', 'discord', 'kick', 'sneedchat', 'steam'].includes(a.service))
const joinable = all.filter((a) => ['irc', 'matrix', 'discord', 'kick', 'sneedchat', 'steam', 'twitch', 'telegram', 'stoat'].includes(a.service))
return (
<div className="join-frame">
@@ -361,6 +367,93 @@ function KickJoin({ account }: { account: Account }): JSX.Element {
)
}
function TwitchJoin({ account }: { account: Account }): JSX.Element {
const store = useStore()
return (
<div className="panel join-panel">
<SubmitField
label="Open a streamer's chat"
placeholder="channel name, or a twitch.tv link"
onSubmit={(name) => void store.joinAndOpen(account.id, name)}
/>
<p className="small muted">
Twitch chat is public, so this works without signing in. Signing in adds talking and the
channels you follow. A channel that does not exist is said so and not kept.
</p>
</div>
)
}
/** Joining a Stoat server by its invite, and asking somebody to be a friend. */
function StoatJoin({ account }: { account: Account }): JSX.Element {
const store = useStore()
const [friend, setFriend] = useState('')
const [busy, setBusy] = useState(false)
const connected = account.state === 'connected'
const ask = (): void => {
const typed = friend.trim()
if (!typed) return
setBusy(true)
void window.moho
.rpc('addStoatFriend', { accountId: account.id, username: typed })
.then(() => {
setFriend('')
store.toast('info', `Friend request sent to ${typed}`)
})
.catch((e: Error) => store.toast('error', e.message))
.finally(() => setBusy(false))
}
return (
<div className="panel join-panel">
<SubmitField
label="Join a server"
placeholder="An invite link, or its code"
onSubmit={(code) => void store.joinAndOpen(account.id, code)}
/>
<span className="small muted">Add a friend</span>
<div className="field-row">
<input
className="text-field"
placeholder="Their name and number, like name#1234"
spellCheck={false}
autoComplete="off"
value={friend}
disabled={!connected || busy}
onChange={(e) => setFriend(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') ask()
}}
/>
<button type="button" className="button" disabled={!connected || busy || !friend.trim()} onClick={ask}>
Add friend
</button>
</div>
<p className="small muted">
Somebody who asks to be your friend waits as a tile in the server list, where you answer. Closing a server
from its menu leaves it, unless you own it.
</p>
</div>
)
}
function TelegramJoin({ account }: { account: Account }): JSX.Element {
const store = useStore()
return (
<div className="panel join-panel">
<SubmitField
label="Join a chat, or open a conversation"
placeholder="@name, or a t.me link"
onSubmit={(name) => void store.joinAndOpen(account.id, name)}
/>
<p className="small muted">
A public group or channel is joined by its @name or t.me link, a private one by the invite link
somebody gave you, and a person's @name opens a conversation with them. Telegram may ask an
administrator to accept a request to join. Closing a chat in the list leaves it.
</p>
</div>
)
}
/** One person in a friend request. */
interface SteamPerson {
steamid: string
+59 -4
View File
@@ -1,10 +1,14 @@
import { Icon } from './Icon'
import { useStore } from '../state/hooks'
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 const KICK_EVENT_KINDS = ['kicks', 'gift-sub', 'raid', 'redeem', 'subscribe', 'moderation', 'stream', 'raid-out']
/** The glyphs of a moderation card that is a penalty: red. The rest (a mode, an unban, a shoutout) are not. */
const PUNISHING = ['gavel', 'timer_off', 'delete', 'block']
export function isKickEvent(embed: Embed): boolean {
return !!embed.kind && KICK_EVENT_KINDS.includes(embed.kind)
@@ -30,7 +34,8 @@ function KicksMark({ size = 16 }: { size?: number }): JSX.Element {
* 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 {
export function KickEventCard({ embed, bufferId }: { embed: Embed; bufferId?: string }): JSX.Element | null {
const store = useStore()
const who = embed.author
if (!who) return null
const name = (colour?: string): JSX.Element => (
@@ -86,6 +91,39 @@ export function KickEventCard({ embed }: { embed: Embed }): JSX.Element | null {
)
}
// Twitch: the channel being watched is sending its viewers to another (the embed's title is how many, its
// description the other channel's login), and the card offers to go along.
if (embed.kind === 'raid-out') {
const account = bufferId ? store.accountFor(bufferId) : undefined
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">
<span className="kick-event-verb">Raiding</span>
{name()}
{embed.title && (
<>
<span className="kick-event-verb">with</span>
<strong className="kick-event-amount">{embed.title}</strong>
<span className="kick-event-verb">{embed.title === '1' ? 'viewer.' : 'viewers.'}</span>
</>
)}
</div>
{account && embed.description && (
<div className="kick-event-message">
<button type="button" className="button subtle" onClick={() => void store.joinAndOpen(account.id, embed.description ?? '')}>
Open {who}'s chat
</button>
</div>
)}
</div>
</div>
)
}
if (embed.kind === 'redeem') {
return (
<div className="kick-event redeem selectable" style={tint}>
@@ -127,9 +165,9 @@ export function KickEventCard({ embed }: { embed: Embed }): JSX.Element | null {
if (embed.kind === 'moderation') {
return (
<div className="kick-event moderation selectable">
<div className={`kick-event moderation selectable${PUNISHING.includes(embed.icon ?? 'gavel') ? '' : ' soft'}`}>
<span className="kick-event-badge lead">
<Icon name="gavel" size={20} />
<Icon name={embed.icon || 'gavel'} size={20} />
</span>
<div className="kick-event-line">
<strong className="kick-event-name">{who}</strong>
@@ -192,3 +230,20 @@ export function KickEventCard({ embed }: { embed: Embed }): JSX.Element | null {
</div>
)
}
/**
* A line the service wrote rather than somebody saying it - a join, a topic change, a notice about the
* conversation - as the same card the events above are, so every protocol's notifications look alike and a
* protocol added later gets them by sending a line that is not speech. It carries the words as the log draws
* them (links and emoji in place), a mark for what kind of line it was, and takes the red of a warning.
*/
export function NotificationCard({ icon, warning, children }: { icon: string; warning?: boolean; children: React.ReactNode }): JSX.Element {
return (
<div className={`kick-event notification selectable${warning ? ' warning' : ''}`}>
<span className="kick-event-badge lead">
<Icon name={icon} size={18} />
</span>
<div className="kick-event-line">{children}</div>
</div>
)
}
+22 -144
View File
@@ -1,70 +1,33 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { Icon } from './Icon'
import { useStore } from '../state/hooks'
import { useEscapeLayer } from '../lib/layers'
import { useChat } from '../state/hooks'
import { RewardsPanel } from './RewardsPanel'
/**
* Channel points, and what the streamer will trade them for.
* A Kick channel's points, and the way into what the streamer will trade them for.
*
* A plaque at the foot of the viewer list, because that is where Kick puts it
* and because it belongs to the channel rather than to the conversation - it
* is a fact about standing in this room, like the viewer count above it.
*
* The list opens upward. The plaque sits at the bottom of a full-height
* column, so a pane growing downward from it would be drawn mostly off the
* window; anchoring it to the plaque's top edge and letting it grow up is the
* only direction with room. Its height is capped and it scrolls, because a
* channel may offer more rewards than the window is tall.
* A plaque at the foot of the viewer list, because that is where Kick puts it and because it belongs to the
* channel rather than to the conversation - it is a fact about standing in this room, like the viewer count
* above it. Pressing it opens the rewards panel over the chat (RewardsPanel.tsx), the same one Twitch's plaque
* opens, instead of a menu of its own.
*/
interface Reward {
id: string
title: string
cost: number
description?: string
enabled: boolean
paused: boolean
needsInput: boolean
}
/** How often the balance is asked for while this channel is on screen. */
const POINTS_INTERVAL_MS = 60_000
interface Answer {
rewards: Reward[]
points: number | null
}
export function KickPoints({ bufferId }: { bufferId: string }): JSX.Element | null {
const store = useStore()
const [open, setOpen] = useState(false)
const [answer, setAnswer] = useState<Answer | null>(null)
const [busy, setBusy] = useState('')
const box = useRef<HTMLDivElement>(null)
const [points, setPoints] = useState<number | null>(null)
const refresh = (): void => {
void window.moho
.rpc<Answer>('listKickRewards', { bufferId })
.then((a) => {
setAnswer(a)
if (a.points !== null) setPoints(a.points)
})
.catch(() => setAnswer(null))
}
// Told by Kick the moment it changes, where the daemon is following it; the timer below is for a channel that
// is not live, which is not followed.
const pushed = useChat((s) => s.kickPointsByBuffer[bufferId])
useEffect(() => {
if (pushed !== undefined) setPoints(pushed)
}, [pushed])
// The balance, shown whether or not the pane is open, and kept current
// while somebody is actually looking at this channel.
//
// Only while looking: this component exists only for the conversation on
// screen, so a channel scrolled away from stops costing anything the moment
// it is closed. A poll per open channel would be a request a minute for
// every Kick channel an account watches, forever, to keep numbers nobody is
// reading up to date.
//
// Paused with the window too. A minimised client is not somebody watching,
// and points that are a few minutes stale when the window comes back are
// corrected by the next tick before anyone could spend them.
// The balance, shown whether or not the panel is open, and kept current while somebody is actually looking at
// this channel: this component exists only for the conversation on screen, and is paused with the window.
useEffect(() => {
let cancelled = false
const ask = (): void => {
@@ -72,15 +35,12 @@ export function KickPoints({ bufferId }: { bufferId: string }): JSX.Element | nu
void window.moho
.rpc<{ points: number }>('kickPoints', { bufferId })
.then((a) => !cancelled && setPoints(a.points))
// A channel that will not say leaves the last number rather than
// blanking the plaque - it was true a minute ago, which is closer
// than nothing.
// A channel that will not say leaves the last number rather than blanking the plaque.
.catch(() => {})
}
setPoints(null)
ask()
const timer = setInterval(ask, POINTS_INTERVAL_MS)
// Coming back to the window should not wait out the rest of a minute.
document.addEventListener('visibilitychange', ask)
return () => {
cancelled = true
@@ -89,99 +49,17 @@ export function KickPoints({ bufferId }: { bufferId: string }): JSX.Element | nu
}
}, [bufferId])
// The rest - what the points can buy - only when the pane is opened. A
// channel's rewards are set up once and edited rarely, so asking for them
// every minute would be a request spent on an answer that does not change.
useEffect(() => {
if (open) refresh()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, bufferId])
// Closing the channel takes the pane with it, so it cannot be left open
// over a conversation it is not about.
// Closing the channel takes the panel with it, so it cannot be left open over a conversation it is not about.
useEffect(() => setOpen(false), [bufferId])
useEscapeLayer(() => setOpen(false), open)
useEffect(() => {
if (!open) return
const away = (e: MouseEvent): void => {
if (!box.current?.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', away)
return () => {
document.removeEventListener('mousedown', away)
}
}, [open])
const redeem = (reward: Reward): void => {
setBusy(reward.id)
void window.moho
.rpc<{ points: number | null }>('redeemKickReward', { bufferId, rewardId: reward.id })
.then((a) => {
store.toast('info', `Redeemed ${reward.title}`)
// The balance the server now holds, rather than this one minus the
// cost - a redeem that was refunded or cost something else should not
// leave the plaque disagreeing with Kick.
if (a.points !== null) setPoints(a.points)
})
.catch((e: Error) => store.toast('error', e.message))
.finally(() => setBusy(''))
}
return (
<div className="kick-points" ref={box}>
{open && (
<div className="kick-rewards" role="menu">
<div className="kick-rewards-head small muted">
{answer ? `${answer.rewards.length} rewards` : 'Asking Kick…'}
</div>
<div className="kick-rewards-list">
{answer?.rewards.map((r) => {
// Shown whatever their state - the list is also how somebody
// learns what this channel does - but only one of them can
// actually be pressed.
const why = !r.enabled
? 'The streamer has this turned off'
: r.paused
? 'Paused right now'
: r.needsInput
? 'This one asks you to type something, which moho cannot do yet'
: points !== null && points < r.cost
? `You have ${points.toLocaleString()} of ${r.cost.toLocaleString()}`
: ''
return (
<button
key={r.id}
type="button"
className={`kick-reward${why ? ' locked' : ''}`}
disabled={!!why || busy === r.id}
title={why || r.description || r.title}
onClick={() => redeem(r)}
>
<span className="ellipsis">{r.title}</span>
<span className="kick-reward-cost small">{r.cost.toLocaleString()}</span>
</button>
)
})}
{answer?.rewards.length === 0 && (
<div className="small muted kick-rewards-empty">This channel offers nothing for points.</div>
)}
</div>
</div>
)}
<button
type="button"
className={`kick-points-plaque${open ? ' open' : ''}`}
title="Channel points, and what they buy"
onClick={() => setOpen(!open)}
>
<div className="kick-points">
<button type="button" className={`kick-points-plaque${open ? ' open' : ''}`} title="Channel points, and what they buy" onClick={() => setOpen(true)}>
<Icon name="toll" size={15} />
<span className="ellipsis">{points === null ? 'Points' : points.toLocaleString()}</span>
<Icon name={open ? 'expand_more' : 'expand_less'} size={15} />
<Icon name="redeem" size={15} />
</button>
{open && <RewardsPanel bufferId={bufferId} service="kick" onClose={() => setOpen(false)} onBalance={setPoints} />}
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { useMediaUrl } from '../lib/route'
import { useStore } from '../state/hooks'
import { Icon } from './Icon'
import type { Embed } from '../../../shared/wire'
/**
* The preview of a link as Telegram draws it: the site's name, the page's title and description, its picture, and
* - where Telegram has made an article of the page - an Instant View button that opens the article in a window of its
* own. The same card for any link the service previewed; it is only the button that depends on the article.
*/
export function LinkCard({ embed, bufferId, messageId }: { embed: Embed; bufferId: string; messageId: string }): JSX.Element {
const store = useStore()
const media = useMediaUrl()
const instant = embed.kind === 'instant'
const openPage = (): void => {
if (embed.url) void window.moho.openExternal(embed.url)
}
const openArticle = (): void => {
window.moho
.rpc<{ title: string; html: string }>('telegramInstantView', { bufferId, messageId })
.then((article) => window.moho.openArticle(article.title, article.html))
.catch((e: Error) => store.toast('error', e.message))
}
return (
<div className="link-card selectable">
{embed.provider && <div className="link-card-site">{embed.provider}</div>}
{embed.title && (
<a
className="link-card-title"
href={embed.url}
onClick={(e) => {
e.preventDefault()
openPage()
}}
>
{embed.title}
</a>
)}
{embed.description && <div className="link-card-desc">{embed.description}</div>}
{embed.imageUrl && (
<img className="link-card-image" src={media(embed.imageUrl)} alt="" loading="lazy" onClick={instant ? openArticle : openPage} />
)}
{instant && (
<button type="button" className="link-card-instant" onClick={openArticle}>
<Icon name="bolt" size={15} />
INSTANT VIEW
</button>
)}
</div>
)
}
+3 -3
View File
@@ -149,7 +149,7 @@ function Card({ card, reviewing = false }: { card: LiveCard; reviewing?: boolean
{/* Ending it is the streamer's own gesture, and the service refuses
it for anybody else - so it is offered and allowed to fail there
rather than guessed at from a role this client may not know. */}
{!reviewing && !isPrediction && open && (
{!reviewing && !isPrediction && open && !card.readOnly && !card.noEnd && (
<IconButton name="delete" size={16} title="End this poll" onClick={() => store.endPoll(card.bufferId, card.id)} />
)}
<IconButton
@@ -175,7 +175,7 @@ function Card({ card, reviewing = false }: { card: LiveCard; reviewing?: boolean
)}
// A poll row is the vote; a prediction row only chooses what the
// points go on, because the amount is the other half of a bet.
disabled={reviewing || !open || (!isPrediction && card.hasVoted)}
disabled={reviewing || !open || card.readOnly || (!isPrediction && card.hasVoted)}
onClick={() =>
isPrediction ? setPicked(option.id) : store.votePoll(card.bufferId, option.id)
}
@@ -216,7 +216,7 @@ function Card({ card, reviewing = false }: { card: LiveCard; reviewing?: boolean
{/* The bet itself. Only while it is taking them, and only where the
service says what this account has to spend - offering to bet
points that cannot be counted is offering a button that fails. */}
{isPrediction && !reviewing && open && (
{isPrediction && !reviewing && open && !card.readOnly && (
<div className="poll-bet">
<input
className="poll-bet-amount"
+79 -40
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { Icon } from './Icon'
import { Lightbox, type LightboxSource } from './Lightbox'
import { registerMedia } from '../lib/gallery'
import { VideoPlayer } from './VideoPlayer'
import { VoiceMessage } from './VoiceMessage'
import { LottieSticker, lottieStickerId } from './LottieSticker'
import { fullImageFor, knownFullImage } from '../lib/fullimage'
@@ -45,6 +46,8 @@ interface Props {
* fills the cell and crops to it rather than keeping its own shape.
*/
tile?: boolean
/** Present when the message this is in can be edited by this person: the video's pencil. */
onEdit?: () => void
}
/** What Discord puts at the front of a file name to hide it behind a spoiler. */
@@ -94,7 +97,8 @@ export function MediaEmbed({
onOpenInDiscord,
onRefresh,
onStale,
tile
tile,
onEdit
}: Props): JSX.Element | null {
// Through the account's route when it is strictly routed (#257).
const media = useMediaUrl()
@@ -178,7 +182,8 @@ export function MediaEmbed({
viewerRef.current = viewerSource
useEffect(() => {
const el = wrapRef.current
if (!el || (kind !== 'image' && kind !== 'video')) return
// Pictures only: a video goes full screen by itself and is not stepped to from the viewer.
if (!el || kind !== 'image') return
return registerMedia(el, () => viewerRef.current())
}, [kind])
@@ -421,6 +426,36 @@ export function MediaEmbed({
// video/quicktime, which canPlayType refuses outright while the browser
// then plays the file perfectly well once it looks inside. Letting it try
// and handling the failure is the honest test.
//
// A Telegram video is not in the cache until it is fetched, and fetching is
// the whole file: a feed of videos would pull hundreds of megabytes just to
// be scrolled. So it is drawn as its preview, and fetched when played.
const deferred =
!attachment?.gif && !!attachment?.thumbnailPath && !previewGone && !playing && /[\\/]telegram-media[\\/]/.test(fullSrc)
if (deferred) {
return (
<span className={classes('media-embed-wrap video', tile && 'tile', hidden && 'spoilered')} style={{ ...ratio, position: 'relative' }} {...reveal}>
<img
className="media-embed"
style={ratio}
src={media(previewSrc)}
alt={attachment?.filename || ''}
loading="lazy"
onError={() => setPreviewGone(true)}
/>
<button
type="button"
className="video-expand"
title="Play"
style={{ position: 'absolute', inset: 0, margin: 'auto', width: 48, height: 48 }}
onClick={() => setPlaying(true)}
>
<Icon name="play_circle" size={32} />
</button>
{cover}
</span>
)
}
return (
<>
<span
@@ -429,46 +464,50 @@ export function MediaEmbed({
style={ratio}
{...reveal}
>
<video
className="media-embed"
style={ratio}
src={media(fullSrc)}
// A GIF is silent, runs on its own and goes round, and has no
// player to operate: it plays when it is shown if autoplay is on,
// and otherwise while the pointer is over it. Any other video is a
// video, with its controls.
{...(attachment?.gif
? {
muted: true,
autoPlay: autoplay,
loop: true,
onMouseEnter: (e: React.MouseEvent<HTMLVideoElement>) => !autoplay && void e.currentTarget.play().catch(() => {}),
onMouseLeave: (e: React.MouseEvent<HTMLVideoElement>) => !autoplay && e.currentTarget.pause()
}
: { controls: true, loop })}
// Enough to draw the first frame and know how long it is, without
// pulling megabytes for every video in a scrolled-past backlog.
preload="metadata"
playsInline
onError={() => setFailed(true)}
/>
<button
type="button"
className="video-expand"
title="Expand"
onClick={() => setExpanded(true)}
>
<Icon name="fullscreen" size={16} />
</button>
{attachment?.gif ? (
<video
className="media-embed"
style={ratio}
src={media(fullSrc)}
// A GIF is silent, runs on its own and goes round, and has no player to operate: it plays when it is
// shown if autoplay is on, and otherwise while the pointer is over it.
muted
autoPlay={autoplay}
loop
onMouseEnter={(e) => !autoplay && void e.currentTarget.play().catch(() => {})}
onMouseLeave={(e) => !autoplay && e.currentTarget.pause()}
preload="metadata"
playsInline
onError={() => setFailed(true)}
/>
) : (
// Any other video is a video, with the one set of controls every service gets.
<VideoPlayer
src={media(fullSrc)}
className="media-embed"
style={ratio}
loop={loop}
autoPlay={playing}
filename={shownName}
saveFrom={openTarget}
onEdit={onEdit}
onError={() => setFailed(true)}
/>
)}
{/* A video has its own full-screen button in its controls. One without controls (a silent looping clip)
has none, so it gets this: the same full screen, never the viewer. */}
{attachment?.gif && (
<button
type="button"
className="video-expand"
title="Full screen"
onClick={(e) => void e.currentTarget.parentElement?.querySelector('video')?.requestFullscreen?.().catch(() => {})}
>
<Icon name="fullscreen" size={16} />
</button>
)}
{cover}
</span>
{expanded && (
<Lightbox
source={viewerSource()}
anchor={wrapRef.current}
onClose={() => setExpanded(false)}
/>
)}
</>
)
}
+152 -28
View File
@@ -14,16 +14,52 @@ import type { BufferEntry } from '../state/store'
*
* Sits above the composer rather than replacing it. The box still works for
* every other server, and hiding it here would be one more thing to explain.
*
* The dialog is the server's own form as Discord's client draws it: the rules
* as a numbered list with the agreement under them, and any question the
* server added - a line of text, a paragraph, or a choice of answers.
*/
interface FormField {
field_type?: string
label?: string
description?: string | null
required?: boolean
values?: string[] | null
placeholder?: string | null
}
interface VerificationForm {
description?: string | null
form_fields?: { field_type?: string; label?: string; description?: string | null }[]
form_fields?: FormField[]
guild?: {
id?: string
name?: string
icon?: string | null
approximate_member_count?: number
approximate_presence_count?: number
}
}
/** The daemon's answer - or, in the screenshot harness only, a fixture standing in for it (see RewardsPanel). */
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)
}
/** Whether the person has done what this field needs. */
function answered(field: FormField, answer: unknown): boolean {
if (!field.required && field.field_type !== 'TERMS') return true
if (field.field_type === 'TERMS') return answer === true
if (field.field_type === 'MULTIPLE_CHOICE') return typeof answer === 'number'
return typeof answer === 'string' && answer.trim().length > 0
}
export function MembershipGate({ buffer }: { buffer: BufferEntry }): JSX.Element | null {
const store = useStore()
const groups = useChat((s) => s.groups)
const [rules, setRules] = useState<VerificationForm | null>(null)
const [answers, setAnswers] = useState<unknown[]>([])
const [busy, setBusy] = useState(false)
const group = groups.find((g) => g.id === buffer.groupId)
@@ -32,22 +68,36 @@ export function MembershipGate({ buffer }: { buffer: BufferEntry }): JSX.Element
const open = (): void => {
setBusy(true)
void window.moho
.rpc<VerificationForm>('getDiscordMemberVerification', {
void ask<VerificationForm>('getDiscordMemberVerification', {
accountId: group.accountId,
guildId
})
// An empty form still opens the dialog: the server has a gate even if
// it wrote nothing to explain it, and agreeing is still what lifts it.
.then((form) => setRules(form ?? {}))
.then((form) => {
setAnswers([])
setRules(form ?? {})
})
.catch((e: Error) => store.toast('error', e.message))
.finally(() => setBusy(false))
}
const fields = rules?.form_fields ?? []
const ready = fields.every((f, i) => answered(f, answers[i]))
const answer = (i: number, value: unknown): void =>
setAnswers((current) => {
const next = [...current]
next[i] = value
return next
})
const accept = (): void => {
setBusy(true)
void window.moho
.rpc('acceptDiscordMemberVerification', { accountId: group.accountId, guildId })
void ask('acceptDiscordMemberVerification', {
accountId: group.accountId,
guildId,
responses: fields.map((_, i) => answers[i] ?? null)
})
.then(() => {
setRules(null)
store.toast('info', `You can talk in ${group.name} now.`)
@@ -56,40 +106,114 @@ export function MembershipGate({ buffer }: { buffer: BufferEntry }): JSX.Element
.finally(() => setBusy(false))
}
// The rules as the server wrote them. Discord keeps them in the TERMS
// field's label, with the description above it as a preamble.
const terms = rules?.form_fields?.find((f) => f.field_type === 'TERMS') ?? rules?.form_fields?.[0]
const guild = rules?.guild
const icon = guild?.id && guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=128` : group.iconUrl
const name = guild?.name || group.name
return (
<>
<div className="membership-gate">
<Icon name="lock" size={16} />
<span className="ellipsis">
You must complete a few more steps before you can talk in {group.name}.
</span>
<span className="ellipsis">You must complete a few more steps before you can talk in {group.name}.</span>
<button type="button" className="button primary" disabled={busy} onClick={open}>
Complete
</button>
</div>
{rules && (
<Modal title={group.name} icon="gavel" className="membership-rules" onClose={() => setRules(null)}>
{rules.description && <p className="small">{rules.description}</p>}
{terms?.label && <p className="small">{terms.label}</p>}
{terms?.description && <p className="small muted">{terms.description}</p>}
{!rules.description && !terms?.label && (
<p className="small muted">
This server asks new members to agree to its rules before talking.
</p>
)}
<div className="membership-rules-actions">
<button type="button" className="button" onClick={() => setRules(null)} autoFocus>
Cancel
</button>
<button type="button" className="button primary" disabled={busy} onClick={accept}>
Agree and continue
</button>
<Modal title={name} icon="gavel" className="membership-rules" onClose={() => setRules(null)}>
<div className="membership-body">
<div className="membership-card">
{icon ? <img className="membership-card-icon" src={icon} alt="" /> : <Icon name="groups" size={40} />}
<div className="membership-card-name">{name}</div>
{(guild?.approximate_presence_count || guild?.approximate_member_count) && (
<div className="small muted">
{guild.approximate_presence_count ? `${guild.approximate_presence_count.toLocaleString()} online` : ''}
{guild.approximate_presence_count && guild.approximate_member_count ? ' · ' : ''}
{guild.approximate_member_count ? `${guild.approximate_member_count.toLocaleString()} members` : ''}
</div>
)}
</div>
<div className="membership-form">
<h2 className="membership-title">Before you can talk here…</h2>
<p className="small muted">You&apos;ll need to complete the required steps below.</p>
{rules.description && <p className="small">{rules.description}</p>}
{fields.length === 0 && (
<p className="small muted">This server asks new members to agree to its rules before talking.</p>
)}
{fields.map((field, i) => (
<div key={i} className="membership-field">
{field.field_type === 'TERMS' ? (
<>
<div className="membership-field-label">{field.label || 'Agree to rules'}</div>
{field.description && <p className="small muted">{field.description}</p>}
{!!field.values?.length && (
<ol className="membership-rules-list">
{field.values.map((rule, k) => (
<li key={k}>{rule}</li>
))}
</ol>
)}
<label className="membership-agree">
<input type="checkbox" checked={answers[i] === true} onChange={(e) => answer(i, e.target.checked)} />
I have read and agree to the rules
</label>
</>
) : field.field_type === 'MULTIPLE_CHOICE' ? (
<>
<div className="membership-field-label">
{field.label}
{field.required && <span className="muted"> *</span>}
</div>
{field.description && <p className="small muted">{field.description}</p>}
{(field.values ?? []).map((choice, k) => (
<label key={k} className="membership-agree">
<input type="radio" name={`membership-${i}`} checked={answers[i] === k} onChange={() => answer(i, k)} />
{choice}
</label>
))}
</>
) : (
<label className="field">
<span className="membership-field-label">
{field.label}
{field.required && <span className="muted"> *</span>}
</span>
{field.description && <span className="small muted">{field.description}</span>}
{field.field_type === 'PARAGRAPH' ? (
<textarea
className="text-field"
rows={4}
placeholder={field.placeholder ?? ''}
value={typeof answers[i] === 'string' ? (answers[i] as string) : ''}
onChange={(e) => answer(i, e.target.value)}
/>
) : (
<input
className="text-field"
placeholder={field.placeholder ?? ''}
value={typeof answers[i] === 'string' ? (answers[i] as string) : ''}
onChange={(e) => answer(i, e.target.value)}
/>
)}
</label>
)}
</div>
))}
<div className="membership-rules-actions">
<button type="button" className="button" onClick={() => setRules(null)}>
Cancel
</button>
<button type="button" className="button primary" disabled={busy || !ready} onClick={accept}>
Submit
</button>
</div>
</div>
</div>
</Modal>
)}
</>
+30 -11
View File
@@ -4,7 +4,7 @@ import { MessageRow, useRowContext, type MessageMode } from './MessageRow'
import { LiveCards } from './LiveCards'
import { PinnedBar } from './PinnedBar'
import { CallView } from './CallView'
import { CallStage, RoomCallBar } from './CallStage'
import { CallStage, RoomCallBar, StoatCallBar } from './CallStage'
import { StreamStage } from './StreamStage'
import { ServiceBanner } from './ServiceBanner'
import { ConversationNotice } from './ConversationNotice'
@@ -12,6 +12,7 @@ import { RouteAccount, useStrictRoute } from '../lib/route'
import { useChat, usePref, useStore } from '../state/hooks'
import { bufferDisplayName, dayLabel, isChatKind, startsNewDay } from '../lib/util'
import type { ChannelIndex } from '../lib/format'
import { applyRemoved, type RemovedMessages } from '../lib/moderation'
import type { ChatMessage } from '../state/store'
/**
@@ -234,6 +235,8 @@ export function MessageList(): JSX.Element {
const [showPolls] = usePref<boolean>('kick.showPolls', true)
const [showStream] = usePref<boolean>('kick.showStreamEvents', true)
const [showKickMod] = usePref<boolean>('kick.showModeration', true)
// What Kick took down: left out, struck through, or replaced by a notice.
const [removedMode] = usePref<RemovedMessages>('kick.removedMessages', 'strike')
const [showReaders] = usePref<boolean>('matrix.showReadReceipts', true)
const [blockedNicks] = usePref<string[]>('blockedNicks', [])
const readers = useChat((s) => s.readersByBuffer)[bufferId]
@@ -253,13 +256,16 @@ export function MessageList(): JSX.Element {
* whose replies are older than the loaded window, which is the honest
* failure - it says "at least this many", never "none" for a live one.
*/
const told = useChat((s) => (bufferId ? s.threadCounts[bufferId] : undefined))
const threadReplies = useMemo(() => {
const counts: Record<string, number> = {}
// What the service says wins where it says: a Telegram channel's comments are in another chat altogether.
const counts: Record<string, number> = { ...(told ?? {}) }
for (const m of all) {
if (m.replyTo?.thread) counts[m.replyTo.id] = (counts[m.replyTo.id] ?? 0) + 1
const root = m.replyTo?.thread ? m.replyTo.id : m.replyTo?.threadRoot
if (root && !(told && root in told)) counts[root] = (counts[root] ?? 0) + 1
}
return counts
}, [all])
}, [all, told])
const messages = useMemo(() => {
const allowed: Record<string, boolean> = {
@@ -279,14 +285,17 @@ export function MessageList(): JSX.Element {
stream: showStream,
moderation: showKickMod
}
return all.filter((m) => {
if (accountId && blockedNicks.includes(`${accountId}|${m.from}`)) return false
const kind = m.kind || 'chat'
if (isChatKind(kind) || kind === 'system') return true
return allowed[kind] !== false
})
return applyRemoved(
all.filter((m) => {
if (accountId && blockedNicks.includes(`${accountId}|${m.from}`)) return false
const kind = m.kind || 'chat'
if (isChatKind(kind) || kind === 'system') return true
return allowed[kind] !== false
}),
removedMode
)
}, [
all, accountId, blockedNicks,
all, accountId, blockedNicks, removedMode,
showJoin, showPart, showNick, showTopic, showMode,
showMxJoin, showMxInvite, showMxKick, showMxQuit,
showRedemptions, showSubs, showRaids, showPolls, showStream, showKickMod
@@ -312,6 +321,15 @@ export function MessageList(): JSX.Element {
const scrollRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const [anchored, setAnchored] = useState(true)
// Read as far as the newest line, while it is on screen at the bottom of a window that is looked at: a moment after
// it arrives, so one that is scrolled past at once is not reported as read.
const newestId = messages[messages.length - 1]?.id
useEffect(() => {
if (!anchored || !newestId) return
const timer = setTimeout(() => store.ackIfReading(bufferId), 1500)
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [anchored, newestId, bufferId])
const [missedCount, setMissedCount] = useState(0)
/** How many of the newest messages are built as rows. */
const [shown, setShown] = useState(RENDER_WINDOW)
@@ -792,6 +810,7 @@ export function MessageList(): JSX.Element {
{/* And a call already happening here, which nobody was invited to
because a group call has no invitation - only people in it. */}
<RoomCallBar bufferId={bufferId} />
<StoatCallBar bufferId={bufferId} />
{/* And a Kick stream in the same place, for the same reason: the picture
and the chat about it are one conversation. Never both at once - the
store refuses to start a stream during a call. */}
+302 -62
View File
@@ -8,12 +8,16 @@ import { EventSource } from './EventSource'
import { ForwardPicker } from './ForwardPicker'
import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu'
import { MediaEmbed } from './MediaEmbed'
import { KickEventCard, isKickEvent } from './KickEventCard'
import { LinkCard } from './LinkCard'
import { isPlainEmoji } from '../lib/recents'
import { KickEventCard, NotificationCard, 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'
import { ReplyBody } from './ReplyBody'
import { useChat, usePref, useStore } from '../state/hooks'
import { DEFAULT_SEARCH_ENGINE, engineName, searchUrl, shortened } from '../lib/searchengines'
import { useTick } from '../lib/clock'
import { useSniffedTypes, sniffUrl } from '../lib/sniff'
import { useMediaUrl, useStrictRoute } from '../lib/route'
@@ -36,6 +40,7 @@ import {
formatMessage,
normalizeBBCode,
clearnetLinks,
sneedGreentext,
thumbnailLinks,
youtubeId,
stripCodeBlocks,
@@ -58,9 +63,11 @@ import {
isWarning,
isReward,
isWhisper,
nickColor
nickColor,
readableColor
} from '../lib/util'
import { useEscapeLayer } from '../lib/layers'
import { removedReason } from '../lib/moderation'
/**
* A badge, short enough to sit beside a name.
@@ -71,6 +78,11 @@ import { useEscapeLayer } from '../lib/layers'
* being dropped - an unfamiliar badge still says the person has one.
*/
function badgeLabel(badge: MessageBadge): string {
return ROLE_WORDS[badge.type] ?? badge.text.slice(0, 3).toUpperCase()
}
/** The words that stand for the roles worth looking for in a name's badges. */
const ROLE_WORDS: Record<string, string> = ((): Record<string, string> => {
const short: Record<string, string> = {
broadcaster: 'HOST',
moderator: 'MOD',
@@ -84,10 +96,22 @@ function badgeLabel(badge: MessageBadge): string {
// Not a title like the rest of these - a warning that whoever sent this
// was using a device nobody has vouched for. Without its own short form
// the fallback below would abbreviate the explanation into "SEN".
unverified: '⚠'
unverified: '⚠',
// Twitch's: somebody's first message in a channel, and a message mirrored
// from another channel of a shared chat.
'first-message': 'NEW',
'shared-chat': 'SHARED',
// A cheer: bits sent with the message.
cheer: 'BITS',
// Discord's mark on a bot or application, and on one Discord has verified.
app: 'APP',
// What Discord's own account sends, so a message that claims to be from Discord can be told from one that is.
official: '✓ OFFICIAL',
'verified-app': '✓ APP',
lead_moderator: 'MOD'
}
return short[badge.type] ?? badge.text.slice(0, 3).toUpperCase()
}
return short
})()
/** A handful of one-click reactions on the hover toolbar. */
/** What a line that is not speech is marked with, by the daemon's word for it. */
@@ -109,7 +133,9 @@ const DEFAULT_REACTIONS = ['👍', '❤️', '😂', '🔥']
* belongs to a server and may not be sendable from wherever the toolbar is.
*/
function quickReactions(accountId?: string): string[] {
const recent = readRecent(accountId).filter((e) => !e.startsWith('<') && !e.startsWith(':'))
// Only ordinary emoji: anything with letters or digits in it is a stand-in for one (a custom emoji's token, or the
// bare number older builds stored for one), and is not a picture.
const recent = readRecent(accountId).filter(isPlainEmoji)
return [...new Set([...recent, ...DEFAULT_REACTIONS])].slice(0, 4)
}
@@ -350,7 +376,16 @@ function MessageRowBody({
// preview. The object itself, so the row only re-renders when that one
// message changes.
const replyId = message.replyTo && !message.replyTo.thread && !message.replyTo.forwarded ? message.replyTo.id : ''
// The thread open beside the room, if any: a reply to its first message has
// nothing to say that the panel's own heading does not.
const openRoot = useChat((s) => s.openThread?.rootId)
const [showBadgePictures] = usePref<boolean>('kick.showBadgePictures', true)
const twitchModerator = useChat((s) => s.twitchRights[bufferId] === true)
const twitchBlocker = useChat((s) => s.accounts.find((a) => bufferId.startsWith(`${a.id}|`))?.moderation === true)
const original = useChat((s) => (replyId ? s.messagesByBuffer[bufferId]?.find((m) => m.id === replyId) : undefined))
// What a reply quotes. Twitch sends the parent's words as plain text, with no word of which are emotes; where the
// original is on screen it has them, as it was drawn, so that is what is quoted.
const quoted = service === 'twitch' && original?.body ? original.body : message.replyTo?.body ?? ''
// Which message is open for editing is the window's, not each row's, so
// Up arrow in the message box can open the last one.
const editing = useChat((s) => s.editingId === message.id)
@@ -388,8 +423,12 @@ function MessageRowBody({
// Discord, Sneedchat and Matrix support editing and deleting your own
// messages; IRC has no such concept.
const canEditDelete =
!!message.isOwn && (service === 'discord' || service === 'sneedchat' || service === 'matrix')
const canReact = service === 'discord' || service === 'matrix' || service === 'steam'
!!message.isOwn && (service === 'discord' || service === 'sneedchat' || service === 'matrix' || service === 'telegram' || service === 'stoat')
const beginEdit = (): void => {
setDraft(message.body)
setEditing(true)
}
const canReact = service === 'discord' || service === 'matrix' || service === 'steam' || service === 'telegram' || service === 'stoat'
// Steam's stickers, asked for when the reaction picker opens: it reacts with them too.
const [steamStickers, setSteamStickers] = useState<StickerEntry[]>([])
useEffect(() => {
@@ -424,6 +463,9 @@ function MessageRowBody({
// 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)
// Any other line the service wrote rather than somebody saying it - a join, a topic, a notice - is drawn as the
// same card the events are, whatever protocol it came from (see ARCHITECTURE.md, "Notifications are cards").
const systemCard = isSystem && !kickEvent && !!message.body
// 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.
@@ -471,7 +513,8 @@ function MessageRowBody({
// because it is about what this reader's browser can reach, and it has to
// hold for an edited line and for backlog alike - both of which arrive at
// this function again and neither of which is re-stored.
const raw = clearnetLinks(message.body || '')
// Sneedchat's own greentext is put back to the plain ">" first, so it is one thing however it arrived.
const raw = isSneedchat ? sneedGreentext(clearnetLinks(message.body || '')) : clearnetLinks(message.body || '')
const normalized = normalizeBBCode(raw)
const media = markSpoilered(extractMedia(normalized, {
contentSniffing,
@@ -482,7 +525,8 @@ function MessageRowBody({
thumbnails: thumbnailLinks(raw)
}), normalized)
const codeBlocks = extractCodeBlocks(normalized)
const quoteBlocks = extractQuoteBlocks(stripCodeBlocks(normalized))
// Not on Sneedchat, where a line starting with ">" is greentext and stays in the line it is in.
const quoteBlocks = isSneedchat ? [] : extractQuoteBlocks(stripCodeBlocks(normalized))
// The page a GIF was linked from is not shown as well as the GIF: it is the
// same thing said twice, and is how a posted GIF read as an address.
@@ -491,7 +535,7 @@ function MessageRowBody({
.map((e) => ({ url: e.url!, kind: 'image' as const }))
let body = stripEmbeddedUrls(normalized, [...media, ...gifPages])
body = stripCodeBlocks(body)
body = stripQuoteBlocks(body)
if (!isSneedchat) body = stripQuoteBlocks(body)
return {
// A sender's own formatting, where the protocol carried any: Matrix
@@ -510,6 +554,7 @@ function MessageRowBody({
isSneedchat,
smilies: smilieIndex,
channels,
markdownEscapes: service === 'discord',
// Only IRC's own traffic is drawn with IRC's codes. Elsewhere they
// are stripped, which is what the formatter does when not told.
ircFormatting: service === 'irc' && ircColours ? 'render' : 'strip'
@@ -527,6 +572,12 @@ function MessageRowBody({
// have let go of the selection.
const selectedRef = useRef('')
const selected = selectedRef.current
const [searchEngine] = usePref<string>('search.engine', DEFAULT_SEARCH_ENGINE)
const [searchCustom] = usePref<string>('search.customUrl', '')
const searchAddress = selected ? searchUrl(selected, searchEngine, searchCustom) : null
const search = searchAddress
? { url: searchAddress, name: engineName(searchEngine, searchCustom), shown: shortened(selected) }
: null
const canOpenProfile = !!message.from && !isSystem && !message.isOwn
const openProfile = (): void => store.showProfile(bufferId, message.from, message.senderId)
// Back to what this answers, bringing it into view first if it is a long
@@ -578,6 +629,12 @@ function MessageRowBody({
...(selected
? ([{ label: 'Copy', icon: 'content_copy', onClick: () => void window.moho.copyText(selected) }] as MenuEntry[])
: []),
// Looking up what is selected: in the browser the person already uses, with
// the engine they chose in Settings. Only for a selection - there is no
// sensible thing to search for in a whole message.
...(search
? ([{ label: `Search ${search.name} for “${search.shown}”`, icon: 'search', onClick: () => void window.moho.openExternal(search.url) }] as MenuEntry[])
: []),
...(message.body && !isSystem
? ([{ label: 'Copy text', icon: 'content_copy', onClick: () => void window.moho.copyText(message.body) }] as MenuEntry[])
: []),
@@ -615,7 +672,7 @@ function MessageRowBody({
// rather than gated on a power level read here: the server decides, and
// it refuses in words worth showing - hiding the action from somebody who
// could have used it is the worse mistake.
...((service === 'matrix' || service === 'discord') && !isSystem
...((service === 'matrix' || service === 'discord' || service === 'telegram' || service === 'stoat') && !isSystem
? ([
{
label: pinned ? 'Unpin from this conversation' : 'Pin to this conversation',
@@ -686,6 +743,19 @@ function MessageRowBody({
? ([{ separator: true } as MenuEntry] as MenuEntry[])
: []),
...moderationEntries(message, permissions, bufferId, store),
// Twitch's own block list, for whoever turned those rights on: server side, so it holds in every
// app and channel, which the window's own Ignore does not.
...(twitchBlocker && service === 'twitch' && !message.isOwn && !isSystem && message.from
? ([
{ label: `Block ${message.from} on Twitch`, icon: 'block', danger: true, onClick: () => twitchAct(store, bufferId, message, 'block', {}, `${message.from} is blocked`) },
{ label: `Unblock ${message.from}`, icon: 'check_circle', onClick: () => twitchAct(store, bufferId, message, 'unblock', {}, `${message.from} is unblocked`) }
] as MenuEntry[])
: []),
// A Twitch moderator's: only where Twitch said this account is one for the channel, and not for their
// own lines. Timing out is by the sender's login; removing a message is by its id.
...(twitchModerator && service === 'twitch' && !message.isOwn && !isSystem && message.from
? twitchModerationEntries(message, bufferId, store)
: []),
// Telling whoever runs the homeserver. Beside the moderation entries and
// deliberately not gated like them: those need power in the room and this
// needs none, which is the point of it - it is the answer available to
@@ -804,6 +874,36 @@ function MessageRowBody({
return { claimed, loose: parts.media.filter((m) => !taken.has(m.url)) }
}, [message.embeds, parts.media])
/** The words of the line itself: its text with its links, emoji and marks. */
const renderBody = (): React.ReactNode =>
parts.html && (
<span className="message-body selectable" title={message.moderated && !message.moderated.replaced ? removedReason(message) : undefined}>
<RichText
html={parts.html}
// Ours unless the sender sent their own formatting, which
// is the one case where a picture it names is a stranger's
// server being told this message was read.
ownMarkup={!message.html}
onRevealSpoiler={(i) => setRevealed((r) => ({ ...r, [i]: true }))}
// Following a channel link moves the rail too: the channel
// is usually in the same guild, but a cross-guild link is
// exactly the case where landing on a list that isn't
// showing it would be baffling.
onOpenChannel={(id) => void store.selectBuffer(id, true)}
onOpenLink={(url) => store.followDeepLink(url)}
/>
{message.edited && (
<span
className="edited-tag small muted"
title={message.editedTs ? `Edited ${formatFullTime(message.editedTs)}` : 'Edited'}
>
{' '}
(edited)
</span>
)}
</span>
)
return (
<>
<div
@@ -815,9 +915,10 @@ function MessageRowBody({
isWarning(message.kind, message.id) && 'warning',
message.pending && 'pending',
message.failed && 'failed',
message.moderated && (message.moderated.replaced ? 'removed replaced' : 'removed'),
isSystem && 'system',
reward && 'reward',
kickEvent && 'has-kick-event',
(kickEvent || systemCard) && 'has-kick-event',
grouped && 'grouped',
fresh && 'fresh',
comfy && 'comfy',
@@ -860,7 +961,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 && !kickEvent && (
{mode === 'comfy' && isSystem && !kickEvent && !systemCard && (
<span className="message-avatar system-mark" aria-hidden>
<Icon name={SYSTEM_MARKS[message.kind] ?? 'info'} size={16} />
</span>
@@ -914,13 +1015,38 @@ function MessageRowBody({
<span className="message-byline">
<span
className={classes('message-from', canOpenProfile && 'clickable')}
style={{ color: message.senderColor || nickColor(message.from) }}
style={{ color: message.senderColor ? readableColor(message.senderColor) : nickColor(message.from) }}
onClick={canOpenProfile ? openProfile : undefined}
role={canOpenProfile ? 'button' : undefined}
title={canOpenProfile ? `Profile of ${message.from}` : undefined}
>
{message.isAction ? `* ${message.from}` : message.from}
</span>
{/* Beside the name, ahead of the time: a mark on who is talking, as Discord puts its APP pill. */}
{message.badges?.map((badge) =>
badge.image && showBadgePictures ? (
// The service's own pictures, unless they are turned off. The tooltip
// and the alt text say what it is for anybody who cannot see it.
<img
key={badge.type}
className="sender-badge-image"
src={media(badge.image)}
alt={badge.text}
title={badge.count ? `${badge.text} — ${badge.count} months` : badge.text}
loading="lazy"
draggable={false}
/>
) : // Turned off, a badge that has a word for it is that word and one that has none is nothing.
badge.image && !(badge.type in ROLE_WORDS) ? null : (
<span
key={badge.type}
className={`sender-badge small ${badge.type}`}
title={badge.count ? `${badge.text} — ${badge.count} months` : badge.text}
>
{badgeLabel(badge)}
</span>
)
)}
{/* The time on the name's own line, as Discord does it, in the
full form the tooltip also carries. */}
{mode === 'comfy' && (
@@ -928,15 +1054,6 @@ function MessageRowBody({
{timeLabel}
</span>
)}
{message.badges?.map((badge) => (
<span
key={badge.type}
className={`sender-badge small ${badge.type}`}
title={badge.count ? `${badge.text} — ${badge.count} months` : badge.text}
>
{badgeLabel(badge)}
</span>
))}
</span>
)}
@@ -977,6 +1094,7 @@ function MessageRowBody({
the panel's title already says. */}
{message.replyTo &&
!(inThread && message.replyTo.thread) &&
!(inThread && message.replyTo.id === openRoot) &&
(message.replyTo.thread && !inThread ? (
<button
type="button"
@@ -985,8 +1103,79 @@ function MessageRowBody({
>
<Icon name="forum" size={13} />
<span>In thread</span>
{message.replyTo.body && <span className="ellipsis">{message.replyTo.body}</span>}
{message.replyTo.body && (
<span className="ellipsis">
<ReplyBody text={message.replyTo.body} service={service} channels={channels} />
</span>
)}
</button>
) : service === 'telegram' && message.replyTo.forwarded ? (
// Where a forwarded message came from, as Telegram says it: "Forwarded from" and the source, which is
// somewhere to go when it was a post in a channel this account has open.
(() => {
const source = message.replyTo
const open = source.chat && source.id
return (
<div className="tg-forward small">
<span className="muted">Forwarded from </span>
{open ? (
<button
type="button"
className="tg-forward-source link-button"
title="Go to the original"
onClick={() =>
void store.selectBuffer(source.chat!, true).then(() =>
store.jumpToMessage(source.chat!, source.id).then((found) => {
if (found) store.setJumpTarget(source.id)
})
)
}
>
<Icon name="campaign" size={13} />
{source.from}
</button>
) : (
<span className="tg-forward-source">{source.from}</span>
)}
</div>
)
})()
) : service === 'telegram' && !message.replyTo.forwarded ? (
// As Telegram draws an answer: an accent bar, who said it (and where, when it was another channel), what
// they said, and the quote mark. Going to it is a click, to the other channel if that is where it is.
<div
className="tg-quote selectable"
role="button"
tabIndex={0}
title={message.replyTo.chat ? 'Go to the original' : 'Go to the message'}
onClick={() => {
const target = message.replyTo!
if (target.chat) {
void store.selectBuffer(target.chat, true).then(() =>
store.jumpToMessage(target.chat!, target.id).then((found) => {
if (found) store.setJumpTarget(target.id)
})
)
} else jumpToReply()
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
;(e.currentTarget as HTMLElement).click()
}
}}
>
<div className="tg-quote-from">
{message.replyTo.chat && <Icon name="campaign" size={13} />}
{message.replyTo.from || 'Message'}
</div>
{message.replyTo.body && (
<div className="tg-quote-body">
<ReplyBody text={message.replyTo.body} service={service} channels={channels} />
</div>
)}
<Icon name="format_quote" size={16} className="tg-quote-mark" />
</div>
) : (
<div
className={classes('reply-preview small muted', replyId && 'reply-jump')}
@@ -1018,12 +1207,29 @@ function MessageRowBody({
style={
message.replyTo.forwarded
? undefined
: { color: original?.senderColor || nickColor(message.replyTo.from) }
: { color: original?.senderColor ? readableColor(original.senderColor) : nickColor(message.replyTo.from) }
}
>
{message.replyTo.from}
</span>
<span className="ellipsis">{message.replyTo.body}</span>
<span className="ellipsis">
<ReplyBody text={quoted} service={service} channels={channels} />
</span>
{/* Kick groups a chain of replies under the message it began
with; a reply is a way into that conversation. */}
{!inThread && message.replyTo.threadRoot && (
<button
type="button"
className="reply-thread-open small"
title="Open the thread"
onClick={(e) => {
e.stopPropagation()
void store.openThreadPanel(bufferId, message.replyTo!.threadRoot!)
}}
>
<Icon name="forum" size={13} />
</button>
)}
</div>
))}
@@ -1091,35 +1297,13 @@ function MessageRowBody({
)}
</div>
) : kickEvent ? (
<KickEventCard embed={kickEvent} />
<KickEventCard embed={kickEvent} bufferId={bufferId} />
) : systemCard ? (
<NotificationCard icon={SYSTEM_MARKS[message.kind] ?? 'info'} warning={isWarning(message.kind, message.id)}>
{renderBody()}
</NotificationCard>
) : (
parts.html && (
<span className="message-body selectable">
<RichText
html={parts.html}
// Ours unless the sender sent their own formatting, which
// is the one case where a picture it names is a stranger's
// server being told this message was read.
ownMarkup={!message.html}
onRevealSpoiler={(i) => setRevealed((r) => ({ ...r, [i]: true }))}
// Following a channel link moves the rail too: the channel
// is usually in the same guild, but a cross-guild link is
// exactly the case where landing on a list that isn't
// showing it would be baffling.
onOpenChannel={(id) => void store.selectBuffer(id, true)}
onOpenLink={(url) => store.followDeepLink(url)}
/>
{message.edited && (
<span
className="edited-tag small muted"
title={message.editedTs ? `Edited ${formatFullTime(message.editedTs)}` : 'Edited'}
>
{' '}
(edited)
</span>
)}
</span>
)
renderBody()
)}
{parts.quoteBlocks.map((quote, i) => (
@@ -1135,7 +1319,9 @@ function MessageRowBody({
))}
{(message.embeds || []).map((embed, i) =>
embed.kind === 'gif' || isKickEvent(embed) ? null : (
embed.kind === 'gif' || isKickEvent(embed) ? null : service === 'telegram' ? (
<LinkCard key={`e${i}`} embed={embed} bufferId={bufferId} messageId={message.id} />
) : (
<div
key={`e${i}`}
className={classes('rich-embed selectable', embed.kind === 'notice' && 'notice')}
@@ -1172,7 +1358,7 @@ function MessageRowBody({
{embed.description && (
<div className="rich-embed-desc small">
<RichText
html={formatMessage(embed.description, { channels })}
html={formatMessage(embed.description, { channels, markdownEscapes: service === 'discord' })}
ownMarkup
onOpenChannel={(id) => void store.selectBuffer(id, true)}
onOpenLink={(url) => store.followDeepLink(url)}
@@ -1184,7 +1370,17 @@ function MessageRowBody({
{embed.fields.map((f, k) => (
<div key={k} className="rich-embed-field">
<div className="rich-embed-field-name">{f.name}</div>
<div>{f.value}</div>
{/* Markdown on Discord, like the description above; plain text elsewhere. */}
{service === 'discord' ? (
<RichText
html={formatMessage(f.value, { channels, markdownEscapes: true })}
ownMarkup
onOpenChannel={(id) => void store.selectBuffer(id, true)}
onOpenLink={(url) => store.followDeepLink(url)}
/>
) : (
<div>{f.value}</div>
)}
</div>
))}
</div>
@@ -1250,6 +1446,7 @@ function MessageRowBody({
onOpenInDiscord={(b, m) => void store.openInDiscord(b, m)}
onRefresh={(b, m) => store.refreshAttachments(b, m)}
onStale={(b, m) => store.resignWhenIdle(b, m)}
onEdit={canEditDelete ? beginEdit : undefined}
/>
))}
</div>
@@ -1271,6 +1468,7 @@ function MessageRowBody({
onOpenInDiscord={(b, m) => void store.openInDiscord(b, m)}
onRefresh={(b, m) => store.refreshAttachments(b, m)}
onStale={(b, m) => store.resignWhenIdle(b, m)}
onEdit={canEditDelete ? beginEdit : undefined}
/>
))}
{/* Links someone typed, unfurled from the body. Still needed
@@ -1612,11 +1810,50 @@ function ReactionEmoji({ emoji }: { emoji: string }): JSX.Element {
// 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} />
// Stoat's, which the daemon holds as the picture's own token (see format.ts) so the pill is drawn with it.
const stoat = emoji.match(/^\[stoat:emoji:([A-Za-z0-9_]{1,32}):(file:\/\/[^\]\s]*\/stoat-media\/[A-Za-z0-9._-]+)\]$/)
if (stoat) return <img className="reaction-emoji" src={media(stoat[2])} alt={stoat[1]} />
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]} />
}
function twitchAct(
store: ReturnType<typeof useStore>,
bufferId: string,
message: ChatMessage,
action: string,
extra: Record<string, unknown> = {},
done?: string
): void {
void window.moho
.rpc('twitchModerate', { bufferId, action, login: message.from.toLowerCase(), userId: message.senderId, ...extra })
.then(() => done && store.toast('info', done))
.catch((e: Error) => store.toast('error', e.message))
}
function twitchModerationEntries(message: ChatMessage, bufferId: string, store: ReturnType<typeof useStore>): MenuEntry[] {
const act = (action: string, extra: Record<string, unknown> = {}, done?: string): void => twitchAct(store, bufferId, message, action, extra, done)
const timeout = (label: string, seconds: number): MenuEntry => ({
label: `Time out ${label}`,
icon: 'timer',
onClick: () => act('timeout', { seconds }, `${message.from} timed out for ${label}`)
})
return [
{ separator: true },
...(message.id
? ([{ label: 'Remove message', icon: 'delete', danger: true, onClick: () => act('delete', { messageId: message.id }) }] as MenuEntry[])
: []),
timeout('1 minute', 60),
timeout('10 minutes', 600),
timeout('1 hour', 3600),
timeout('1 day', 86400),
{ label: `Shout out ${message.from}`, icon: 'campaign', onClick: () => act('shoutout', {}, `Shouted out ${message.from}`) },
{ label: `Ban ${message.from}`, icon: 'gavel', danger: true, onClick: () => act('ban', {}, `${message.from} banned`) },
{ label: `Unban ${message.from}`, icon: 'lock_open', onClick: () => act('unban', {}, `${message.from} can chat again`) }
] as MenuEntry[]
}
function moderationEntries(
message: ChatMessage,
permissions: { canKick?: boolean; canBan?: boolean; canMute?: boolean },
@@ -1627,12 +1864,15 @@ function moderationEntries(
// display names collide and can be changed at will.
const userId = message.senderId
if (!userId) return []
const telegram = store.accountFor(bufferId)?.service === 'telegram'
const call = (method: string): void => {
const account = store.accountFor(bufferId)
if (!account) return
void window.moho
.rpc(method, { accountId: account.id, bufferId, userId })
.catch((e: Error) => store.toast('error', e.message))
// Telegram's three are one call that names which.
const asked = telegram
? window.moho.rpc('moderateTelegramMember', { bufferId, userId, action: method.replace('MatrixMember', '') })
: window.moho.rpc(method, { accountId: account.id, bufferId, userId })
void asked.catch((e: Error) => store.toast('error', e.message))
}
const out: MenuEntry[] = []
if (permissions.canMute)
@@ -1643,7 +1883,7 @@ function moderationEntries(
out.push({ label: 'Ban sender', icon: 'gavel', danger: true, onClick: () => call('banMatrixMember') })
// Reachable only from here, not the member list: a banned user is no
// longer a member, so their scrollback is the one place left to find them.
out.push({ label: 'Unban sender', icon: 'lock_open', onClick: () => call('unbanMatrixMember') })
if (!telegram) out.push({ label: 'Unban sender', icon: 'lock_open', onClick: () => call('unbanMatrixMember') })
}
return out
}
+47 -11
View File
@@ -6,6 +6,7 @@ import { Icon, IconButton } from './Icon'
import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu'
import { useActiveBuffer, useChat, useComfy, useIdSetPref, useStore } from '../state/hooks'
import { classes, hasDirectMessages, nickColor } from '../lib/util'
import { TwitchPoints } from './TwitchPoints'
import { KickPoints } from './KickPoints'
import { ReasonPrompt } from './ReasonPrompt'
import type { Member } from '../../../shared/wire'
@@ -88,10 +89,10 @@ function kickPermissions(members: Member[], me: string): Record<string, boolean>
/**
* Why the list says what it says, for the one service where it needs saying.
*/
function rosterNote(service: string | undefined): string | undefined {
return service === 'kick'
? 'Kick has no viewer list — this is everybody who has spoken here recently'
: undefined
function rosterNote(service: string | undefined, total: number, listed: number): string | undefined {
if (service !== 'kick' && service !== 'twitch') return undefined
const base = `${service === 'twitch' ? 'Twitch' : 'Kick'} has no viewer list — this counts everybody who has spoken here since you opened it`
return total > listed ? `${base}. The ${listed.toLocaleString()} most recent are listed.` : base
}
/**
@@ -140,6 +141,7 @@ export function NickList(): JSX.Element {
const [query, setQuery] = useState('')
const members = (buffer && presenceByBuffer[buffer.id]) || []
const total = useChat((s) => (buffer ? s.presenceTotalByBuffer[buffer.id] : undefined)) ?? members.length
const account = buffer && accounts.find((a) => a.id === buffer.accountId)
// Any protocol that reports presence gets the online/offline split.
const hasPresence = members.some((m) => m.status !== undefined)
@@ -170,9 +172,9 @@ export function NickList(): JSX.Element {
const [discordPowers, setDiscordPowers] = useState<DiscordPowers | null>(null)
useEffect(() => {
setDiscordPowers(null)
if (account?.service !== 'discord' || !buffer) return
if ((account?.service !== 'discord' && account?.service !== 'stoat') || !buffer) return
void window.moho
.rpc<DiscordPowers>('getDiscordPowers', { bufferId: buffer.id })
.rpc<DiscordPowers>(account.service === 'stoat' ? 'getStoatPowers' : 'getDiscordPowers', { bufferId: buffer.id })
.then(setDiscordPowers)
.catch(() => {
/* advisory only: no answer means no moderation entries */
@@ -204,7 +206,7 @@ export function NickList(): JSX.Element {
? ircPermissions(members, account.currentNick)
: account?.service === 'kick'
? kickPermissions(members, account.currentNick)
: account?.service === 'discord'
: account?.service === 'discord' || account?.service === 'stoat'
? discordPowers ?? {}
: (buffer && permissions[buffer.id]) || {}
@@ -263,8 +265,8 @@ export function NickList(): JSX.Element {
// Named for what the list actually is. Kick has no viewer list to
// ask for - it is built from who has spoken - and calling that
// "members" would claim to know something nobody here can know.
<span className="small muted ellipsis" title={rosterNote(account?.service)}>
{members.length} {account?.service === 'kick' ? 'recently talking' : 'members'}
<span className="small muted ellipsis" title={rosterNote(account?.service, total, members.length)}>
{total.toLocaleString()} {account?.service === 'kick' || account?.service === 'twitch' ? 'recently talking' : 'members'}
</span>
)}
<IconButton
@@ -337,6 +339,16 @@ export function NickList(): JSX.Element {
}
: undefined
}
onUnfriend={
account?.service === 'stoat' && member.relationship === 'Friend' && member.userId
? () => {
void window.moho
.rpc('answerStoatFriendRequest', { accountId: account.id, userId: member.userId, accept: false })
.then(() => store.toast('info', `${member.nick} is no longer a friend`))
.catch((e: Error) => store.toast('error', e.message))
}
: undefined
}
onToggleBlock={toggleBlocked}
onIgnore={(ignored) => {
if (!account) return
@@ -379,6 +391,14 @@ export function NickList(): JSX.Element {
.catch((e: Error) => store.toast('error', e.message))
return
}
if (account.service === 'telegram') {
if (action !== 'kick' && action !== 'ban' && action !== 'mute') return
void window.moho
.rpc('moderateTelegramMember', { bufferId: buffer.id, userId: member.userId, action })
.then(() => store.toast('info', `${action === 'ban' ? 'Banned' : action === 'mute' ? 'Muted for 10 minutes' : 'Removed'} ${member.nick}`))
.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
@@ -397,6 +417,14 @@ export function NickList(): JSX.Element {
.catch((e: Error) => store.toast('error', e.message))
return
}
if (account.service === 'stoat') {
if (action !== 'kick' && action !== 'ban' && action !== 'mute') return
void window.moho
.rpc('moderateStoatMember', { bufferId: buffer.id, userId: member.userId, action, minutes: 10 })
.then(() => store.toast('info', `${action === 'ban' ? 'Banned' : action === 'mute' ? 'Muted for 10 minutes' : '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
@@ -432,7 +460,7 @@ export function NickList(): JSX.Element {
onSetRole={(roleId, give) => {
if (!buffer) return
void window.moho
.rpc('setDiscordMemberRole', {
.rpc(account?.service === 'stoat' ? 'setStoatMemberRole' : 'setDiscordMemberRole', {
bufferId: buffer.id,
userId: member.userId || member.nick,
roleId,
@@ -478,6 +506,10 @@ export function NickList(): JSX.Element {
{account?.service === 'kick' && buffer?.kind === 'channel' && buffer && (
<KickPoints bufferId={buffer.id} />
)}
{/* Twitch's, for an account that has the twitch.tv session that can see them. */}
{account?.service === 'twitch' && account.webSession && buffer?.kind === 'channel' && buffer && (
<TwitchPoints bufferId={buffer.id} />
)}
</div>
)
}
@@ -532,6 +564,8 @@ interface MemberRowProps {
onProfile?: () => void
/** Matrix only: tell the homeserver's moderators about this person. */
onReport?: (reason: string) => void
/** Stoat only: stop being friends with somebody who is one. */
onUnfriend?: () => void
onCall: () => void
}
@@ -558,6 +592,7 @@ function MemberRow({
onCall,
onProfile,
onReport,
onUnfriend,
onRemoveFromGroup
}: MemberRowProps): JSX.Element {
const { menu, open, close } = useContextMenu()
@@ -641,7 +676,7 @@ function MemberRow({
// Discord's roles, offered as entries rather than a submenu: a guild has
// a handful worth giving by hand, and the rest are managed by bots and
// were filtered out before they got here.
...(service === 'discord' && perms.canAssignRoles && (perms.roles?.length ?? 0) > 0
...((service === 'discord' || service === 'stoat') && perms.canAssignRoles && (perms.roles?.length ?? 0) > 0
? ([
{ separator: true },
...(perms.roles ?? []).slice(0, 12).map((role) => ({
@@ -672,6 +707,7 @@ function MemberRow({
// somebody does for themselves; kicking and banning need power here and
// reach only this room. This is the one that reaches whoever can act
// across all of them, and anybody may send it.
...(onUnfriend ? ([{ separator: true }, { label: 'Remove friend', icon: 'person_remove', danger: true, onClick: onUnfriend }] as MenuEntry[]) : []),
...(onReport
? ([
{ separator: true },
+1 -1
View File
@@ -89,7 +89,7 @@ function PinnedMessagesBanner({ bufferId }: { bufferId: string }): JSX.Element |
const rows = useChat((s) => s.pinnedRows)[bufferId]
const [at, setAt] = useState(0)
const [, redraw] = useState(0)
const hasPins = account.startsWith('discord:') || account.startsWith('matrix:')
const hasPins = account.startsWith('discord:') || account.startsWith('matrix:') || account.startsWith('telegram:') || account.startsWith('stoat:')
useEffect(() => {
setAt(0)
+4 -1
View File
@@ -5,6 +5,8 @@ import { useStore } from '../state/hooks'
/** The spec's own ceiling, so twenty-one answers is refused here not there. */
const MAX_ANSWERS = 20
/** Telegram's: ten answers, and a count that can be held back until the poll closes. */
const TELEGRAM_MAX_ANSWERS = 10
/**
* Starting a poll.
@@ -18,6 +20,7 @@ const MAX_ANSWERS = 20
*/
export function PollComposer({ bufferId, onClose }: { bufferId: string; onClose: () => void }): JSX.Element {
const store = useStore()
const max = store.accountFor(bufferId)?.service === 'telegram' ? TELEGRAM_MAX_ANSWERS : MAX_ANSWERS
const [question, setQuestion] = useState('')
const [answers, setAnswers] = useState(['', ''])
const [disclosed, setDisclosed] = useState(true)
@@ -77,7 +80,7 @@ export function PollComposer({ bufferId, onClose }: { bufferId: string; onClose:
<button
type="button"
className="button subtle small"
disabled={answers.length >= MAX_ANSWERS}
disabled={answers.length >= max}
onClick={() => setAnswers([...answers, ''])}
>
Add an answer
+18
View File
@@ -0,0 +1,18 @@
import { useMemo } from 'react'
import { RichText } from '../lib/richtext'
import { replyPreviewHtml } from '../lib/replypreview'
import { useChat, usePref } from '../state/hooks'
import type { ChannelIndex } from '../lib/format'
/**
* What a reply is answering, as words: the original's text drawn the way its own line is - its emotes, emoji and
* stickers as pictures, not as the codes they travel as.
*/
export function ReplyBody({ text, service, channels }: { text: string; service?: string; channels?: ChannelIndex }): JSX.Element {
const smilies = useChat((s) => s.smilieIndex)
const [ircColours] = usePref<boolean>('irc.renderColours', true)
const html = useMemo(() => replyPreviewHtml(text, { service, smilies, channels, ircColours }), [text, service, smilies, channels, ircColours])
// Ours: the markup is what the formatter built from the text, so the pictures it names are public emote and emoji
// addresses. A link is not followed from here, and a spoiler is not opened.
return <RichText html={html} ownMarkup onRevealSpoiler={() => {}} onOpenChannel={() => {}} onOpenLink={() => {}} />
}
@@ -0,0 +1,310 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Icon } from './Icon'
import { useStore } from '../state/hooks'
import { classes } from '../lib/util'
import { useEscapeLayer } from '../lib/layers'
/**
* What a channel will trade its points for, in a panel over the chat like the quick switcher (Ctrl+K).
*
* One panel for every service that has channel points - Kick and Twitch - instead of a menu or a dialog of its
* own for each. A reward is a row: its name, what it asks, what it costs. Pressing one arms it (a second press,
* or the Redeem button, spends the points; Escape takes it back), so an errant click never costs anything. A
* reward that asks the person to type something shows its field when it is armed.
*/
export interface Reward {
id: string
title: string
cost: number
description?: string | null
enabled: boolean
paused: boolean
needsInput: boolean
color?: string | null
image?: string | null
/** Why it cannot be had from here, where that is known beforehand. */
unavailable?: string | null
/** What it asks the person to pick from: an emote to unlock. */
choices?: Choice[]
}
/** One thing a reward asks the person to pick: an emote, or a change to one. */
export interface Choice {
id: string
label: string
image?: string | null
/** For a change to an emote: the emote, and its picture. */
group?: string | null
groupImage?: string | null
}
interface Answer {
rewards: Reward[]
points: number | null
name?: string | null
}
/** The daemon's answer - or, in the screenshot harness only, a fixture standing in for it (see SteamGroupSettings). */
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)
}
/** How each service lists and redeems, named once here so the panel need not know who it is for. */
const SERVICES = {
kick: { list: 'listKickRewards', redeem: 'redeemKickReward', canType: false },
twitch: { list: 'listTwitchRewards', redeem: 'redeemTwitchReward', canType: true }
} as const
/** Why a reward cannot be pressed, in words; empty when it can. */
export function whyNot(r: Reward, points: number | null, canType: boolean): string {
if (r.unavailable) return r.unavailable
if (!r.enabled) return 'The streamer has this turned off'
if (r.paused) return 'Paused right now'
if (r.needsInput && !canType) return 'This one asks you to type something, which moho cannot do for this service yet'
if (points !== null && points < r.cost) return `You have ${points.toLocaleString()} of ${r.cost.toLocaleString()}`
return ''
}
/**
* The emotes a reward offers, shown as the emotes - pictures to press, with a search - the way the emoji picker shows
* them, and not as a list of names. A change to an emote (Modify a Single Emote) is two steps: the emote, then what
* to do to it.
*/
function EmoteChooser({ choices, picked, onPick }: { choices: Choice[]; picked: string; onPick: (id: string) => void }): JSX.Element {
const [filter, setFilter] = useState('')
const [base, setBase] = useState<string | null>(null)
const grouped = choices.some((c) => c.group)
const q = filter.trim().toLowerCase()
const bases = useMemo(() => {
const seen = new Map<string, string | null | undefined>()
for (const c of choices) if (c.group && !seen.has(c.group)) seen.set(c.group, c.groupImage)
return [...seen.entries()].map(([name, image]) => ({ name, image }))
}, [choices])
const cell = (key: string, label: string, image: string | null | undefined, selected: boolean, onClick: () => void, caption?: string): JSX.Element => (
<button key={key} type="button" className={classes('reward-emote', selected && 'selected')} title={label} onClick={onClick}>
{image ? <img src={image} alt={label} loading="lazy" /> : <span>{label}</span>}
{caption && <span className="reward-emote-caption small muted">{caption}</span>}
</button>
)
const inBase = base ? choices.filter((c) => c.group === base) : []
return (
<div className="reward-chooser">
{!(grouped && base) && (
<input className="text-field" placeholder="Search emotes" value={filter} spellCheck={false} onChange={(e) => setFilter(e.target.value)} />
)}
{grouped && base ? (
<>
<div className="reward-chooser-head small">
<button type="button" className="link-button" onClick={() => setBase(null)}>
← {base}
</button>
<span className="muted">Choose what is done to it</span>
</div>
<div className="reward-grid">{inBase.map((c) => cell(c.id, c.label, c.image, picked === c.id, () => onPick(c.id), c.label.slice(base.length + 2)))}</div>
</>
) : grouped ? (
<div className="reward-grid">{bases.filter((b) => !q || b.name.toLowerCase().includes(q)).map((b) => cell(b.name, b.name, b.image, false, () => setBase(b.name)))}</div>
) : (
<div className="reward-grid">{choices.filter((c) => !q || c.label.toLowerCase().includes(q)).map((c) => cell(c.id, c.label, c.image, picked === c.id, () => onPick(c.id)))}</div>
)}
{picked && <div className="small muted reward-picked">{choices.find((c) => c.id === picked)?.label}</div>}
</div>
)
}
export function RewardsPanel({
bufferId,
service,
onClose,
onBalance
}: {
bufferId: string
service: keyof typeof SERVICES
onClose: () => void
/** The balance after a redeem, for the plaque behind the panel. */
onBalance: (points: number) => void
}): JSX.Element {
const store = useStore()
const api = SERVICES[service]
const [answer, setAnswer] = useState<Answer | null>(null)
const [error, setError] = useState('')
const [query, setQuery] = useState('')
const [index, setIndex] = useState(0)
const [armed, setArmed] = useState<string | null>(null)
const [typed, setTyped] = useState('')
const [picked, setPicked] = useState('')
const [busy, setBusy] = useState(false)
const input = useRef<HTMLInputElement>(null)
const list = useRef<HTMLDivElement>(null)
useEffect(() => {
let cancelled = false
void ask<Answer>(api.list, { bufferId })
.then((a) => !cancelled && setAnswer(a))
.catch((e: Error) => !cancelled && setError(e.message))
return () => {
cancelled = true
}
}, [api.list, bufferId])
useEffect(() => input.current?.focus(), [])
// Escape takes an armed reward back first, then closes the panel - wherever focus is.
useEscapeLayer(() => (armed ? setArmed(null) : onClose()), true)
// Cheapest first, the ones that can be had now above the ones that cannot (switched off, paused, too dear).
const { shown, firstDisabled } = useMemo(() => {
const q = query.trim().toLowerCase()
const all = answer?.rewards ?? []
const found = q ? all.filter((r) => r.title.toLowerCase().includes(q) || (r.description ?? '').toLowerCase().includes(q)) : all
const byCost = (a: Reward, b: Reward): number => a.cost - b.cost || a.title.localeCompare(b.title)
const pressable = (r: Reward): boolean => !whyNot(r, answer?.points ?? null, api.canType)
const active = found.filter(pressable).sort(byCost)
const disabled = found.filter((r) => !pressable(r)).sort(byCost)
return { shown: [...active, ...disabled], firstDisabled: active.length }
}, [answer, query, api.canType])
useEffect(() => {
list.current?.querySelector('.switcher-row.selected')?.scrollIntoView({ block: 'nearest' })
}, [index, shown.length])
const points = answer?.points ?? null
const unit = answer?.name || 'points'
const redeem = (r: Reward): void => {
setBusy(true)
void ask<{ points: number | null }>(api.redeem, { bufferId, rewardId: r.id, ...(r.needsInput ? { input: typed } : {}), ...(r.choices?.length ? { choice: picked } : {}) })
.then((a) => {
store.toast('info', `Redeemed ${r.title}`)
if (a.points !== null && a.points !== undefined) {
setAnswer((cur) => (cur ? { ...cur, points: a.points } : cur))
onBalance(a.points)
}
setArmed(null)
setTyped('')
setPicked('')
// An emote unlock changes what there is left to unlock: asked again.
if (r.choices?.length) {
void ask<Answer>(api.list, { bufferId }).then(setAnswer).catch(() => undefined)
}
})
.catch((e: Error) => store.toast('error', e.message))
.finally(() => setBusy(false))
}
const press = (r: Reward): void => {
if (whyNot(r, points, api.canType) || busy) return
if (armed === r.id && (!r.needsInput || typed.trim()) && (!r.choices?.length || picked)) redeem(r)
else {
setArmed(r.id)
setTyped('')
setPicked('')
}
}
return createPortal(
<div className="modal-scrim switcher-scrim" onMouseDown={onClose}>
<div className="switcher rewards" role="dialog" aria-modal="true" aria-label="Channel points rewards" onMouseDown={(e) => e.stopPropagation()}>
<div className="switcher-field">
<Icon name="toll" size={18} />
<input
ref={input}
className="switcher-input"
placeholder="Spend channel points on…"
value={query}
spellCheck={false}
onChange={(e) => {
setQuery(e.target.value)
setIndex(0)
setArmed(null)
}}
onKeyDown={(e) => {
if (e.key === 'ArrowDown' || (e.key === 'Tab' && !e.shiftKey)) {
e.preventDefault()
setIndex((i) => Math.min(shown.length - 1, i + 1))
setArmed(null)
} else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
e.preventDefault()
setIndex((i) => Math.max(0, i - 1))
setArmed(null)
} else if (e.key === 'Enter' && shown[index]) {
e.preventDefault()
press(shown[index])
}
}}
/>
<span className="rewards-balance small" title={`Your ${unit} here`}>
{points === null ? '' : `${points.toLocaleString()} ${unit === 'points' ? '' : unit}`.trim()}
</span>
</div>
<div className="switcher-list" ref={list} role="listbox">
{error && <div className="switcher-empty muted">{error}</div>}
{!error && !answer && <div className="switcher-empty muted">Asking {service === 'kick' ? 'Kick' : 'Twitch'}…</div>}
{answer && shown.length === 0 && <div className="switcher-empty muted">{answer.rewards.length === 0 ? 'This channel offers nothing for points.' : 'Nothing by that name.'}</div>}
{shown.map((r, i) => {
const why = whyNot(r, points, api.canType)
const isArmed = armed === r.id
return (
<div key={r.id} className={classes('reward-item', isArmed && 'armed')}>
{i === 0 && firstDisabled > 0 && <div className="reward-group small muted">Available</div>}
{i === firstDisabled && shown.length > firstDisabled && <div className="reward-group small muted">Unavailable</div>}
<button
type="button"
role="option"
aria-selected={i === index}
disabled={!!why || busy}
title={why || r.description || r.title}
className={classes('switcher-row', 'reward-row', i === index && 'selected', why && 'locked')}
onMouseMove={() => i !== index && setIndex(i)}
onClick={() => {
setIndex(i)
press(r)
}}
>
<span className="switcher-lead">
{r.image ? <img className="reward-image" src={r.image} alt="" /> : <Icon name="redeem" size={16} color={r.color ?? undefined} />}
</span>
<span className="switcher-title ellipsis">{r.title}</span>
<span className="switcher-where muted small ellipsis">{why || r.description || ''}</span>
<span className="reward-cost small">{r.cost.toLocaleString()}</span>
</button>
{isArmed && (
<div className="reward-confirm">
{r.needsInput && (
<input
className="text-field"
autoFocus
placeholder={r.description || 'What it asks for'}
value={typed}
maxLength={500}
onChange={(e) => setTyped(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && typed.trim()) redeem(r)
}}
/>
)}
{!!r.choices?.length && <EmoteChooser choices={r.choices} picked={picked} onPick={setPicked} />}
<button type="button" className="button primary" disabled={busy || (r.needsInput && !typed.trim()) || (!!r.choices?.length && !picked)} onClick={() => redeem(r)}>
Redeem for {r.cost.toLocaleString()}
</button>
<button type="button" className="button subtle" onClick={() => setArmed(null)}>
Cancel
</button>
</div>
)}
</div>
)
})}
</div>
<div className="switcher-hint muted small">
<span>↑↓ to move</span>
<span>Enter to choose, again to spend</span>
<span>Esc to close</span>
</div>
</div>
</div>,
document.body
)
}
+29 -3
View File
@@ -13,6 +13,7 @@ import { RAIL_TIP, hideRailTip, showRailTip, type RailTipDetail } from '../lib/r
import { bufferDisplayName, classes, nickColor, resolveMediaUrl, serviceIcon } from '../lib/util'
import { SpaceRooms } from './SpaceRooms'
import { SteamGroupSettings } from './SteamGroupSettings'
import { StoatServerSettings } from './StoatServerSettings'
import { steamGroupOfRail } from '../lib/steamchat'
import {
isFixedEntry,
@@ -75,6 +76,8 @@ interface TileProps {
onToggleMute: () => void
/** Absent where there is nothing to leave - an account's own entry. */
onLeave?: () => void
/** Makes an invite link to the server and copies it. Stoat. */
onInvite?: () => 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. */
@@ -288,10 +291,19 @@ function RailTile(props: TileProps): JSX.Element {
} as const
]
: []),
...(props.onInvite
? [
{
label: 'Copy invite link',
icon: 'link',
onClick: props.onInvite
} as const
]
: []),
...(props.onSettings
? [
{
label: 'Group settings',
label: props.group.service === 'stoat' ? 'Server settings' : 'Group settings',
icon: 'settings',
onClick: props.onSettings
} as const
@@ -304,7 +316,7 @@ function RailTile(props: TileProps): JSX.Element {
? [
{ separator: true } as const,
{
label: group.kind === 'space' ? 'Leave space' : 'Leave server',
label: group.kind === 'space' ? 'Leave space' : group.service === 'telegram' ? 'Leave group' : 'Leave server',
icon: 'logout',
danger: true,
onClick: props.onLeave
@@ -423,6 +435,7 @@ export function ServerRail(): JSX.Element | null {
const [browsing, setBrowsing] = useState<RailGroup | null>(null)
/** The Steam chat group whose settings are open, if any. */
const [configuring, setConfiguring] = useState<RailGroup | null>(null)
const [stoatSettings, setStoatSettings] = useState<RailGroup | null>(null)
/** Hovering the space below the column, which means "put it last". */
const [tailTarget, setTailTarget] = useState(false)
@@ -575,7 +588,19 @@ 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}
onInvite={
g.service === 'stoat' && g.kind === 'guild'
? () => {
const inside = buffers.find((b) => b.groupId === g.id)
if (!inside) return
void window.moho
.rpc<{ link: string }>('createStoatInvite', { bufferId: inside.id })
.then((r) => window.moho.copyText(r.link).then(() => store.toast('info', 'Invite link copied')))
.catch((e: Error) => store.toast('error', e.message))
}
: undefined
}
onSettings={steamGroupOfRail(g.id) ? () => setConfiguring(g) : g.service === 'stoat' && g.kind === 'guild' ? () => setStoatSettings(g) : undefined}
unread={t?.unread ?? 0}
highlight={t?.highlight ?? false}
mentions={t?.mentions ?? 0}
@@ -749,6 +774,7 @@ export function ServerRail(): JSX.Element | null {
/>
)}
{stoatSettings && buffers.find((b) => b.groupId === stoatSettings.id) && <StoatServerSettings bufferId={buffers.find((b) => b.groupId === stoatSettings.id)!.id} onClose={() => setStoatSettings(null)} />}
{configuring && <SteamGroupSettings accountId={configuring.accountId} railId={configuring.id} onClose={() => setConfiguring(null)} />}
{browsing && (
<SpaceRooms
@@ -0,0 +1,111 @@
import { useEffect, useState } from 'react'
import { Icon } from './Icon'
import { useStore } from '../state/hooks'
import { resolveMediaUrl } from '../lib/util'
import type { Account } from '../../../shared/wire'
/** What the account looks like to everybody on Stoat. */
interface OwnProfile {
handle: string
displayName: string
pronouns: string
bio: string
statusText: string
avatarUrl?: string | null
}
/**
* The account's own profile on Stoat, under its card in the Accounts pane. Separate from the local name moho calls the
* account by: this is what every server and conversation shows. Only what was changed is sent.
*/
export function StoatAccountTools({ account }: { account: Account }): JSX.Element | null {
const store = useStore()
const [profile, setProfile] = useState<OwnProfile | null>(null)
const [form, setForm] = useState({ displayName: '', pronouns: '', bio: '', statusText: '' })
const [busy, setBusy] = useState(false)
const load = (): void => {
void window.moho
.rpc<OwnProfile>('stoatOwnProfile', { accountId: account.id })
.then((p) => {
setProfile(p)
setForm({ displayName: p.displayName, pronouns: p.pronouns, bio: p.bio, statusText: p.statusText })
})
// An account still connecting has no answer yet; the panel shows nothing in its place.
.catch(() => setProfile(null))
}
// Asked again once the account is up, since the first open can be before it is.
useEffect(load, [account.id, account.state])
if (!profile) return null
const changed = (Object.keys(form) as (keyof typeof form)[]).filter((k) => form[k].trim() !== profile[k].trim())
const save = (): void => {
setBusy(true)
const body: Record<string, string> = {}
for (const k of changed) body[k] = form[k]
void window.moho
.rpc('setStoatProfile', { accountId: account.id, ...body })
.then(() => {
store.toast('info', 'Profile changed')
load()
})
.catch((e: Error) => store.toast('error', e.message))
.finally(() => setBusy(false))
}
const picture = (path: string): void => {
setBusy(true)
void window.moho
.rpc('setStoatProfileAvatar', { accountId: account.id, path })
.then(() => {
store.toast('info', path ? 'Picture changed' : 'Picture taken off')
load()
})
.catch((e: Error) => store.toast('error', e.message))
.finally(() => setBusy(false))
}
const field = (key: keyof typeof form, label: string, placeholder = '', area = false): JSX.Element => (
<label className="field">
<span className="small muted">{label}</span>
{area ? (
<textarea className="text-field" rows={3} placeholder={placeholder} value={form[key]} disabled={busy} onChange={(e) => setForm({ ...form, [key]: e.target.value })} />
) : (
<input className="text-field" type="text" placeholder={placeholder} value={form[key]} disabled={busy} onChange={(e) => setForm({ ...form, [key]: e.target.value })} />
)}
</label>
)
return (
<div className="account-tools">
<div className="setting-row">
<div className="setting-text">
<div>Your profile on Stoat ({profile.handle})</div>
<div className="small muted">Changing these changes them for everyone who can see you.</div>
</div>
</div>
<div className="device-row">
{profile.avatarUrl ? <img className="account-avatar" src={resolveMediaUrl(profile.avatarUrl)} alt="" /> : <Icon name="account_circle" size={18} />}
<div className="setting-text" />
<button type="button" className="button subtle" disabled={busy} onClick={() => void window.moho.pickFile().then((path) => path && picture(path))}>
Change picture
</button>
{profile.avatarUrl && (
<button type="button" className="button subtle" disabled={busy} onClick={() => picture('')}>
Take it off
</button>
)}
</div>
<div className="field-row">
{field('displayName', 'Display name', 'Your username is shown if empty')}
{field('pronouns', 'Pronouns', 'she, her')}
</div>
{field('statusText', 'Beside your status', 'What you are up to')}
{field('bio', 'About you', 'Shown on your profile card', true)}
<div className="field-row">
<button type="button" className="button primary" disabled={busy || changed.length === 0} onClick={save}>
Save
</button>
</div>
</div>
)
}
@@ -0,0 +1,704 @@
import { useCallback, useEffect, useMemo, 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 { resolveMediaUrl } from '../lib/util'
/**
* Asks the daemon; or, in a window the screenshot harness started, takes the answer it left in `__rpcFixture`, since
* the harness has no Stoat 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)
}
interface Permission {
key: string
label: string
group: string
/** Means something in one channel, not only server-wide. */
channel: boolean
}
interface Category {
id: string
title: string
channels: string[]
}
interface Override {
allow: string[]
deny: string[]
}
interface Channel {
id: string
name: string
description: string
voice: boolean
nsfw: boolean
slowmode: number
category: string | null
roles: ({ roleId: string } & Override)[]
default: Override
}
interface Role extends Override {
id: string
name: string
colour?: string | null
hoist: boolean
rank: number
}
interface Overview {
serverId: string
name: string
description: string
iconUrl?: string | null
can: { owner: boolean; server: boolean; channels: boolean; roles: boolean; permissions: boolean; webhooks: boolean; audit: boolean }
categories: Category[]
channels: Channel[]
roles: Role[]
defaultPermissions: string[]
permissions: Permission[]
limits: { channels: number; roles: number }
}
interface Webhook {
id: string
name: string
token?: string | null
url?: string | null
}
interface AuditEntry {
id: string
text: string
ts: number
}
type Tab = 'overview' | 'channels' | 'roles' | 'webhooks' | 'audit'
type Act = <T>(action: string, extra?: Record<string, unknown>) => Promise<T | undefined>
type Stance = 'inherit' | 'allow' | 'deny'
const SLOWMODES: [number, string][] = [
[0, 'Off'],
[5, '5 seconds'],
[10, '10 seconds'],
[30, '30 seconds'],
[60, '1 minute'],
[300, '5 minutes'],
[900, '15 minutes'],
[3600, '1 hour'],
[21600, '6 hours']
]
/**
* Looking after a Stoat server: its name and picture, channels and the headings they sit under, roles and what each may
* do, who may do what in one channel, webhooks, and what moderators did.
*
* Read from the daemon each time it is opened and after every change, so what is shown is what Stoat holds. What the
* account may not change is not offered; Stoat checks again and refuses in words if it was wrong about that.
*/
export function StoatServerSettings({ bufferId, onClose }: { bufferId: string; onClose: () => void }): JSX.Element {
const store = useStore()
const [server, setServer] = useState<Overview | null>(null)
const [tab, setTab] = useState<Tab>('overview')
const [busy, setBusy] = useState(false)
const reload = useCallback(async () => {
setServer(await ask<Overview>('stoatManage', { bufferId, action: 'overview' }))
}, [bufferId])
useEffect(() => {
void reload().catch((e: Error) => {
store.toast('error', e.message)
onClose()
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reload])
/** One change, then the server read again; the error is Stoat's own. */
const act: Act = useCallback(
async <T,>(action: string, extra: Record<string, unknown> = {}): Promise<T | undefined> => {
setBusy(true)
try {
const done = await ask<T>('stoatManage', { bufferId, action, ...extra })
if (action !== 'webhooks' && action !== 'auditLog') await reload()
return done
} catch (e) {
store.toast('error', (e as Error).message)
return undefined
} finally {
setBusy(false)
}
},
[bufferId, reload, store]
)
if (!server) {
return (
<Modal title="Server" icon="dns" onClose={onClose} className="steam-group stoat-server">
<p className="small muted">
<span className="spinner" /> Asking Stoat…
</p>
</Modal>
)
}
const tabs: [Tab, string][] = [
['overview', 'Overview'],
['channels', 'Channels'],
['roles', 'Roles'],
['webhooks', 'Webhooks'],
['audit', 'Audit log']
]
return (
<Modal title={server.name || 'Server'} icon="dns" onClose={onClose} className="steam-group stoat-server">
<div className="room-settings-tabs" role="tablist">
{tabs
.filter(([id]) => id !== 'audit' || server.can.audit)
.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 === 'overview' && <Overview server={server} busy={busy} act={act} />}
{tab === 'channels' && <Channels server={server} busy={busy} act={act} />}
{tab === 'roles' && <Roles server={server} busy={busy} act={act} />}
{tab === 'webhooks' && <Webhooks server={server} busy={busy} act={act} />}
{tab === 'audit' && <Audit act={act} />}
</div>
</Modal>
)
}
function Overview({ server, busy, act }: { server: Overview; busy: boolean; act: Act }): JSX.Element {
const [name, setName] = useState(server.name)
const [description, setDescription] = useState(server.description)
const may = server.can.server
return (
<>
<div className="field">
<span className="small muted">Picture</span>
<div className="field-row steam-group-row">
{server.iconUrl ? <img className="account-avatar" src={resolveMediaUrl(server.iconUrl)} alt="" /> : <Icon name="dns" size={18} />}
<span className="grow small muted">{may ? 'Shown on the rail.' : 'Only those who may manage the server can change it.'}</span>
<button type="button" className="button subtle small" disabled={!may || busy} onClick={() => void window.moho.pickFile(['jpg', 'jpeg', 'png', 'gif', 'webp']).then((path) => path && void act('editServer', { icon: path }))}>
Change picture
</button>
{server.iconUrl && (
<button type="button" className="button subtle small" disabled={!may || busy} onClick={() => void act('editServer', { icon: '' })}>
Take it off
</button>
)}
</div>
</div>
<div className="field">
<span className="small muted">Name</span>
<div className="field-row">
<input className="text-field" value={name} disabled={!may || busy} onChange={(e) => setName(e.target.value)} />
<button type="button" className="button" disabled={!may || busy || !name.trim() || name === server.name} onClick={() => void act('editServer', { name })}>
Save
</button>
</div>
</div>
<div className="field">
<span className="small muted">Description</span>
<textarea className="text-field" rows={3} value={description} disabled={!may || busy} onChange={(e) => setDescription(e.target.value)} />
<div className="field-row">
<button type="button" className="button" disabled={!may || busy || description === server.description} onClick={() => void act('editServer', { description })}>
Save
</button>
</div>
</div>
<p className="small muted">
{server.can.owner ? 'You own this server.' : 'What is offered here is what your roles allow.'} {server.channels.length} of at most {server.limits.channels} channels, {server.roles.length} of at most {server.limits.roles} roles.
</p>
</>
)
}
/** What a set of allows and denies says about one permission. */
function stanceOf(o: Override, key: string): Stance {
return o.allow.includes(key) ? 'allow' : o.deny.includes(key) ? 'deny' : 'inherit'
}
/**
* Every permission with its choice: inherit what is below, allow, or deny. Grouped as Stoat's own app groups them.
* `onlyAllow` is for the server's default, which can only grant.
*/
function PermissionEditor({
permissions,
value,
onChange,
channel,
onlyAllow,
disabled
}: {
permissions: Permission[]
value: Override
onChange: (next: Override) => void
channel: boolean
onlyAllow?: boolean
disabled?: boolean
}): JSX.Element {
const shown = permissions.filter((p) => !channel || p.channel)
const groups = [...new Set(shown.map((p) => p.group))]
const set = (key: string, stance: Stance): void => {
const allow = value.allow.filter((k) => k !== key)
const deny = value.deny.filter((k) => k !== key)
if (stance === 'allow') allow.push(key)
if (stance === 'deny') deny.push(key)
onChange({ allow, deny })
}
return (
<div className="steam-group-perms">
{groups.map((g) => (
<div key={g} className="field">
<span className="small muted">{g}</span>
{shown
.filter((p) => p.group === g)
.map((p) => (
<div key={p.key} className="field-row steam-group-row">
<span className="grow ellipsis">{p.label}</span>
<select className="text-field small" aria-label={p.label} disabled={disabled} value={stanceOf(value, p.key)} onChange={(e) => set(p.key, e.target.value as Stance)}>
<option value="inherit">{onlyAllow ? 'No' : 'Inherit'}</option>
<option value="allow">Allow</option>
{!onlyAllow && <option value="deny">Deny</option>}
</select>
</div>
))}
</div>
))}
</div>
)
}
function Channels({ server, busy, act }: { server: Overview; busy: boolean; act: Act }): JSX.Element {
const [open, setOpen] = useState<{ id: string; what: 'edit' | 'permissions' } | null>(null)
const [fresh, setFresh] = useState('')
const [voice, setVoice] = useState(false)
const [heading, setHeading] = useState('')
const may = server.can.channels
const byId = useMemo(() => new Map(server.channels.map((c) => [c.id, c])), [server.channels])
const sections: { id: string | null; title: string; channels: Channel[] }[] = [
...server.categories.map((c) => ({ id: c.id as string | null, title: c.title, channels: c.channels.map((x) => byId.get(x)).filter((x): x is Channel => !!x) })),
{ id: null, title: server.categories.length ? 'No heading' : 'Channels', channels: server.channels.filter((c) => !c.category) }
]
const write = (next: Category[]): void => void act('setCategories', { categories: next })
/** The headings with one channel moved under another (or, with `to` null, out of them all). */
const moved = (channel: string, to: string | null): Category[] => server.categories.map((c) => ({ ...c, channels: [...c.channels.filter((x) => x !== channel), ...(c.id === to ? [channel] : [])] }))
const shifted = (category: Category, channel: string, by: number): Category[] =>
server.categories.map((c) => {
if (c.id !== category.id) return c
const list = [...c.channels]
const at = list.indexOf(channel)
const to = at + by
if (at < 0 || to < 0 || to >= list.length) return c
;[list[at], list[to]] = [list[to], list[at]]
return { ...c, channels: list }
})
return (
<>
{sections.map((s) => (
<div key={s.id ?? 'none'} className="field">
<div className="field-row steam-group-row">
<span className="grow small muted">{s.title}</span>
{may && s.id && <HeadingTools category={server.categories.find((c) => c.id === s.id)!} categories={server.categories} write={write} busy={busy} />}
</div>
{s.channels.map((c) => {
const category = server.categories.find((x) => x.id === c.category)
return (
<div key={c.id} className="steam-group-person">
<div className="field-row steam-group-row">
<Icon name={c.voice ? 'volume_up' : 'tag'} size={16} />
<span className="grow ellipsis">{c.name}</span>
{c.nsfw && <span className="small muted">18+</span>}
{c.slowmode > 0 && <span className="small muted">slow {SLOWMODES.find(([s]) => s === c.slowmode)?.[1] ?? `${c.slowmode}s`}</span>}
{may && category && (
<>
<button type="button" className="button subtle small" aria-label={`Move ${c.name} up`} disabled={busy} onClick={() => write(shifted(category, c.id, -1))}>
↑
</button>
<button type="button" className="button subtle small" aria-label={`Move ${c.name} down`} disabled={busy} onClick={() => write(shifted(category, c.id, 1))}>
↓
</button>
</>
)}
{may && (
<button type="button" className="button subtle small" onClick={() => setOpen(open?.id === c.id && open.what === 'edit' ? null : { id: c.id, what: 'edit' })}>
Edit
</button>
)}
{server.can.permissions && (
<button type="button" className="button subtle small" onClick={() => setOpen(open?.id === c.id && open.what === 'permissions' ? null : { id: c.id, what: 'permissions' })}>
Who may
</button>
)}
{may && <ConfirmButton label="Delete" question={`Delete ${c.name} and what was said in it?`} confirmLabel="Delete" danger onConfirm={() => void act('deleteChannel', { channelId: c.id })} />}
</div>
{open?.id === c.id && open.what === 'edit' && <ChannelEdit channel={c} categories={server.categories} busy={busy} act={act} onMove={(to) => write(moved(c.id, to))} />}
{open?.id === c.id && open.what === 'permissions' && <ChannelPermissions channel={c} server={server} busy={busy} act={act} />}
</div>
)
})}
</div>
))}
{may && (
<>
<div className="field-row">
<input className="text-field" placeholder="A new channel" value={fresh} onChange={(e) => setFresh(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && fresh.trim() && void act('createChannel', { name: fresh, voice }).then(() => setFresh(''))} />
<button type="button" className="button" disabled={busy || !fresh.trim()} onClick={() => void act('createChannel', { name: fresh, voice }).then(() => setFresh(''))}>
Add channel
</button>
</div>
<SwitchRow title="A voice channel" description="People in it can talk, as well as write" checked={voice} onChange={setVoice} />
<div className="field-row">
<input className="text-field" placeholder="A new heading" maxLength={32} value={heading} onChange={(e) => setHeading(e.target.value)} />
<button
type="button"
className="button"
disabled={busy || !heading.trim()}
onClick={() => {
write([...server.categories, { id: Math.random().toString(36).slice(2, 10).toUpperCase(), title: heading.trim(), channels: [] }])
setHeading('')
}}
>
Add heading
</button>
</div>
</>
)}
</>
)
}
/** Renaming a heading, moving it, and taking it away (its channels stay, under no heading). */
function HeadingTools({ category, categories, write, busy }: { category: Category; categories: Category[]; write: (next: Category[]) => void; busy: boolean }): JSX.Element {
const [name, setName] = useState<string | null>(null)
const at = categories.findIndex((c) => c.id === category.id)
const swap = (by: number): Category[] => {
const list = [...categories]
;[list[at], list[at + by]] = [list[at + by], list[at]]
return list
}
if (name !== null) {
return (
<>
<input className="text-field small" autoFocus maxLength={32} value={name} onChange={(e) => setName(e.target.value)} />
<button
type="button"
className="button small"
disabled={busy || !name.trim()}
onClick={() => {
write(categories.map((c) => (c.id === category.id ? { ...c, title: name.trim() } : c)))
setName(null)
}}
>
Save
</button>
<button type="button" className="button subtle small" onClick={() => setName(null)}>
Cancel
</button>
</>
)
}
return (
<>
<button type="button" className="button subtle small" aria-label={`Move ${category.title} up`} disabled={busy || at === 0} onClick={() => write(swap(-1))}>
↑
</button>
<button type="button" className="button subtle small" aria-label={`Move ${category.title} down`} disabled={busy || at === categories.length - 1} onClick={() => write(swap(1))}>
↓
</button>
<button type="button" className="button subtle small" onClick={() => setName(category.title)}>
Rename
</button>
<ConfirmButton label="Remove" question={`Remove the heading ${category.title}? Its channels stay.`} confirmLabel="Remove" danger onConfirm={() => write(categories.filter((c) => c.id !== category.id))} />
</>
)
}
function ChannelEdit({ channel, categories, busy, act, onMove }: { channel: Channel; categories: Category[]; busy: boolean; act: Act; onMove: (to: string | null) => void }): JSX.Element {
const [name, setName] = useState(channel.name)
const [description, setDescription] = useState(channel.description)
const [nsfw, setNsfw] = useState(channel.nsfw)
const [slowmode, setSlowmode] = useState(channel.slowmode)
const changed = name !== channel.name || description !== channel.description || nsfw !== channel.nsfw || slowmode !== channel.slowmode
return (
<div className="steam-group-perms">
<input className="text-field" aria-label="Name" value={name} disabled={busy} onChange={(e) => setName(e.target.value)} />
<input className="text-field" aria-label="Topic" placeholder="Topic" value={description} disabled={busy} onChange={(e) => setDescription(e.target.value)} />
<SwitchRow title="Age-restricted" description="Asks people to confirm they are of age before they look" checked={nsfw} onChange={setNsfw} />
{!channel.voice && (
<div className="field-row steam-group-row">
<span className="grow">Slowmode</span>
<select className="text-field small" aria-label="Slowmode" value={slowmode} onChange={(e) => setSlowmode(Number(e.target.value))}>
{SLOWMODES.map(([s, label]) => (
<option key={s} value={s}>
{label}
</option>
))}
</select>
</div>
)}
{categories.length > 0 && (
<div className="field-row steam-group-row">
<span className="grow">Under</span>
<select className="text-field small" aria-label="Heading" value={channel.category ?? ''} onChange={(e) => onMove(e.target.value || null)}>
<option value="">No heading</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.title}
</option>
))}
</select>
</div>
)}
<div className="field-row">
<button
type="button"
className="button"
disabled={busy || !changed || !name.trim()}
onClick={() => void act('editChannel', { channelId: channel.id, name, description, nsfw, slowmode: channel.voice ? undefined : slowmode })}
>
Save
</button>
</div>
</div>
)
}
/** Who may do what in one channel: everybody's default, and each role's. */
function ChannelPermissions({ channel, server, busy, act }: { channel: Channel; server: Overview; busy: boolean; act: Act }): JSX.Element {
const [who, setWho] = useState('')
const held = who === '' ? channel.default : (channel.roles.find((r) => r.roleId === who) ?? { allow: [], deny: [] })
const [value, setValue] = useState<Override>({ allow: held.allow, deny: held.deny })
useEffect(() => setValue({ allow: held.allow, deny: held.deny }), [who, held.allow.join(), held.deny.join()]) // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="steam-group-perms">
<div className="field-row steam-group-row">
<span className="grow">For</span>
<select className="text-field small" aria-label="For" value={who} onChange={(e) => setWho(e.target.value)}>
<option value="">Everybody</option>
{server.roles.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</div>
<PermissionEditor permissions={server.permissions} value={value} onChange={setValue} channel disabled={busy} />
<div className="field-row">
<button type="button" className="button" disabled={busy} onClick={() => void act('setChannelPermissions', { channelId: channel.id, roleId: who, allow: value.allow, deny: value.deny })}>
Save
</button>
</div>
</div>
)
}
function Roles({ server, busy, act }: { server: Overview; busy: boolean; act: Act }): JSX.Element {
const [open, setOpen] = useState<string | null>(null)
const [fresh, setFresh] = useState('')
const may = server.can.roles
const order = server.roles.map((r) => r.id)
const shifted = (id: string, by: number): string[] => {
const list = [...order]
const at = list.indexOf(id)
const to = at + by
if (at < 0 || to < 0 || to >= list.length) return list
;[list[at], list[to]] = [list[to], list[at]]
return list
}
return (
<>
<p className="small muted">The most powerful role is first. A role can only be given, changed or moved by someone with a role above it.</p>
<div className="steam-group-person">
<div className="field-row steam-group-row">
<Icon name="groups" size={16} />
<span className="grow ellipsis">Everybody</span>
{server.can.permissions && (
<button type="button" className="button subtle small" onClick={() => setOpen(open === '' ? null : '')}>
What everybody may do
</button>
)}
</div>
{open === '' && <RoleEditor server={server} role={null} busy={busy} act={act} />}
</div>
{server.roles.map((r) => (
<div key={r.id} className="steam-group-person">
<div className="field-row steam-group-row">
<span style={{ width: 12, height: 12, borderRadius: 6, background: r.colour || 'var(--outline-medium)', display: 'inline-block' }} />
<span className="grow ellipsis">{r.name}</span>
{r.hoist && <span className="small muted">listed apart</span>}
{may && (
<>
<button type="button" className="button subtle small" aria-label={`Move ${r.name} up`} disabled={busy} onClick={() => void act('reorderRoles', { order: shifted(r.id, -1) })}>
↑
</button>
<button type="button" className="button subtle small" aria-label={`Move ${r.name} down`} disabled={busy} onClick={() => void act('reorderRoles', { order: shifted(r.id, 1) })}>
↓
</button>
</>
)}
{(may || server.can.permissions) && (
<button type="button" className="button subtle small" onClick={() => setOpen(open === r.id ? null : r.id)}>
Edit
</button>
)}
{may && <ConfirmButton label="Delete" question={`Delete the role ${r.name}?`} confirmLabel="Delete" danger onConfirm={() => void act('deleteRole', { roleId: r.id })} />}
</div>
{open === r.id && <RoleEditor server={server} role={r} busy={busy} act={act} />}
</div>
))}
{may && (
<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>
)}
</>
)
}
/** One role's name, colour and what it allows and denies; with no role, what everybody may do. */
function RoleEditor({ server, role, busy, act }: { server: Overview; role: Role | null; busy: boolean; act: Act }): JSX.Element {
const [name, setName] = useState(role?.name ?? '')
const [colour, setColour] = useState(role?.colour ?? '')
const [hoist, setHoist] = useState(role?.hoist ?? false)
const [value, setValue] = useState<Override>(role ? { allow: role.allow, deny: role.deny } : { allow: server.defaultPermissions, deny: [] })
const mayLook = role ? server.can.roles : false
return (
<div className="steam-group-perms">
{role && (
<>
<input className="text-field" aria-label="Name" value={name} disabled={busy || !mayLook} onChange={(e) => setName(e.target.value)} />
<div className="field-row steam-group-row">
<span className="grow">Colour</span>
<input type="color" aria-label="Colour" value={/^#[0-9a-f]{6}$/i.test(colour) ? colour : '#808080'} disabled={busy || !mayLook} onChange={(e) => setColour(e.target.value)} />
<button type="button" className="button subtle small" disabled={busy || !mayLook || !colour} onClick={() => setColour('')}>
None
</button>
</div>
<div className="field-row steam-group-row">
<span className="grow">Listed apart in the member list</span>
<Switch checked={hoist} onChange={setHoist} label="Listed apart" disabled={busy || !mayLook} />
</div>
<div className="field-row">
<button
type="button"
className="button"
disabled={busy || !mayLook || !name.trim() || (name === role.name && colour === (role.colour ?? '') && hoist === role.hoist)}
onClick={() => void act('editRole', { roleId: role.id, name, colour, hoist })}
>
Save the role
</button>
</div>
</>
)}
<PermissionEditor permissions={server.permissions} value={value} onChange={setValue} channel={false} onlyAllow={!role} disabled={busy || !server.can.permissions} />
{server.can.permissions && (
<div className="field-row">
<button type="button" className="button" disabled={busy} onClick={() => void act('setRolePermissions', { roleId: role?.id ?? '', allow: value.allow, deny: value.deny })}>
Save what it may do
</button>
</div>
)}
</div>
)
}
function Webhooks({ server, busy, act }: { server: Overview; busy: boolean; act: Act }): JSX.Element {
const store = useStore()
const text = server.channels.filter((c) => !c.voice)
const [channel, setChannel] = useState(text[0]?.id ?? '')
const [list, setList] = useState<Webhook[] | null>(null)
const [name, setName] = useState('')
const load = useCallback(() => {
if (!channel) return
void act<{ webhooks: Webhook[] }>('webhooks', { channelId: channel }).then((r) => setList(r?.webhooks ?? []))
}, [channel, act])
useEffect(load, [load])
if (!server.can.webhooks) return <p className="small muted">Only someone who may manage webhooks can see these.</p>
return (
<>
<p className="small muted">A webhook lets another program post in a channel. Its address is a secret: whoever has it can speak there.</p>
<div className="field-row steam-group-row">
<span className="grow">Channel</span>
<select className="text-field small" aria-label="Channel" value={channel} onChange={(e) => setChannel(e.target.value)}>
{text.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
{list === null && <p className="small muted">Asking Stoat…</p>}
{list?.length === 0 && <p className="small muted">None in this channel.</p>}
{list?.map((w) => (
<div key={w.id} className="field-row steam-group-row">
<Icon name="webhook" size={16} />
<span className="grow ellipsis">{w.name}</span>
{w.url && (
<button type="button" className="button subtle small" onClick={() => void window.moho.copyText(w.url!).then(() => store.toast('info', 'Webhook address copied'))}>
Copy address
</button>
)}
<ConfirmButton label="Delete" question={`Delete the webhook ${w.name}?`} confirmLabel="Delete" danger onConfirm={() => void act('deleteWebhook', { webhookId: w.id, token: w.token ?? '' }).then(load)} />
</div>
))}
<div className="field-row">
<input className="text-field" placeholder="A new webhook" maxLength={32} value={name} onChange={(e) => setName(e.target.value)} />
<button
type="button"
className="button"
disabled={busy || !name.trim() || !channel}
onClick={() =>
void act('createWebhook', { channelId: channel, name }).then(() => {
setName('')
load()
})
}
>
Add webhook
</button>
</div>
</>
)
}
function Audit({ act }: { act: Act }): JSX.Element {
const [entries, setEntries] = useState<AuditEntry[] | null>(null)
const [more, setMore] = useState(true)
const load = (before?: string): void => {
void act<{ entries: AuditEntry[] }>('auditLog', before ? { before } : {}).then((r) => {
const got = r?.entries ?? []
setEntries((held) => [...(before ? (held ?? []) : []), ...got])
setMore(got.length >= 50)
})
}
useEffect(() => load(), []) // eslint-disable-line react-hooks/exhaustive-deps
if (entries === null) return <p className="small muted">Asking Stoat…</p>
if (entries.length === 0) return <p className="small muted">Nothing has been recorded.</p>
return (
<>
{entries.map((e) => (
<div key={e.id} className="field-row steam-group-row">
<span className="grow">{e.text}</span>
<span className="small muted">{new Date(e.ts * 1000).toLocaleString()}</span>
</div>
))}
{more && (
<div className="field-row">
<button type="button" className="button subtle" onClick={() => load(entries[entries.length - 1].id)}>
Older
</button>
</div>
)}
</>
)
}
@@ -0,0 +1,39 @@
import { Avatar } from './Avatar'
import { Icon } from './Icon'
import { useChat } from '../state/hooks'
/**
* Who is in a Stoat voice channel, listed under it in the channel list as a Discord server's are: what anybody
* decides on before joining. What the daemon last said, which it says as people come, go, mute and turn a camera on.
*/
export function StoatVoiceWho({ bufferId, accountId }: { bufferId: string; accountId: string }): JSX.Element | null {
const members = useChat((s) => s.stoatVoice[bufferId]) ?? []
if (members.length === 0) return null
return (
<>
{members.map((m) => (
<div key={m.userId} className="voice-member small">
<span className="voice-member-face">
<Avatar name={m.name} url={m.avatarUrl ?? undefined} size={20} accountId={accountId} />
</span>
<span className="ellipsis">{m.name}</span>
{m.screen && (
<span className="voice-state" title={`${m.name} is sharing a screen`}>
<Icon name="screen_share" size={13} />
</span>
)}
{m.camera && (
<span className="voice-state" title={`${m.name} has a camera on`}>
<Icon name="videocam" size={13} />
</span>
)}
{m.muted && (
<span className="voice-state" title={`${m.name} is muted`}>
<Icon name="mic_off" size={13} />
</span>
)}
</div>
))}
</>
)
}
+317 -46
View File
@@ -1,14 +1,17 @@
import { useEffect, useRef, useState } from 'react'
import Hls from 'hls.js'
import { IconButton } from './Icon'
import { Icon, IconButton } from './Icon'
import { ivsAbr, ivsLoader } from '../lib/ivsplaylist'
import { autoCeiling, captionTrack, menuOrder, renditionLabel, renditionOf, type CaptionTrack, type Rendition } from '../lib/streamplayer'
import { useEscapeLayer } from '../lib/layers'
import { VideoStage } from './VideoStage'
import { fitTiles } from './CallStage'
import { useChat, useStore } from '../state/hooks'
import { useChat, usePref, useStore } from '../state/hooks'
import { bufferDisplayName } from '../lib/util'
import { StageWindow, STREAM_WINDOW_NAME } from './stage/StageWindow'
/**
* A Kick channel's stream, in the surface a call uses.
* A Kick or a Twitch channel's stream, in the surface a call uses.
*
* The other half of the picture this window can show. A call is real-time
* media negotiated with somebody and arrives as a `MediaStream` on
@@ -24,8 +27,71 @@ import { StageWindow, STREAM_WINDOW_NAME } from './stage/StageWindow'
* stream has one picture, and the grid a call draws would be a grid of one.
*/
/** The rendition ceiling. See the comment where it is used. */
const MAX_HEIGHT = 720
/** What automatic choice of rendition may go up to: a picture this size beside a chat has no use for more. */
const AUTO_MAX_HEIGHT = 720
/**
* How far behind the newest segment the player settles, in seconds.
*
* In seconds because hls.js counts the other way in multiples of the playlist's target duration, and Kick's
* playlists say six seconds for segments that are two: "three segments" was eighteen seconds behind, which is
* where most of the delay between the broadcaster and the picture came from. With the prefetched segments
* (see lib/ivsplaylist.ts) this is a few seconds behind the broadcaster, and no catch-up speed: playing a little
* fast to close the gap was measured to stall.
*/
const LIVE_SYNC_SECONDS = 4
const LIVE_MAX_LATENCY_SECONDS = 10
/** Where the numbers of captions carried inside the video start, clear of the playlist's own. */
const IN_VIDEO = 1000
/** The renditions of a player's playlist, as the choices are made from them. */
function renditionsOf(player: Hls): Rendition[] {
return player.levels.map((l) => ({ height: l.height, frameRate: Number(l.attrs?.['FRAME-RATE']) || undefined, bitrate: l.bitrate }))
}
interface MenuChoice {
label: string
detail?: string
checked: boolean
onClick: () => void
}
/** A short list drawn over the picture, since a popped-out window is a document of its own and a menu in this one would open in the wrong place. */
function StreamMenu({ title, choices, onClose }: { title: string; choices: MenuChoice[]; onClose: () => void }): JSX.Element {
useEscapeLayer(onClose)
return (
<>
<div className="stream-menu-scrim" onClick={onClose} />
<div className="context-menu stream-menu" role="menu" aria-label={title}>
<div className="stream-menu-title small muted">{title}</div>
{choices.map((c) => (
<button
key={c.label}
type="button"
role="menuitemradio"
aria-checked={c.checked}
className="context-menu-item"
onClick={() => {
c.onClick()
onClose()
}}
>
<span className="context-menu-label">{c.label}</span>
{c.detail && <span className="small muted">{c.detail}</span>}
{c.checked && <Icon name="check" size={16} className="context-menu-check" />}
</button>
))}
</div>
</>
)
}
interface Choice {
index: number
label: string
height: number
}
export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'window' }): JSX.Element | null {
const store = useStore()
@@ -36,17 +102,46 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
const video = useRef<HTMLVideoElement>(null)
const frame = useRef<HTMLDivElement>(null)
const hls = useRef<Hls | null>(null)
/** The newest playback URL, for the error path to reach without being
* rebuilt every time one arrives. */
/** The newest playback URL, for a player that has to begin again to reach without being rebuilt every time
* one arrives. */
const latest = useRef('')
const [room, setRoom] = useState({ width: 640, height: 260 })
const [muted, setMuted] = useState(false)
// How tall the picture is above the conversation, as it was left by dragging its bottom edge; none is the default
// (nearly half the window, to a limit). Followed while dragging and remembered when it is let go.
const [savedHeight, setSavedHeight] = usePref<number | null>('ui.streamHeight', null)
const [liveHeight, setLiveHeight] = useState<number | null | undefined>(undefined)
const inlineHeight = liveHeight === undefined ? savedHeight : liveHeight
// Remembered between channels and between sessions: turning it down for one
// stream and being deafened by the next is how a player loses a person.
const [muted, setMuted] = usePref<boolean>('stream.muted', false)
const [volume, setVolume] = usePref<number>('stream.volume', 1)
/** Seconds left of an ad the stream is playing, or null when it is not playing one. */
const [ad, setAd] = useState<number | null>(null)
const adRanges = useRef<{ start: number; end: number }[]>([])
/** What the stream offers, once its playlist has been read. */
const [choices, setChoices] = useState<Choice[]>([])
const [listed, setListed] = useState<CaptionTrack[]>([])
/** Captions carried inside the video itself (CEA-608/708), which hls.js draws as text tracks of its own. */
const [inVideo, setInVideo] = useState<CaptionTrack[]>([])
const captions = [...listed, ...inVideo]
/** What automatic choice is playing now, for the menu to say. */
const [playing, setPlaying] = useState('')
const [menu, setMenu] = useState<'quality' | 'captions' | null>(null)
// Remembered between streams, apart for each service because their ladders differ: 0 is automatic, and an
// empty language is captions off.
const service = useChat((s) => s.accounts.find((a) => a.id === (watching?.accountId ?? ''))?.service)
const [quality, setQuality] = usePref<number>(service === 'twitch' ? 'stream.quality.twitch' : 'stream.quality', 0)
const [captionLanguage, setCaptionLanguage] = usePref<string>('stream.captions', '')
const stream = watching ? streams[watching.bufferId] : undefined
// The current one rather than the one this started with: the signature in a
// playback URL expires, and the refresh already running is what replaces it.
// The newest address rather than the one this began with: the signature in it expires, and the daemon asks for
// a new one before it does. The player is not rebuilt for it - it is only where a player that has to start
// over, after being refused, starts from.
const url = stream?.playbackUrl ?? ''
latest.current = url
// A different picture is a different stream or a different broadcast; the same one with a new address is not.
const picture = `${watching?.bufferId ?? ''}|${stream?.livestreamId ?? ''}`
const hasUrl = url !== ''
// The same measuring the call stage does, for the same reason: this is a
// different shape in a narrow window, a full-screen one, and the corner.
@@ -66,7 +161,7 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
? // The corner: as tall as its width makes the picture, so
// resizing the panel resizes the picture.
(el.clientWidth * 9) / 16
: Math.min(view.innerHeight * 0.45, 460)
: (inlineHeight ?? Math.min(view.innerHeight * 0.45, 460))
})
measure()
const observer = new ResizeObserver(measure)
@@ -76,51 +171,72 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
observer.disconnect()
view.removeEventListener('resize', measure)
}
}, [mode, minimized, watching])
}, [mode, minimized, watching, inlineHeight])
// The player itself, torn down and rebuilt only when the picture changes -
// not when the viewer count ticks over, which it does every minute.
// The player itself, torn down and rebuilt only when the picture changes - not when the viewer count ticks over,
// and not when the address is renewed, which it is every few minutes.
useEffect(() => {
const el = video.current
if (!el || !url || minimized) return
if (!el || !hasUrl || minimized) return
if (!Hls.isSupported()) {
store.toast('error', 'This build cannot play a stream')
return
}
const player = new Hls({
// Enough buffer to ride out a hiccup, not so much that "live" means two
// minutes ago: this is a chat window beside the stream, and people talk
// about what just happened.
liveSyncDurationCount: 3,
// Never fetch a rendition larger than the box it is drawn in.
capLevelToPlayerSize: true
// Kick's playlists, which list what is being made as well as what is made.
loader: ivsLoader(2),
abrController: ivsAbr(),
// How far behind to play, in seconds: see LIVE_SYNC_SECONDS.
liveSyncDuration: LIVE_SYNC_SECONDS,
liveMaxLatencyDuration: LIVE_MAX_LATENCY_SECONDS,
// Never fetch a rendition larger than the box it is drawn in, when the choice is automatic.
capLevelToPlayerSize: true,
// A stream that runs for hours must not keep them all.
backBufferLength: 20
})
hls.current = player
// For the harness that measures how far behind the broadcaster the picture runs; see scripts/watch-stream-stalls.mjs.
if (window.moho.uiShots) (window as unknown as { __mohoHls: Hls }).__mohoHls = player
player.attachMedia(el)
player.loadSource(url)
player.loadSource(latest.current)
player.on(Hls.Events.MANIFEST_PARSED, () => {
// 1080p60 is 5.8 Mbps and a real amount of CPU to decode beside a chat
// client, and the ladder below it is cheaper for a picture this size.
// A ceiling rather than a fixed choice: the bitrate ladder underneath
// still adapts to the connection.
const ceiling = player.levels.reduce(
(best, level, i) => (level.height <= MAX_HEIGHT && level.height >= (player.levels[best]?.height ?? 0) ? i : best),
-1
)
if (ceiling >= 0) player.autoLevelCapping = ceiling
setChoices(menuOrder(renditionsOf(player)))
void el.play().catch(() => {
/* A picture that will not start is not worth an error dialog. */
})
})
player.on(Hls.Events.LEVEL_LOADED, (_e, data) => {
// Behind by a segment and a half at the least: where they are 5 seconds long, 4 seconds is less than one.
const longest = Math.max(0, ...data.details.fragments.map((f) => f.duration))
const behind = Math.max(LIVE_SYNC_SECONDS, longest * 1.5)
if (player.config.liveSyncDuration !== behind) {
player.config.liveSyncDuration = behind
player.config.liveMaxLatencyDuration = Math.max(LIVE_MAX_LATENCY_SECONDS, behind * 2.5)
}
})
// Ads Twitch puts into a stream are marked in its playlist as date ranges of their own, with a start and a
// length; which of them the picture is playing is worked out against where the playhead is (see below).
player.on(Hls.Events.LEVEL_UPDATED, (_e, data) => {
adRanges.current = Object.values(data.details.dateRanges)
.filter((r) => r && r.class === 'twitch-stitched-ad')
.map((r) => ({ start: r!.startDate.getTime(), end: r!.startDate.getTime() + (r!.duration ?? r!.plannedDuration ?? 0) * 1000 }))
})
player.on(Hls.Events.LEVEL_SWITCHED, (_e, data) => {
const level = player.levels[data.level]
if (level) setPlaying(renditionLabel({ height: level.height, frameRate: Number(level.attrs?.['FRAME-RATE']) || undefined }))
})
player.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_e, data) => {
setListed(data.subtitleTracks.map((t) => ({ id: t.id, name: t.name, lang: t.lang })))
})
player.on(Hls.Events.ERROR, (_e, data) => {
if (!data.fatal) return
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
// A refused segment is an expired token, not a dead stream: the
// daemon re-asks Kick about the channel every minute, so the next
// playback URL is already on its way and this only has to wait for
// it. Reloading the source picks up whichever one is current.
// A refused segment is an expired token, not a dead stream: the daemon
// renews the address before it expires, so the next one is already
// here and this only has to begin from it. Reloading the source picks
// up whichever one is current.
if (latest.current) player.loadSource(latest.current)
player.startLoad()
return
@@ -133,11 +249,105 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
store.stopWatching()
})
// Whether the playhead is inside an ad, and how long it has left, said once a second.
const adTimer = setInterval(() => {
const at = player.playingDate?.getTime()
const inside = at === undefined ? undefined : adRanges.current.find((r) => at >= r.start && at < r.end)
setAd(inside && at !== undefined ? Math.max(1, Math.ceil((inside.end - at) / 1000)) : null)
}, 1000)
return () => {
clearInterval(adTimer)
player.destroy()
hls.current = null
adRanges.current = []
setAd(null)
setChoices([])
setListed([])
setInVideo([])
setPlaying('')
}
}, [url, minimized, store, watching?.bufferId])
}, [hasUrl, picture, minimized, store])
// Captions inside the picture. Twitch lists none in its playlist; a streamer whose encoder sends them
// (none of the busiest channels sampled did) has them in the video, and hls.js turns them into text tracks.
// Numbered from where the listed ones can never reach, so one choice means one track.
useEffect(() => {
const el = video.current
if (!el || !hasUrl || minimized) return
const scan = (): void => {
const found: CaptionTrack[] = []
for (let i = 0; i < el.textTracks.length; i++) {
const t = el.textTracks[i]
if (t.kind === 'captions') found.push({ id: IN_VIDEO + i, name: t.label || `Captions ${found.length + 1}`, lang: t.language || undefined })
}
setInVideo((was) => (JSON.stringify(was) === JSON.stringify(found) ? was : found))
}
scan()
el.textTracks.addEventListener('addtrack', scan)
el.textTracks.addEventListener('removetrack', scan)
return () => {
el.textTracks.removeEventListener('addtrack', scan)
el.textTracks.removeEventListener('removetrack', scan)
}
}, [hasUrl, picture, minimized])
// Twitch gives points for time spent watching and counts it from what the website's player reports once a minute,
// which a player of our own does not send. Told to the daemon once a minute while this picture is really
// playing and showing; a paused, hidden, minimised or closed stage says nothing, and the daemon sends nothing
// for a channel that is not live (see backend/twitch/watch.rs).
const [reportWatching] = usePref<boolean>('twitch.reportWatching', true)
useEffect(() => {
if (service !== 'twitch' || !watching || !hasUrl || minimized || !reportWatching) return
const bufferId = watching.bufferId
const timer = setInterval(() => {
const el = video.current
if (!el || el.paused || el.ended || el.readyState < 3 || document.visibilityState !== 'visible') return
void window.moho.rpc('reportTwitchWatching', { bufferId }).catch(() => {
/* Said nowhere: it is points, and the stream is playing either way. */
})
}, 60_000)
return () => clearInterval(timer)
}, [service, watching, hasUrl, picture, minimized, reportWatching])
// The sound as it was left, whenever there is a picture to apply it to.
useEffect(() => {
const el = video.current
if (!el) return
el.muted = muted
el.volume = Math.min(1, Math.max(0, volume))
}, [muted, volume, hasUrl, picture, minimized])
// Which rendition: automatic, or the height that was chosen when this stream offers it.
useEffect(() => {
const player = hls.current
if (!player || choices.length === 0) return
const wanted = quality > 0 ? renditionOf(renditionsOf(player), quality) : -1
if (wanted >= 0) {
player.autoLevelCapping = -1
player.currentLevel = wanted
} else {
player.currentLevel = -1
player.autoLevelCapping = autoCeiling(renditionsOf(player), AUTO_MAX_HEIGHT)
}
}, [quality, choices])
// Which captions: none, or the language that was chosen when this stream has captions in it.
useEffect(() => {
const player = hls.current
if (!player) return
const track = captionTrack(captions, captionLanguage, navigator.language)
const own = track >= IN_VIDEO
player.subtitleDisplay = track >= 0 && !own
player.subtitleTrack = own ? -1 : track
// The ones inside the video are shown or hidden on the element itself.
const el = video.current
if (el) {
for (let i = 0; i < el.textTracks.length; i++) {
if (el.textTracks[i].kind === 'captions') el.textTracks[i].mode = track === IN_VIDEO + i ? 'showing' : 'disabled'
}
}
}, [captionLanguage, listed, inVideo])
// A stream that has ended is not a picture any more, and the surface should
// not sit there showing the last frame of it.
@@ -152,6 +362,7 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
const where = buffers.find((b) => b.id === watching.bufferId)?.name ?? ''
const fit = fitTiles(1, room.width, room.height, Infinity)
const captionsOn = captionTrack(captions, captionLanguage, navigator.language) >= 0
const watchers = stream?.viewers
const subtitle = `Live${watchers ? ` · ${watchers.toLocaleString()} watching` : ''}${
where ? ` · ${bufferDisplayName(where)}` : ''
@@ -170,22 +381,82 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
endIcon="stop_circle"
onPopOut={mode === 'window' ? undefined : () => store.setStreamPoppedOut(true)}
onBringBack={() => store.setStreamPoppedOut(false)}
height={Math.round(room.height)}
onHeight={(h, commit) => {
setLiveHeight(commit ? undefined : h)
if (commit) setSavedHeight(h)
}}
controls={
<IconButton
name={muted ? 'volume_off' : 'volume_up'}
title={muted ? 'Unmute' : 'Mute'}
className={muted ? 'calling' : undefined}
onClick={() => {
const next = !muted
setMuted(next)
if (video.current) video.current.muted = next
}}
/>
<>
<IconButton
name={captionsOn ? 'closed_caption' : 'closed_caption_disabled'}
title={captions.length === 0 ? 'No captions for this stream' : captionsOn ? 'Captions on' : 'Captions off'}
className={captionsOn ? 'active' : undefined}
disabled={captions.length === 0}
onClick={() => {
if (captions.length === 1) setCaptionLanguage(captionsOn ? '' : captions[0].lang || captions[0].name)
else setMenu(menu === 'captions' ? null : 'captions')
}}
/>
<IconButton
name="high_quality"
title={quality > 0 ? `Resolution: ${quality}p` : 'Resolution: automatic'}
disabled={choices.length === 0}
onClick={() => setMenu(menu === 'quality' ? null : 'quality')}
/>
<IconButton
name={muted ? 'volume_off' : 'volume_up'}
title={muted ? 'Unmute' : 'Mute'}
className={muted ? 'calling' : undefined}
onClick={() => setMuted(!muted)}
/>
<input
type="range"
className="stream-volume"
min={0}
max={1}
step={0.05}
value={muted ? 0 : volume}
aria-label="Volume"
title="Volume"
onChange={(e) => {
const next = Number(e.target.value)
setVolume(next)
setMuted(next === 0)
}}
/>
</>
}
>
<div className={mode === 'window' ? 'video-stage in-window' : 'video-stage'} ref={frame}>
<div className="video-tile" style={{ width: `${Math.floor(fit.tileWidth)}px` }}>
<video ref={video} className="video-tile-picture" autoPlay playsInline />
{ad !== null && (
<div className="stream-ad small" role="status" title="Twitch plays ads in a stream for an account without Turbo or a subscription to the channel">
<Icon name="campaign" size={14} />
<span>Ad break · {ad} s</span>
</div>
)}
{menu === 'quality' && (
<StreamMenu
title="Resolution"
onClose={() => setMenu(null)}
choices={[
{ label: 'Auto', detail: quality === 0 && playing ? playing : undefined, checked: quality === 0 || !choices.some((c) => c.height === quality), onClick: () => setQuality(0) },
...choices.map((c) => ({ label: c.label, checked: quality === c.height, onClick: () => setQuality(c.height) }))
]}
/>
)}
{menu === 'captions' && (
<StreamMenu
title="Captions"
onClose={() => setMenu(null)}
choices={[
{ label: 'Off', checked: !captionsOn, onClick: () => setCaptionLanguage('') },
...captions.map((t) => ({ label: t.name, checked: captionsOn && captionTrack(captions, captionLanguage, navigator.language) === t.id, onClick: () => setCaptionLanguage(t.lang || t.name) }))
]}
/>
)}
</div>
</div>
</VideoStage>
+10 -4
View File
@@ -5,6 +5,7 @@ import { IconButton } from './Icon'
import { Composer } from './Composer'
import { useChat, usePref, useStore } from '../state/hooks'
import type { ChatMessage } from '../state/store'
import { applyRemoved, type RemovedMessages } from '../lib/moderation'
/**
* A thread, beside the room it belongs to.
@@ -26,6 +27,7 @@ export function ThreadPanel(): JSX.Element | null {
// to ask for `ui.*`, which nothing writes, so it ignored both.
const [comfy] = usePref<'classic' | 'comfy' | 'bubbles'>('display.messageMode', 'comfy')
const [relativeTimestamps] = usePref<boolean>('display.relativeTimestamps', false)
const [removedMode] = usePref<RemovedMessages>('kick.removedMessages', 'strike')
const store = useStore()
// Before the early return below, as every hook here has to be.
const shared = useRowContext(thread?.bufferId ?? '')
@@ -36,15 +38,15 @@ export function ThreadPanel(): JSX.Element | null {
const messages = useMemo((): ChatMessage[] => {
if (!thread) return []
const live = (messagesByBuffer[thread.bufferId] || []).filter(
(m) => m.id === thread.rootId || (m.replyTo?.thread && m.replyTo.id === thread.rootId)
(m) => m.id === thread.rootId || (m.replyTo?.thread && m.replyTo.id === thread.rootId) || m.replyTo?.threadRoot === thread.rootId
)
// By id, so a message that is both fetched and live appears once. The
// live copy wins: it is the one carrying edits and reactions that have
// happened since the fetch.
const byId = new Map<string, ChatMessage>()
for (const m of [...thread.messages, ...live]) byId.set(m.id, m)
return [...byId.values()].sort((a, b) => a.ts - b.ts)
}, [thread, messagesByBuffer])
return applyRemoved([...byId.values()].sort((a, b) => a.ts - b.ts), removedMode)
}, [thread, messagesByBuffer, removedMode])
// Opened on, and following, the newest reply: a thread is read from its end.
const count = messages.length
@@ -53,6 +55,10 @@ export function ThreadPanel(): JSX.Element | null {
if (el) el.scrollTop = el.scrollHeight
}, [thread?.rootId, count])
// Not counting the message it grew from - which is not always here: a thread can outlive the scrollback
// its first message was in, and then every message shown is a reply.
const replyCount = messages.filter((m) => m.id !== thread?.rootId).length
if (!thread) return null
return (
@@ -60,7 +66,7 @@ export function ThreadPanel(): JSX.Element | null {
<div className="thread-header">
<span className="thread-title ellipsis">Thread</span>
<span className="small muted">
{Math.max(0, messages.length - 1) === 1 ? '1 reply' : `${Math.max(0, messages.length - 1)} replies`}
{replyCount === 1 ? '1 reply' : `${replyCount} replies`}
</span>
<IconButton name="close" title="Close thread" onClick={() => store.closeThreadPanel()} />
</div>
@@ -0,0 +1,58 @@
import { useEffect, useState } from 'react'
import { Icon } from './Icon'
import { useChat } from '../state/hooks'
import { RewardsPanel } from './RewardsPanel'
/**
* A Twitch channel's points balance, and the way into the rewards panel (RewardsPanel.tsx) that Kick's plaque opens too.
*
* At the foot of the viewer list, where Kick's plaque is. Twitch's points are the person's own twitch.tv
* session's to see and spend (see nobilis backend/twitch/points.rs), so it is drawn only for an account that has
* that session, and the balance is read once a minute while the window is
* on this channel. Moho does not claim the bonus chest or send the "watching" reports a points bot would to
* earn faster.
*/
const REFRESH_MS = 60_000
export function TwitchPoints({ bufferId }: { bufferId: string }): JSX.Element | null {
const pushed = useChat((s) => s.twitchPoints[bufferId])
const [open, setOpen] = useState(false)
const [unavailable, setUnavailable] = useState(false)
useEffect(() => {
let cancelled = false
setUnavailable(false)
const ask = (): void => {
if (document.visibilityState !== 'visible') return
void window.moho
.rpc('twitchPoints', { bufferId })
.catch(() => !cancelled && setUnavailable(true))
}
ask()
const timer = setInterval(ask, REFRESH_MS)
document.addEventListener('visibilitychange', ask)
return () => {
cancelled = true
clearInterval(timer)
document.removeEventListener('visibilitychange', ask)
}
}, [bufferId])
// Closing the channel takes the panel with it.
useEffect(() => setOpen(false), [bufferId])
// A channel with no points (or no way to read them) draws nothing rather than a plaque that says nothing.
if (unavailable && !pushed) return null
const points = pushed?.points
const name = pushed?.name || 'points'
return (
<div className="kick-points twitch-points">
<button type="button" className={`kick-points-plaque${open ? ' open' : ''}`} title={`Your channel ${name} here, and what they buy`} onClick={() => setOpen(true)}>
<Icon name="toll" size={15} />
<span className="ellipsis">{points === null || points === undefined ? 'Points' : `${points.toLocaleString()} ${name === 'points' ? '' : name}`.trim()}</span>
<Icon name="redeem" size={15} />
</button>
{open && <RewardsPanel bufferId={bufferId} service="twitch" onClose={() => setOpen(false)} onBalance={() => undefined} />}
</div>
)
}
@@ -0,0 +1,133 @@
import { useEffect, useState } from 'react'
import { useStore } from '../state/hooks'
/**
* Signing in to Twitch.
*
* One action. A window of twitch.tv opens; the person signs in there (typed
* into Twitch's own page, never seen by moho) and then, in the same window,
* approves what moho is asked to be allowed to do. That gives the account both
* what Twitch's API takes (chat, follows, moderation) and the session its
* player takes (video as the person, with their Turbo or subscriptions) -
* see src/main/twitch-signin.ts and nobilis backend/twitch/login.rs. Waiting
* can take minutes, so this listens for events while the window is open.
*/
interface Scope {
scope: string
why: string
}
/** The scopes the sign-in asks for, said before it is started so nobody has to guess. */
const ASKS: Scope[] = [
{ scope: 'chat:read', why: 'read chat' },
{ scope: 'chat:edit', why: 'send messages' },
{ scope: 'user:write:chat', why: 'hear why Twitch turned a message down' },
{ scope: 'user:read:follows', why: 'list the channels you follow' },
{ scope: 'user:read:emotes', why: 'show the emotes you can use' }
]
/** What moderation, whispers and the rest ask for, said before it is asked. */
const MODERATION_ASKS: Scope[] = [
{ scope: 'moderator:manage:banned_users', why: 'time out, ban and unban where you moderate' },
{ scope: 'moderator:manage:chat_messages', why: 'remove messages where you moderate' },
{ scope: 'moderator:manage:chat_settings', why: 'change slow, followers-only, subscribers-only and emote-only modes' },
{ scope: 'moderator:manage:announcements', why: 'make announcements' },
{ scope: 'user:manage:chat_color', why: 'change your name colour' },
{ scope: 'user:manage:whispers', why: 'send whispers' },
{ scope: 'whispers:read', why: 'read whispers sent to you' },
{ scope: 'moderator:manage:shoutouts', why: 'give shoutouts where you moderate' },
{ scope: 'user:manage:blocked_users', why: 'block and unblock people' }
]
export function TwitchSignIn({
useTor,
onDone,
label = 'Sign in with Twitch',
children
}: {
useTor: boolean
onDone: () => void
label?: string
/** Other things to offer beside the sign-in button, while nothing is waiting. */
children?: React.ReactNode
}): JSX.Element {
const store = useStore()
const [busy, setBusy] = useState(false)
const [detail, setDetail] = useState('')
const [userCode, setUserCode] = useState('')
const [scopes, setScopes] = useState<Scope[]>([...ASKS, ...MODERATION_ASKS])
useEffect(() => {
const off = window.moho.onEvent((e) => {
if (!busy || !e.event.startsWith('twitchLogin')) return
const d = e.data as { detail?: string; scopes?: Scope[]; userCode?: string }
if (e.event === 'twitchLoginCode') {
if (d.scopes) setScopes(d.scopes)
if (d.userCode) setUserCode(d.userCode)
}
else if (e.event === 'twitchLoginStatus') setDetail(d.detail || '')
})
return off
}, [busy])
const begin = (): void => {
setBusy(true)
setDetail('')
void window.moho
.twitchSignIn({ useTor })
.then((r) => {
if (r.ok) {
store.toast('info', 'Signed in to Twitch for chat and video')
void store.refreshAccounts()
onDone()
} else if (r.error && r.error !== 'cancelled') {
store.toast('error', r.error)
}
})
.catch((e: Error) => store.toast('error', e.message))
.finally(() => {
setBusy(false)
setDetail('')
setUserCode('')
void store.refreshAccounts()
})
}
if (busy) {
return (
<div className="add-form">
<p>Sign in on twitch.tv in the window that opened. It then goes to Twitch's "Activate your device" page, where moho types this code in for you; then press Authorize.</p>
{userCode && (
<div className="field-row">
<strong className="twitch-code" aria-label="Your sign-in code">
{userCode}
</strong>
<button type="button" className="button subtle" onClick={() => void window.moho.copyText(userCode)}>
Copy code
</button>
</div>
)}
<p className="small muted">
moho will be allowed to: {scopes.map((s) => s.why).join(', ')}. Twitch lists the same on its own page. You can end it any time at
twitch.tv/settings/connections, and removing the account here ends it too. Closing the window cancels the sign-in.
</p>
{detail && <p className="small muted">{detail}…</p>}
</div>
)
}
return (
<>
<p className="small muted">
One sign-in on twitch.tv, in a window that opens here: typed on Twitch's own page, never seen by moho. It signs you in for
chat and for video together, so video plays as you. It asks to be allowed to: {scopes.map((s) => s.why).join(', ')}.
</p>
<div className="field-row">
<button type="button" className="button primary" onClick={begin}>
{label}
</button>
{children}
</div>
</>
)
}
+1 -1
View File
@@ -58,7 +58,7 @@ export function UserFooter({ account }: { account?: Account }): JSX.Element {
const detail = details[account.id]
// A signed-out Kick account is a reader: it has no presence to show, and
// "Online" claimed one.
const watching = account.service === 'kick' && !account.hasPassword && status !== 'connecting' && status !== 'offline'
const watching = (account.service === 'kick' || account.service === 'twitch') && !account.hasPassword && status !== 'connecting' && status !== 'offline'
// 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.
+248
View File
@@ -0,0 +1,248 @@
import { useEffect, useRef, useState } from 'react'
import { Icon } from './Icon'
import { classes } from '../lib/util'
import { useStore } from '../state/hooks'
/**
* A video with the same controls on every service, drawn over the picture: play, a seek bar, the volume (which opens
* out to a slider when the pointer is on it), the time (a click shows what is left instead of what has passed), the
* speed, and full screen - which is the player's own, not a viewer's. Down the right at the top: save it, and edit the
* message it is in when that message is the person's own.
*
* Over a real `<video>` element, so the rest of the window still sees a video: the "fetch it again" recovery for a
* cached file that has gone listens for that element failing.
*/
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2]
/** The volume one video was left at is the volume of the next. */
let lastVolume = 1
let lastMuted = false
function clock(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00'
const whole = Math.floor(seconds)
const h = Math.floor(whole / 3600)
const m = Math.floor((whole % 3600) / 60)
const s = whole % 60
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`
}
export function VideoPlayer({
src,
className,
style,
loop,
autoPlay,
filename,
saveFrom,
onEdit,
onError
}: {
src: string
className?: string
style?: React.CSSProperties
loop: boolean
autoPlay: boolean
filename?: string
/** What to save: the original where there is one, else what is playing. */
saveFrom?: string
/** Present when the message the video is in can be edited by this person. */
onEdit?: () => void
onError: () => void
}): JSX.Element {
const store = useStore()
const wrap = useRef<HTMLDivElement>(null)
const video = useRef<HTMLVideoElement>(null)
const [playing, setPlaying] = useState(false)
const [time, setTime] = useState(0)
const [length, setLength] = useState(0)
const [remaining, setRemaining] = useState(false)
const [volume, setVolume] = useState(lastVolume)
const [muted, setMuted] = useState(lastMuted)
const [rate, setRate] = useState(1)
const [speeds, setSpeeds] = useState(false)
const [full, setFull] = useState(false)
const [saving, setSaving] = useState(false)
useEffect(() => {
const v = video.current
if (!v) return
v.volume = lastVolume
v.muted = lastMuted
}, [])
useEffect(() => {
const onFull = (): void => setFull(document.fullscreenElement === wrap.current)
document.addEventListener('fullscreenchange', onFull)
return () => document.removeEventListener('fullscreenchange', onFull)
}, [])
// The speed list closes on a click anywhere else.
useEffect(() => {
if (!speeds) return
const away = (e: MouseEvent): void => {
if (!(e.target as HTMLElement | null)?.closest('.vp-speed')) setSpeeds(false)
}
document.addEventListener('mousedown', away)
return () => document.removeEventListener('mousedown', away)
}, [speeds])
const toggle = (): void => {
const v = video.current
if (!v) return
if (v.paused) void v.play().catch(() => {})
else v.pause()
}
const seek = (to: number): void => {
const v = video.current
if (v) v.currentTime = to
}
const setLevel = (level: number): void => {
const v = video.current
if (!v) return
v.volume = level
v.muted = level === 0
lastVolume = level || lastVolume
lastMuted = level === 0
}
const mute = (): void => {
const v = video.current
if (!v) return
v.muted = !v.muted
lastMuted = v.muted
if (!v.muted && v.volume === 0) v.volume = 1
}
const fullScreen = (): void => {
if (document.fullscreenElement) void document.exitFullscreen().catch(() => {})
else void wrap.current?.requestFullscreen?.().catch(() => {})
}
const save = (): void => {
if (saving) return
setSaving(true)
void window.moho
.downloadMedia(saveFrom || src, filename)
.then((res) => store.toast(res.error ? 'error' : 'info', res.error ? `Couldn't save: ${res.error}` : `Saved to ${res.path}`))
.catch((e: Error) => store.toast('error', `Couldn't save: ${e.message}`))
.finally(() => setSaving(false))
}
const shownVolume = muted ? 0 : volume
const progress = length > 0 ? (time / length) * 100 : 0
return (
<div ref={wrap} className={classes('vp', playing && 'playing', full && 'full')} style={style}>
<video
ref={video}
className={classes('vp-video', className)}
src={src}
loop={loop}
autoPlay={autoPlay}
// Enough to draw the first frame and know how long it is, without pulling megabytes for every video in a
// scrolled-past backlog.
preload="metadata"
playsInline
onClick={toggle}
onDoubleClick={fullScreen}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onTimeUpdate={(e) => setTime(e.currentTarget.currentTime)}
onDurationChange={(e) => setLength(e.currentTarget.duration)}
onLoadedMetadata={(e) => setLength(e.currentTarget.duration)}
onVolumeChange={(e) => {
setVolume(e.currentTarget.volume)
setMuted(e.currentTarget.muted)
}}
onRateChange={(e) => setRate(e.currentTarget.playbackRate)}
onError={onError}
/>
<div className="vp-top">
{onEdit && (
<button type="button" className="vp-button" title="Edit message" onClick={onEdit}>
<Icon name="edit" size={18} />
</button>
)}
<button type="button" className="vp-button" title="Save to your downloads folder" disabled={saving} onClick={save}>
<Icon name={saving ? 'hourglass_empty' : 'download'} size={18} />
</button>
</div>
<div className="vp-bar">
<input
className="vp-seek"
type="range"
min={0}
max={length || 0}
step="any"
value={Math.min(time, length || 0)}
style={{ ['--vp-progress' as string]: `${progress}%` }}
aria-label="Seek"
onChange={(e) => seek(Number(e.target.value))}
/>
<div className="vp-controls">
<button type="button" className="vp-button" title={playing ? 'Pause' : 'Play'} onClick={toggle}>
<Icon name={playing ? 'pause' : 'play_arrow'} size={22} fill />
</button>
<div className="vp-volume">
<button type="button" className="vp-button" title={muted ? 'Unmute' : 'Mute'} onClick={mute}>
<Icon name={shownVolume === 0 ? 'volume_off' : shownVolume < 0.5 ? 'volume_down' : 'volume_up'} size={20} fill />
</button>
<input
className="vp-level"
type="range"
min={0}
max={1}
step="0.01"
value={shownVolume}
style={{ ['--vp-progress' as string]: `${shownVolume * 100}%` }}
aria-label="Volume"
onChange={(e) => setLevel(Number(e.target.value))}
/>
</div>
<button
type="button"
className="vp-time"
title={remaining ? 'Showing the time left; click for the time passed' : 'Showing the time passed; click for the time left'}
onClick={() => setRemaining((r) => !r)}
>
{remaining ? `-${clock(Math.max(0, length - time))}` : clock(time)}
</button>
<span className="vp-fill" />
<div className="vp-speed">
<button type="button" className="vp-button" title="Playback speed" aria-haspopup="menu" aria-expanded={speeds} onClick={() => setSpeeds((o) => !o)}>
<Icon name="speed" size={20} />
{rate !== 1 && <span className="vp-rate">{rate}×</span>}
</button>
{speeds && (
<div className="vp-speeds" role="menu">
{SPEEDS.map((s) => (
<button
key={s}
type="button"
role="menuitemradio"
aria-checked={rate === s}
className={classes('vp-speed-option', rate === s && 'on')}
onClick={() => {
if (video.current) video.current.playbackRate = s
setSpeeds(false)
}}
>
{s === 1 ? 'Normal' : `${s}×`}
</button>
))}
</div>
)}
</div>
<button type="button" className="vp-button" title={full ? 'Exit full screen' : 'Full screen'} onClick={fullScreen}>
<Icon name={full ? 'fullscreen_exit' : 'fullscreen'} size={20} />
</button>
</div>
</div>
</div>
)
}
+82 -24
View File
@@ -7,6 +7,12 @@ import { classes } from '../lib/util'
const EDGE = 4
/** The narrowest the corner panel may be made: its controls still fit. */
const MIN_PIP_WIDTH = 240
/** The tallest and shortest the picture above a conversation may be made. */
const MIN_INLINE_HEIGHT = 140
/** Which edge or corner of the corner panel is held: top, bottom, left, right and their corners. */
type Edge = 't' | 'b' | 'l' | 'r' | 'tl' | 'tr' | 'bl' | 'br'
const EDGES: Edge[] = ['t', 'b', 'l', 'r', 'tl', 'tr', 'bl', 'br']
/**
* The frame a moving picture lives in, whatever is feeding it.
@@ -42,6 +48,8 @@ export function VideoStage({
onPopOut,
/** Back from that window into moho. */
onBringBack,
height,
onHeight,
children
}: {
/** Above the conversation, in a corner of the window, or a window of its own. */
@@ -57,6 +65,9 @@ export function VideoStage({
controls?: ReactNode
onPopOut?: () => void
onBringBack?: () => void
/** Inline only: how tall the picture is now, and how to change it. `null` puts it back to what it was. */
height?: number
onHeight?: (height: number | null, commit: boolean) => void
children: ReactNode
}): JSX.Element {
const panel = useRef<HTMLDivElement>(null)
@@ -83,7 +94,8 @@ export function VideoStage({
*/
const [savedWidth, setSavedWidth] = usePref<number | null>('ui.pipWidth', null)
const [width, setWidth] = useState(savedWidth)
const sizing = useRef<{ x: number; w: number; h: number; right: number; bottom: number } | null>(null)
const sizing = useRef<{ x: number; y: number; w: number; dir: Edge; left: number; top: number; right: number; bottom: number } | null>(null)
const rise = useRef<{ y: number; h: number } | null>(null)
/**
* Where the panel may be put, in the coordinates it is positioned in.
@@ -167,15 +179,16 @@ export function VideoStage({
}
/**
* Resizing from the top-left corner - the inside corner, for a panel that
* starts in the bottom-right - so the edges against the window stay put and
* the panel grows towards the middle of it.
* Resizing from any edge or corner. The picture keeps its shape, so every handle changes the width and the
* height follows. The side opposite the one held stays where it is: the edges against the window stay put when
* the panel is grown from the inside, and the panel grows towards the middle of the window.
*/
const startResize = (e: PointerEvent<HTMLDivElement>): void => {
const startResize = (e: PointerEvent<HTMLDivElement>, dir: Edge): void => {
const box = panel.current?.getBoundingClientRect()
if (!box) return
const corner = local(box.right, box.bottom)
sizing.current = { x: e.clientX, w: box.width, h: box.height, right: corner.x, bottom: corner.y }
const from = local(box.left, box.top)
const to = local(box.right, box.bottom)
sizing.current = { x: e.clientX, y: e.clientY, w: box.width, dir, left: from.x, top: from.y, right: to.x, bottom: to.y }
e.currentTarget.setPointerCapture(e.pointerId)
e.preventDefault()
e.stopPropagation()
@@ -186,11 +199,22 @@ export function VideoStage({
if (!from) return
const room = (panel.current?.offsetParent as HTMLElement | null)?.getBoundingClientRect()
const largest = Math.max(MIN_PIP_WIDTH, (room?.width ?? window.innerWidth) * 0.9)
const w = Math.round(Math.min(Math.max(from.w + (from.x - e.clientX), MIN_PIP_WIDTH), largest))
setWidth(w)
const sideways = from.dir.includes('r') ? e.clientX - from.x : from.dir.includes('l') ? from.x - e.clientX : 0
const upwards = (from.dir.includes('b') ? e.clientY - from.y : from.dir.includes('t') ? from.y - e.clientY : 0) * (16 / 9)
// A corner follows whichever way it was pulled further.
const grow = Math.abs(sideways) >= Math.abs(upwards) ? sideways : upwards
setWidth(Math.round(Math.min(Math.max(from.w + grow, MIN_PIP_WIDTH), largest)))
}
// The bottom-right corner stays where it was while the panel is resized.
/** Where the panel's top-left goes for the size it came out at, with the far side held. */
const anchored = (from: NonNullable<typeof sizing.current>, box: DOMRect): { x: number; y: number } =>
clamp(
// Held by its left when it was pulled from the right; otherwise by its right (the corner it starts in).
from.dir.includes('r') ? from.left : from.right - box.width,
from.dir.includes('b') ? from.top : from.bottom - box.height
)
// The far corner stays where it was while the panel is resized.
// Placed from the size the panel actually came out at, not a guess at it:
// the title and controls do not grow with the picture, so scaling the whole
// height drifted the bottom edge up the screen.
@@ -198,7 +222,8 @@ export function VideoStage({
const from = sizing.current
const box = panel.current?.getBoundingClientRect()
if (!from || !box) return
setSpot(clamp(from.right - box.width, from.bottom - box.height))
setSpot(anchored(from, box))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [width, clamp])
const endResize = (e: PointerEvent<HTMLDivElement>): void => {
@@ -215,13 +240,33 @@ export function VideoStage({
requestAnimationFrame(() =>
requestAnimationFrame(() => {
const box = panel.current?.getBoundingClientRect()
const rest = box ? clamp(from.right - box.width, from.bottom - box.height) : null
const rest = box ? anchored(from, box) : null
if (rest) setSpot(rest)
setSaved(rest)
})
)
}
// Above a conversation, the picture is resized by its bottom edge.
const startRise = (e: PointerEvent<HTMLDivElement>): void => {
rise.current = { y: e.clientY, h: height ?? 0 }
e.currentTarget.setPointerCapture(e.pointerId)
e.preventDefault()
}
const onRise = (e: PointerEvent<HTMLDivElement>): void => {
if (!rise.current || !onHeight) return
const view = e.currentTarget.ownerDocument.defaultView ?? window
onHeight(Math.round(Math.min(Math.max(rise.current.h + e.clientY - rise.current.y, MIN_INLINE_HEIGHT), view.innerHeight * 0.8)), false)
}
const endRise = (e: PointerEvent<HTMLDivElement>): void => {
if (!rise.current || !onHeight) return
const view = e.currentTarget.ownerDocument.defaultView ?? window
const done = Math.round(Math.min(Math.max(rise.current.h + e.clientY - rise.current.y, MIN_INLINE_HEIGHT), view.innerHeight * 0.8))
rise.current = null
e.currentTarget.releasePointerCapture(e.pointerId)
onHeight(done, true)
}
/** Where it sits: the corner it started in, or where it was put. */
const placed =
mode === 'pip'
@@ -254,18 +299,19 @@ export function VideoStage({
return (
<div className={classes('call-stage', mode === 'pip' && 'pip', mode === 'window' && 'in-window')} ref={panel} style={placed}>
{mode === 'pip' && (
// A grip in the inside corner, the way a window is resized from its
// corner.
<div
className="call-stage-resize"
title="Drag to resize"
onPointerDown={startResize}
onPointerMove={onResize}
onPointerUp={endResize}
onPointerCancel={endResize}
/>
)}
{mode === 'pip' &&
// Any edge or corner, the way a window is resized.
EDGES.map((dir) => (
<div
key={dir}
className={`call-stage-resize edge-${dir}`}
title="Drag to resize"
onPointerDown={(e) => startResize(e, dir)}
onPointerMove={onResize}
onPointerUp={endResize}
onPointerCancel={endResize}
/>
))}
{mode === 'window' && (
// A window of its own has no frame, so this is its title bar: it moves
// the window, and carries the two things a window is done with.
@@ -300,6 +346,18 @@ export function VideoStage({
{children}
{mode === 'inline' && onHeight && (
<div
className="stage-resize"
title="Drag to resize · double-click to reset"
onPointerDown={startRise}
onPointerMove={onRise}
onPointerUp={endRise}
onPointerCancel={endRise}
onDoubleClick={() => onHeight(null, true)}
/>
)}
<div className="call-stage-controls">
{/* Which conversation this belongs to, because the picture no longer
sits under it: it outlives looking at something else. */}
@@ -6,6 +6,7 @@ import {
DirectorySetting,
SavedMark,
SettingsSection,
DropdownSetting,
SelectionSetting,
StringSetting,
ToggleSetting,
@@ -17,6 +18,7 @@ import { acceleratorLabel, acceleratorOf } from '../../lib/accelerator'
import { AccountsPanel } from '../AccountsPanel'
import { DownloadsPanel } from '../DownloadsPanel'
import { humanBytes } from '../../lib/util'
import { DEFAULT_SEARCH_ENGINE, SEARCH_ENGINES, validTemplate } from '../../lib/searchengines'
import { TunnelAllSwitch, useNetSettings } from './Tunnel'
import type { Account, DccPrefs } from '../../../../shared/wire'
@@ -126,6 +128,9 @@ const CATEGORIES: { id: string; label: string; group: string; icon?: string; ser
{ id: 'discord', label: 'Discord', group: 'Networks', service: 'discord' },
{ id: 'matrix', label: 'Matrix', group: 'Networks', service: 'matrix' },
{ id: 'kick', label: 'Kick', group: 'Networks', service: 'kick' },
{ id: 'twitch', label: 'Twitch', group: 'Networks', service: 'twitch' },
{ id: 'telegram', label: 'Telegram', group: 'Networks', service: 'telegram' },
{ id: 'stoat', label: 'Stoat', group: 'Networks', service: 'stoat' },
{ id: 'about', label: 'About', group: 'About' }
]
@@ -244,6 +249,12 @@ function Page({ id }: { id: string }): JSX.Element | null {
return <DiscordSettings />
case 'kick':
return <KickSettings />
case 'twitch':
return <TwitchSettings />
case 'telegram':
return <TelegramSettings />
case 'stoat':
return <StoatSettings />
case 'about':
return <AboutSettings />
default:
@@ -840,6 +851,41 @@ function AppearanceSettings(): JSX.Element {
)
}
/**
* Which search engine "Search for…" uses on selected text. The search opens in
* the system's default browser, so moho sends the engine nothing itself - and
* neither Tor nor a proxy set up here applies to it.
*/
function SearchSettings(): JSX.Element {
const [engine] = usePref<string>('search.engine', DEFAULT_SEARCH_ENGINE)
const [custom] = usePref<string>('search.customUrl', '')
return (
<SettingsSection
title="Search"
description="Select text in a message, right-click it and search for it on the web. The search opens in your default browser, outside moho, so it does not go through Tor or a proxy set here."
>
<DropdownSetting
settingKey="search.engine"
label="Search engine"
defaultValue={DEFAULT_SEARCH_ENGINE}
options={SEARCH_ENGINES.map((e) => ({ label: e.label, value: e.id }))}
/>
{engine === 'custom' && (
<StringSetting
settingKey="search.customUrl"
label="Address"
description={
custom && !validTemplate(custom)
? 'Not usable yet: it has to start with http:// or https:// and contain %s where the search goes.'
: 'A web address with %s where the search goes, for example https://example.org/search?q=%s'
}
placeholder="https://example.org/search?q=%s"
/>
)}
</SettingsSection>
)
}
function GeneralSettings(): JSX.Element {
const [defaultDir, setDefaultDir] = useState('')
useEffect(() => {
@@ -885,6 +931,8 @@ function GeneralSettings(): JSX.Element {
/>
</SettingsSection>
<SearchSettings />
<SettingsSection title="Media">
<ToggleSetting
settingKey="media.autoplay"
@@ -1222,6 +1270,188 @@ function KickSettings(): JSX.Element {
defaultValue={true}
/>
</SettingsSection>
<SettingsSection
title="Badges"
description="Kick's level, founder and event badges, and a channel's own, drawn as pictures beside a name. Moderator, subscriber and the like stay as words."
>
<ToggleSetting
settingKey="kick.showBadgePictures"
label="Show badge pictures"
description="Loaded from Kick's servers as they are shown"
defaultValue={true}
/>
</SettingsSection>
<SettingsSection
title="Messages Kick removes"
description="When a moderator or Kick's automatic moderation takes a message down. The text is kept, so you can still see what was said."
>
<SelectionSetting
settingKey="kick.removedMessages"
label="Removed messages"
description="Struck through in red keeps what was said beside the fact that it is gone"
defaultValue="strike"
options={[
{ label: 'Delete', value: 'remove' },
{ label: 'Strikethrough', value: 'strike' },
{ label: 'Replace with notice', value: 'replace' }
]}
/>
</SettingsSection>
</>
)
}
/**
* Twitch. What is here is what the plan (Gitea issue #55) has built so far:
* where a channel's recent past comes from, and what is said about signing in.
*/
function TelegramSettings(): JSX.Element {
return (
<>
<SettingsSection
title="Telegram"
description="Each account signs in with its own api_id and api_hash, which Telegram gives you at my.telegram.org/apps, and one session that moho keeps and reuses. It never asks for a new code just because the connection dropped. Add an account from the Accounts pane."
/>
<SettingsSection
title="Where moho shows up"
description="Telegram lists every session of an account under Settings → Devices in its own apps, named after this computer, for example “moho (linux x86_64)”. Ending it there signs moho out; moho then says so and stops, rather than signing in again."
/>
<SettingsSection
title="Muting and notifications"
description="Muting a chat from its menu is Telegram's own mute for the account, so it holds on your phone as well, and a chat you muted on your phone is muted here."
/>
<SettingsSection
title="What moho does not do"
description="Secret chats, voice and video calls, stories, payments and bots' inline mode are not part of moho. Signing in by QR code, folders, forum topics, drafts and scheduled messages are not done yet."
/>
</>
)
}
/**
* Stoat (formerly Revolt). What is built is in Gitea issue #32; this says how the parts that are not obvious behave.
*/
function StoatSettings(): JSX.Element {
return (
<>
<SettingsSection
title="Stoat"
description="An account names its instance (the official one, or one you or a friend host) and signs in once with email and password, or with a session token pasted from another app. moho keeps the session, never the password, and does not make accounts or answer captchas: do that in Stoat's own app first. Add an account from the Accounts pane."
/>
<SettingsSection
title="Typing"
description="Stoat's own apps tell the others when you are typing, and so does moho. Turn it off here if you would rather not."
>
<ToggleSetting settingKey="stoat.sendTyping" label="Tell others when I am typing" defaultValue={true} />
</SettingsSection>
<SettingsSection
title="Muting and notifications"
description="Muting a conversation from its menu is kept in your Stoat account's own settings, so it holds in the app on your phone as well, and one you muted there is muted here. A server you muted there silences its channels here too."
/>
<SettingsSection
title="Presence"
description="Online, Idle, Do not disturb (Stoat's Busy) and Invisible from the status menu are Stoat's own presences for the account. The line you write beside your status is set under the account in the Accounts pane, with your display name, pronouns, about text and picture."
/>
<SettingsSection
title="Calls"
description="Voice channels and direct messages use Stoat's media servers: microphone, camera and a shared screen. Starting a call in a direct message or a group rings the others on their phones, and one that begins there while you are away rings here. While an account is new (the instance says for how long) Stoat holds sound and video to lower limits, and moho says so as the call begins."
/>
<SettingsSection
title="Servers you look after"
description="Where your permissions allow it, a server's menu opens its settings: name, picture and description, channels and their headings, roles and what each may do, who may do what in one channel, webhooks, and the record of what moderators did. moho shows only what you may do, and Stoat checks again and says why in words if it refuses."
/>
<SettingsSection
title="Limits"
description="The instance says how long a message may be, how many files, how large each, how many servers, roles, channels and reactions, and how many friend requests may wait. moho keeps to them before it asks, and says which one it is when one applies."
/>
<SettingsSection
title="What moho does not do"
description="Making or deleting accounts, changing the email or password, bots and their invites, the server discovery list, web push, and Stoat's plugin API are not part of moho. Stoat has no stickers, so there is no sticker button."
/>
</>
)
}
function TwitchSettings(): JSX.Element {
return (
<>
<SettingsSection
title="Twitch"
description="Channels are opened from the Join panel by name. Twitch's chat is public, so an account that never signs in still reads every channel; signing in adds talking and the channels you follow. Add an account from the Accounts pane."
/>
<SettingsSection
title="What to show besides chat"
description="The same choices as Kick's: one setting covers a kind of event on both services."
>
<ToggleSetting settingKey="kick.showSubscriptions" label="Show subscriptions and gifted subs" defaultValue={true} />
<ToggleSetting settingKey="kick.showRaids" label="Show raids" defaultValue={true} />
<ToggleSetting
settingKey="kick.showModeration"
label="Show timeouts and bans"
description="What a moderator did, which everybody in the channel can see happening anyway"
defaultValue={true}
/>
<SelectionSetting
settingKey="kick.removedMessages"
label="Removed messages"
description="When a moderator takes a message down. The text is kept, so you can still see what was said."
defaultValue="strike"
options={[
{ label: 'Delete', value: 'remove' },
{ label: 'Strikethrough', value: 'strike' },
{ label: 'Replace with notice', value: 'replace' }
]}
/>
</SettingsSection>
<SettingsSection
title="Badges"
description="Twitch's own pictures beside a name: the channel's subscriber badge, moderator, VIP and the rest. With pictures off, the roles that have a short word are shown as that word."
>
<ToggleSetting
settingKey="kick.showBadgePictures"
label="Show badge pictures"
description="Loaded from Twitch's servers as they are shown. The same switch as Kick's."
defaultValue={true}
/>
</SettingsSection>
<SettingsSection
title="Watching"
description="Twitch gives channel points for time spent watching, and counts it from a short report its own player sends once a minute. Moho plays the video itself, so while a Twitch stream is playing in the window (not paused, minimised or hidden) and you are signed in, it sends that same report for the channel, once a minute, and nothing when the channel is not live. It claims nothing and sends no more often than that."
>
<ToggleSetting settingKey="twitch.reportWatching" label="Report watching to Twitch" defaultValue={true} />
</SettingsSection>
<SettingsSection
title="Polls, predictions and raids"
description="Shown as they happen, for every channel you have open, from the same live service twitch.tv's own pages use, which needs no account. You can vote and bet channel points from here, as your twitch.tv session; each only when you press its button. Moho does not claim the bonus chest and sends nothing to earn points faster."
/>
<SettingsSection
title="Recent messages"
description="Twitch keeps no chat history, so a channel you open starts empty and fills as people speak."
>
<ToggleSetting
settingKey="twitch.recentHistory"
label="Fill a channel with its recent messages"
description="Asks recent-messages.robotty.de, a service run by a third party that remembers each channel's last few hundred lines. It is told which channel you open, through the account's route. Off until you turn it on."
defaultValue={false}
/>
</SettingsSection>
</>
)
}
@@ -1356,6 +1586,18 @@ function DiscordSettings(): JSX.Element {
}
return (
<>
<SettingsSection
title="Typing"
description="Discord's own apps tell others when you are typing. A client that is not Discord's own is told apart from them by exactly this kind of traffic, and accounts using one have been flagged, so moho does not send it unless you turn it on. You still see when other people are typing."
>
<ToggleSetting
settingKey="discord.sendTyping"
label="Tell others when I am typing"
description="Sends Discord's typing notice, at most once every eight seconds in a conversation."
defaultValue={false}
/>
</SettingsSection>
<SettingsSection
title="Scrollback"
description="Re-reads the last 50 messages of every Discord conversation on every account and stores any that are missing here. Nothing already stored is changed. A conversation takes about a third of a second, so a large account takes a few minutes."
@@ -1368,6 +1610,7 @@ function DiscordSettings(): JSX.Element {
</div>
{result && <p className="small muted">{result}</p>}
</SettingsSection>
</>
)
}
@@ -158,6 +158,40 @@ export function SelectionSetting({
)
}
/** A choice among too many to lay out as buttons: a drop-down of them. */
export function DropdownSetting({
settingKey,
label,
description,
defaultValue,
options
}: {
settingKey: string
label: string
description?: string
defaultValue: string
options: { label: string; value: string }[]
}): JSX.Element {
const [value, setValue] = usePref<string>(settingKey, defaultValue)
return (
<div className="setting-row">
<div className="setting-text">
<div>{label}</div>
{description && <div className="small muted">{description}</div>}
</div>
<div className="setting-actions">
<select className="text-field" value={value} aria-label={label} onChange={(e) => setValue(e.target.value)}>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
)
}
/**
* A folder chosen through the system picker, with the platform default shown
* (and used) until the user overrides it.
@@ -69,6 +69,21 @@ describe('serialising the box', () => {
})
})
describe('what the browser makes in the box', () => {
it('reads its own bold, italic, underline and strikethrough as ours', () => {
const made = box(el('B', [text('b')]), text(' '), el('I', [text('i')]), text(' '), el('U', [text('u')]), text(' '), el('STRIKE', [text('s')]))
expect(serialize(made, 'discord')).toBe('**b** *i* __u__ ~~s~~')
expect(serialize(made, 'sneedchat')).toBe('[b]b[/b] [i]i[/i] [u]u[/u] [s]s[/s]')
})
it('sees through the plain wrapper put round content when a format is taken off', () => {
const wrapped = box(el('SPAN', [text('one '), fmt('bold', [text('two')]), text(' three')], { plain: '' }))
expect(serialize(wrapped, 'discord')).toBe('one **two** three')
})
it('does not say bold twice for bold inside bold', () => {
expect(serialize(box(el('B', [fmt('bold', [text('x')])])), 'discord')).toBe('**x**')
})
})
describe('markup details', () => {
it('fences code with more backticks than it holds', () => {
expect(wrap('discord', 'code', 'a`b')).toBe('``a`b``')
@@ -94,3 +109,13 @@ describe('markup details', () => {
expect(wrap('sneedchat', 'color', 'g', '#00fc00')).toBe('[color=#00fc00]g[/color]')
})
})
describe('Telegram formatting', () => {
it('is offered the formats the daemon turns into entities, written as Discord reads them', () => {
expect(formatsFor('telegram')).toEqual(['bold', 'italic', 'underline', 'strike', 'code', 'spoiler', 'quote'])
expect(wrap('telegram', 'bold', 'x')).toBe('**x**')
expect(wrap('telegram', 'underline', 'x')).toBe('__x__')
expect(wrap('telegram', 'spoiler', 'x')).toBe('||x||')
expect(wrap('telegram', 'quote', 'a\nb')).toBe('\n> a\n> b\n')
})
})
+7 -5
View File
@@ -21,6 +21,8 @@ const CAPABILITIES: Record<string, FormatKind[]> = {
discord: ['bold', 'italic', 'underline', 'strike', 'code', 'spoiler', 'quote'],
// markup.rs in the daemon: bold, italic, strike and code.
matrix: ['bold', 'italic', 'strike', 'code'],
// Written as Markdown, the way Discord reads it, and turned into Telegram's entities by the daemon (telegram/markup.rs).
telegram: ['bold', 'italic', 'underline', 'strike', 'code', 'spoiler', 'quote'],
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.
@@ -129,7 +131,7 @@ export function wrap(service: string | undefined, kind: FormatKind, inner: strin
const trail = inner.match(/\s*$/)?.[0] ?? ''
const core = inner.slice(lead.length, inner.length - trail.length)
const marked = (open: string, close = open): string => `${lead}${open}${core}${close}${trail}`
const markdown = service === 'discord' || service === 'matrix'
const markdown = service === 'discord' || service === 'matrix' || service === 'telegram' || service === 'stoat'
if (service === 'sneedchat') {
switch (kind) {
@@ -172,11 +174,11 @@ export function wrap(service: string | undefined, kind: FormatKind, inner: strin
const [open, close] = codeFence(core)
return marked(open, close)
}
case 'underline': return service === 'discord' ? marked('__') : inner
case 'spoiler': return service === 'discord' ? marked('||') : inner
case 'underline': return service === 'discord' || service === 'telegram' ? marked('__') : inner
case 'spoiler': return service === 'discord' || service === 'telegram' ? marked('||') : service === 'stoat' ? marked('!!') : inner
case 'quote': {
if (service !== 'discord') return inner
// A quote is a line of its own on Discord, however it was typed.
if (service !== 'discord' && service !== 'telegram' && service !== 'stoat') return inner
// A quote is a line of its own on Discord and Telegram, however it was typed.
const quoted = core.split('\n').map((line) => `> ${line}`).join('\n')
return `${lead}\n${quoted}\n${trail}`
}
@@ -0,0 +1,126 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import { textNodes, transformFormat } from './composeFormatDom'
import type { FormatKind } from './composeFormat'
/** What the box says, with the formats marked as they would be on Discord. */
function marked(root: Node): string {
const mark: Record<string, string> = { bold: '**', italic: '_', underline: '__', strike: '~~', code: '`', spoiler: '||', quote: '> ' }
return Array.from(root.childNodes)
.map((n) => {
if (n.nodeType === 3) return n.nodeValue
if (n instanceof HTMLElement && n.dataset.fmt === 'color') return `{${n.dataset.color}:${marked(n)}}`
if (n instanceof HTMLElement && n.dataset.fmt) return `${mark[n.dataset.fmt]}${marked(n)}${mark[n.dataset.fmt]}`
return marked(n)
})
.join('')
}
/** A box holding `html`, with `needle` selected in its text. */
function box(html: string, needle: string): { root: HTMLElement; range: Range } {
const root = document.createElement('div')
root.innerHTML = html
const nodes = textNodes(root)
const all = nodes.map((n) => n.nodeValue).join('')
const at = all.indexOf(needle)
expect(at).toBeGreaterThanOrEqual(0)
const locate = (offset: number, preferEnd: boolean): [Text, number] => {
let seen = 0
for (const n of nodes) {
const len = n.nodeValue!.length
if (preferEnd ? offset <= seen + len : offset < seen + len) return [n, offset - seen]
seen += len
}
throw new Error('outside')
}
const range = document.createRange()
range.setStart(...locate(at, false))
range.setEnd(...locate(at + needle.length, true))
return { root, range }
}
function run(html: string, needle: string, kind: FormatKind, colour?: string): { text: string; selected: string; done: boolean } {
const { root, range } = box(html, needle)
const done = transformFormat(root, range, kind, colour)
return { text: marked(root), selected: range.toString(), done }
}
const B = (s: string): string => `<span data-fmt="bold">${s}</span>`
describe('putting a format on', () => {
it('wraps the selection and leaves it selected', () => {
expect(run('one two three', 'two', 'bold')).toEqual({ text: 'one **two** three', selected: 'two', done: true })
})
it('makes a selection that is partly bold all bold, once', () => {
const r = run(`one ${B('two')} three four`, 'one two thr', 'bold')
expect(r.text).toBe('**one two thr**ee four')
expect(r.selected).toBe('one two thr')
})
it('joins a run that ends where the new one begins', () => {
expect(run(`${B('ab')}cd`, 'cd', 'bold').text).toBe('**abcd**')
expect(run(`ab${B('cd')}`, 'ab', 'bold').text).toBe('**abcd**')
})
it('stacks on another format without breaking it', () => {
const r = run('one <span data-fmt="italic">two three</span> four', 'three four', 'bold')
expect(r.text).toBe('one _two _**_three_ four**')
})
it('does nothing with nothing selected', () => {
const { root, range } = box('one two', 'two')
range.collapse(true)
expect(transformFormat(root, range, 'bold')).toBe(false)
expect(marked(root)).toBe('one two')
})
})
describe('taking a format off', () => {
it('takes it off a whole run', () => {
expect(run(`one ${B('two')} three`, 'two', 'bold')).toEqual({ text: 'one two three', selected: 'two', done: true })
})
it('takes it off part of a run and leaves the rest of the run bold', () => {
const r = run(`one ${B('two three')} four`, 'wo th', 'bold')
expect(r.text).toBe('one **t**wo th**ree** four')
expect(r.selected).toBe('wo th')
})
it('takes it off the end of a run', () => {
expect(run(`one ${B('two three')}`, 'three', 'bold').text).toBe('one **two **three')
})
it('takes it off from two runs side by side when all of the selection has it', () => {
expect(run(`${B('one')}${B('two')}`, 'netw', 'bold').text).toBe('**o**ne' + 'tw' + '**o**')
})
it('does not take it off a selection that reaches a space which is not bold: that is put on instead', () => {
expect(run(`${B('one')} ${B('two')}`, 'e t', 'bold').text).toBe('**one two**')
})
it('only the format asked for', () => {
const r = run('<span data-fmt="bold"><span data-fmt="italic">two</span></span>', 'two', 'bold')
expect(r.text).toBe('_two_')
})
})
describe('colour', () => {
it('puts a colour on, and a different one replaces it', () => {
const on = box('one two', 'two')
expect(transformFormat(on.root, on.range, 'color', '#ff0000')).toBe(true)
expect(marked(on.root)).toBe('one {#ff0000:two}')
expect(transformFormat(on.root, on.range, 'color', '#00ff00')).toBe(true)
expect(marked(on.root)).toBe('one {#00ff00:two}')
expect(transformFormat(on.root, on.range, 'color')).toBe(true)
expect(marked(on.root)).toBe('one two')
})
it('takes no colour that is not a plain hex value', () => {
expect(run('one two', 'two', 'color', 'red; background:url(x)').done).toBe(false)
})
it('has nothing to take off when there is no colour', () => {
expect(run('one two', 'two', 'color').done).toBe(false)
})
})
+314 -55
View File
@@ -1,68 +1,327 @@
import type { FormatKind } from './composeFormat'
/** The nearest enclosing element in the box that stands for `kind`, if any. */
function holderOf(node: Node | null, root: HTMLElement, kind: FormatKind): HTMLElement | null {
for (let n: Node | null = node; n && n !== root; n = n.parentNode) {
if (n instanceof HTMLElement && n.dataset.fmt === kind) return n
}
return null
}
/** Colours reach a style attribute, so only a plain hex value gets there. */
const HEX = /^#[0-9a-fA-F]{6}$/
const isRun = (node: Node | null): node is HTMLElement => node instanceof HTMLElement && node.dataset.fmt !== undefined
const isKind = (node: Node | null, kind: FormatKind): node is HTMLElement => isRun(node) && node.dataset.fmt === kind
const isMarker = (node: Node | null): boolean => node instanceof HTMLElement && node.dataset.mark !== undefined
const sameRun = (a: HTMLElement, b: HTMLElement): boolean => a.dataset.fmt === b.dataset.fmt && a.dataset.color === b.dataset.color
function nearestRun(from: Node, root: Node, kind: FormatKind): HTMLElement | null {
for (let n: Node | null = from.parentNode; n && n !== root; n = n.parentNode) {
if (isKind(n, kind)) return n
}
return null
}
function marker(which: 's' | 'e'): HTMLElement {
const m = document.createElement('i')
m.dataset.mark = which
return m
}
/**
* Applies a format to what is selected in the box, or takes it off again if
* the selection is exactly that format already. Returns whether it did
* anything.
*
* Goes through `insertHTML` rather than rearranging nodes, so the box's own
* undo still steps back over it.
* Takes a marker out of every run of `kind` around it, splitting each run in
* two at the marker. A start marker ends up in front of the second half and
* an end marker behind the first, so what lies between the two markers is
* never inside a run that reaches beyond it.
*/
export function applyFormat(root: HTMLElement, kind: FormatKind, colour?: string): boolean {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return false
const range = selection.getRangeAt(0)
if (range.collapsed || !root.contains(range.commonAncestorContainer)) return false
root.focus()
// The selection can be a whole formatted run (it is, right after one is
// applied, so another can be stacked on), which names the run's parent as
// its ancestor and the run itself only by position.
const whole =
range.startContainer === range.endContainer && range.endOffset - range.startOffset === 1
? range.startContainer.childNodes[range.startOffset]
: null
const holder = holderOf(whole ?? range.commonAncestorContainer, root, kind)
if (holder && (kind === 'color' ? !colour : holder.textContent === range.toString())) {
const around = document.createRange()
around.selectNode(holder)
selection.removeAllRanges()
selection.addRange(around)
document.execCommand('insertHTML', false, holder.innerHTML)
return true
function lift(root: Node, point: HTMLElement, kind: FormatKind, atStart: boolean): void {
for (let run = nearestRun(point, root, kind); run; run = nearestRun(point, root, kind)) {
const rest = document.createRange()
if (atStart) rest.setStartBefore(point)
else rest.setStartAfter(point)
rest.setEnd(run, run.childNodes.length)
const tail = run.cloneNode(false) as HTMLElement
tail.append(rest.extractContents())
run.after(tail)
if (atStart) tail.before(point)
else run.after(point)
}
if (kind === 'color' && !(colour && HEX.test(colour))) return false
}
const scratch = document.createElement('div')
scratch.appendChild(range.cloneContents())
const colourAttrs = kind === 'color' ? ` data-color="${colour}" style="color:${colour}"` : ''
document.execCommand(
'insertHTML',
false,
`<span data-fmt="${kind}"${colourAttrs} data-new="1">${scratch.innerHTML}</span>`
/** Every text node under `node`, in document order. */
export function textNodes(node: Node, into: Text[] = []): Text[] {
for (const child of Array.from(node.childNodes)) {
if (child.nodeType === 3) into.push(child as Text)
else textNodes(child, into)
}
return into
}
/** Text between two markers, in document order. */
function textBetween(root: Node, from: Node, to: Node): Text[] {
return textNodes(root).filter(
(n) =>
n.nodeValue &&
from.compareDocumentPosition(n) & Node.DOCUMENT_POSITION_FOLLOWING &&
to.compareDocumentPosition(n) & Node.DOCUMENT_POSITION_PRECEDING
)
// Left selected, so another format can be stacked on without selecting again.
const made = root.querySelector('[data-new]')
if (made) {
made.removeAttribute('data-new')
// The run itself rather than its contents: Chromium replaces the whole
// element when what is selected is all of what is inside it, which would
// swallow the format just applied.
const around = document.createRange()
around.selectNode(made)
selection.removeAllRanges()
selection.addRange(around)
}
function charactersBefore(root: Node, point: Node): number {
return textNodes(root).reduce((sum, n) => sum + (point.compareDocumentPosition(n) & Node.DOCUMENT_POSITION_PRECEDING ? n.nodeValue!.length : 0), 0)
}
/** The point in the text that `count` characters in: a start prefers the beginning of the next piece, an end the finish of the last. */
function place(root: Node, count: number, atStart: boolean): [Node, number] {
const text = textNodes(root)
let seen = 0
for (const n of text) {
const length = n.nodeValue!.length
if (atStart ? count < seen + length : count <= seen + length) return [n, count - seen]
seen += length
}
const last = text[text.length - 1]
return last ? [last, last.nodeValue!.length] : [root, 0]
}
function unwrap(run: HTMLElement): void {
run.replaceWith(...Array.from(run.childNodes))
}
/** Splits leave runs and wrappers with nothing in them; they would serialise as stray markers. */
function dropEmpty(parent: Node): void {
for (const child of Array.from(parent.childNodes)) {
if (child.nodeType === 3) {
if (!child.nodeValue) child.remove()
} else if (child instanceof HTMLElement && !isMarker(child)) {
dropEmpty(child)
if (!child.hasChildNodes() && child.nodeName !== 'BR' && child.nodeName !== 'IMG') child.remove()
}
}
}
/** Two runs of one format side by side are one run, or they would be marked up twice. Markers between them stay in order. */
function mergeRuns(parent: Node): void {
let child = parent.firstChild
while (child) {
if (isRun(child)) {
const between: Node[] = []
let next = child.nextSibling
while (next && (isMarker(next) || (next.nodeType === 3 && !next.nodeValue))) {
between.push(next)
next = next.nextSibling
}
if (isRun(next) && sameRun(child, next)) {
child.append(...between, ...Array.from(next.childNodes))
next.remove()
continue
}
}
child = child.nextSibling
}
for (const element of Array.from(parent.childNodes)) if (element instanceof HTMLElement && !isMarker(element)) mergeRuns(element)
}
/**
* The selection of `range` with `kind` put on - or taken off, if all of it
* already has it. Works on `root` (a copy of the box, in practice) and
* leaves `range` on the same text afterwards. False when there was nothing
* to do.
*
* Applying over text that is part bold makes all of it bold, once, and
* taking it off from the middle of a run leaves the rest of the run as it was;
* both come from cutting every run of the kind at the selection's ends first.
*/
export function transformFormat(root: HTMLElement, range: Range, kind: FormatKind, colour?: string): boolean {
if (kind === 'color' && colour && !HEX.test(colour)) return false
const start = marker('s')
const end = marker('e')
const atEnd = range.cloneRange()
atEnd.collapse(false)
atEnd.insertNode(end)
const atStart = range.cloneRange()
atStart.collapse(true)
atStart.insertNode(start)
const leave = (done: boolean): boolean => {
start.remove()
end.remove()
return done
}
if (textBetween(root, start, end).length === 0) return leave(false)
lift(root, end, kind, false)
lift(root, start, kind, true)
const text = textBetween(root, start, end)
const runs = Array.from(root.querySelectorAll<HTMLElement>(`[data-fmt="${kind}"]`)).filter(
(run) => start.compareDocumentPosition(run) & Node.DOCUMENT_POSITION_FOLLOWING && end.compareDocumentPosition(run) & Node.DOCUMENT_POSITION_PRECEDING
)
const covered = text.every((t) => {
for (let n: Node | null = t.parentNode; n && n !== root; n = n.parentNode) if (isKind(n, kind)) return true
return false
})
if (kind === 'color' && !colour) {
if (runs.length === 0) return leave(false)
}
// A colour is never toggled by choosing it again: a different one replaces.
const off = kind === 'color' ? !colour : covered
for (const run of runs) unwrap(run)
if (!off) {
const around = document.createRange()
around.setStartAfter(start)
around.setEndBefore(end)
const span = document.createElement('span')
span.dataset.fmt = kind
if (kind === 'color' && colour) {
span.dataset.color = colour
span.style.color = colour
}
span.append(around.extractContents())
around.insertNode(span)
}
dropEmpty(root)
mergeRuns(root)
// The markers say where the selection was, as a count of characters; the
// text nodes are about to be joined, and a count survives that.
const from = charactersBefore(root, start)
const to = charactersBefore(root, end)
start.remove()
end.remove()
root.normalize()
range.setStart(...place(root, from, true))
range.setEnd(...place(root, to, false))
return true
}
/** The formats the browser can make and unmake itself, and its name for each. */
const NATIVE: Partial<Record<FormatKind, string>> = { bold: 'bold', italic: 'italic', underline: 'underline', strike: 'strikeThrough' }
/** Where a point in the box is, by child positions from the box down, so it can be found again in a copy. */
function pathOf(root: Node, node: Node, offset: number): number[] {
const path = [offset]
for (let n: Node = node; n !== root; n = n.parentNode as Node) {
path.unshift(Array.prototype.indexOf.call((n.parentNode as Node).childNodes, n))
}
return path
}
function resolve(root: Node, path: number[]): [Node, number] {
let node = root
for (const index of path.slice(0, -1)) node = node.childNodes[index]
return [node, path[path.length - 1]]
}
/**
* Applies a format to what is selected in the box, or takes it off again if
* all of the selection already has it. Returns whether it did anything.
*
* The change is worked out on a copy of the box and put back by replacing the
* box's contents with one `insertHTML`, so it is a single step of the box's
* own undo - and so the browser's habit of keeping the formatting of what a
* replacement lands in cannot put back a format that was just taken off.
*/
export function applyFormat(root: HTMLElement, kind: FormatKind, colour?: string): boolean {
const native = NATIVE[kind]
if (native) {
// The browser's own bold, italic, underline and strikethrough: it knows how to take them off again from all of a
// selection, part of one or the whole box - the last of which the replacement below cannot do, because the
// browser keeps the formatting of what it replaces when that is everything in the box - and its undo steps over
// them and puts the selection back by itself. Serialising reads the elements it makes as it does ours.
root.focus()
document.execCommand('styleWithCSS', false, 'false')
return document.execCommand(native)
}
const before = root.innerHTML
const beforeSelection = currentSelection(root)
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return false
const picked = selection.getRangeAt(0)
if (picked.collapsed || !root.contains(picked.commonAncestorContainer)) return false
root.focus()
const copy = root.cloneNode(true) as HTMLElement
// Content that begins with plain text is put in a wrapper of its own. The browser keeps the formatting of whatever
// it replaces when the replacement is text and the selection is everything inside one formatted run - so taking a
// format off all of the box put it straight back - and does not when the replacement starts with an element.
const wrapped = copy.firstChild?.nodeType === 3
const shift = (path: number[]): number[] => (wrapped ? [0, ...path] : path)
if (wrapped) {
const wrapper = document.createElement('span')
wrapper.dataset.plain = ''
wrapper.append(...Array.from(copy.childNodes))
copy.append(wrapper)
}
const range = document.createRange()
range.setStart(...resolve(copy, shift(pathOf(root, picked.startContainer, picked.startOffset))))
range.setEnd(...resolve(copy, shift(pathOf(root, picked.endContainer, picked.endOffset))))
if (!transformFormat(copy, range, kind, colour)) return false
const start = pathOf(copy, range.startContainer, range.startOffset)
const end = pathOf(copy, range.endContainer, range.endOffset)
selection.selectAllChildren(root)
document.execCommand('insertHTML', false, copy.innerHTML)
// Left selected, so another format can be stacked on without selecting again.
select(root, { start, end })
if (beforeSelection) remember({ before, beforeSelection, after: root.innerHTML, afterSelection: { start, end } })
return true
}
interface Picked {
start: number[]
end: number[]
}
/** What the box held and had selected either side of a format, so undo and redo can put the selection back. */
interface Edit {
before: string
beforeSelection: Picked
after: string
afterSelection: Picked
}
const edits: Edit[] = []
function remember(edit: Edit): void {
edits.push(edit)
if (edits.length > 100) edits.shift()
}
function currentSelection(root: HTMLElement): Picked | null {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return null
const range = selection.getRangeAt(0)
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return null
return { start: pathOf(root, range.startContainer, range.startOffset), end: pathOf(root, range.endContainer, range.endOffset) }
}
function select(root: HTMLElement, picked: Picked): void {
const selection = window.getSelection()
if (!selection) return
try {
const range = document.createRange()
range.setStart(...resolve(root, picked.start))
range.setEnd(...resolve(root, picked.end))
selection.removeAllRanges()
selection.addRange(range)
} catch {
/* the contents are not built the way they were; the caret is wherever the browser left it */
}
}
/**
* Undo and redo step over a format as the whole box being replaced, and the
* browser leaves everything selected afterwards - where the next key typed
* would replace the message. Called after either, this puts back the
* selection that was there at that point, when the box holds exactly what it
* held then.
*/
export function restoreSelectionAfterHistory(root: HTMLElement, inputType: string): void {
const undo = inputType === 'historyUndo'
if (!undo && inputType !== 'historyRedo') return
const now = root.innerHTML
for (let i = edits.length - 1; i >= 0; i--) {
const edit = edits[i]
if ((undo ? edit.before : edit.after) === now) {
select(root, undo ? edit.beforeSelection : edit.afterSelection)
return
}
}
}
+106
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
buildSmilieIndex,
cheerUrl,
discordEmojiUrl,
embedColor,
emojiPreview,
@@ -14,6 +15,10 @@ import {
hostnameOf,
ircFormat,
normalizeBBCode,
sneedGreentext,
sneedSameText,
sourcePick,
stoatSameText,
stripCodeBlocks,
stripEmbeddedUrls,
stripQuoteBlocks,
@@ -307,3 +312,104 @@ describe('the forum\'s own files', () => {
expect(extractMedia('https://i.ibb.co/JRkN8BFv/photo.avif', {}).map((m) => m.kind)).toEqual(['image'])
})
})
describe("Twitch's emotes and cheers in a message", () => {
it("draws Twitch's own, still and moving, and reads a stored third-party emote as its word", () => {
const html = formatMessage('[twitch:emote:25:Kappa] [twitch:aemote:emotesv2_abc:Dance] [twitch:bttv:a1:OMEGALUL] [twitch:7tv:01AB:GAMBA]')
expect(html).toContain('emoticons/v2/25/default/dark/2.0')
expect(html).toContain('emoticons/v2/emotesv2_abc/animated/dark/2.0')
expect(html).toContain('OMEGALUL GAMBA')
expect(html).not.toContain('cdn.7tv.app')
expect(html).not.toContain('[twitch:')
expect(html).toContain('alt="Kappa"')
})
it('draws a cheer as its picture and its amount, at the tier the amount reaches', () => {
const html = formatMessage('[twitch:cheer:cheer:250] [twitch:cheer:kappa:5]')
expect(html).toContain('actions/cheer/dark/animated/100/2.gif')
expect(html).toContain('actions/kappa/dark/animated/1/2.gif')
expect(html).toContain('<span class="cheer-amount">250</span>')
expect(cheerUrl('Cheer', 10000)).toContain('/cheer/dark/animated/10000/')
expect(cheerUrl('cheer', 999)).toContain('/animated/100/')
})
it('shows what the composer would put for one, and nothing for what is not a token', () => {
expect(emojiPreview('[twitch:emote:25:Kappa]')?.label).toBe('Kappa')
expect(emojiPreview('[twitch:7tv:01AB:GAMBA]')).toBeNull()
expect(emojiPreview('[twitch:nonsense:1:x]')).toBeNull()
// An id with a slash in it is not an id, and cannot build another address.
expect(formatMessage('[twitch:emote:../x:y]')).not.toContain('emoticons/v2/..')
})
})
describe("Sneedchat's greentext", () => {
it('is a line that starts with ">", in 4chan\'s green, on that line only', () => {
const html = formatMessage('plain\n>be me\nstill plain', { isSneedchat: true })
expect(html).toBe('plain<br><span class="greentext">&gt;be me</span><br>still plain')
// Anywhere else it is just a character.
expect(formatMessage('>not green')).not.toContain('greentext')
})
it("is the same thing whether the site wrapped it in its own colour or not, and its echo matches what was sent", () => {
expect(sneedGreentext('[color=#72ff72]>archlinuxwoman')).toBe('>archlinuxwoman')
expect(sneedGreentext('[color=#72ff72]>a\nb')).toBe('>a\nb')
expect(sneedGreentext('[color=#72ff72]>wrapped[/color]')).toBe('>wrapped')
expect(sneedGreentext('[color=#ff0000]red stays')).toBe('[color=#ff0000]red stays')
expect(sneedSameText('>archlinuxwoman', '[color=#72ff72]>archlinuxwoman')).toBe(true)
expect(sneedSameText('hello', '[color=#72ff72]>hello')).toBe(false)
})
})
describe('markdown escapes', () => {
const on = { markdownEscapes: true }
it('drops the backslash of an escaped mark, and keeps the mark as text', () => {
expect(formatMessage('The Austin Police \\(APD\\) is aware\\. Call 911\\.', on)).toBe('The Austin Police (APD) is aware. Call 911.')
expect(formatMessage('a \\* b \\* c', on)).toBe('a * b * c')
expect(formatMessage('one \\\\ backslash', on)).toBe('one \\ backslash')
expect(formatMessage('Q \\& A', on)).toBe('Q &amp; A')
})
it('keeps an escaped asterisk from starting bold', () => {
expect(formatMessage('\\*not bold\\*', on)).toBe('*not bold*')
})
it('leaves backslashes alone where nobody asked', () => {
expect(formatMessage('a regex \\d+\\.\\d', {})).toBe('a regex \\d+\\.\\d')
})
it('leaves a backslash before a letter alone even when asked', () => {
expect(formatMessage('C:\\Users\\me', on)).toBe('C:\\Users\\me')
})
})
describe('what a picker section sends back', () => {
it("is Discord's own token for a custom emoji, never the bare number that names it", () => {
expect(sourcePick('discord', { id: '1413156421880647762', name: 'lettyCrazy' })).toBe('<:lettyCrazy:1413156421880647762>')
expect(sourcePick('discord', { id: '99', name: 'dance', animated: true })).toBe('<a:dance:99>')
})
it('leaves the other services as they were', () => {
expect(sourcePick('sneedchat', { id: ':lol:' })).toBe(':lol:')
expect(sourcePick('steam', { id: 'steam:steambored' })).toBe('[steam:emote:steambored]')
})
})
describe('Stoat text', () => {
it('a spoiler typed with !! is the one logged with ||', () => {
expect(stoatSameText('the !!end!! is near', 'the ||end|| is near')).toBe(true)
expect(stoatSameText('plain', 'plain ')).toBe(true)
expect(stoatSameText('one', 'two')).toBe(false)
})
it('a custom emoji typed by name is the picture token logged', () => {
expect(stoatSameText('yay :party:', 'yay [stoat:emoji:party:file:///home/me/.cache/nobilis/stoat-media/abc.png]')).toBe(true)
expect(stoatSameText('yay :other:', 'yay [stoat:emoji:party:file:///home/me/.cache/nobilis/stoat-media/abc.png]')).toBe(false)
})
it('draws a custom emoji from the daemon’s own cache folder and from nowhere else', () => {
const own = formatMessage('hi [stoat:emoji:party:file:///home/me/.cache/nobilis/stoat-media/abc.png]')
expect(own).toContain('<img src="file:///home/me/.cache/nobilis/stoat-media/abc.png" class="custom-emoji" alt=":party:">')
const forged = formatMessage('hi [stoat:emoji:x:file:///etc/passwd]')
expect(forged).not.toContain('<img')
})
it('is picked by name between colons', () => {
expect(sourcePick('stoat', { id: '01ABC', name: 'party' })).toBe(':party:')
})
})
+99 -2
View File
@@ -82,6 +82,27 @@ export function kickEmoteUrl(id: string): string {
return resolveMediaUrl(drawableEmoteUrl(`https://files.kick.com/emotes/${id}/fullsize`))
}
/**
* The picture for one of Twitch's own emotes, by id.
*
* Public and built from the id, like Discord's and Kick's, so it draws for an
* emote this account cannot use, which is the point of one somebody else sent.
* The dark-background version, at twice the size: chat is drawn on a dark
* surface and an emote is a row's height at most.
*/
export function twitchEmoteUrl(id: string, animated = false): string {
return resolveMediaUrl(`https://static-cdn.jtvnw.net/emoticons/v2/${encodeURIComponent(id)}/${animated ? 'animated' : 'default'}/dark/2.0`)
}
/**
* The picture for a Twitch cheermote: the prefix's animated picture of the tier the amount reaches. Twitch
* has tiers at 1, 100, 1,000, 5,000 and 10,000 bits.
*/
export function cheerUrl(prefix: string, amount: number): string {
const tier = [10000, 5000, 1000, 100, 1].find((t) => amount >= t) ?? 1
return resolveMediaUrl(`https://d3aqoihi2n8ty8.cloudfront.net/actions/${encodeURIComponent(prefix.toLowerCase())}/dark/animated/${tier}/2.gif`)
}
/**
* The picture for one of Steam's emoticons or stickers, by name.
*
@@ -135,6 +156,8 @@ 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 twitch = text.match(/^\[twitch:(a?emote):([A-Za-z0-9_]+):([^\]]{0,64})\]$/)
if (twitch) return { src: twitchEmoteUrl(twitch[2], twitch[1] === 'aemote'), label: twitch[3] }
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))
@@ -149,8 +172,43 @@ export function emojiPreview(text: string, smilies: SmilieEntry[] = []): EmojiPr
* `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
export function sourcePick(service: string, e: { id: string; name?: string; animated?: boolean }): string {
if (service === 'steam' && e.id.startsWith('steam:')) return `[steam:emote:${e.id.slice(6)}]`
// Discord's is named by a number alone, which is no message at all: picked from another server's section it went
// into the box (and the recent list) as the bare digits. It leaves as the form Discord parses, which the box draws
// as its picture - and which Nitro lets any server take.
if (service === 'discord' && /^\d+$/.test(e.id)) return `<${e.animated ? 'a' : ''}:${e.name || 'emoji'}:${e.id}>`
// Stoat's is typed by name between colons; the daemon turns it into the id Stoat reads as it goes out.
if (service === 'stoat') return `:${e.name || e.id}:`
return e.id
}
/**
* Sneedchat's own greentext: the site turns a line that starts with ">" into the same line wrapped in a
* green colour tag (`[color=#72ff72]>text`), so what comes back is never what was typed. Taken back to the
* plain ">text", which is what the window draws green itself (4chan's, see `.greentext`) and what a sent
* message is matched against.
*/
export function sneedGreentext(body: string): string {
return body.replace(/^\[color=#72ff72\](>[^\n]*?)(?:\[\/color\])?$/gim, '$1')
}
/** Whether a Sneedchat line that came back is the one that was sent, allowing for the site's greentext. */
export function sneedSameText(sent: string, logged: string): boolean {
return sent.trim() === sneedGreentext(logged).trim()
}
/**
* Whether what was typed into a Stoat conversation is what Stoat logged.
*
* Not spelled alike when the message holds a spoiler (`!!x!!` is typed and Stoat's marks are carried here as `||x||`) or a custom
* emoji (typed `:party:`, logged as the daemon's picture token): matched strictly, the message sat grey until it timed out as
* failed, with its own echo shown beside it.
*/
export function stoatSameText(sent: string, logged: string): boolean {
const sentForm = sent.replace(/!!([^\n]+?)!!/g, '||$1||').trim()
const loggedForm = logged.replace(/\[stoat:emoji:([A-Za-z0-9_]+):file:\/\/[^\]\s]*\]/g, ':$1:').trim()
return sentForm === loggedForm
}
/**
@@ -342,6 +400,14 @@ export interface FormatOptions {
* bold.
*/
ircFormatting?: 'render' | 'strip'
/**
* Whether a backslash in front of a punctuation mark is an escape and goes away, leaving the mark as plain text.
*
* Discord's markdown works that way, and the text it hands over for a link's preview is escaped for it
* (`\(APD\)`, `\.`): drawn as it is, the backslashes show. Not elsewhere - a regular expression or a path pasted
* into IRC has backslashes that mean themselves.
*/
markdownEscapes?: boolean
}
/**
@@ -519,6 +585,12 @@ export function formatMessage(text: string, opts: FormatOptions = {}): string {
let out = text
// Escapes first, so an escaped mark is never read as markdown by a later pass. What follows a backslash is
// already HTML-escaped (a `&` is `&amp;` by now), so those forms are matched whole.
if (opts.markdownEscapes) {
out = out.replace(/\\(&amp;|&lt;|&gt;|&quot;|&#39;|[!#-%(-\/:-@[-`{-~])/g, (_m, mark: string) => stow(mark))
}
// Inline `code` spans, stowed before anything else touches them so backticked
// content is never reinterpreted as markdown or links, and escaped since code
// commonly contains < > & that would corrupt the parse.
@@ -546,6 +618,12 @@ export function formatMessage(text: string, opts: FormatOptions = {}): string {
stow(`<img src="${discordEmojiUrl(id, 48)}" class="custom-emoji" alt=":${name}:">`)
)
// Stoat's custom emoji, which the daemon writes as `[stoat:emoji:name:file://...]`: the picture is a file it has named in
// its own cache (and fetches when this draws it), and only that folder is accepted, so a message cannot name another.
out = out.replace(/\[stoat:emoji:([A-Za-z0-9_]{1,32}):(file:\/\/[^\]\s]*\/stoat-media\/[A-Za-z0-9._-]+)\]/g, (_m, name: string, src: string) =>
stow(`<img src="${src}" class="custom-emoji" alt=":${name}:">`)
)
// Kick emotes, which arrive as `[emote:1082364:xqcAM]`. Like Discord's
// above, no lookup is needed: the id is in the token and the picture is
// public, so this draws every emote in a channel - including the
@@ -556,6 +634,20 @@ export function formatMessage(text: string, opts: FormatOptions = {}): string {
stow(`<img src="${kickEmoteUrl(id)}" class="custom-emoji" alt=":${name}:">`)
)
// Twitch's own emotes, which arrive as `[twitch:emote:25:Kappa]`: the id for
// the picture and the name for the words, so a screen reader and a copied
// line both read "Kappa". Public and named by the token, like the others.
out = out.replace(/\[twitch:(a?emote):([A-Za-z0-9_]+):([^\]]{0,64})\]/g, (_m, kind: string, id: string, name: string) =>
stow(`<img src="${twitchEmoteUrl(id, kind === 'aemote')}" class="custom-emoji" alt="${name}" title="${name}">`)
)
// BetterTTV, FrankerFaceZ and 7TV are not drawn (Twitch does not carry them). A line stored while they were
// is read as the words it stands for.
out = out.replace(/\[twitch:(?:bttv|ffz|7tv):[A-Za-z0-9_]+:([^\]]{0,64})\]/g, '$1')
// A cheer: its picture and the amount beside it, as Twitch's chat draws one.
out = out.replace(/\[twitch:cheer:([a-z0-9]+):(\d{1,9})\]/g, (_m, prefix: string, amount: string) =>
stow(`<img src="${cheerUrl(prefix, Number(amount))}" class="custom-emoji" alt="${prefix}" title="${prefix} ${amount}"><span class="cheer-amount">${amount}</span>`)
)
// 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
@@ -664,6 +756,11 @@ export function formatMessage(text: string, opts: FormatOptions = {}): string {
// A literal "\n" is just collapsible whitespace in HTML; a structured
// multi-line message (a relay bot's author/link/stats/footer post) would
// otherwise render as one run-together line.
// Sneedchat's greentext: a line that starts with ">" is the green of 4chan's. The text is escaped by now, so
// the mark is "&gt;"; a line is closed inside itself, so the wrap cannot straddle another.
if (opts.isSneedchat) {
out = out.split('\n').map((line) => (line.startsWith('&gt;') ? `<span class="greentext">${line}</span>` : line)).join('\n')
}
out = out.replace(/\n/g, '<br>')
return out
}
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import { withPrefetch } from './ivsplaylist'
/** A rendition's playlist as Kick's stream serves one: two-second segments, six-second target, two prefetch lines. */
const playlist = [
'#EXTM3U',
'#EXT-X-VERSION:3',
'#EXT-X-TARGETDURATION:6',
'#EXT-X-MEDIA-SEQUENCE:2939',
'#EXT-X-PROGRAM-DATE-TIME:2026-10-13T22:58:41.442Z',
'#EXTINF:2.000,live',
'https://cdn.example/v1/segment/a',
'#EXT-X-PROGRAM-DATE-TIME:2026-10-13T22:58:43.442Z',
'#EXTINF:2.000,live',
'https://cdn.example/v1/segment/b',
'#EXT-X-PREFETCH:https://cdn.example/v1/segment/c',
'#EXT-X-PREFETCH:https://cdn.example/v1/segment/d',
''
].join('\n')
describe('a playlist that lists what is being made', () => {
it('has each prefetch line become a segment as long as the last complete one, after the complete ones', () => {
const lines = withPrefetch(playlist).split('\n').filter(Boolean)
expect(lines.slice(-4)).toEqual(['#EXTINF:2.000,prefetch', 'https://cdn.example/v1/segment/c', '#EXTINF:2.000,prefetch', 'https://cdn.example/v1/segment/d'])
expect(lines.some((l) => l.startsWith('#EXT-X-PREFETCH'))).toBe(false)
expect(lines.filter((l) => l.startsWith('https://')).length).toBe(4)
})
it('can take only some of them, or none', () => {
expect(withPrefetch(playlist, 1).split('\n').filter((l) => l.startsWith('https://'))).toHaveLength(3)
expect(withPrefetch(playlist, 0)).toBe(playlist)
})
it('is left as it was when it has none, when it is of a stream that has ended, or when nothing is complete to measure by', () => {
const plain = playlist.replace(/#EXT-X-PREFETCH:\S+\n/g, '')
expect(withPrefetch(plain)).toBe(plain)
const ended = `${playlist}#EXT-X-ENDLIST\n`
expect(withPrefetch(ended)).toBe(ended)
const none = ['#EXTM3U', '#EXT-X-TARGETDURATION:6', '#EXT-X-PREFETCH:https://cdn.example/v1/segment/c', ''].join('\n')
expect(withPrefetch(none)).toBe(none)
})
it('keeps everything else about the playlist, in the order it had it', () => {
const out = withPrefetch(playlist).split('\n')
expect(out.slice(0, 5)).toEqual(playlist.split('\n').slice(0, 5))
expect(out).toContain('#EXT-X-PROGRAM-DATE-TIME:2026-10-13T22:58:43.442Z')
})
})
describe('withPrefetch on segments of uneven length', () => {
it('leaves the playlist as it is when the last segment is no guide to the next', () => {
const playlist = ['#EXTM3U', '#EXTINF:3.200,live', 'a.ts', '#EXTINF:5.166,live', 'b.ts', '#EXT-X-PREFETCH:c.ts', ''].join('\n')
expect(withPrefetch(playlist)).toBe(playlist)
})
})
describe('withPrefetch on a Twitch playlist', () => {
const twitch = [
'#EXTM3U',
'#EXT-X-VERSION:6',
'#EXT-X-TARGETDURATION:5',
'#EXT-X-MEDIA-SEQUENCE:100',
'#EXTINF:2.000,live',
'https://cdn.example/a.mp4',
'#EXTINF:2.000,live',
'https://cdn.example/b.mp4',
'#EXT-X-TWITCH-PREFETCH:https://cdn.example/c.mp4',
'#EXT-X-TWITCH-PREFETCH:https://cdn.example/d.mp4',
''
].join('\n')
it('leaves the prefetch lines under Twitch name alone: their address is not the finished segment\'s', () => {
// Taken as segments, each would change address in the next refresh and hls.js would reject the playlist.
expect(withPrefetch(twitch)).toBe(twitch)
})
it('leaves a playlist with none - an ad is playing, and ads are not prefetched - as it is', () => {
const ad = twitch.replace(/#EXT-X-TWITCH-PREFETCH:.*\n/g, '').replace(/live/g, 'Amazon|2474283100494')
expect(withPrefetch(ad)).toBe(ad)
})
})
+112
View File
@@ -0,0 +1,112 @@
import Hls from 'hls.js'
/**
* Amazon IVS's low-latency playlists, which Kick's streams are, list what is already complete and then
* what is being made: `#EXT-X-PREFETCH:<address>`, one per segment of the next few seconds. The address
* answers as soon as it is asked and sends the segment as it is produced, so asking early is the way to
* be seconds nearer the broadcaster. IVS's own player does that. hls.js does not know the tag and
* leaves it out, which is why a stream played with it runs several seconds behind one played with
* IVS's.
*
* Here each of those lines becomes a segment of the playlist like any other, the same length as the
* last complete one, so hls.js asks for it and plays it as it arrives.
*
* Twitch's playlists carry the same under another name - `#EXT-X-TWITCH-PREFETCH` - and are NOT read this way,
* though they once were. An IVS prefetch address is the address the segment will have when it is complete, so a
* segment is the same one in the playlist before and after it is made. Twitch's is not: the prefetch address and
* the address of the finished segment are different (measured on summit1g's playlists), so a segment taken from a
* prefetch line changes address in the next refresh, hls.js sees "media sequence mismatch" for it on every
* refresh, and gives up on the rendition every ten seconds or so, which is a picture that goes black and starts
* again. Left as they are, hls.js ignores the lines and plays the finished segments, a few seconds nearer the
* past and without a stop.
*/
/**
* The addresses that were listed as prefetch, which hls.js must not judge the connection by: one of
* them is sent as it is made, so it takes as long to arrive as it takes to broadcast (two seconds of
* picture in two seconds), and bandwidth worked out from that is the stream's own bitrate. Taken for
* the connection's, it moved the picture down a rendition at every other segment.
*/
const ahead = new Set<string>()
function remember(address: string): void {
ahead.add(address)
// Only the last few matter; a stream that runs for hours must not grow this for ever.
if (ahead.size > 64) ahead.delete(ahead.values().next().value as string)
}
/** How far apart, in seconds, the longest and shortest segment may be for the next to be taken as their length. */
const UNIFORM_WITHIN = 0.25
export function withPrefetch(playlist: string, take = 2): string {
if (take <= 0 || !/#EXT-X-PREFETCH:/.test(playlist) || playlist.includes('#EXT-X-ENDLIST')) return playlist
const lines = playlist.split('\n')
const kept: string[] = []
const ahead_: string[] = []
let length = ''
const lengths: number[] = []
for (const line of lines) {
const prefetch = /^#EXT-X-PREFETCH:(\S+)/.exec(line.trim())
if (prefetch) {
ahead_.push(prefetch[1])
continue
}
const segment = /^#EXTINF:([0-9.]+)/.exec(line)
if (segment) {
length = segment[1]
lengths.push(Number(segment[1]))
}
kept.push(line)
}
// Nothing to say how long they are if no segment is complete yet.
if (!length || ahead_.length === 0) return playlist
// How long a segment still being made will turn out to be is a guess from the ones before it, and only a
// good one where they are all alike. A broadcaster that is not re-encoded by IVS (one that sends its own
// segments) has them of 3 to 5 seconds, and a guess of 3 for a segment of 5 leaves a gap the picture then
// stops in, every few seconds.
if (Math.max(...lengths) - Math.min(...lengths) > UNIFORM_WITHIN) return playlist
while (kept.length > 0 && kept[kept.length - 1].trim() === '') kept.pop()
for (const address of ahead_.slice(0, take)) {
remember(address)
kept.push(`#EXTINF:${length},prefetch`, address)
}
return `${kept.join('\n')}\n`
}
/**
* hls.js's own judge of the connection, told to leave the prefetch segments out of it. See `ahead`.
*/
export function ivsAbr(): typeof Hls.DefaultConfig.abrController {
const Base = Hls.DefaultConfig.abrController as unknown as new (hls: unknown) => Record<string, unknown>
class Abr extends Base {
// Private in hls.js's types and called by each of its three handlers that measure a segment.
ignoreFragment(frag: { url?: string }): boolean {
const parent = (Base.prototype as { ignoreFragment?: (f: unknown) => boolean }).ignoreFragment
return ahead.has(frag.url ?? '') || (parent ? parent.call(this, frag) : false)
}
}
return Abr as unknown as typeof Hls.DefaultConfig.abrController
}
/**
* hls.js's own loader, with the playlist of a rendition read through `withPrefetch`. Everything else it
* loads - the master playlist, the segments, the captions - is as it was.
*/
export function ivsLoader(take = 2): typeof Hls.DefaultConfig.loader {
const Base = Hls.DefaultConfig.loader as unknown as new (config: unknown) => {
load(context: Record<string, unknown>, config: unknown, callbacks: Record<string, (...args: unknown[]) => void>): void
}
class Loader extends Base {
load(context: Record<string, unknown>, config: unknown, callbacks: Record<string, (...args: unknown[]) => void>): void {
if (context.type !== 'level') return super.load(context, config, callbacks)
super.load(context, config, {
...callbacks,
onSuccess: (response, stats, ctx, details) => {
const r = response as { data?: unknown }
if (typeof r.data === 'string') response = { ...r, data: withPrefetch(r.data, take) }
callbacks.onSuccess(response, stats, ctx, details)
}
})
}
}
return Loader as unknown as typeof Hls.DefaultConfig.loader
}
+17 -7
View File
@@ -21,7 +21,7 @@ import { resolveMediaUrl } from './util'
/** The caches the daemon can restore from, by folder. Kick's emotes are
* not here: lib/emotecache puts Kick's own picture up at once instead. */
const RESTORABLE = /[\\/]nobilis[\\/](matrix-media|sneedchat-attachments|sneedchat-avatars|discord-thumbnails|discord-icons)[\\/]/
const RESTORABLE = /[\\/]nobilis[\\/](matrix-media|sneedchat-attachments|sneedchat-avatars|discord-thumbnails|discord-icons|telegram-media|telegram-avatars|stoat-media|stoat-avatars)[\\/]/
/** Paths being fetched, so a picture drawn twice asks once. */
const inFlight = new Map<string, Promise<string | null>>()
@@ -54,8 +54,10 @@ function restore(path: string, bufferId?: string, messageId?: string): Promise<s
}
/** Lets a failure through to the element's own handler after all. */
function failAfterAll(el: Media): void {
el.dataset.restoreGaveUp = '1'
function failAfterAll(el: Media, path: string): void {
// Of this file, not of the element: a picture that falls back from its preview to the original is the same element
// with another file, and that one has not been tried.
el.dataset.restoreGaveUp = path
el.dispatchEvent(new Event('error'))
}
@@ -63,19 +65,27 @@ export function restoreMissingMedia(): () => void {
const onError = (e: Event): void => {
const el = e.target
if (!(el instanceof HTMLImageElement || el instanceof HTMLVideoElement || el instanceof HTMLAudioElement)) return
if (el.dataset.restoreGaveUp) return
const path = cachedPath(el)
if (!path) return
if (!path) {
if ((el.currentSrc || el.src).includes('telegram')) console.info(`[restore] a failed picture is not one the daemon can restore: ${(el.currentSrc || el.src).slice(-90)}`)
return
}
if (el.dataset.restoreGaveUp === path) return
// Known to be past saving, or already restored once for this element and
// still failing: let it fail as it always did.
if (gone.has(path) || el.dataset.restoreTried === path) return
if (gone.has(path) || el.dataset.restoreTried === path) {
console.info(`[restore] ${path.split(/[\\/]/).slice(-2).join('/')} failed again, not asked for twice`)
return
}
el.dataset.restoreTried = path
e.stopPropagation()
console.info(`[restore] ${path.split(/[\\/]/).slice(-2).join('/')} asked for`)
const row = el.closest<HTMLElement>('[data-msg-id]')
void restore(path, row?.dataset.bufferId, row?.dataset.msgId).then((restored) => {
console.info(`[restore] ${path.split(/[\\/]/).slice(-2).join('/')} ${restored ? 'came back' : 'could not be fetched'}`)
if (!restored) {
failAfterAll(el)
failAfterAll(el, path)
return
}
// A new URL for the same file, so nothing serves the failure again
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import type { ChatMessage } from '../state/store'
import { applyRemoved, removedReason, REMOVED_NOTICE } from './moderation'
const said = (id: string, extra: Partial<ChatMessage> = {}): ChatMessage =>
({ id, bufferId: 'b', from: 'x', body: `text ${id}`, ts: 1, isAction: false, isHighlight: false, kind: 'chat', ...extra }) as ChatMessage
const log = [said('a'), said('b', { moderated: { ai: true, rules: ['hate'] }, html: '<b>x</b>', embeds: [{ kind: 'gif' } as never] }), said('c')]
describe('removed messages', () => {
it('are left out when that is chosen', () => {
expect(applyRemoved(log, 'remove').map((m) => m.id)).toEqual(['a', 'c'])
})
it('keep their words when struck through is chosen, and the list is the same one', () => {
expect(applyRemoved(log, 'strike')).toBe(log)
})
it('have a notice put where their words were when that is chosen', () => {
const [, removed] = applyRemoved(log, 'replace')
expect(removed.body).toBe(REMOVED_NOTICE)
expect(removed.html).toBeUndefined()
expect(removed.embeds).toEqual([])
expect(removed.moderated).toEqual({ ai: true, rules: ['hate'], replaced: true })
// And only that one.
expect(applyRemoved(log, 'replace')[0].body).toBe('text a')
})
it('change nothing in a log where nothing was removed', () => {
const clean = [said('a')]
expect(applyRemoved(clean, 'remove')).toBe(clean)
expect(applyRemoved(clean, 'replace')).toBe(clean)
})
it('say why they were removed when they know', () => {
expect(removedReason(log[1])).toBe("Removed by Kick's automatic moderation: hate")
expect(removedReason(said('d', { moderated: {} }))).toBe('Removed')
})
})
+32
View File
@@ -0,0 +1,32 @@
import type { ChatMessage } from '../state/store'
/**
* What to do with a message the service has taken down. The daemon keeps the
* text and says it is gone; this is the window's say in how that looks.
*/
export type RemovedMessages = 'remove' | 'strike' | 'replace'
export const REMOVED_NOTICE = 'This message was removed by moderation'
/** What a removed message says it was removed for, for a tooltip. */
export function removedReason(m: ChatMessage): string {
const rules = m.moderated?.rules ?? []
const by = m.moderated?.ai ? "Removed by Kick's automatic moderation" : 'Removed'
return rules.length > 0 ? `${by}: ${rules.join(', ')}` : by
}
/**
* The messages to draw, with those that were removed handled as chosen:
* left out, kept as they were (the row draws them struck through), or with
* what they said swapped for a notice. A list with none removed comes back
* as it was, so nothing downstream re-renders for a setting it does not use.
*/
export function applyRemoved(messages: ChatMessage[], mode: RemovedMessages): ChatMessage[] {
if (mode === 'strike' || !messages.some((m) => m.moderated)) return messages
if (mode === 'remove') return messages.filter((m) => !m.moderated)
return messages.map((m) =>
m.moderated
? { ...m, body: REMOVED_NOTICE, html: undefined, embeds: [], attachments: [], components: [], moderated: { ...m.moderated, replaced: true } }
: m
)
}
+3
View File
@@ -6,6 +6,9 @@ describe('reactionLabel', () => {
expect(reactionLabel('<:pepe_sad:12345>')).toBe(':pepe_sad:')
expect(reactionLabel('<a:party_parrot:999>')).toBe(':party_parrot:')
})
it('names a Stoat emoji from its picture token', () => {
expect(reactionLabel('[stoat:emoji:party:file:///home/me/.cache/nobilis/stoat-media/abc.png]')).toBe(':party:')
})
it('looks a Unicode one up, and shows it as itself when there is no name', () => {
const names = new Map([['🔥', 'fire']])
expect(reactionLabel('🔥', names)).toBe(':fire:')
+2
View File
@@ -12,6 +12,8 @@ import { steamTokenOf } from './format'
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 stoat = emoji.match(/^\[stoat:emoji:([A-Za-z0-9_]{1,32}):file:\/\/[^\]\s]*\]$/)
if (stoat) return `:${stoat[1]}:`
const steam = steamTokenOf(emoji)
if (steam) return `:${steam.name}:`
const name = unicodeNames.get(emoji)
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more