82 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
74 changed files with 6375 additions and 259 deletions

No files matched your search

+27
View File
@@ -228,6 +228,33 @@ called `subscribe` for that buffer.
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,
+55
View File
@@ -17,6 +17,61 @@ 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
+3 -2
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, Twitch, 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, Twitch, 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
@@ -34,6 +34,7 @@ stream's chat going past at forty lines a minute you're able to be part of the c
| **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: e4a16278ab...152b832961.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "moho",
"version": "1.2.1",
"version": "1.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "moho",
"version": "1.2.1",
"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.2.1",
"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)
+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
}
+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
+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 }
}
+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
}
+61 -4
View File
@@ -356,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)
})
}
@@ -619,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))
@@ -700,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))
@@ -893,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
@@ -1152,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)
})
})
@@ -1188,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))
})
+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'
]
+3
View File
@@ -135,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.
*
+437 -2
View File
@@ -2982,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);
@@ -2992,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);
@@ -3508,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 {
@@ -5648,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;
}
@@ -5863,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 ---------------------------------------------- */
@@ -6180,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
@@ -10937,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);
}
+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

+511 -2
View File
@@ -3,6 +3,7 @@ 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'
@@ -25,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', 'twitch'] as const
const FORMS = ['irc', 'discord', 'sneedchat', 'matrix', 'kick', 'steam', 'twitch', 'telegram', 'stoat'] as const
type AddableService = (typeof FORMS)[number]
/** What `listProtocols` answers with. */
@@ -138,6 +139,8 @@ export function AccountsPanel(): JSX.Element {
{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
@@ -331,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
@@ -352,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"
@@ -461,6 +478,7 @@ 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' && (
@@ -980,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
@@ -1476,6 +1504,484 @@ 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.
*
@@ -1714,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 {
+95 -14
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
@@ -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.
*
+98 -21
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>
@@ -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 - except Twitch's, whose chat takes no pictures. */}
{!thread && service !== 'steam' && service !== 'twitch' && 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:
@@ -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,7 +713,7 @@ 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
@@ -952,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'}
@@ -980,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. */}
@@ -1085,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)} />
}
+3 -1
View File
@@ -127,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 []
}
+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.
+75 -1
View File
@@ -76,6 +76,10 @@ export function JoinPanel(): JSX.Element {
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':
@@ -84,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', 'twitch'].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">
@@ -380,6 +384,76 @@ function TwitchJoin({ account }: { account: Account }): JSX.Element {
)
}
/** 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
+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>
)
}
+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>
)}
</>
+16 -4
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'
@@ -256,14 +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) {
const root = m.replyTo?.thread ? m.replyTo.id : m.replyTo?.threadRoot
if (root) counts[root] = (counts[root] ?? 0) + 1
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> = {
@@ -319,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)
@@ -799,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. */}
+145 -20
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 { 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'
@@ -99,6 +103,11 @@ const ROLE_WORDS: Record<string, string> = ((): Record<string, string> => {
'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
@@ -124,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)
}
@@ -372,6 +383,9 @@ function MessageRowBody({
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)
@@ -409,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(() => {
@@ -536,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'
@@ -553,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
@@ -604,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[])
: []),
@@ -641,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',
@@ -991,13 +1022,7 @@ function MessageRowBody({
>
{message.isAction ? `* ${message.from}` : message.from}
</span>
{/* The time on the name's own line, as Discord does it, in the
full form the tooltip also carries. */}
{mode === 'comfy' && (
<span className="message-stamp" title={formatFullTime(message.ts)}>
{timeLabel}
</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
@@ -1022,6 +1047,13 @@ function MessageRowBody({
</span>
)
)}
{/* The time on the name's own line, as Discord does it, in the
full form the tooltip also carries. */}
{mode === 'comfy' && (
<span className="message-stamp" title={formatFullTime(message.ts)}>
{timeLabel}
</span>
)}
</span>
)}
@@ -1071,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')}
@@ -1109,7 +1212,9 @@ function MessageRowBody({
>
{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 && (
@@ -1214,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')}
@@ -1251,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)}
@@ -1263,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>
@@ -1329,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>
@@ -1350,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
@@ -1691,6 +1810,9 @@ 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]} />
@@ -1742,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)
@@ -1758,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
}
+35 -5
View File
@@ -172,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 */
@@ -206,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]) || {}
@@ -339,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
@@ -381,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
@@ -399,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
@@ -434,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,
@@ -538,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
}
@@ -564,6 +592,7 @@ function MemberRow({
onCall,
onProfile,
onReport,
onUnfriend,
onRemoveFromGroup
}: MemberRowProps): JSX.Element {
const { menu, open, close } = useContextMenu()
@@ -647,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) => ({
@@ -678,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={() => {}} />
}
+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>
))}
</>
)
}
+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>
)
}
@@ -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'
@@ -127,6 +129,8 @@ const CATEGORIES: { id: string; label: string; group: string; icon?: string; ser
{ 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' }
]
@@ -247,6 +251,10 @@ function Page({ id }: { id: string }): JSX.Element | null {
return <KickSettings />
case 'twitch':
return <TwitchSettings />
case 'telegram':
return <TelegramSettings />
case 'stoat':
return <StoatSettings />
case 'about':
return <AboutSettings />
default:
@@ -843,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(() => {
@@ -888,6 +931,8 @@ function GeneralSettings(): JSX.Element {
/>
</SettingsSection>
<SearchSettings />
<SettingsSection title="Media">
<ToggleSetting
settingKey="media.autoplay"
@@ -1262,6 +1307,83 @@ function KickSettings(): JSX.Element {
* 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 (
<>
@@ -1464,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."
@@ -1476,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
}
}
}
+56
View File
@@ -17,6 +17,8 @@ import {
normalizeBBCode,
sneedGreentext,
sneedSameText,
sourcePick,
stoatSameText,
stripCodeBlocks,
stripEmbeddedUrls,
stripQuoteBlocks,
@@ -357,3 +359,57 @@ describe("Sneedchat's greentext", () => {
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:')
})
})
+42 -2
View File
@@ -172,8 +172,15 @@ 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
}
/**
@@ -191,6 +198,19 @@ 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
}
/**
* Whether what was typed into a Steam conversation is what Steam logged.
*
@@ -380,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
}
/**
@@ -557,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.
@@ -584,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
+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
+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)
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { replyPreviewHtml } from './replypreview'
describe('what a reply quotes, as words', () => {
it('draws a Kick emote as its picture, not its code', () => {
const html = replyPreviewHtml('[emote:5756632:SUSSY][emote:5756632:SUSSY]', { service: 'kick' })
expect(html).not.toContain('[emote:')
expect(html.match(/<img /g)).toHaveLength(2)
expect(html).toContain('alt=":SUSSY:"')
})
it('draws a Discord custom emoji', () => {
const html = replyPreviewHtml('nice <:pog:123456789012345678> and <a:spin:223456789012345678>', { service: 'discord' })
expect(html).not.toContain('&lt;:pog')
expect(html.match(/<img /g)).toHaveLength(2)
})
it('draws Twitch and Steam emotes and stickers', () => {
expect(replyPreviewHtml('[twitch:emote:25:Kappa] hi', { service: 'twitch' })).toMatch(/<img [^>]*alt="Kappa"/)
expect(replyPreviewHtml('[twitch:aemote:300:LUL]', { service: 'twitch' })).toContain('<img ')
expect(replyPreviewHtml('[steam:emote:steamhappy]', { service: 'steam' })).toContain('<img ')
})
it('draws a Sneedchat smilie when it is known', () => {
const smilies = { regex: /:smile:/, byAlias: { ':smile:': { url: 'x.png' } } } as never
expect(replyPreviewHtml(':smile: ok', { service: 'sneedchat', smilies })).toContain('<img src="x.png"')
})
it('leaves plain words plain, and escapes what is not markup', () => {
expect(replyPreviewHtml('just words', {})).toBe('just words')
expect(replyPreviewHtml('<img src=x onerror=alert(1)>', {})).not.toContain('<img')
})
it('shows a link as its words and keeps a spoiler hidden', () => {
const link = replyPreviewHtml('see https://example.org/page now', {})
expect(link).not.toContain('<a ')
expect(link).toContain('example.org/page')
const spoiler = replyPreviewHtml('||secret||', {})
expect(spoiler).toContain('spoiler')
expect(spoiler).not.toContain('revealed')
})
it('has nothing for nothing', () => {
expect(replyPreviewHtml('', {})).toBe('')
})
})
+27
View File
@@ -0,0 +1,27 @@
import { formatMessage, normalizeBBCode, type ChannelIndex, type SmilieIndex } from './format'
/**
* The words of a message as they read in the line that quotes it - the one above a reply, or in the bar over the
* box while one is being written.
*
* Those lines showed the original's text as it was stored, so an emote was its code (`[emote:5756632:SUSSY]`), a
* custom emoji its token, a Steam sticker its name in brackets. Run through the formatter every message is, they
* draw as what they are. What is left out is what a quote has no room or use for: a link is its words and not
* something to follow (the line is the way to the original), and a hidden spoiler stays hidden.
*/
export function replyPreviewHtml(
text: string,
options: { service?: string; smilies?: SmilieIndex | null; channels?: ChannelIndex | null; ircColours?: boolean } = {}
): string {
if (!text) return ''
const sneedchat = options.service === 'sneedchat'
const html = formatMessage(sneedchat ? normalizeBBCode(text) : text, {
isSneedchat: sneedchat,
smilies: options.smilies,
channels: options.channels,
markdownEscapes: options.service === 'discord',
ircFormatting: options.service === 'irc' && options.ircColours ? 'render' : 'strip'
})
// Anchors that are not spoilers: their words, with no link.
return html.replace(/<a href="(?!spoiler:)[^"]*"[^>]*>([\s\S]*?)<\/a>/g, '$1')
}
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { SEARCH_ENGINES, cleanQuery, engineName, searchUrl, shortened, validTemplate } from './searchengines'
describe('where a search goes', () => {
it('encodes the selection into the engine the person chose', () => {
expect(searchUrl('hello world & more', 'google')).toBe('https://www.google.com/search?q=hello%20world%20%26%20more')
expect(searchUrl('café', 'duckduckgo')).toBe('https://duckduckgo.com/?q=caf%C3%A9')
expect(searchUrl('x', 'yandex')).toBe('https://yandex.com/search/?text=x')
})
it('falls back to the first engine for one it does not know', () => {
expect(searchUrl('x', 'nonsense')).toBe(searchUrl('x', SEARCH_ENGINES[0].id))
})
it('has every engine ask for the query once', () => {
for (const e of SEARCH_ENGINES.filter((e) => e.id !== 'custom')) {
expect(e.template.split('%s')).toHaveLength(2)
expect(e.template.startsWith('https://')).toBe(true)
}
})
it('searches nothing for nothing', () => {
expect(searchUrl(' \n ', 'google')).toBeNull()
})
it('keeps a replacement pattern in the query as the text it is', () => {
expect(searchUrl('$& $1', 'google')).toBe('https://www.google.com/search?q=%24%26%20%241')
})
})
describe('the query', () => {
it('is one line, trimmed', () => {
expect(cleanQuery(' two\n\nlines\there ')).toBe('two lines here')
})
it('is cut short rather than sent whole', () => {
expect(cleanQuery('a'.repeat(1000)).length).toBe(300)
})
it('is shortened for a menu entry', () => {
expect(shortened('short')).toBe('short')
expect(shortened('a very long selected sentence that goes on and on')).toMatch(/^a very long.*…$/)
expect(shortened('a very long selected sentence that goes on and on').length).toBeLessThanOrEqual(28)
})
})
describe('an engine of the person’s own', () => {
it('needs a web address with a place for the query', () => {
expect(validTemplate('https://example.org/s?q=%s')).toBe(true)
expect(validTemplate('http://example.org/%s')).toBe(true)
expect(validTemplate('https://example.org/s')).toBe(false)
expect(validTemplate('javascript:alert(%s)')).toBe(false)
expect(validTemplate('file:///etc/passwd?%s')).toBe(false)
expect(validTemplate('')).toBe(false)
})
it('is used when valid and refuses to send anywhere when not', () => {
expect(searchUrl('a b', 'custom', 'https://example.org/s?q=%s')).toBe('https://example.org/s?q=a%20b')
expect(searchUrl('a b', 'custom', 'not a url')).toBeNull()
expect(searchUrl('a b', 'custom')).toBeNull()
})
it('is named by its host', () => {
expect(engineName('custom', 'https://www.example.org/s?q=%s')).toBe('example.org')
expect(engineName('custom', 'garbage')).toBe('the web')
expect(engineName('google', '')).toBe('Google')
})
})
+73
View File
@@ -0,0 +1,73 @@
/**
* The web search engines "Search for…" can send selected text to. Each is a
* template with `%s` where the (percent-encoded) query goes; the person picks
* one in Settings > General, or gives a template of their own.
*/
export interface SearchEngine {
id: string
label: string
/** Where the query goes; `%s` is replaced. Empty for the custom entry, whose template is the person's own. */
template: string
}
export const SEARCH_ENGINES: SearchEngine[] = [
{ id: 'duckduckgo', label: 'DuckDuckGo', template: 'https://duckduckgo.com/?q=%s' },
{ id: 'google', label: 'Google', template: 'https://www.google.com/search?q=%s' },
{ id: 'bing', label: 'Bing', template: 'https://www.bing.com/search?q=%s' },
{ id: 'brave', label: 'Brave Search', template: 'https://search.brave.com/search?q=%s' },
{ id: 'startpage', label: 'Startpage', template: 'https://www.startpage.com/do/search?q=%s' },
{ id: 'ecosia', label: 'Ecosia', template: 'https://www.ecosia.org/search?q=%s' },
{ id: 'qwant', label: 'Qwant', template: 'https://www.qwant.com/?q=%s' },
{ id: 'yandex', label: 'Yandex', template: 'https://yandex.com/search/?text=%s' },
{ id: 'kagi', label: 'Kagi', template: 'https://kagi.com/search?q=%s' },
{ id: 'custom', label: 'Other…', template: '' }
]
export const DEFAULT_SEARCH_ENGINE = 'duckduckgo'
/** The longest query sent: a whole pasted wall of text is not a search, and a URL has limits. */
const MAX_QUERY = 300
/** A custom template is usable if it is a web address with a place for the query. */
export function validTemplate(template: string): boolean {
return /^https?:\/\/[^\s%]+/i.test(template.trim()) && template.includes('%s')
}
/** What the person chose, as a template - or null if it is custom and not (yet) usable. */
function templateFor(engineId: string, custom: string): string | null {
const engine = SEARCH_ENGINES.find((e) => e.id === engineId) ?? SEARCH_ENGINES[0]
if (engine.id === 'custom') return validTemplate(custom) ? custom.trim() : null
return engine.template
}
/** The name shown on the menu entry: the engine's, or the custom address's host. */
export function engineName(engineId: string, custom: string): string {
const engine = SEARCH_ENGINES.find((e) => e.id === engineId) ?? SEARCH_ENGINES[0]
if (engine.id !== 'custom') return engine.label
try {
return new URL(custom.trim().replace('%s', 'x')).hostname.replace(/^www\./, '') || 'the web'
} catch {
return 'the web'
}
}
/** The query as it will be sent: one line, trimmed, and not absurdly long. */
export function cleanQuery(text: string): string {
const one = text.replace(/\s+/g, ' ').trim()
return one.length > MAX_QUERY ? one.slice(0, MAX_QUERY).trimEnd() : one
}
/** The address to open to search for `text`, or null if there is nothing to search for or nowhere to send it. */
export function searchUrl(text: string, engineId: string, custom = ''): string | null {
const query = cleanQuery(text)
const template = templateFor(engineId, custom)
if (!query || !template) return null
// Split rather than replaced: a `$&` in the query is not a replacement pattern.
return template.split('%s').join(encodeURIComponent(query))
}
/** A selection shortened to fit a menu entry. */
export function shortened(text: string, max = 28): string {
const one = cleanQuery(text)
return one.length > max ? `${one.slice(0, max - 1).trimEnd()}…` : one
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import {
folderAssignment,
folderCategories,
folderOrder,
isPendingCategory,
parseTelegramCategory,
pendingCategoryId,
telegramCategoryId
} from './telegramfolders'
const folders = [
{ id: 3, name: 'Work', members: ['100', '101'] },
{ id: 2, name: 'Bots', members: ['101', '200'] }
]
describe('a Telegram folder as a category', () => {
it('is named by its account and folder, and read back', () => {
const id = telegramCategoryId('telegram:12345', 3)
expect(id).toBe('tf:telegram:12345:3')
expect(parseTelegramCategory(id)).toEqual({ accountId: 'telegram:12345', folderId: 3 })
expect(parseTelegramCategory('sg:steam:1:2')).toBeNull()
expect(parseTelegramCategory('tf:nonsense')).toBeNull()
})
it('keeps Telegram’s order and names', () => {
expect(folderCategories('telegram:1', folders)).toEqual([
{ id: 'tf:telegram:1:3', name: 'Work' },
{ id: 'tf:telegram:1:2', name: 'Bots' }
])
})
it('draws a conversation under the first folder that holds it', () => {
const got = folderAssignment('telegram:1', folders, [
{ id: 'a', remoteId: '100' },
{ id: 'b', remoteId: '101' },
{ id: 'c', remoteId: '200' },
{ id: 'd', remoteId: '999' },
{ id: 'e', remoteId: null }
])
expect(got).toEqual({ a: 'tf:telegram:1:3', b: 'tf:telegram:1:3', c: 'tf:telegram:1:2' })
})
it('turns the categories’ order into the folders’ numbers, leaving out what is not a folder', () => {
expect(folderOrder(['tf:telegram:1:2', 'svc:Groups', 'tfnew:telegram:1:x', 'tf:telegram:1:3'])).toEqual([2, 3])
})
it('knows a category that is not a folder yet', () => {
const id = pendingCategoryId('telegram:1')
expect(isPendingCategory(id)).toBe(true)
expect(parseTelegramCategory(id)).toBeNull()
expect(isPendingCategory('tf:telegram:1:2')).toBe(false)
})
})
+78
View File
@@ -0,0 +1,78 @@
import type { CustomCategory } from './categories'
/**
* Telegram's chat folders, as the categories of the account's page.
*
* A folder is a tab of chats on Telegram, and it is drawn here as a category with the conversations on the account's
* page that it holds. Both sides change the other: a folder made, renamed, deleted or reordered on Telegram turns up
* here, and the same done to a category here is done on Telegram. The daemon works out what each folder holds (a folder
* can take chats by kind - every bot, every group - as well as by name) and says it as the list of conversations; this
* only draws that, so what is shown is what Telegram says.
*
* A conversation is under one category here and can be in several folders there. It is drawn under the first folder,
* in Telegram's order, that holds it.
*/
export interface TelegramFolder {
id: number
name: string
emoticon?: string | null
/** The conversations it holds, by their Telegram ids (a conversation's `remoteId`). */
members: string[]
/** A folder shared by link: it has no rules and no exceptions. */
shared?: boolean
}
const PREFIX = 'tf:'
const PENDING = 'tfnew:'
/** The category that stands for one of an account's folders. Stable across renames. */
export function telegramCategoryId(accountId: string, folderId: number): string {
return `${PREFIX}${accountId}:${folderId}`
}
/** Which account and folder a category stands for, if it stands for one. */
export function parseTelegramCategory(id: string): { accountId: string; folderId: number } | null {
if (!id.startsWith(PREFIX)) return null
const rest = id.slice(PREFIX.length)
const colon = rest.lastIndexOf(':')
const folderId = Number(rest.slice(colon + 1))
return colon > 0 && Number.isInteger(folderId) ? { accountId: rest.slice(0, colon), folderId } : null
}
/**
* A category made here that is not a folder yet. Telegram keeps no empty folder, so it is made on Telegram when the
* first conversation is filed into it; until then it is only drawn.
*/
export function pendingCategoryId(accountId: string): string {
return `${PENDING}${accountId}:${Date.now().toString(36)}`
}
export function isPendingCategory(id: string): boolean {
return id.startsWith(PENDING)
}
/** The categories for an account's folders, in Telegram's order. */
export function folderCategories(accountId: string, folders: TelegramFolder[]): CustomCategory[] {
return folders.map((f) => ({ id: telegramCategoryId(accountId, f.id), name: f.name }))
}
/** Which category each conversation is drawn under: the first folder, in order, that holds it. */
export function folderAssignment(
accountId: string,
folders: TelegramFolder[],
buffers: { id: string; remoteId?: string | null }[]
): Record<string, string> {
const out: Record<string, string> = {}
for (const b of buffers) {
if (!b.remoteId) continue
const home = folders.find((f) => f.members.includes(b.remoteId!))
if (home) out[b.id] = telegramCategoryId(accountId, home.id)
}
return out
}
/** The folders' numbers in the order the categories are now drawn in. */
export function folderOrder(keys: string[]): number[] {
return keys.map((k) => parseTelegramCategory(k)?.folderId).filter((n): n is number => n !== undefined)
}
+11 -1
View File
@@ -4,6 +4,8 @@ import matrixSvg from '../assets/matrix-mark.svg'
import kickSvg from '../assets/kick.svg'
import steamSvg from '../assets/steam-mark.svg'
import twitchSvg from '../assets/twitch-mark.svg'
import telegramSvg from '../assets/telegram-mark.svg'
import stoatSvg from '../assets/stoat-mark.svg'
/**
* Discord channel buffers are named "GuildName/#channel" internally - that's
@@ -62,7 +64,7 @@ export interface ServiceIcon {
* the same menus, as "Whisper".
*/
export function hasDirectMessages(service: string | undefined): boolean {
return service === 'irc' || service === 'discord' || service === 'matrix' || service === 'twitch'
return service === 'irc' || service === 'discord' || service === 'matrix' || service === 'twitch' || service === 'telegram' || service === 'stoat'
}
export function serviceIcon(service: string): ServiceIcon {
@@ -75,6 +77,10 @@ export function serviceIcon(service: string): ServiceIcon {
return { mark: steamSvg }
case 'twitch':
return { mark: twitchSvg }
case 'telegram':
return { mark: telegramSvg }
case 'stoat':
return { mark: stoatSvg }
case 'irc':
return { glyph: 'tag' }
case 'slack':
@@ -110,6 +116,10 @@ export function serviceLabel(service: string): string {
return 'Steam'
case 'twitch':
return 'Twitch'
case 'telegram':
return 'Telegram'
case 'stoat':
return 'Stoat'
case 'slack':
return 'Slack'
case 'jabber':
+32
View File
@@ -56,3 +56,35 @@ describe('scrolling to the top of a conversation', () => {
expect(rpc).toHaveBeenCalledTimes(2)
})
})
describe('a conversation whose service keeps its own unread count', () => {
it('starts the count from it when it first appears', () => {
// A conversation new to the window is subscribed to, which asks the daemon.
vi.stubGlobal('window', { moho: { rpc: vi.fn().mockResolvedValue(undefined) } })
const store = new ChatStore() as unknown as { state: { buffers: Record<string, unknown>[] }; handleBufferListChange: (data: unknown) => void }
store.handleBufferListChange({ id: 't|g:5', accountId: 't', kind: 'channel', name: 'Group/#Topic', lastActivityTs: 0, unreadSeed: 7, mentionsSeed: 2 })
const b = store.state.buffers.find((x) => x.id === 't|g:5')!
expect([b.unread, b.mentions, b.highlight]).toEqual([7, 2, true])
vi.unstubAllGlobals()
})
it('sets the count when the service moves it, and leaves it alone when it has not', () => {
const seeded = { unreadSeed: 7, mentionsSeed: 2, unread: 9, mentions: 3, highlight: true }
// Two more were heard since the service last said seven: not a change, so the window keeps counting.
let b = after({ unreadSeed: 7, mentionsSeed: 2 }, seeded)
expect([b.unread, b.mentions]).toEqual([9, 3])
// The service says it was all read elsewhere: the seed goes away and so does the count.
b = after({}, seeded)
expect([b.unread, b.mentions, b.highlight, b.unreadSeed]).toEqual([0, 0, false, 0])
// The service says there are now ten.
b = after({ unreadSeed: 10 }, seeded)
expect([b.unread, b.mentions, b.highlight]).toEqual([10, 0, false])
})
it('does not put a count on the conversation being read', () => {
const store = new ChatStore() as unknown as { state: { buffers: Record<string, unknown>[]; activeBufferId: string | null }; handleBufferListChange: (data: unknown) => void }
store.state = { ...store.state, activeBufferId: 'm|!r', buffers: [{ id: 'm|!r', accountId: 'm', kind: 'channel', name: '!r', lastActivityTs: 0, unread: 0, highlight: false, mentions: 0 }] }
store.handleBufferListChange({ id: 'm|!r', accountId: 'm', kind: 'channel', name: '!r', lastActivityTs: 0, unreadSeed: 4 })
expect(store.state.buffers[0].unread).toBe(0)
})
})
+400 -13
View File
@@ -25,7 +25,8 @@ import type {
import { addToast, type ToastAction, type ToastItem } from '../lib/toasts'
import { ALL_STATUSES, statusesFor, supportsStatus } from '../lib/status'
import type { SteamGroup, GroupCall } from '../lib/steamgroups'
import { buildSmilieIndex, sneedSameText, steamSameText, type SmilieEntry, type SmilieIndex } from '../lib/format'
import type { TelegramFolder } from '../lib/telegramfolders'
import { buildSmilieIndex, sneedSameText, steamSameText, stoatSameText, type SmilieEntry, type SmilieIndex } from '../lib/format'
import { bufferDisplayName, isChatKind, isImageFile, resolveMediaUrl } from '../lib/util'
import { runExport } from '../lib/exporter'
import type { ForumPost } from '../lib/forum'
@@ -34,6 +35,7 @@ import { listInvitations, type Invitation } from '../lib/invitations'
import { ircNetworkFor } from '../lib/networks'
import { parseDeepLink, type IrcLink } from '../../../shared/deeplink'
import { MatrixCalls, type CallMember, type MatrixCallEvent, type RingingCall } from '../lib/matrixcall'
import { SfuCall, type SfuParticipant } from '../lib/livekit'
import type { CallPhase } from '../lib/webrtc'
import { Levels } from '../lib/levels'
import { firstFrame, SCREEN_CHOICE_MS } from '../lib/capture'
@@ -600,6 +602,8 @@ export interface ChatState {
/** Which messages each Matrix room has pinned, newest last. */
/** Which messages a conversation has pinned, where the service says so. */
pinnedMessages: Record<string, string[]>
/** How many replies or comments a message has, where the service says so (Telegram): by conversation, then message. */
threadCounts: Record<string, Record<string, number>>
/**
* The pinned messages themselves, once somebody has asked for them. The ids
* above say that something is pinned; these say what, for the banner over
@@ -698,6 +702,8 @@ export interface ChatState {
discordCameras: Record<string, true>
/** The stream being watched, if any. One at a time, deliberately. */
discordWatching: { accountId: string; streamKey: string; userId: string; nick: string } | null
/** Who is in each Stoat conversation's call, by buffer, as the daemon last said. */
stoatVoice: Record<string, StoatVoiceMember[]>
activeCall: {
accountId: string
bufferId: string
@@ -736,6 +742,8 @@ export interface ChatState {
profile: Profile | null
/** Each Steam account's friend groups as Steam last said them; absent until it has. */
steamGroups: Record<string, SteamGroup[]>
/** Each Telegram account's chat folders as Telegram last said them, in its order; absent until it has. */
telegramFolders: Record<string, TelegramFolder[]>
replyingTo: { id: string; from: string; body: string } | null
/** Whether answering pings the author (Discord's "@ ON"): on for every new reply. */
replyPing: boolean
@@ -771,6 +779,16 @@ function personToRequest(p: SteamPerson): FriendRequest {
return { id: p.steamid, name: p.name, avatarUrl: p.avatarUrl }
}
/** One person in a Stoat call. */
export interface StoatVoiceMember {
userId: string
name: string
avatarUrl?: string | null
camera: boolean
screen: boolean
muted: boolean
}
export type Toast = ToastItem
const INITIAL: ChatState = {
@@ -827,6 +845,7 @@ const INITIAL: ChatState = {
pinnedByBuffer: {},
reviewCard: null,
pinnedMessages: {},
threadCounts: {},
pinnedRows: {},
forumPages: {},
discordFriends: {},
@@ -844,6 +863,7 @@ const INITIAL: ChatState = {
dismissedLiveEvents: [],
eventsPane: null,
eventCreate: null,
stoatVoice: {},
activeCall: null,
discordSharing: null,
discordShareStream: null,
@@ -859,6 +879,7 @@ const INITIAL: ChatState = {
steamDialog: null,
profile: null,
steamGroups: {},
telegramFolders: {},
replyingTo: null,
replyPing: true,
editingId: '',
@@ -903,6 +924,7 @@ function echoMatches(echo: ChatMessage, real: Message): boolean {
if (echo.pendingBody === real.body) return true
if (real.bufferId.startsWith('steam:') && echo.pendingBody !== undefined && steamSameText(echo.pendingBody, real.body)) return true
if (real.bufferId.startsWith('sneedchat:') && echo.pendingBody !== undefined && sneedSameText(echo.pendingBody, real.body)) return true
if (real.bufferId.startsWith('stoat:') && echo.pendingBody !== undefined && stoatSameText(echo.pendingBody, real.body)) return true
// A file is posted as markup the daemon builds - a BBCode image tag on
// Sneedchat, a bare link on IRC - so what comes back is never what was
// typed, and usually nothing was typed at all. Matching on the whole body
@@ -1041,7 +1063,25 @@ function applyOwnReaction(list: Reaction[], emoji: string, add: boolean): Reacti
* backwards through what they do offer.
*/
function canFetchAround(bufferId: string): boolean {
return bufferId.startsWith('discord:') || bufferId.startsWith('matrix:')
return bufferId.startsWith('discord:') || bufferId.startsWith('matrix:') || bufferId.startsWith('telegram:')
}
/**
* What a re-broadcast of a conversation does to its unread count: when the service's own count changed, the count is
* set to it, and otherwise it is left to follow the messages heard. A conversation being read is not given a count.
* The seeds are taken as sent, absent meaning none, since the daemon leaves a zero out.
*/
function seededCount(
before: BufferEntry,
data: WireBuffer,
active: string | null
): Pick<BufferEntry, 'unreadSeed' | 'mentionsSeed'> & Partial<Pick<BufferEntry, 'unread' | 'mentions' | 'highlight'>> {
const unreadSeed = data.unreadSeed ?? 0
const mentionsSeed = data.mentionsSeed ?? 0
const changed = unreadSeed !== (before.unreadSeed ?? 0) || mentionsSeed !== (before.mentionsSeed ?? 0)
if (!changed) return { unreadSeed, mentionsSeed }
if (data.id === active) return { unreadSeed, mentionsSeed }
return { unreadSeed, mentionsSeed, unread: unreadSeed, mentions: mentionsSeed, highlight: mentionsSeed > 0 }
}
function bestEffort(work: Promise<unknown>, what: string): void {
@@ -1112,6 +1152,8 @@ export class ChatStore {
private toastSeq = 0
/** Who is talking in a call this window is holding. */
private levels = new Levels()
/** The Stoat call this window is in, which is held on a media server like Element's. */
private stoatCall: { sfu: SfuCall; bufferId: string; people: SfuParticipant[]; local: MediaStream | null } | null = null
/**
* The Discord screen share, while one is running.
*
@@ -1346,6 +1388,12 @@ export class ChatStore {
this.set({ activeBufferId: initialBufferId, activeGroupId: initialGroupId })
void this.refreshNetSettings()
const looked = (): void => {
const id = this.ackPending
if (id && id === this.state.activeBufferId && this.windowIsActive()) bestEffort(this.ackRead(id), 'ack read')
}
window.addEventListener('focus', looked)
document.addEventListener('visibilitychange', looked)
void window.moho.popout.list().then((popouts) => this.set({ popouts }))
window.moho.popout.onChange((popouts) => this.set({ popouts }))
@@ -1536,6 +1584,15 @@ export class ChatStore {
// Not connected yet, or an older daemon: nothing to answer.
}
}
// Stoat's are the people whose relationship to this account is "incoming": the daemon lists them.
for (const account of this.state.accounts.filter((a) => a.service === 'stoat' && a.state === 'connected')) {
try {
const r = await window.moho.rpc<{ incoming: FriendRequest[] }>('stoatFriendRequests', { accountId: account.id })
next[account.id] = r.incoming
} catch {
// Not connected yet: the daemon's own push fills it in.
}
}
this.set({ friendRequests: next })
// Discord keeps its requests among its friends; the daemon pushes changes,
// and this is the first reading, so one that arrived while moho was
@@ -1584,6 +1641,8 @@ export class ChatStore {
} else if (invitation.kind === 'friend') {
if (invitation.service === 'discord') {
await window.moho.rpc('answerDiscordFriendRequest', { accountId, userId: id, accept })
} else if (invitation.service === 'stoat') {
await window.moho.rpc('answerStoatFriendRequest', { accountId, userId: id, accept })
} else {
await window.moho.rpc('steamAnswerFriendRequest', { accountId, steamid: id, accept })
}
@@ -2117,6 +2176,12 @@ export class ChatStore {
// Optimistic: the ringing stops the instant it is answered, rather than a
// round trip later with the ringtone still going.
this.setRinging({ ...this.callFor(bufferId), ringing: false })
// A Stoat call is answered by joining it on its media server.
if (this.state.buffers.find((b) => b.id === bufferId)?.accountId.startsWith('stoat:')) {
await this.selectBuffer(bufferId, true)
await this.joinStoatCall(bufferId, false)
return
}
try {
await window.moho.rpc('acceptCall', { bufferId })
await this.selectBuffer(bufferId, true)
@@ -2129,7 +2194,7 @@ export class ChatStore {
async declineCall(bufferId: string): Promise<void> {
this.setRinging({ ...this.callFor(bufferId), ringing: false })
try {
await window.moho.rpc('declineCall', { bufferId })
await window.moho.rpc(this.state.buffers.find((b) => b.id === bufferId)?.accountId.startsWith('stoat:') ? 'declineStoatCall' : 'declineCall', { bufferId })
} catch (e) {
this.toast('error', `Couldn't decline: ${(e as Error).message}`)
}
@@ -2159,6 +2224,11 @@ export class ChatStore {
* resolves it to the channel the call happens in.
*/
async callBuffer(bufferId: string): Promise<void> {
// A Stoat conversation is called into on its media server, with the camera off until it is asked for.
if (this.state.buffers.find((b) => b.id === bufferId)?.accountId.startsWith('stoat:')) {
await this.joinStoatCall(bufferId, false)
return
}
if (this.state.buffers.find((b) => b.id === bufferId)?.accountId.startsWith('steam:')) {
await this.callSteamFriend(bufferId)
return
@@ -2217,6 +2287,24 @@ export class ChatStore {
return
}
// A Stoat invite joins the server, in the first Stoat account there is; with none, it is a web page for the browser.
if (link.service === 'stoat') {
const account = this.state.accounts.find((a) => a.service === 'stoat' && a.state === 'connected') ?? this.state.accounts.find((a) => a.service === 'stoat')
if (!account) {
void window.moho.openExternal(link.url)
return
}
this.set({ activePanel: '' })
void this.joinAndOpen(account.id, link.url)
return
}
// A Telegram link goes to wherever it points: the chat, joined if the account is not in it, and the post.
if (link.service === 'telegram') {
void this.openTelegramLink(link.url)
return
}
// A kick.com link names a streamer, and clicking one means both things:
// the stream goes to the browser, which is where video is watched, and
// the chat opens here, which is what this client is for. Doing only the
@@ -2274,6 +2362,36 @@ export class ChatStore {
this.awaitBuffer(existing.id, link.channels[0])
}
/**
* Follows a link to a Telegram chat or post, from a message or from the browser's "View in Telegram" button: the
* daemon finds (or joins) the chat, and this opens its conversation and goes to the post.
*
* With no Telegram account to open it in, a web address goes on to the browser as it would have, and an app link
* says what is missing.
*/
async openTelegramLink(url: string): Promise<void> {
const accounts = this.state.accounts.filter((a) => a.service === 'telegram')
if (accounts.length === 0) {
if (/^https?:/i.test(url)) void window.moho.openExternal(url)
else this.toast('info', 'Add a Telegram account to open that link here')
return
}
// Brought to the front by the click that came from the browser; a link followed in a message is already here.
this.set({ activePanel: '' })
try {
const where = await window.moho.rpc<{ accountId: string; bufferId: string; messageId: string | null }>('openTelegramLink', {
url,
// The account in front, when it is a Telegram one; otherwise the daemon takes the first that is connected.
accountId: accounts.find((a) => this.state.activeBufferId?.startsWith(`${a.id}|`))?.id
})
await this.refreshBuffers()
await this.selectBuffer(where.bufferId, true)
if (where.messageId && (await this.jumpToMessage(where.bufferId, where.messageId))) this.setJumpTarget(where.messageId)
} catch (e) {
this.toast('error', `Couldn't open that Telegram link: ${(e as Error).message}`)
}
}
/**
* Sends a private message to one person on Sneedchat.
*
@@ -2577,9 +2695,9 @@ export class ChatStore {
const wire = await window.moho.rpc<WireBuffer[]>('listBuffers')
const existing = new Map(this.state.buffers.map((b) => [b.id, b]))
const buffers = wire.map((b) => ({
unread: existing.get(b.id)?.unread ?? 0,
highlight: existing.get(b.id)?.highlight ?? false,
mentions: existing.get(b.id)?.mentions ?? 0,
unread: existing.get(b.id)?.unread ?? b.unreadSeed ?? 0,
highlight: existing.get(b.id)?.highlight ?? (b.mentionsSeed ?? 0) > 0,
mentions: existing.get(b.id)?.mentions ?? b.mentionsSeed ?? 0,
...b
}))
this.set({ buffers })
@@ -2602,6 +2720,29 @@ export class ChatStore {
// --- push events ----------------------------------------------------
/** The conversation to be told is read as soon as the window is looked at. */
private ackPending: string | null = null
/** When each conversation was last told it is read, so a busy one is told every few seconds and not every line. */
private ackedAt = new Map<string, number>()
/**
* What is on screen has been read to its end: the window is in front, the conversation is the open one, and the
* list is at its bottom (the caller says so). Told to the service, but not oftener than every few seconds - the
* newer of a run of messages covers the older - and never for a conversation that is not being looked at.
*/
ackIfReading(bufferId: string): void {
if (bufferId !== this.state.activeBufferId || !this.windowIsActive()) return
const now = Date.now()
if (now - (this.ackedAt.get(bufferId) ?? 0) < 5000) return
this.ackedAt.set(bufferId, now)
bestEffort(this.ackRead(bufferId), 'ack read')
}
private windowIsActive(): boolean {
return document.visibilityState === 'visible' && document.hasFocus()
}
/**
* Tells the service a conversation has been read.
*
@@ -2615,6 +2756,14 @@ export class ChatStore {
* leave a room read here unread on your phone forever.
*/
private async ackRead(bufferId: string): Promise<void> {
// Only what is being read. A window that is hidden, or is not the one in front, is not reading anything: a
// conversation opened at startup behind other windows, or selected from a notification, is not read by anybody.
// Said when the window is next looked at (`ackPending`), and only if that is still the conversation on screen.
if (!this.windowIsActive()) {
this.ackPending = bufferId
return
}
this.ackPending = null
const prefs = await window.moho.prefs.getAll()
await window.moho.rpc('markBufferRead', {
bufferId,
@@ -2679,6 +2828,8 @@ export class ChatStore {
attachment,
spoilers
)
// A Telegram comment lands in the channel's discussion group, not in this conversation, so the panel asks again.
if (this.accountFor(open.bufferId)?.service === 'telegram') void this.openThreadPanel(open.bufferId, open.rootId)
}
/**
@@ -2752,6 +2903,65 @@ export class ChatStore {
}
}
/** Asks who is in a Stoat account's calls, for when it has just connected: the daemon says the changes after. */
loadStoatVoice(accountId: string): void {
void window.moho
.rpc<Record<string, StoatVoiceMember[]>>('listStoatVoice', { accountId })
.then((voice) => this.set({ stoatVoice: { ...this.state.stoatVoice, ...voice } }))
.catch(() => {})
}
/** Asks what folders a Telegram account has, for when it has just connected. */
loadTelegramFolders(accountId: string): void {
void window.moho
.rpc<TelegramFolder[]>('telegramFolders', { accountId })
.then((folders) => this.set({ telegramFolders: { ...this.state.telegramFolders, [accountId]: folders } }))
.catch(() => {})
}
/**
* Files a Telegram conversation under a folder, or under none: a change to the account on Telegram, out of the folder it
* is drawn under and into the one chosen. Nothing moves here until Telegram has agreed and said so.
*/
fileTelegramChat(bufferId: string, folderId: number | null): void {
void window.moho
.rpc('fileTelegramChat', { bufferId, folderId })
.catch((e: Error) => this.toast('error', `Telegram did not move the chat: ${e.message}`))
}
/**
* Makes a Telegram folder with one conversation in it, which is how a category made here comes to exist there: a
* folder needs a chat in it. Resolves when it is made.
*/
async createTelegramFolder(bufferId: string, name: string): Promise<boolean> {
try {
await window.moho.rpc('createTelegramFolder', { bufferId, name })
return true
} catch (e) {
this.toast('error', `Telegram did not make the folder: ${(e as Error).message}`)
return false
}
}
renameTelegramFolder(accountId: string, folderId: number, name: string): void {
void window.moho
.rpc('renameTelegramFolder', { accountId, folderId, name })
.catch((e: Error) => this.toast('error', `Telegram did not rename the folder: ${e.message}`))
}
/** Deletes a folder: the chats in it stay where they are, in whatever other folders they are in. */
deleteTelegramFolder(accountId: string, folderId: number): void {
void window.moho
.rpc('deleteTelegramFolder', { accountId, folderId })
.catch((e: Error) => this.toast('error', `Telegram did not delete the folder: ${e.message}`))
}
reorderTelegramFolders(accountId: string, order: number[]): void {
void window.moho
.rpc('reorderTelegramFolders', { accountId, order })
.catch((e: Error) => this.toast('error', `Telegram did not reorder the folders: ${e.message}`))
}
/**
* Whether an IRC account rejoins this channel when it connects.
*
@@ -2929,6 +3139,18 @@ export class ChatStore {
* and guessing at it would mean showing an answer that never came.
*/
useComponent(bufferId: string, messageId: string, customId: string, select: boolean, values: string[]): void {
// A Telegram bot answers a press with a notice, or an address to open, and no more: whatever else it does arrives
// as an edit to the message or a new one.
if (this.accountFor(bufferId)?.service === 'telegram') {
void window.moho
.rpc<{ message?: string | null; alert?: boolean; url?: string | null }>('pressTelegramButton', { bufferId, messageId, customId })
.then((answer) => {
if (answer.message) this.toast(answer.alert ? 'error' : 'info', answer.message)
if (answer.url) void window.moho.openExternal(answer.url)
})
.catch((e: Error) => this.toast('error', e.message))
return
}
void window.moho
.rpc('useDiscordComponent', { bufferId, messageId, customId, select, values })
.catch((e: Error) => this.toast('error', e.message))
@@ -3017,6 +3239,7 @@ export class ChatStore {
/** What is actually arriving in a call held on a media server, if one is
* up: a check that the media is readable rather than merely connected. */
async callArrivals(): Promise<unknown> {
if (this.stoatCall) return this.stoatCall.sfu.arriving()
const group = this.matrixCalls.currentGroup
return group?.sfu ? group.sfu.arriving() : []
}
@@ -3052,7 +3275,79 @@ export class ChatStore {
}
}
/**
* Joins the call in a Stoat conversation, starting one if nobody is in it.
*
* Stoat holds calls on a LiveKit server, as Element does, so the window is the client: the daemon asks Stoat for the
* way in and the rest is the same machinery a Matrix call on a media server runs on - the tiles, the buttons and
* the screen picker are the ones every other call has.
*/
async joinStoatCall(bufferId: string, video: boolean): Promise<void> {
const account = this.accountFor(bufferId)
if (!account || account.service !== 'stoat') return
if (this.state.activeCall || this.stoatCall) {
this.toast('info', 'Leave the call you are in first')
return
}
this.set({
callMinimized: false,
activeCall: { accountId: account.id, bufferId, phase: 'connecting', video, muted: false, sharingScreen: false, cameraOn: video, onServer: true }
})
try {
const way = await window.moho.rpc<{ token: string; url: string; note?: string | null }>('joinStoatCall', { bufferId })
// What a new account is held to is said once, as the call starts.
if (way.note) this.toast('info', way.note)
let wasConnected = false
const sfu = new SfuCall(
{
onParticipants: (people) => {
if (this.stoatCall) this.stoatCall.people = people
this.set({})
},
onConnected: (connected) => {
if (!this.stoatCall) return
// Connecting is the room's first state, and not being connected: only losing a connection that was made
// ends the call.
if (connected) wasConnected = true
if (!connected && wasConnected && this.state.activeCall?.bufferId === bufferId) {
this.stoatCall = null
this.levels.dispose()
this.set({ activeCall: null })
return
}
if (this.state.activeCall) this.set({ activeCall: { ...this.state.activeCall, phase: connected ? 'connected' : 'connecting' } })
},
onError: (message) => this.toast('error', message)
},
false
)
this.stoatCall = { sfu, bufferId, people: [], local: null }
await sfu.join(way.url, way.token, video)
if (this.stoatCall) this.stoatCall.local = sfu.local
const current = this.state.activeCall as ChatState['activeCall']
if (current) this.set({ activeCall: { ...current, cameraOn: sfu.cameraOn } })
} catch (e) {
this.stoatCall?.sfu.leave().catch(() => {})
this.stoatCall = null
this.set({ activeCall: null })
this.toast('error', `Couldn't join the call: ${(e as Error).message}`)
}
}
/** Somebody's name in a Stoat call: the daemon's, which knows people the roster has not listed. */
private stoatCallName(bufferId: string, userId: string): string {
return this.state.stoatVoice[bufferId]?.find((m) => m.userId === userId)?.name ?? this.matrixDisplayName(bufferId, userId)
}
hangUpMatrixCall(): void {
if (this.stoatCall) {
const call = this.stoatCall
this.stoatCall = null
void call.sfu.leave().catch(() => {})
this.levels.dispose()
this.set({ activeCall: null })
return
}
// Whichever kind is up: leaving a room's call withdraws the membership
// that says this end is in it, as well as hanging up on everybody.
if (this.matrixCalls.currentGroup) {
@@ -3172,6 +3467,33 @@ export class ChatStore {
}
callTiles(): CallTile[] {
// A call on a Stoat server: this end and everybody the media server sends.
const stoat = this.stoatCall
if (stoat) {
this.levels.watch('self', stoat.sfu.local)
const tiles: CallTile[] = [
{
id: 'self',
label: this.ownNameIn(stoat.bufferId) || 'You',
stream: stoat.sfu.local,
hasVideo: !!stoat.sfu.local?.getVideoTracks().some((t) => t.readyState === 'live' && !t.muted),
self: true,
muted: !!this.state.activeCall?.muted,
speaking: !this.state.activeCall?.muted && this.levels.speaking('self')
}
]
for (const person of stoat.people) {
this.levels.watch(person.key, person.stream)
tiles.push({
id: person.key,
label: this.stoatCallName(stoat.bufferId, person.userId),
stream: person.stream,
hasVideo: person.hasVideo,
speaking: this.levels.speaking(person.key)
})
}
return tiles
}
// A call between everybody in a room: one tile for this end and one per
// person answered, which is what the fitted grid was built for.
const group = this.matrixCalls.currentGroup
@@ -3266,6 +3588,12 @@ export class ChatStore {
if (!this.state.activeCall) return
// Three kinds of call reach this button and the button does not care:
// one to one, a mesh in a room, and a room call held on a media server.
if (this.stoatCall) {
void this.stoatCall.sfu.toggleMute().then((muted) => {
if (this.state.activeCall) this.set({ activeCall: { ...this.state.activeCall, muted } })
})
return
}
const group = this.matrixCalls.currentGroup
if (group?.sfu) {
void group.sfu.toggleMute().then((muted) => {
@@ -3298,6 +3626,14 @@ export class ChatStore {
*/
async toggleCamera(): Promise<void> {
if (!this.state.activeCall) return
if (this.stoatCall) {
const on = await this.stoatCall.sfu.toggleCamera().catch((e: Error) => {
this.toast('error', `Couldn't turn the camera on: ${e.message}`)
return false
})
if (this.state.activeCall) this.set({ activeCall: { ...this.state.activeCall, cameraOn: on } })
return
}
const want = !this.matrixCalls.cameraOn
const on = await this.matrixCalls.setCamera(want).catch((e: Error) => {
// The usual failure is a machine with no camera, or one already taken
@@ -3331,8 +3667,9 @@ export class ChatStore {
// On a media server the screen is opened here, exactly as for the other
// two, and handed to LiveKit to publish.
const group = this.matrixCalls.currentGroup
if (group?.sfu) {
const sfu = group.sfu
const serverCall = this.stoatCall?.sfu ?? group?.sfu
if (serverCall) {
const sfu = serverCall
try {
let sharing: boolean
if (sfu.sharingScreen) {
@@ -3601,7 +3938,7 @@ export class ChatStore {
// forward without an author.
case 'messageReplyRenamed': {
this.mapMessage(data.bufferId, data.id, (m) =>
m.replyTo ? { ...m, replyTo: { ...m.replyTo, from: data.from } } : m
m.replyTo ? { ...m, replyTo: { ...m.replyTo, from: data.from, ...(data.body ? { body: data.body } : {}) } } : m
)
break
}
@@ -3642,6 +3979,14 @@ export class ChatStore {
this.mapMessage(data.bufferId, data.id, (m) => ({ ...m, moderated: data.moderated ?? {} }))
break
case 'threadCount': {
const d = data as unknown as { bufferId: string; id: string; count: number }
const mine = this.state.threadCounts[d.bufferId] ?? {}
if (mine[d.id] === d.count) break
this.set({ threadCounts: { ...this.state.threadCounts, [d.bufferId]: { ...mine, [d.id]: d.count } } })
break
}
case 'reactionsChanged':
this.mapMessage(data.bufferId, data.id, (m) => ({ ...m, reactions: data.reactions }))
break
@@ -3891,6 +4236,12 @@ export class ChatStore {
break
}
// Telegram says what folders an account has, or that they changed.
case 'telegramFolders': {
this.set({ telegramFolders: { ...this.state.telegramFolders, [data.accountId as string]: (data.folders ?? []) as TelegramFolder[] } })
break
}
case 'profile': {
const answer = data as unknown as Profile
const open = this.state.profile
@@ -3941,8 +4292,13 @@ export class ChatStore {
[data.bufferId as string]: (data.pinned as string[]) || []
}
})
// The pins changed under rows already read: read them again.
if (this.state.pinnedRows[data.bufferId as string]) void this.loadPins(data.bufferId as string).catch(() => {})
// The pins changed under rows already read: read them again. Only when they did - the daemon says the pins
// each time it is asked for them, and reading again on every telling would ask for ever.
{
const held = this.state.pinnedRows[data.bufferId as string]
const now = (data.pinned as string[]) || []
if (held && (held.length !== now.length || held.some((m) => !now.includes(m.id)))) void this.loadPins(data.bufferId as string).catch(() => {})
}
break
case 'pinnedMessage': {
@@ -4111,6 +4467,22 @@ export class ChatStore {
void this.refreshAccounts()
break
// Stoat says who has asked an account to be their friend, whenever that changes.
case 'stoatFriendRequests': {
this.set({ friendRequests: { ...this.state.friendRequests, [data.accountId as string]: (data.incoming ?? []) as FriendRequest[] } })
break
}
// Who is in a Stoat conversation's call, said as people come and go.
case 'stoatVoice': {
const members = (data.members ?? []) as StoatVoiceMember[]
const next = { ...this.state.stoatVoice }
if (members.length > 0) next[data.bufferId as string] = members
else delete next[data.bufferId as string]
this.set({ stoatVoice: next })
break
}
case 'steamFriendRequests': {
const incoming = ((data.requests as { incoming?: SteamPerson[] } | undefined)?.incoming ?? []).map(personToRequest)
this.set({ friendRequests: { ...this.state.friendRequests, [data.accountId as string]: incoming } })
@@ -4262,7 +4634,8 @@ export class ChatStore {
favourite: data.favourite ?? false,
lowPriority: data.lowPriority ?? false,
readOnly: data.readOnly,
forum: data.forum ?? false
forum: data.forum ?? false,
...seededCount(b, data, this.state.activeBufferId)
}
: b
)
@@ -4270,7 +4643,12 @@ export class ChatStore {
this.followPeekJoin(data)
return
}
this.set({ buffers: [...buffers, { unread: 0, highlight: false, mentions: 0, ...data }] })
this.set({
buffers: [
...buffers,
{ unread: data.unreadSeed ?? 0, highlight: (data.mentionsSeed ?? 0) > 0, mentions: data.mentionsSeed ?? 0, ...data }
]
})
// Filed under a rail entry this window has never been told of. An
// account's own entry is not announced when the account is made - the
// daemon lists one for every account when asked - so the first
@@ -5432,6 +5810,15 @@ export class ChatStore {
}
return
}
if (account?.service === 'telegram') {
try {
const perms = await window.moho.rpc<RoomPermissions>('telegramPermissions', { bufferId })
this.set({ matrixPermissions: { ...this.state.matrixPermissions, [bufferId]: perms } })
} catch {
/* advisory only */
}
return
}
if (account?.service !== 'matrix') return
try {
const perms = await window.moho.rpc<RoomPermissions>('getMatrixRoomPermissions', {
+12
View File
@@ -38,6 +38,18 @@ export function useDmCategories(): void {
for (const id of connectedSteam.split(',').filter(Boolean)) store.loadSteamGroups(id)
}, [connectedSteam, store])
// The same for a Telegram account's folders, which the account's page draws as its categories.
const connectedTelegram = accounts.filter((a) => a.service === 'telegram' && a.state === 'connected').map((a) => a.id).join(',')
useEffect(() => {
for (const id of connectedTelegram.split(',').filter(Boolean)) store.loadTelegramFolders(id)
}, [connectedTelegram, store])
// And who is in a Stoat account's calls.
const connectedStoat = accounts.filter((a) => a.service === 'stoat' && a.state === 'connected').map((a) => a.id).join(',')
useEffect(() => {
for (const id of connectedStoat.split(',').filter(Boolean)) store.loadStoatVoice(id)
}, [connectedStoat, store])
useEffect(() => {
if (!ready) return
// The account headings first, then the Steam groups on top of what they
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { isDeepLink, isTelegramLink, parseDeepLink, stoatInviteCode } from './deeplink'
describe('a Telegram link', () => {
it('is what the View in Telegram button of a post opens, and what a message says', () => {
for (const url of [
'tg://resolve?domain=disclosetv&post=21953',
'https://t.me/disclosetv/21953',
'https://t.me/disclosetv',
'https://telegram.me/disclosetv/5',
'https://t.me/+AbC_d-123',
'https://t.me/joinchat/AbC_d-123',
'tg://join?invite=AbC_d-123',
'https://t.me/c/1234567/89',
'tg://privatepost?channel=1234567&post=89'
]) {
expect(isTelegramLink(url), url).toBe(true)
expect(isDeepLink(url), url).toBe(true)
expect(parseDeepLink(url)).toEqual({ service: 'telegram', url })
}
})
it('is not a page about something else, another host, or another tg: link', () => {
for (const url of [
'https://t.me/addstickers/Pack',
'https://t.me/s/disclosetv',
'https://t.me/proxy?server=a&port=1',
'https://t.me/',
'https://example.com/disclosetv/1',
'https://t.me.example.com/disclosetv/1',
'tg://login?token=abc',
'tg://resolve?domain=telegrampassport',
'tg://addstickers?set=x'
]) {
expect(isTelegramLink(url), url).toBe(false)
expect(isDeepLink(url), url).toBe(false)
}
})
it('does not turn an irc link into one, nor the other way round', () => {
expect(parseDeepLink('irc://irc.libera.chat/moho')?.service).toBe('irc')
expect(isDeepLink('irc://irc.libera.chat/moho')).toBe(true)
expect(parseDeepLink('tg://login?token=abc')).toBeNull()
expect(parseDeepLink('https://kick.com/xqc')?.service).toBe('kick')
})
})
describe('a Stoat invite', () => {
it('is read from the official addresses, and only those', () => {
expect(stoatInviteCode('https://stoat.chat/invite/gJhyqXTt')).toBe('gJhyqXTt')
expect(stoatInviteCode('https://app.stoat.chat/invite/gJhyqXTt/')).toBe('gJhyqXTt')
expect(stoatInviteCode('https://rvlt.gg/Abc123')).toBe('Abc123')
expect(stoatInviteCode('https://stoat.chat/')).toBeNull()
expect(stoatInviteCode('https://example.com/invite/abc')).toBeNull()
expect(stoatInviteCode('https://stoat.chat.example.com/invite/abc')).toBeNull()
expect(stoatInviteCode('https://stoat.chat/invite/a b')).toBeNull()
expect(parseDeepLink('https://stoat.chat/invite/gJhyqXTt')).toEqual({ service: 'stoat', code: 'gJhyqXTt', url: 'https://stoat.chat/invite/gJhyqXTt' })
expect(isDeepLink('https://stoat.chat/invite/gJhyqXTt')).toBe(true)
})
})
+86 -4
View File
@@ -27,7 +27,23 @@ export interface KickLink {
handle: string
}
export type DeepLink = IrcLink | KickLink
/**
* A link to a Telegram chat or a post in one, as a `tg://` link (what the "View in Telegram" button on a web preview
* opens) or a t.me address in a message. The daemon reads it: this only recognises one, and passes it on whole.
*/
export interface TelegramLink {
service: 'telegram'
url: string
}
/** An invitation to a Stoat server: its link in a message, which joins it where the account has one. */
export interface StoatInvite {
service: 'stoat'
code: string
url: string
}
export type DeepLink = IrcLink | KickLink | TelegramLink | StoatInvite
/**
* Schemes moho asks the desktop to send it.
@@ -36,12 +52,75 @@ export type DeepLink = IrcLink | KickLink
* link is an ordinary web address, and registering for `https` would mean
* volunteering to be the system browser.
*/
export const DEEP_LINK_SCHEMES = ['irc', 'ircs']
export const DEEP_LINK_SCHEMES = ['irc', 'ircs', 'tg']
/** Whether a string is a link this client would act on. */
export function isDeepLink(value: string): boolean {
const lower = value.toLowerCase()
return DEEP_LINK_SCHEMES.some((s) => lower.startsWith(`${s}:`)) || !!kickHandle(value)
return DEEP_LINK_SCHEMES.some((s) => lower.startsWith(`${s}:`)) ? isTelegramOrIrc(value) : !!kickHandle(value) || isTelegramLink(value) || !!stoatInviteCode(value)
}
/**
* The code in a link to a Stoat invite (`stoat.chat/invite/Code`, the same on the web app or Revolt's old names, or the
* short `rvlt.gg/Code`), or null. Only the official addresses: a self-hosted instance's own are joined by pasting them
* into the join panel, since nothing here can tell its address from any other website's.
*/
export function stoatInviteCode(value: string): string | null {
let url: URL
try {
url = new URL(value.trim())
} catch {
return null
}
if (!/^https?:$/.test(url.protocol)) return null
const host = url.hostname.replace(/^www\./, '').toLowerCase()
const parts = url.pathname.split('/').filter(Boolean)
let code: string | undefined
if (['stoat.chat', 'app.stoat.chat', 'revolt.chat', 'app.revolt.chat'].includes(host) && parts.length === 2 && parts[0] === 'invite') code = parts[1]
else if (['rvlt.gg', 'stt.gg'].includes(host) && parts.length === 1) code = parts[0]
return code && /^[A-Za-z0-9_-]{1,32}$/.test(code) ? code : null
}
/** A `tg:` link is only one of ours if it is a kind that opens a chat; the others (login, proxy, themes) are not. */
function isTelegramOrIrc(value: string): boolean {
return value.toLowerCase().startsWith('tg:') ? isTelegramLink(value) : true
}
/** Pages on t.me that are about something other than a chat. */
const TELEGRAM_PAGES = ['s', 'addstickers', 'addemoji', 'setlanguage', 'proxy', 'socks', 'share', 'login', 'iv', 'addtheme', 'bg', 'boost', 'm', 'addlist', 'invoice', 'giftcode', 'contact']
/**
* Whether a string is a link to a Telegram chat, a post, or a private invite: `tg://resolve`, `tg://privatepost`,
* `tg://join`, and t.me, telegram.me and telegram.dog addresses that name one. The same reading the daemon makes
* (nobilis backend/telegram/links.rs), which has the last word.
*/
export function isTelegramLink(value: string): boolean {
let url: URL
try {
url = new URL(value.trim())
} catch {
return false
}
if (url.protocol === 'tg:') {
const params = url.searchParams
if (url.hostname === 'resolve') {
const domain = params.get('domain') ?? ''
return /^[A-Za-z][A-Za-z0-9_]{4,31}$/.test(domain) && domain !== 'telegrampassport'
}
if (url.hostname === 'privatepost') return /^\d+$/.test(params.get('channel') ?? '')
if (url.hostname === 'join') return /^[A-Za-z0-9_-]+$/.test(params.get('invite') ?? '')
return false
}
if (!/^https?:$/.test(url.protocol)) return false
if (!['t.me', 'telegram.me', 'telegram.dog'].includes(url.hostname.replace(/^www\./, ''))) return false
const parts = url.pathname.split('/').filter(Boolean)
const first = parts[0]
if (!first) return false
if (first.startsWith('+')) return /^[A-Za-z0-9_-]+$/.test(first.slice(1))
if (first === 'joinchat') return /^[A-Za-z0-9_-]+$/.test(parts[1] ?? '')
if (first === 'c') return /^\d+$/.test(parts[1] ?? '')
if (TELEGRAM_PAGES.includes(first)) return false
return /^[A-Za-z][A-Za-z0-9_]{4,31}$/.test(first)
}
/**
@@ -91,6 +170,9 @@ export function kickHandle(value: string): string | null {
export function parseDeepLink(value: string): DeepLink | null {
const kick = kickHandle(value)
if (kick) return { service: 'kick', handle: kick }
if (isTelegramLink(value)) return { service: 'telegram', url: value.trim() }
const invite = stoatInviteCode(value)
if (invite) return { service: 'stoat', code: invite, url: value.trim() }
let url: URL
try {
@@ -100,7 +182,7 @@ export function parseDeepLink(value: string): DeepLink | null {
}
const scheme = url.protocol.replace(/:$/, '').toLowerCase()
if (!DEEP_LINK_SCHEMES.includes(scheme)) return null
if (scheme !== 'irc' && scheme !== 'ircs') return null
const host = url.hostname
if (!host) return null
+3
View File
@@ -9,6 +9,7 @@ export const IPC = {
windowClose: 'moho:window:close',
windowIsMaximized: 'moho:window:isMaximized',
openExternal: 'moho:openExternal',
openArticle: 'moho:openArticle',
resolveImagePage: 'moho:resolveImagePage',
pickFile: 'moho:pickFile',
pickSavePath: 'moho:pickSavePath',
@@ -118,4 +119,6 @@ export interface EditMenuRequest {
suggestions: string[]
can: Record<EditAction, boolean>
hasSelection: boolean
/** What is selected, for searching the web for it. */
selection: string
}
+14 -1
View File
@@ -3,7 +3,7 @@
export interface Account {
id: string
service: 'irc' | 'discord' | 'sneedchat' | 'matrix' | 'kick' | 'steam' | 'twitch' | 'jabber' | 'slack'
service: 'irc' | 'discord' | 'sneedchat' | 'matrix' | 'kick' | 'steam' | 'twitch' | 'telegram' | 'stoat' | 'jabber' | 'slack'
displayName: string
/**
* How the user is presenting: "online" | "idle". Distinct from
@@ -118,6 +118,13 @@ export interface Buffer {
readOnly?: string
/** A Discord forum: a list of posts, each a thread, not a conversation. */
forum?: boolean
/**
* What the service counts as unread here, where it keeps a count this window could not work out (a Telegram topic
* never opened here): a starting point for the count, which then follows live messages.
*/
unreadSeed?: number
/** How many of those mention this account. */
mentionsSeed?: number
/** Matrix only - absent, not false, for protocols with no encryption concept. */
encrypted?: boolean
/**
@@ -173,6 +180,8 @@ export interface Buffer {
* refuses, so offering to leave it is offering something that fails.
*/
serviceRoom?: boolean
/** A glyph drawn in front of the name in the list: Telegram's broadcast channels and bots. */
nameIcon?: string
/**
* Set while the buffer exists but the service has not yet said what is in
* it - a Matrix room between joining it and its first sync. The list draws
@@ -437,6 +446,8 @@ export interface ReplyPreview {
* says which thread that message is part of, and a reply can be both.
*/
threadRoot?: string
/** The conversation (a buffer id) the answered message is in, when it is not this one: Telegram can answer a post in another channel. */
chat?: string
}
/**
@@ -717,6 +728,8 @@ export interface Member {
bot?: boolean
/** The roles this person holds here, by name. Discord only. */
roles?: string[]
/** What they are to this account where the service keeps friends: "Friend", "Blocked", "None"... Stoat only. */
relationship?: string
powerLevel?: number
/**
* The protocol's own presence word - "online", "idle", "dnd", "offline".