241 Commits
Author SHA1 Message Date
SalastilandClaude Sonnet 5.5 44208364cd Merge development into master: 1.2.0, Twitch
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 21:55:18 -04:00
SalastilandClaude Sonnet 5.5 d9f9f6a127 1.2.0: Twitch; README and CHANGELOG
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 21:54:30 -04:00
SalastilandClaude Sonnet 5.5 934dc19820 Twitch video: do not read its prefetch lines as segments (their address changes when the segment is made), which dropped the picture every ten seconds
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 21:39:30 -04:00
SalastilandClaude Sonnet 5.5 d1e39f18b0 Windows can be resized by their edges: channel list, member list, stream above the chat, and the corner panel from every edge
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 21:14:42 -04:00
SalastilandClaude Sonnet 5.5 dd4bd2e257 Twitch: the stage reports watching once a minute while playing; Settings switch
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 21:04:29 -04:00
SalastilandClaude Sonnet 5.5 b5ff4435a3 Emote picker: unique keys; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 20:44:15 -04:00
SalastilandClaude Sonnet 5.5 f103f9fc6a Emote picker: channel avatar / Twitch mark on the rail; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 19:58:36 -04:00
SalastilandClaude Sonnet 5.5 a6fd981b6f Twitch: no third-party emote services; channel emotes greyed in the picker
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 19:52:54 -04:00
SalastilandClaude Sonnet 5.5 ae98f70d95 Twitch emotes: refresh in the picker, own line draws emotes; check against the real services
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 19:42:45 -04:00
SalastilandClaude Sonnet 5.5 b656e5ee81 Twitch emote rewards: right pools for unlock and change; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 19:25:20 -04:00
SalastilandClaude Sonnet 5.5 38794e7cc5 Rewards: the emotes a reward offers are pictures to press, with a search and a second step for changes; integrity as the account
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 18:58:27 -04:00
SalastilandClaude Sonnet 5.5 aacbde768f Rewards panel: choose-an-emote and typed rewards; recent emoji only where they can be sent
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 18:47:33 -04:00
SalastilandClaude Sonnet 5.5 5518f1c534 Rewards panel: cheapest first, available above unavailable
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 18:29:06 -04:00
SalastilandClaude Sonnet 5.5 835878bbc0 Rewards panel: a font size that is defined
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 18:24:36 -04:00
SalastilandClaude Sonnet 5.5 399f521f55 One rewards panel over the chat for Kick and Twitch channel points, opened from the plaque
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 18:23:01 -04:00
SalastilandClaude Sonnet 5.5 46304f7750 Twitch: no bonus claiming; the points plaque shows the balance only
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 18:08:32 -04:00
Salastil 3cc8c14fb5 nobilis pointer 2026-10-14 18:04:00 -04:00
SalastilandClaude Sonnet 5.5 3d77002de3 Twitch: whisper badge shows (no presence dot), a script that checks the Twitch parts of the window
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 17:17:55 -04:00
SalastilandClaude Sonnet 5.5 68c6ec1581 Twitch: the points plaque, and vote and bet buttons that work
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 17:00:04 -04:00
Salastil 9dfb777e2e nobilis pointer 2026-10-14 16:46:45 -04:00
SalastilandClaude Sonnet 5.5 bb2edf573d Sneedchat: greentext in 4chan's green, and a sent greentext line is matched with the site's coloured echo
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 16:36:59 -04:00
SalastilandClaude Sonnet 5.5 34fcddf495 IRC mode explanations (nobilis pointer)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 16:20:18 -04:00
SalastilandClaude Sonnet 5.5 9ef8a67ac4 Every service notification is drawn as a card; the rule is in ARCHITECTURE.md
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 15:11:23 -04:00
SalastilandClaude Sonnet 5.5 c346b20e3d Twitch: whisper badge, moderation cards with their own glyphs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 15:03:51 -04:00
SalastilandClaude Sonnet 5.5 e4123abc05 Twitch: nobilis pointer, whispers get their own conversations
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 14:48:39 -04:00
SalastilandClaude Sonnet 5.5 f4fc379a60 Twitch: remove the Forget the video sign-in button
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 14:21:08 -04:00
SalastilandClaude Sonnet 5.5 49a09d8507 Twitch: one sign-in has every feature; the separate moderation button is gone
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 14:02:39 -04:00
SalastilandClaude Sonnet 5.5 98f7d049f0 Twitch: emotes picked from the picker show as pictures in the box; no GIF picker
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 12:05:53 -04:00
SalastilandClaude Sonnet 5.5 b2c793477b Twitch sign-in: type the code into Twitch's Activate page and press Activate; show the code in the form
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 11:53:14 -04:00
SalastilandClaude Sonnet 5.5 be013ea47c Twitch sign-in: wait out the site's own redirect after login and bring the window back to the approval page; find the session by a second cookie too
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 11:39:08 -04:00
SalastilandClaude Sonnet 5.5 5b2438bba5 Twitch: one sign-in window gives the account chat and video together
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 10:56:30 -04:00
SalastilandClaude Sonnet 5.5 0d28308ab8 Twitch: signing in also signs in on twitch.tv for video; the separate button is gone
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 10:36:46 -04:00
SalastilandClaude Sonnet 5.5 b7ee906037 Twitch: follows are opened on first connect (nobilis pointer)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 10:23:08 -04:00
SalastilandClaude Sonnet 5.5 61044b65b8 Twitch: raid cards that offer to go along, polls and predictions shown read-only, block and shoutout in the menu
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 09:56:47 -04:00
SalastilandClaude Sonnet 5.5 5356ddba55 Twitch: emotes and badges drawn, moderation and whispers in the window, README
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 09:18:31 -04:00
SalastilandClaude Sonnet 5.5 ac60123e7d Twitch: the stream plays in the window like Kick's, with its ad breaks said
Twitch prefetch segments are read as Kick's are, the ad break is said over the picture from the
playlist's own markers, mute and volume are remembered, the resolution choice is kept per service,
captions inside the video are offered, and the account's card can sign in on twitch.tv for video, open
what it follows, and forget the sign-in. A hidden page in main makes an integrity token on request.

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

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 19:57:03 -04:00
SalastilandClaude Sonnet 5.5 335ac8b59d Merge development into master: 1.1.0, Steam
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 15:49:35 -04:00
SalastilandClaude Sonnet 5.5 b877529367 1.1.0: Steam fully ported; the README lists the protocols
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 15:49:22 -04:00
Salastil 9eb3078996 Merge remote-tracking branch 'origin/development' into protocol/steam
# Conflicts:
#	nobilis
2026-10-13 15:48:36 -04:00
SalastilandClaude Sonnet 5.5 fea81593ed nobilis: no service headings for Steam conversations
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 15:35:47 -04:00
SalastilandClaude Sonnet 5.5 83fb0542b7 nobilis: remembered Steam names
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 15:26:12 -04:00
SalastilandClaude Sonnet 5.5 ba19eaaf26 Copying a Steam invite link goes through the main process's clipboard, as everywhere else: the page's own is refused
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 15:21:31 -04:00
SalastilandClaude Sonnet 5.5 239c1e0ced Direct-messages headings made from a Steam account's folders are called protocol, then folder; a probe for QR sign-in
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 15:17:43 -04:00
SalastilandClaude Sonnet 5.5 21b5076899 History: scrolling to the top stops asking once there is nothing older. Direct-messages headings start from a Steam account's folders once and are local after that
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 15:05:26 -04:00
SalastilandClaude Sonnet 5.5 2329abe29b Probes: leave a Steam group through the window, and list an account's groups after a fresh sign-in
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 14:55:04 -04:00
SalastilandClaude Sonnet 5.5 a1be5673fc The New category dialog says Steam's own client shows a folder made here after it is restarted
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 14:52:07 -04:00
SalastilandClaude Sonnet 5.5 f03f8b276b Leaving a Steam group: a room's menu offers it, the settings dialog says when it fails, and a voice room is left with the group
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 14:41:44 -04:00
SalastilandClaude Sonnet 5.5 ed194f4c3b nobilis: the group sticker probe
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 14:33:04 -04:00
SalastilandClaude Sonnet 5.5 9616b3de25 nobilis: the group picture probe
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 14:30:13 -04:00
SalastilandClaude Sonnet 5.5 c6e3dd7ebf Steam group invitation on the Invitations tile, photographed; nobilis updated
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 14:10:38 -04:00
SalastilandClaude Sonnet 5.5 8fcf7533da Steam group invitations are on the Invitations tile: accepted opens the group, declined takes it off
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 13:54:02 -04:00
SalastilandClaude Sonnet 5.5 0633a2c835 Dropping a conversation on a category lights the whole category, not a line above it
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 13:24:02 -04:00
SalastilandClaude Sonnet 5.5 786936e6e0 Steam account page: Steam's folders are headings that are made, renamed, deleted and filled on Steam
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 13:19:54 -04:00
SalastilandClaude Sonnet 5.5 b0bc9b6d3d Steam account page: friends under Steam's folders, moved by dragging, and the move made on Steam
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 13:08:12 -04:00
SalastilandClaude Sonnet 5.5 9f8a104c53 A probe that places and answers a Steam 1:1 call against the official client
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 12:45:54 -04:00
SalastilandClaude Sonnet 5.5 48c070915b Steam voice: a refused acknowledgement of a new offer does not end the call, as in the client; a probe that joins a group's voice room
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 12:35:25 -04:00
SalastilandClaude Sonnet 5.5 f492181d2d nobilis: the whole-tree reformat taken back out, the Steam fixes kept
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 11:58:59 -04:00
SalastilandClaude Sonnet 5.5 4ffc656cc1 Steam window: no Reply or Mute where Steam has none
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 11:56:07 -04:00
SalastilandClaude Sonnet 5.5 7634dc33db Steam voice: try again as the client does when a call fails in passing
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 08:55:41 -04:00
SalastilandClaude Sonnet 5.5 f87928bcde Steam window: kick lengths, two notification settings, role giving as its own permission, calls that may not ring
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 08:12:12 -04:00
SalastilandClaude Sonnet 5.5 741726dbcd nobilis: a group's picture address
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 07:47:43 -04:00
SalastilandClaude Sonnet 5.5 90539c57f9 Steam group settings: Change picture
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 07:21:53 -04:00
SalastilandClaude Sonnet 5.5 810f780c73 Steam voice in the window: a group's voice rooms and calls to friends
The window holds the WebRTC session (lib/steamvoice.ts, a port of the Steam
client's own order of calls) and the daemon carries the signalling. A voice
room is listed under a group's channels with who is in it and who is speaking,
the call you are in is the same Voice panel as any other, a friend can be
called from their conversation, a call ringing is the same incoming-call
panel, and the footer's mute and deafen apply. Tried against real Steam as the
throwaway account: connected in two seconds with audio going to the relay.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 07:10:53 -04:00
SalastilandClaude Sonnet 5.5 0a07aa6f28 Steam chat groups in the window: a group is a server, its rooms are channels
The rail entry has Group settings (name and tagline, rooms, people and what
they may do, roles and permissions, invite links, how loudly to be told);
the member list moderates a Steam group's members; the Join page joins one
from an invite link and starts one; a room's own messages can be deleted.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 01:30:24 -04:00
SalastilandClaude Sonnet 5.5 2488bb2d08 Steam: sign in with a QR code; what Steam says about the account, and where it is signed in
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 00:49:20 -04:00
SalastilandClaude Sonnet 5.5 dc051ca232 Steam: Profile opens the card every service uses, with the names they have had; invite links and blocked people on the add-a-friend page
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 00:32:56 -04:00
SalastilandClaude Sonnet 5.5 6cc8578c41 Steam friend groups are headings on the direct-messages page, kept the same both ways
A group is one of moho's own categories: its friends' conversations are under it, and
filing, renaming or deleting it here is done to the group on Steam, as what Steam does is
done here. What was last agreed is remembered so neither side echoes the other.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 00:19:36 -04:00
SalastilandClaude Sonnet 5.5 7eac8104f3 Steam: Profile in a friend's right-click menu opens a column about them
Where the member list would be: their status, the game they are playing, when they were
last seen, the names they have had, and what they wrote on their profile. Status and game
are live from their presence.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 00:06:59 -04:00
SalastilandClaude Sonnet 5.5 c7730e4c49 nobilis: the Steam warning banner survives a rename
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 23:47:40 -04:00
SalastilandClaude Sonnet 5.5 ac75118270 nobilis: Steam warning banner survives a restart
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 23:41:00 -04:00
SalastilandClaude Sonnet 5.5 3b427131c0 A Steam warning is a red dismissable banner over the conversation and a red line in the log
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 23:28:34 -04:00
SalastilandClaude Sonnet 5.5 5b3eaa8b00 nobilis: presence probe
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 22:20:38 -04:00
SalastilandClaude Sonnet 5.5 ce38d537ec Steam: the message just sent is recognised when Steam made a picture or card of its link
Before, the grey copy never matched the stored one, so an image link showed twice.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 22:06:46 -04:00
SalastilandClaude Sonnet 5.5 0770478d70 A link at the end of a clause does not take the clause's punctuation
Steam pages' cards draw as any link's do; checked in the real window.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 21:56:37 -04:00
SalastilandClaude Sonnet 5.5 2a2fda35fb A Steam sticker is drawn at the size Steam draws it, not as a small emoji
The rule sizing every emoji to the line outranked the sticker's own size.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 21:24:51 -04:00
SalastilandClaude Sonnet 5.5 8b2f867c59 Steam stickers: names with spaces in the window
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 21:14:57 -04:00
SalastilandClaude Sonnet 5.5 6e319f1b51 nobilis: Steam picker refreshes the account's list
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 21:04:13 -04:00
SalastilandClaude Sonnet 5.5 042f7b6116 nobilis: Kick chat on the new realtime service
Kick turned its Pusher application off; chat moves to the service kick.com now uses.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 20:56:11 -04:00
SalastilandClaude Sonnet 5.5 5342e6670a nobilis: Kick chat on the new realtime service
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 20:52:25 -04:00
SalastilandClaude Sonnet 5.5 f1c87cd314 nobilis: Steam reconnect keeps names; heartbeat during catch-up; endings logged
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 20:40:49 -04:00
SalastilandClaude Sonnet 5.5 846feb114a Steam videos are drawn, and a spoilered picture or video is covered like Discord's
The marker names a picture or a video, which an address with no extension cannot.
Anything in a spoiler - Steam's, Sneedchat's, a Discord ||link|| - gets the SPOILER
cover, and a link followed by || no longer loses its extension to the bars.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 20:21:29 -04:00
SalastilandClaude Sonnet 5.5 9365d7cee9 The file picker offers only the kinds of file Steam takes
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 20:12:21 -04:00
SalastilandClaude Sonnet 5.5 3bc85427b1 Steam pictures are drawn as pictures, and a spoilered one is hidden until clicked
Steam's uploaded pictures have no file extension in their address, so they are
recognised by where Steam keeps them. A picture inside a spoiler is covered like a
Discord spoiler attachment. The stored form of the tag is read too.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 20:09:57 -04:00
SalastilandClaude Sonnet 5.5 aa73b27db2 nobilis: Steam file upload reads Steam's answer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 20:03:27 -04:00
SalastilandClaude Sonnet 5.5 8473cc0a76 Steam: attach pictures and videos, with a spoiler toggle; unsupported files are refused at the box
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 19:37:25 -04:00
SalastilandClaude Sonnet 5.5 0967c2601b Steam: a message with an emoticon no longer sits pending and times out
The box sends :name: and Steam logs a picture, which comes back as [steam:emote:name];
a typed bracket likewise comes back changed. The window matched the sent message to its
echo by exact text, so neither matched and the send was reported failed. Steam's
conversations now compare the two as the same text.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 19:21:02 -04:00
SalastilandClaude Sonnet 5.5 77d168be0b Steam: picking an emoticon from the picker inserts the emoticon, not the words steam:name
The picker's per-service sections sent back the entry's id, which for Steam is the
steam:name key. Steam's section now sends the [steam:emote:name] token the box draws
and the message and reaction paths read.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 19:16:13 -04:00
SalastilandClaude Sonnet 5.5 6728b53f6a A Steam conversation's emoji picker has no Unicode emoji and no GIF button
The picker offers the account's own emoticons, with a Stickers tab for its
stickers; reactions pick from the same two; the GIF button is not drawn in a
Steam conversation.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 19:10:36 -04:00
SalastilandClaude Sonnet 5.5 a3635beb44 Steam friends: set a nickname, remove, block, and mute from the menu
A Steam friend's conversation menu - on its row and in its header - offers
what to call them, removing them as a friend, and blocking them, the last two
behind a question. Muting a Steam conversation is told to Steam so it follows
the account.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 18:38:10 -04:00
SalastilandClaude Sonnet 5.5 ab740a3d49 Steam: reactions are drawn and can be added from a message
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 16:52:46 -04:00
SalastilandClaude Sonnet 5.5 a6997fef4f Steam: emoticons draw and send; the composer offers no formatting for Steam
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 16:44:16 -04:00
SalastilandClaude Sonnet 5.5 d1d07706d0 nobilis: Sneedchat member pictures
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 16:22:25 -04:00
SalastilandClaude Sonnet 5.5 68684a4004 The profile card has a Status section
How they are showing (the dot and its word) and what they are doing - a game,
a track, or words they wrote, with an away message standing in for the words -
in a section of its own at the top of the card. What they are doing comes from
the lookup where the service gave it, and otherwise from the member lists,
which already carry it for every service.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 16:16:08 -04:00
SalastilandClaude Sonnet 5.5 82665ee4a5 Comfy lists follow Discord on every service; classic stays names
In comfy a member list has a face on everybody and, under each name, what they
are doing - a gamepad and the game, a note and the artist, or words with no
picture - and a direct message's row says the same under its name. In classic
both are plain names, as IRC and Sneedchat always were. A screenshot script
photographs the two.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 16:10:39 -04:00
SalastilandClaude Sonnet 5.5 0200d7b8a7 nobilis: Steam history, read state and typing
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 15:52:29 -04:00
SalastilandClaude Sonnet 5.5 0e3e1fcfe5 Steam: show what a friend is doing beside their name
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 15:25:25 -04:00
SalastilandClaude Sonnet 5.5 72046a4b70 nobilis: Steam message ids
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 15:07:01 -04:00
SalastilandClaude Sonnet 5.5 9ee99216c0 nobilis: hear Steam's service notifications
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 14:58:20 -04:00
SalastilandClaude Sonnet 5.5 61827ead9e Direct messages sort under a heading per account: "Protocol - Username"
Each account that has direct messages gets a heading on the direct-messages
page the first time it is seen, and its conversations are filed under it as
they arrive. Conversations that already exist are filed the first time this
runs, which is how accounts added before this get sorted without being
removed and added again. The headings are the ordinary ones, so renaming,
moving or deleting them sticks: a deleted heading is not made again and a
conversation moved out is not put back.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 14:49:56 -04:00
SalastilandClaude Sonnet 5.5 f2494a9cb6 Every yes-or-no a service asks goes to the Invitations tile
Matrix invitations leave the Join page, Discord's incoming friend requests
leave the Join page's request list, and a Matrix verification waiting to be
accepted is offered there instead of as a dialog (the dialog takes over once it
is accepted, for the emoji comparison). Accepting a Discord friend opens the
direct message with them. What this account sent stays on Join.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 14:44:15 -04:00
SalastilandClaude Sonnet 5.5 f10e16136c Invitations from every service share one rail tile and one list
A single envelope tile, there while anything waits on an answer. Selecting it
lists every pending invitation in the channel pane - Matrix rooms and direct
messages, Steam friend requests - and the pane beside it offers Accept and
Decline for the chosen one. Accepting turns that pane into the conversation
(the friend's direct message, the room) as soon as the service reports it, and
the invitation leaves the list the moment it is answered.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 14:36:17 -04:00
SalastilandClaude Sonnet 5.5 72774f1fb4 Friend requests wait in the server list beside Matrix invitations
Each request is a tile of its own, drawn with a person-add icon, and its page
says who asked with Accept and Decline. Answered here or on another client,
the tile goes. Steam is the first service to use it; the state and the tile
are any service's. The Join page keeps the add box and the requests still
waiting on the other side.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 14:28:14 -04:00
SalastilandClaude Sonnet 5.5 a77b8458c6 Steam: the sign-in form no longer offers the desktop session
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 14:22:27 -04:00
SalastilandClaude Sonnet 5.5 994a4fe783 nobilis: error 24 named
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 14:18:33 -04:00
SalastilandClaude Sonnet 5.5 2e71a1697d nobilis: a refused friend request is logged and explained
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 14:15:35 -04:00
SalastilandClaude Sonnet 5.5 2c5ca3eb27 Steam: friends are added from Add or join a server, not from Accounts
Adding someone and answering requests is joining something, so it lives
with the other services' join pages. A Steam account is offered there, and
the request toast points to it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 14:11:38 -04:00
SalastilandClaude Sonnet 5.5 cdd3874661 Steam: the account's page asks, accepts, declines and cancels friend requests
An add box taking an account name, a Steam id or a friend code; the people
who have asked listed with Accept and Decline; the people asked listed with
Cancel request. A new request is announced once as a toast pointing here.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 14:07:27 -04:00
SalastilandClaude Sonnet 5.5 97dee82e55 Steam: the window signs in, and offers online, away and invisible
A Steam form on the Accounts page - account name, password, which kind of
session, and Tor - that follows the sign-in as the daemon reports it: the
authenticator or emailed code Steam asks for, or an approval on the phone,
and a mistyped code asked for again. Closing the form cancels a sign-in
that is still waiting. Steam gets its mark, its name, and its three states.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 13:23:00 -04:00
SalastilandClaude Sonnet 5.5 3c6f6e90c3 Development takes only the README from GitHub: master's .gitattributes and PKGBUILD stay on master
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 20:31:53 -04:00
SalastilandClaude Sonnet 5.5 74a6a43a61 nobilis: the README as it stands on GitHub
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 20:30:36 -04:00
Salastil f046e28a37 Revise README for protocol updates and clarity
Updated README to reflect changes in protocol support and features.
2026-10-11 20:19:55 -04:00
Salastil 6d4545f5ec Add showcase screenshot to README
Added a showcase screenshot to the README.
2026-10-11 20:11:14 -04:00
SalastilandClaude Sonnet 5.5 7e94675b2f Merge branch 'development'
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 17:26:25 -04:00
SalastilandClaude Sonnet 5.5 02e5aa364f moho 1.0.0
Moho 1.0. Five protocols in one window - IRC, Discord, Matrix, Kick and Sneedchat - over a daemon that keeps them connected; this is everything since the first release candidate: the rebuilt interface and settings, GIFs and link cards on every service, idle detection, the tray, Kick events as cards, conversations that open where you stopped reading, and every Rust test read.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 17:26:09 -04:00
SalastilandClaude Sonnet 5.5 09c060df51 Which end of a Matrix call offers now has a test where the rule lives, and nobilis carries the test review: every test read, the ones that could not fail made to, and the faults they were hiding fixed
The offering rule was only ever tested through a copy of it compiled for the daemon's tests; the window's own is checked here, including that two devices of one person tell each other apart and that nobody offers to themselves.

nobilis: ddd4503

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 16:14:00 -04:00
SalastilandClaude Sonnet 5.5 47653c5f7f nobilis: tests for the Sneedchat caches
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 14:54:18 -04:00
SalastilandClaude Sonnet 5.5 f1183958ab Kick subscriptions, moderation and the stream's going live or ending are cards in the same theme as the gifts, raids and redeems
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 13:44:49 -04:00
SalastilandClaude Sonnet 5.5 b0e67750ff nobilis: tests found by mutation testing, and an unpin that never reached the screen
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 01:15:58 -04:00
SalastilandClaude Sonnet 5.5 8a7e3e7337 nobilis: tests that could not fail now can
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 00:37:28 -04:00
SalastilandClaude Sonnet 5.5 d99ad2ba68 Documentation brought up to date: the settings pages, how a conversation opens, presence and the tray, link cards and Kick events, Discord mutes set on the account, the tests and the tidy step, and the daemon described as it is rather than as it was meant to be
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 00:30:08 -04:00
SalastilandClaude Sonnet 5.5 97611c8742 No reference to the maintainer's own server: .gitmodules and PKGBUILD are the public ones on every branch, the nobilis release is its own script and the mirroring is kept outside the repository, and the maintainer is [email protected] everywhere it is named
Closes #335

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 00:28:47 -04:00
SalastilandClaude Sonnet 5.5 64ade92ee1 Kick cards sit where a person does: no join mark beside them, starting at the picture's edge with the name where a name is in the log - in comfy and in bubbles, where they had been centred - and with the air above them a message has
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 00:07:59 -04:00
SalastilandClaude Sonnet 5.5 39dcba548a A conversation opens as Discord's does: at its first unread only when the run is short (fifty or fewer), otherwise at its end with the bar to go back by - and the line from a visit is dropped when the conversation is left, so a return no longer goes up the history to a place nothing is unread
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 23:58:14 -04:00
SalastilandClaude Sonnet 5.5 7bae0eaaa8 Kick events are cards in Kick's shape on this client's surfaces: KICKs sent with the gift and its tier, gifted subs with their recipients on a purple bar, a raid, and a redeemed reward in the streamer's colour
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 20:49:58 -04:00
SalastilandClaude Sonnet 5.5 db495d2e98 Merge branch 'development'
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 16:37:52 -04:00
SalastilandClaude Sonnet 5.5 9b14536112 moho 1.0.0-rc.5
Fifth release candidate: GIFs on every service with a picker, idle detection, YouTube and GIF cards on every service including over Tor, the tray's number counted by the window, mentions-only counts that survive a mute, and Discord mutes set on the account.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 16:37:23 -04:00
SalastilandClaude Sonnet 5.5 5d826a785d Mirror script: MIRROR_GITEA reaches Gitea by its own address for the files Cloudflare will not take
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 16:33:00 -04:00
SalastilandClaude Sonnet 5.5 99e03116e9 Mirror script: a file over Cloudflare's 100 MB limit stays on GitHub only, said plainly, instead of stopping the mirror before nobilis
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 16:28:04 -04:00
SalastilandClaude Sonnet 5.5 ee0120ff5e A deploy tidies first: old AppImages, debug and cross builds, and a daemon build directory past 8 GB are removed, so a checkout stops growing to a hundred gigabytes
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 16:12:52 -04:00
SalastilandClaude Sonnet 5.5 7803cc38a6 A mute stops the interruptions, not the count: a muted channel still shows its mentions in the list, the rail, the switcher and the tray; a muted direct message shows none; and muting on Discord is written to the account as it is on Matrix
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 13:33:05 -04:00
SalastilandClaude Sonnet 5.5 987f31cf77 The number beside a conversation is what is addressed to you: mentions in a channel, each unread message in a direct message - the bold name says there is activity, a count of it does not
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 13:21:56 -04:00
SalastilandClaude Sonnet 5.5 336335a8a3 Scrolling a long unread run no longer snaps back: the place being read is found through the bar laid over the top of the log, not read off the bar, so a card or picture settling above does not put the view back where the bar appeared
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 13:17:22 -04:00
SalastilandClaude Sonnet 5.5 638b02e07f nobilis: link cards survive edits and reach backlog on every service
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 23:07:05 -04:00
SalastilandClaude Sonnet 5.5 8064e79bc3 nobilis: YouTube cards for accounts on Tor, from the video's page when oEmbed is refused
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 22:57:05 -04:00
SalastilandClaude Sonnet 5.5 1e314f85b6 The tray's number is the window's: counted by the rail's rule - muted and hidden conversations out, a direct message by each unread message, anywhere else by its mentions - and told to the tray whenever it changes, instead of a tally of its own that drifted
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 20:08:34 -04:00
SalastilandClaude Sonnet 5.5 07f4fc2ef6 The window does not load the forum's own files itself: the daemon fetches them through the account's route, and asked for from the reader's address the host refuses them and Tor is bypassed
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 19:06:23 -04:00
SalastilandClaude Sonnet 5.5 55248eb583 GIFs: a linked GIF is drawn as the picture - silent, looping, without the player - on every service, a GIF picker with trending, categories and search, and Klipy/Tenor/Giphy pages are never probed (#331)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 17:55:10 -04:00
SalastilandClaude Sonnet 5.5 23c87dd353 Idle detection: how long since anything was used, from the system where it will say and moho's own windows where it will not; a setting for it and for how long; the plaque says Idle (away) and keeps what was chosen checked (#332)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 17:35:58 -04:00
Salastil 74af46a6f2 Merge branch 'development' 2026-10-09 15:40:06 -04:00
SalastilandClaude Sonnet 5.5 069371959a moho 1.0.0-rc.4: the rail's mention counts and Matrix unmute fixes join the release
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 15:40:06 -04:00
SalastilandClaude Sonnet 5.5 95f1784dbc A room unmuted on the account stops being drawn as muted: the buffer merge takes the flags the daemon leaves out as cleared; an unmute the server did not take is reported
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 15:37:39 -04:00
SalastilandClaude Sonnet 5.5 0dc048cdc0 Server rail: a tile counts the mentions waiting in it and not the messages, and a muted room counts for nothing, mentions included
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 15:28:01 -04:00
Salastil 5929fff8bb Merge branch 'development' 2026-10-09 15:21:54 -04:00
SalastilandClaude Sonnet 5.5 dc94dc3aca moho 1.0.0-rc.4
Fourth release candidate: a rebuilt tray, statuses by service, Do not disturb that silences notifications, and fixes to switching servers, Matrix rate limits and mutes, and Discord's connection.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 15:21:54 -04:00
SalastilandClaude Sonnet 5.5 745641a1ad The window stays where it is put: the link coming up is acted on once, a refresh does not follow the open conversation to its server, and the tray is told only what changed; Matrix's invisible is called offline; a mute held on a Matrix account can be taken off from the menu
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 11:45:38 -04:00
SalastilandClaude Sonnet 5.5 c1bcb72a38 Status: the plaque and the tray offer what each service has; idle from the tray reaches Discord and IRC and leaves the rest online; do-not-disturb silences desktop notifications everywhere
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 11:26:52 -04:00
SalastilandClaude Sonnet 5.5 0dfff0d74b nobilis: Matrix rate limits are waited out (status from the tray)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 11:15:38 -04:00
SalastilandClaude Sonnet 5.5 53fb0f34ce Tray: no notifications switch of its own - Do not disturb is that, and now silences an account's desktop notifications and the taskbar flash (#330)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 11:07:50 -04:00
SalastilandClaude Sonnet 5.5 24106e51f4 Tray: a bubble drawn for the system's colour scheme with the number waiting on it; a menu of what is new, Status, notifications, Settings, Restart moho and Quit; no daemon plumbing (#330)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 11:00:10 -04:00
SalastilandClaude Sonnet 5.5 b151ef8c07 nobilis: Discord request timeouts, history fetch guard, gateway tally
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 10:37:44 -04:00
Salastil 7f9f903af5 Merge branch 'development' 2026-10-08 23:51:20 -04:00
SalastilandClaude Sonnet 5.5 c6206958b4 moho 1.0.0-rc.3
Third release candidate: Discord forums, a redesigned interface and settings, the one-box composer, Discord's notices and read-only channels, and window recovery after a crash.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 23:51:16 -04:00
Salastil 1993e308d0 nobilis: forums read live and archived posts 2026-10-08 23:40:36 -04:00
Salastil f6ecc6f634 nobilis: forum search diagnostics 2026-10-08 23:37:16 -04:00
Salastil f8b69909be nobilis: forum posts 2026-10-08 23:33:04 -04:00
SalastilandClaude Sonnet 5.5 dd0c25e054 Discord forums: a pane of posts (title, tags, first message, replies, age, picture, reaction), search, sort, older posts, New Post; a forum has no message box (#329)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 23:26:07 -04:00
SalastilandClaude Sonnet 5.5 06bb812ed0 Recovery also covers a page that exits cleanly while its window is still there (that is what the log showed); the URL is logged
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 23:12:07 -04:00
SalastilandClaude Sonnet 5.5 6899694394 A window whose page dies is reloaded and the reason is logged (it was left as an empty shell with nothing said)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 22:59:21 -04:00
SalastilandClaude Sonnet 5.5 6a329c61e9 Dialogs: the profile card, room search and the three events panes are built from Modal (own heading supported); one scrim, one Escape stack (#315)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 22:54:50 -04:00
SalastilandClaude Sonnet 5.5 dad55432b4 Server rail: the + is pinned below the list, under a rule and above the cog; the list scrolls above it
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 22:50:28 -04:00
SalastilandClaude Sonnet 5.5 d7c4215473 Discord's notices drawn as cards: a glyph, the time, the words and a button bar - red where it acts on the account (#328)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 22:45:03 -04:00
SalastilandClaude Sonnet 5.5 2a4f797b7f Opening Discord's notices reads its cards again once a run (#328)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 22:36:52 -04:00
SalastilandClaude Sonnet 5.5 ff7d313b46 Where the message box would be: a banner saying why nothing can be written here; embed fields and footer drawn (#328)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 22:32:40 -04:00
SalastilandClaude Sonnet 5.5 9f03d6b8dd Channel list: channels hold their place instead of jumping to the top as each message arrives; direct messages still go by recency
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 22:22:19 -04:00
SalastilandClaude Sonnet 5.5 2b41c5eb1c Join: browsing an IRC network's channels asks the daemon for a page, times out after a minute, can be stopped, and says when the network refuses
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 22:14:43 -04:00
SalastilandClaude Sonnet 5.5 b4c1ced186 User footer: the join button goes; the rail's + is the one way in
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 22:08:12 -04:00
SalastilandClaude Sonnet 5.5 50aaa97041 Quiet buttons (Cancel and its kind) get their own fill, lighter than any dialog or panel behind them
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 22:02:36 -04:00
SalastilandClaude Sonnet 5.5 cc4c9f667d Settings rail: 'You' becomes 'Personal', a rule under each heading, About set apart by a rule above (#327)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 21:58:57 -04:00
SalastilandClaude Sonnet 5.5 f8936c6734 Room settings: a tabbed dialog - Security, Moderation, Advanced - instead of a 400px popover (#324)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 21:56:20 -04:00
SalastilandClaude Sonnet 5.5 078fe41261 Mentions page: service and unread filters, day groups, two-line bodies, put away one at a time (#314)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 21:53:34 -04:00
SalastilandClaude Sonnet 5.5 f8dcccdb8f Accounts and Join: groups open when there are few accounts, Remove in a danger zone, the add form stays open with a Cancel, Join picks its account and says Join (#323)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 21:51:06 -04:00
SalastilandClaude Sonnet 5.5 82a0bdf8a8 Settings: a Keybinds page - every shortcut listed, and the global hotkey is recorded by pressing it (#322)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 21:48:22 -04:00
SalastilandClaude Sonnet 5.5 adca84d657 Settings: a Notifications page - desktop notifications, sound, taskbar flash, unread badge (#321)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 21:45:53 -04:00
SalastilandClaude Sonnet 5.5 fe6ad97dae Settings: an Appearance page - layout, spacing, zoom, clock, relative times, reduce motion (#320)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 21:43:29 -04:00
SalastilandClaude Sonnet 5.5 66a0c0f20a Settings: the gear opens it directly, a grouped rail with Accounts and Downloads as pages, a search over every setting, one switch for every boolean, segmented sign-in, a saved tick (#319)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 18:21:28 -04:00
SalastilandClaude Sonnet 5.5 7f4c356eb3 (edited) says when, on hover, for edits seen live (#300)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 18:15:47 -04:00
SalastilandClaude Sonnet 5.5 59d4893fbc Incoming calls: Discord's and Matrix's in one list, one style, stacked (#325)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 18:12:26 -04:00
SalastilandClaude Sonnet 5.5 f12007679b Thread panel: the room's own message box in thread mode - emoji, names, files, formatting (#310)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 18:09:52 -04:00
SalastilandClaude Sonnet 5.5 420a2f793c Dialogs: forward picker, message source, bot forms, poll, space rooms, folder settings, rules and the screen picker move onto the shared frame; the events panes share its scrim; a dialog focuses its first text box (#315, mostly)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 18:05:47 -04:00
SalastilandClaude Sonnet 5.5 2ef9e35c9e Header: one 48px row with a divider across every column, toggles drawn filled when on, and the topic beside the name (#289)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 18:01:17 -04:00
SalastilandClaude Sonnet 5.5 c026609e4b User footer: muted and deafened are red, the voice buttons stay put on every service, and the status is a popout with a custom status and Apply to all accounts (#290)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 17:54:41 -04:00
SalastilandClaude Sonnet 5.5 506140db86 Reactions: a sentence over each one naming the emoji and who gave it; the daemon answers who reacted on request (#303)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 17:48:26 -04:00
SalastilandClaude Sonnet 5.5 3a74cf030b Pins: a banner over the log for Discord and Matrix with a click-through to each, and the count on the header button (#311)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 17:42:25 -04:00
SalastilandClaude Sonnet 5.5 87ea3b8c31 Media: several pictures in one message lay out as a grid; SPOILER_ files are blurred until clicked (#304)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 17:39:11 -04:00
SalastilandClaude Sonnet 5.5 a59c1abc25 Composer: one box with the + on the left and emoji, stickers, voice and Send on the right; Send only when there is something to send (#306)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 17:34:06 -04:00
SalastilandClaude Sonnet 5.5 15e7061f97 Attachment tray: cards with name and size, spoiler toggle, token-coloured remove; dropped files go into the tray (#308)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 17:29:17 -04:00
SalastilandClaude Sonnet 5.5 99fd0d350f Emoji picker: arrow-key walking, Enter picks the top result, skin tones kept in prefs (#313)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 17:22:16 -04:00
SalastilandClaude Sonnet 5.5 817cb1e6fc Lightbox: step through the conversation's media, wheel/pinch zoom with panning, Copy picture, grows in (#312)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 17:17:58 -04:00
SalastilandClaude Sonnet 5.5 28d8b3c54e Autocomplete: one style for names, commands and emoji; member pictures; :shortcode emoji while typing (#309)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 17:12:23 -04:00
SalastilandClaude Sonnet 5.5 f48185e67a Reply bar: its own surface, the author's face, Esc to cancel, focus on reply, @ ping switch on Discord (#307)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 17:08:01 -04:00
SalastilandClaude Sonnet 5.5 9ba630cb17 Shift+Enter makes a new line, and pasted line breaks stay (#305)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 17:00:39 -04:00
SalastilandClaude Sonnet 5.5 5cb30cec24 Dialogs: a shared frame with a close button, focus kept inside and handed back, scrim below the title bar (#315, partly)
lib/dialogs.ts watches for anything marked role=dialog aria-modal and gives it the keyboard: it takes focus when it opens, Tab and Shift+Tab stay inside the topmost one, and closing returns focus (to the message box when what had it is gone). That covers every dialog at once. Modal.tsx is the common frame - heading, X, Escape on the layer stack, growing in - and the reason prompt, the export dialog and the leave confirmation use it. The .modal-scrim stops under the title bar so the window stays draggable.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 16:54:49 -04:00
SalastilandClaude Sonnet 5.5 6d8a050271 Motion pass: hover fades, turning fold arrows, arriving messages, reaction pop, overlays growing in (#283)
Rows, buttons and menu items fade between states on the shared durations. The fold arrow rotates instead of being swapped. A message said a moment ago arrives (fade and rise); history does not. A reaction that is new or has gone up pops. Popovers, pickers, dialogs and the switcher grow in, scrims fade, and the jump-to-present pill slides up. All of it stands still when the system asks for reduced motion.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 16:51:43 -04:00
SalastilandClaude Sonnet 5.5 55fd19282e Open a conversation at its first unread, with a bar that says how many are above (#301)
Opened with a lot unread, the log starts at the New line rather than at the end. While that line is out of sight above, a strip along the top says how many new messages since when and offers Mark as read; clicking the words goes back to the line. The line goes where reading stopped (the time last marked read) when the conversation has not been loaded yet. The harness now sweeps up anything a run leaves behind.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 16:48:41 -04:00
SalastilandClaude Sonnet 5.5 fcb7df5d3a Context menu: place it by its layout size, not the size it is drawn at mid-animation
The entry animation scales the menu, so measuring it with getBoundingClientRect parked it a few pixels past the window's edge once it reached full size. The layout check caught it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 15:07:08 -04:00
SalastilandClaude Sonnet 5.5 4065963640 Context menu: keyboard navigation, checked items, shortcut hints, entry animation, anchored to its button (#317)
Arrow keys, Home and End move through the entries, Enter does the focused one, Tab or Escape closes it and gives focus back. role=menu and menuitem(checkbox). An entry can be checked (the status menu uses it instead of appending a tick to the label) or show its shortcut. It fades and scales in, closes when the window is left or something scrolls, and a menu button's menu opens beside the button.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 15:05:53 -04:00
SalastilandClaude Sonnet 5.5 2d1b8beb8d Toasts: slide in and out, three kinds, pause on hover, fold repeats, cap at three, optional action (#318)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 15:02:16 -04:00
SalastilandClaude Sonnet 5.5 33129f9056 Thread panel: reads the display settings, groups replies, says '1 reply', follows the newest reply (#269)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 15:00:11 -04:00
SalastilandClaude Sonnet 5.5 c5e761924a Escape closes the top layer only; full-page panels close on Escape (#270)
Every popup, dialog and panel registers in the order it opened (lib/layers.ts) and Escape goes to the last one, not to all of them. Twenty-odd window-level Escape listeners moved onto it. Settings, Accounts, Downloads and Join close on Escape, under whatever opened over them. A field that handles Escape itself still wins.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 14:57:51 -04:00
SalastilandClaude Sonnet 5.5 2c8609ca98 Buttons: primary, subtle and one danger; the restarts ask first (#316)
Cancel is the quiet button next to a filled one, in every dialog; Decline on an invitation is quiet too. There is one filled red danger button, with the outline version in theme.css gone. Restart Tor and Restart daemon ask again in place.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 14:53:09 -04:00
SalastilandClaude Sonnet 5.5 3a7677ef16 Comfy messages in Discord's shape; amber mentions, one code and quote style, marked system lines, a red NEW pill (#295, #302)
A 40px picture, the name with its time beside it, air above each new speaker, and the time of a follow-up line in the picture's column on hover. Classic keeps its time column and bubbles are unchanged. A mention row is amber instead of the selection blue. Quotes, code blocks and inline code have one look each. Join, part and topic lines get a mark in the picture's column so their text lines up. The New divider is a red rule with its marker at the right. The harness's rail selector uses aria-label now that tiles have no title.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 14:50:50 -04:00
SalastilandClaude Sonnet 5.5 843288d61a Voice channel list: faces, muted and deafened marks, a speaking ring, a collapsible heading (#293)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 14:45:56 -04:00
SalastilandClaude Sonnet 5.5 c912ed3522 Member list: faces, presence dots, click for the profile (#288)
Where the service reports presence each member gets their picture and a dot (idle amber, do-not-disturb red, offline a hollow ring). Clicking a member opens their profile; Shift+click, or the menu, mentions them. The Discord daemon now sends each member's avatar.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 14:43:09 -04:00
SalastilandClaude Sonnet 5.5 8072b2496b Server rail: mention counts, folders that say what is waiting and stay open, instant name flyout, pill growth, a + tile; red close button (#285, #291)
A server tile with a mention shows how many are waiting in the corner its service mark sat in. A closed folder gets the pill and the summed count. Which folder is open is remembered. The browser's slow tooltip is replaced by an instant flyout in the app's style, the pill grows on hover, and a dashed + tile at the foot of the list opens Join. The close button goes red on hover and maximise/restore have their own glyphs.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 14:35:55 -04:00
SalastilandClaude Sonnet 5.5 6a82c3261c Pending sends dim only their text and failed ones can be deleted; one set of dots; short ageing times; Up-to-edit and a proper editor (#272, #277, #278, #280, part of #300)
A pending row dims what was said, not the upload ring beside it, and a failed send has Delete beside Retry. Presence dots share one palette and one stylesheet (do-not-disturb red, offline and away a hollow ring, connecting pulsing and no longer labelled Offline), also used by the server header's connection dot. Relative times in the column are short (now, 8m, 3h, Yest, Mar 3) and age on one shared timer. Up arrow in an empty box edits your last message; the editor is multi-line, stays open when it loses focus, has a hint, and an empty save asks about deleting.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 14:31:41 -04:00
SalastilandClaude Sonnet 5.5 6598043483 Six bugs from the UI audit: nested voice button, folded categories, muted mentions, overlapping corners, typing strip, pinned service marks (#271, #273, #274, #275, #276, #281)
The voice row's Disconnect is a sibling of the join button, not inside it. A folded category keeps its unread channels visible and counts what is waiting, not how many channels it has. A muted channel or server still shows a mention, and a quiet muted row dims. Toasts move to the top right so they cannot sit under or over the file panel or a verification. The typing strip always keeps its line and has animated dots. The pinned page marks each room with its service.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 14:26:21 -04:00
SalastilandClaude Sonnet 5.5 57e185645f Hover toolbar: fades in, reachable by keyboard, Edit and Copy, recent reactions (#298)
It is always in the page and see-through until the row is pointed at or has focus in it, so it fades and its buttons can be tabbed to. Gone from join lines, notices and unsent messages. Your own messages get Edit; every message with text gets Copy text; the four reactions are the account's recent ones topped up with the usual. Room above the first line so it is no longer clipped.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 14:18:33 -04:00
SalastilandClaude Sonnet 5.5 0033955adf Copy and Copy text on every message; Copy message link on Discord (#299)
The row's own context menu replaced the window's, so right-clicking highlighted text offered no Copy. It now leads with Copy for the selection, then Copy text for the whole message. Discord gets Copy message link beside Open in Discord, from the daemon's existing link.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 14:16:01 -04:00
SalastilandClaude Sonnet 5.5 bf45147b47 Replies jump to the original; names and pictures open the profile (#296, #297)
A reply's whole preview line goes to the message it answers, bringing it into view first and saying so if it is gone. It shows the author's picture and name colour where the original is loaded. A name or avatar opens the profile card, as in Discord, Slack and Element. The highlight on a jumped-to row now fades out as well as in.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 14:14:35 -04:00
SalastilandClaude Sonnet 5.5 ad0f0c08cf Voice panel: a horizontal microphone bar, the connection's latency, and a share button (#292)
The three rising strokes looked like phone signal but were the microphone level. It is now a bar along the panel that fills as you speak (polled ten times a second, on a square-root scale between a quiet room and loud speech), with the voice server's round trip beside the title in green, amber or red. Discord calls get a screen-share button.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 14:11:20 -04:00
SalastilandClaude Sonnet 5.5 1eef37ee76 Ctrl+K quick switcher and keyboard navigation (#287)
Ctrl+K opens a switcher over every account's conversations: what is waiting first, then a ranked search by name and place. Alt+Up/Down move through the channel list as drawn, Alt+Shift+Up/Down to the next with something waiting, Ctrl+, opens Settings. A search button sits in the main window's title bar.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 14:06:28 -04:00
SalastilandClaude Sonnet 5.5 2745a2f532 Mark as read on channels, categories and servers; Escape reads what you are in (#286)
Channel rows and the header menu offer Mark as read while something is waiting. Every category heading has a menu now, with Mark as read first (custom headings keep Rename and Remove). A server tile's menu leads with it too. Escape reads the conversation you are in and goes to its end, Shift+Escape reads the server - from the message box too, but never when a menu, dialog or picker is open or another field has focus.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 14:02:36 -04:00
SalastilandClaude Sonnet 5.5 b2464e4b3a Date separators in the message list (#294)
A centred "Today", "Yesterday" or full date where the day changes, and on the first line drawn. Days are compared by the wall clock, and lines with no time are ignored.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 13:58:49 -04:00
SalastilandClaude Sonnet 5.5 4ca37dd3ed Unread channels read as unread; mentions are red; one badge style (#284)
Unread rows go bold with a bar at the edge. Mention badges (channel rows, rail corners, the mentions button) are red with white text instead of the selection blue. The five differently sized badges share one rule in two sizes.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 13:56:16 -04:00
SalastilandClaude Sonnet 5.5 3cdfe37c6f Apply the design tokens across the stylesheet; one class name per thing (#282)
Colours, shadows, layers, transition durations, radii, font sizes and weights now come from theme.css instead of literals. Timestamps and reply previews use the quieter text colour. The status dot in the join dialogs and the voice panel's mic bars no longer share class names with the avatar's presence dot and the voice recorder's level bar. Dead rules removed; icons are drawn at their own optical size. A test fails if the stylesheet uses an undefined custom property.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 13:50:21 -04:00
SalastilandClaude Opus 5.5 9db25c8d73 Define the tokens the stylesheet used but never had; add danger, mention, motion, shadow, layer and type tokens (#268, part of #282)
Seven undefined custom properties made borders fall back to the text colour (ExportDialog's dates, the emoji picker, the history gap, the voice play button); they now point at real tokens. theme.css gains visible hover/pressed/selected fills, red and amber, muted text, motion, shadow, z-index, radius, font-size and weight tokens, a keyboard focus ring and a global reduced-motion rule. Icon buttons hover neutral instead of blue.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 21:01:37 -04:00
SalastilandClaude Opus 5.5 88375ba485 UI screenshot harness for before/after pictures (#326)
scripts/ui-shots.mjs photographs a fixed list of scenes from an isolated copy of the app (scratch home, its own daemon, a fake IRC server with several days of history) and records motion as mp4. Discord-shaped screens come from a test-only state hook that exists only when main is started with MOHO_UI_SHOTS=1. The launch and fake-server code is shared with the layout check in scripts/lib/harness.mjs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 20:52:58 -04:00
SalastilandClaude Sonnet 5.5 9888c8050c Staged files go to Discord as one message; the upload ring fills as it uploads
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-07 19:57:12 -04:00
SalastilandClaude Sonnet 5.5 6edae7a7f0 Sneedchat: pictures posted back to back by a script no longer merge into one URL
[url=X][img]X[/img][/url] pairs were collapsed to a bare URL with no separator, so two in a row became one address that loaded neither.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-07 19:46:19 -04:00
SalastilandClaude Sonnet 5.5 a8d9f419ad Mirror script: the 'does the release exist yet' probe no longer prints a traceback when it does not
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-07 19:30:44 -04:00
Salastil fb424ca763 Merge branch 'development' 2026-10-07 18:06:12 -04:00
SalastilandClaude Sonnet 5.5 6c6de335d1 Layout check finds the channel row by its title: the name no longer carries the #
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-07 18:06:12 -04:00
Salastil 92712363bd Merge branch 'development' 2026-10-07 17:47:59 -04:00
SalastilandClaude Sonnet 5.5 25c362a194 moho 1.0.0-rc.2
Second release candidate: YouTube cards for every kind of video, the formatting strip and right-click menu in the message box, typing anywhere, one # per channel row (#237, #265, #266, #267).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-07 17:47:46 -04:00
SalastilandClaude Sonnet 5.5 ccdf09044a Channel rows say # once: the glyph is the hash, so the name drops its own
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-07 17:41:21 -04:00
SalastilandClaude Sonnet 5.5 b5f920f1ff Typing anywhere in the window goes into the message box
Printable keys, paste and coming back to the window focus the box when nothing else is being typed into and no dialog is open, as other chat clients do.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-07 17:35:23 -04:00
SalastilandClaude Sonnet 5.5 9ca477cc54 Formatting for selected text in the message box (#267)
A strip appears over a selection offering what the service can carry: Discord Markdown (bold, italic, underline, strike, code, spoiler, quote), Matrix the subset its daemon converts, Sneedchat BBCode with colour, IRC control codes with colour, Kick nothing. The box shows the formatting; it becomes the service's markup only when sent. Ctrl+B/I/U go through the same path. IRC strikethrough and monospace now render in received messages too.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-07 15:18:49 -04:00
SalastilandClaude Sonnet 5.5 b2a62101a6 Right-click menu where text is typed, with spelling suggestions (#265, #266)
Chromium says what is misspelled and what could replace it; the window draws the menu in the app's own style: suggestions and Add to dictionary, then Undo, Redo, Cut, Copy, Paste, Delete, Select all. Every field gets it, main window and popouts alike.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-07 15:18:49 -04:00
SalastilandClaude Sonnet 5.5 03687965f4 Update nobilis: YouTube cards survive Tor exits being refused (#237)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-07 15:06:42 -04:00
SalastilandClaude Sonnet 5.5 916abd4b18 Every YouTube video gets the card: live and premiere thumbnails fall back instead of vanishing (#237)
Live broadcasts and unstarted premieres have no hqdefault picture; the 404 hid the whole embed. Try the live and smaller pictures, then show a dark frame. Also recognise watch links with v= after other parameters, music.youtube.com and youtube-nocookie.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-07 15:00:40 -04:00
SalastilandClaude Opus 5.5 9967d8f776 Every version tag is a full release, release candidates included (#239)
v1.0.0-rc.1 was published as a pre-release, which GitHub does not show as
the repository's release. Every version tag now publishes a full release
marked "Latest". The already-published v1.0.0-rc.1 was changed to match,
for moho and nobilis, on GitHub and Gitea.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-04 16:58:58 -04:00
SalastilandClaude Opus 5.5 dc80485390 Settings shows "development" for development builds, the version for releases
package.json's version is what a release will be, not what a build from
the development branch is - so About said 0.1.0, then 1.0.0-rc.1, for
builds that were neither. scripts/version-label.mjs decides once, for the
window (__APP_VERSION__) and the daemon (NOBILIS_CHANNEL, exported by the
daemon build scripts): the version for CI building its v<version> tag, a
checkout on that tag, or the master branch; "development" for anything
else.

A development build is never offered a newer release: "development" is not
a version to compare.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-04 16:54:25 -04:00
SalastilandClaude Opus 5.5 3bc9003711 Build only for version tags (#239)
A push to master and a tag on it were two push events, so every release
was built twice: once as master's check, once to publish. Releases are
versions only now, so master pushes build nothing. A branch can still be
checked by starting the workflow by hand from the Actions tab - a run that
is not for a tag builds and tests everything and publishes nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-04 16:44:15 -04:00
256 changed files with 25502 additions and 3227 deletions
+45 -31
View File
@@ -1,10 +1,13 @@
# Builds moho and, for a pushed version tag (v1.0.0), publishes the release.
#
# A push to master builds and tests everything the same way and publishes
# nothing: it is the check that master is releasable. Only a tag publishes,
# Builds moho for a pushed version tag (v1.0.0) and publishes the release,
# with the notes for that version taken from CHANGELOG.md and its files named
# after the version (#239). A tag with a pre-release suffix (v1.0.0-rc.1)
# publishes as a pre-release, to rehearse a release without making one.
# after the version (#239). Every version tag publishes a full release, marked
# GitHub's "Latest" - a release candidate (v1.0.0-rc.1) included, so it is
# shown on the repository's page like any other release.
#
# Tags only. A push to master builds nothing: master and a tag on it were two
# push events, so every release was built twice. To check a branch without
# releasing it, run this by hand from the Actions tab - a run that is not for
# a tag builds and tests everything and publishes nothing.
#
# The rolling `latest` build this file used to keep is retired: there are
# versions now, and only versions.
@@ -32,22 +35,12 @@ name: release
on:
push:
branches: [master]
# A version: built, then published with its changelog. The path filter
# below does not apply to tags.
tags: ['v*']
# A README edit should not cost forty minutes of compute. Note the absence
# of .github/** - editing this workflow SHOULD trigger a run that
# exercises the edit. A submodule bump touches the `nobilis` gitlink,
# which is a path change, so a daemon update is never filtered out.
paths-ignore:
- '**/*.md'
- 'LICENSE'
workflow_dispatch:
# Newest push wins on master: a check of a commit master has already moved
# past is of no use. A tag is its own ref, so a release is never cancelled by
# a push to master, nor one tag by another.
# A hand-started check of a branch gives way to a newer one of the same
# branch. A tag is its own ref and is never cancelled, by a check or by
# another tag.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref_type != 'tag' }}
@@ -106,9 +99,8 @@ jobs:
build: ${{ steps.stamp.outputs.build }}
steps:
# nobilis is a submodule, and on master its URL is the public GitHub
# one - on development it points at Gitea. That is the other reason this
# workflow only ever runs on master.
# nobilis is a submodule at a commit its own master holds, which is why
# this workflow only ever runs on master.
#
# Depth 1 is enough: nobilis/build.rs only asks git for a short sha and
# a dirty flag, and falls back to "unknown" rather than failing.
@@ -277,6 +269,32 @@ jobs:
- name: layout check
run: xvfb-run -a npm run test:ui
# The tray cannot be looked at, so this reads what main gave it - icon,
# tooltip, menu - and presses its entries, including Restart moho, which
# has to bring back both the window and a daemon of its own.
- name: tray check
run: xvfb-run -a npm run test:tray
# GIFs: that a message carrying one is drawn as the picture, that a page
# nothing could read stays a link, and that the picker finds and sends.
# Needs ffmpeg, for the pictures it plays.
- name: gif check
run: |
sudo apt-get install -y --no-install-recommends ffmpeg
xvfb-run -a npm run test:gif
# Scrolling through a long unread run: that the view stays where it is
# read when something above it changes size, with the "new messages" bar
# up over the top of the log.
- name: scroll check
run: xvfb-run -a npm run test:scroll
# Kick event cards: where they sit in each layout, measured against a
# chat line beside them - the picture's column, the name's place, and the
# air above.
- name: kick card check
run: xvfb-run -a npm run test:kick
# MOHO_BUILD is computed inside the npm script - a shell assignment on
# the electron-builder line - so it cannot be injected from out here.
# The only way to control what the artifact is called is to guarantee
@@ -321,7 +339,7 @@ jobs:
retention-days: 7
# The daemon on its own, for nobilis's release - attached to that by
# scripts/mirror-release.sh, since this job's token can only release
# scripts/release-nobilis.sh, since this job's token can only release
# here. Not an asset of moho's release: the AppImage already carries it.
- uses: actions/upload-artifact@v4
if: github.ref_type == 'tag'
@@ -584,17 +602,14 @@ jobs:
cat "$RUNNER_TEMP/notes.md"
# Created as a draft and published once its files are attached, so
# nobody lands on a version with nothing to download. A pre-release
# (a suffix after the version) is never GitHub's "Latest". A tag pushed
# nobody lands on a version with nothing to download. Always a full
# release and GitHub's "Latest", a release candidate included - shown on
# the repository's page, not tucked away as a pre-release. A tag pushed
# again finds its release there: the files are replaced, the notes too,
# since the changelog is where they are corrected.
- name: publish the version
run: |
version="${GITHUB_REF_NAME#v}"
case "$version" in
*-*) kind="--prerelease --latest=false" ;;
*) kind="--latest" ;;
esac
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
gh release edit "$GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/notes.md"
gh release upload "$GITHUB_REF_NAME" artifacts/* --clobber
@@ -602,6 +617,5 @@ jobs:
gh release create "$GITHUB_REF_NAME" --draft --verify-tag \
--title "moho $version" --notes-file "$RUNNER_TEMP/notes.md"
gh release upload "$GITHUB_REF_NAME" artifacts/*
# shellcheck disable=SC2086 - two flags, deliberately split
gh release edit "$GITHUB_REF_NAME" --draft=false $kind
gh release edit "$GITHUB_REF_NAME" --draft=false --latest
fi
+71 -12
View File
@@ -6,9 +6,10 @@ How the two halves fit together, what the daemon speaks, and where it puts thing
## Why two parts
- **`nobilis`** (`nobilis/`, Rust) speaks each protocol directly and translates it into a small
unified JSON model (accounts, buffers, messages) over a Unix socket. It never exposes
protocol-specific concepts to the client; it speaks the unified model, and each backend module
happens to be how that model gets populated for a given service.
unified JSON model (accounts, buffers, messages) over a Unix socket. What every service has in
common - sending, editing, reacting, backlog, search - is one set of calls and events. What
only one service has - a Matrix room's power levels, a Discord poll, a Kick prediction - is a
call of its own, so a client never has to pretend the services are the same where they are not.
- **The Electron client** (`src/`) spawns and supervises `nobilis` and renders whatever it reports.
Keeping the protocol work in a separate long-lived daemon means connections, Tor circuits, and
@@ -42,14 +43,29 @@ Three panes: an account-grouped buffer sidebar, the message log, and a member li
sidebar and member list independently foldable. Closing the window hides it to the tray; nobilis
keeps running, so connections and unread tracking survive.
- **Message rendering** - an IRC-style log by default, with nick colouring, BBCode and
markdown, spoilers, code and quote blocks, inline image/video/YouTube embeds, custom emoji,
emotes and stickers (Discord's animated Lottie ones included), reactions, replies, inline
edit, and Sneedchat's bundled smilies. "Comfy" groups consecutive messages under one avatar,
and "Bubbles" draws them as chat bubbles.
- **Settings** - a category rail covering General, IRC, Sneedchat, Tor, Discord, Matrix, Kick
- **Message rendering** - an IRC-style log by default ("Classic"), with nick colouring, BBCode and
markdown, spoilers, code and quote blocks, inline image/video embeds, custom emoji, emotes and
stickers (Discord's animated Lottie ones included), reactions, replies, inline edit, and
Sneedchat's bundled smilies. "Comfy" groups consecutive messages under one avatar, and "Bubbles"
draws them as chat bubbles. A link is described by the daemon for every service, not only the
ones that do it themselves: a YouTube link becomes a titled card, a Tenor or Giphy link is drawn
as the GIF, and a GIF picker sends one from the composer. What somebody did on a Kick stream -
KICKs sent, subscriptions gifted and to whom, a raid, a redeemed reward - is drawn as a card.
- **Settings** - a searchable rail of categories: Accounts and Downloads; General, Appearance,
Notifications and Keybinds; a page for each network (IRC, Sneedchat, Tor, Discord, Matrix, Kick);
and About. Message-kind filters (joins, parts, mode changes) are applied client-side, so toggling
one takes effect immediately without a reconnect.
- **Moving around** - Ctrl+K opens a quick switcher over every conversation on every account.
A conversation opens at its first unread message when the unread run is short, and at its end,
with a bar to go back by, when it is long - as Discord does it. Escape marks the conversation
read and Shift+Escape the whole server; a muted conversation stays quiet but still counts the
times your name was used in it.
- **Presence and the tray** - the status menu offers only the statuses each service has (Discord
all four, IRC away and back, Matrix online and offline) and sends nothing to a service that
has no such thing. Do not disturb silences desktop notifications everywhere and is Discord's
real do-not-disturb on Discord. When nothing has been used for a while an account is put on
Idle and back, from the system's idle time where it will say and moho's own windows where it
will not. The tray draws a number counted by the window by the rule the server rail uses.
- **Per-protocol join pages** - each service's mechanism is genuinely different (IRC joins by
channel name, Discord offers the friends list and sends joining, adding a friend and making
a server to discord.com - see below - Matrix takes a room address
@@ -165,7 +181,8 @@ called `subscribe` for that buffer.
gateway. Messages with edit/delete/reactions/replies/forwarding, threads and forum posts, slash
commands with buttons, menus and modal forms, polls that can be voted in, stickers sent and
drawn, pinned messages, Discord's own search, invites, scheduled events, AutoMod's verdicts,
and server and channel mutes read from the account itself.
and server and channel mutes - read from the account itself, and set on it when a channel is muted
here, so a phone is quiet too.
Voice and video are moho's own connections rather than a library's: voice, the camera and Go
Live streams - hosted and watched, with sound - over Discord's RTP with DAVE end-to-end
@@ -197,10 +214,50 @@ called `subscribe` for that buffer.
is a renegotiation rather than a new call.
- **Kick** - the streaming site's chat, over its Pusher socket. Joins by streamer handle, imports
the account's follows, and carries the three emote tiers with subscriber gating (and 7TV and
BTTV beside them), redemptions, subscriptions, gifted subs and raids, moderation, chat modes,
and polls and predictions that can be answered rather than only watched. The stream itself
BTTV beside them), KICKs sent to a streamer, redemptions, subscriptions, gifted subs with who
received them, raids, moderation, chat modes, and polls and predictions that can be answered
rather than only watched. The stream itself
plays in the window over HLS, with its VODs and clips.
- **Twitch** - a streamer's chat over Twitch's IRC service (read with no account, sent through the
official API), with emotes (Twitch's own, animated ones, and the channel's whole set in the picker with the ones the
account cannot use greyed out; BetterTTV, FrankerFaceZ and 7TV are browser extensions Twitch does not carry, so
they are not drawn), badges as pictures, cheers, replies and threads, subscriptions, gifts and
raids, raids out, polls and predictions as they happen, moderation, whispers, shoutouts and blocking.
One sign-in on twitch.tv gives the account its API token and the website session its player takes, so
the stream plays in the window as the person (their Turbo or subscriptions count) with Twitch's ad
breaks said and never skipped.
### Notifications are cards
Anything a service *says* rather than somebody *saying* - a join or part, a topic or mode change, a kick,
a ban, a timeout, a room's slow mode, a removed message, a raid, a gift, a pinned message, a warning, a
notice from the server itself - is drawn in the window as a **card**: the same plate Kick's and Twitch's
events use (`KickEventCard.tsx`), with a mark for what it was, its subject in bold, and what happened.
This holds for every protocol, and **for every protocol added later**; it is a rule of the design and not
a styling choice of any one backend.
How a backend takes part, which is the whole of its job:
- It sends the line with a kind that is not speech (`system`, `join`, `part`, `topic`, `mode`, `kick`,
`moderation`, and so on - `isChatKind` in `lib/util.ts` is the test). The window then draws a
`NotificationCard` for it on its own: a mark chosen by kind (`SYSTEM_MARKS` in `MessageRow.tsx`), the
line's own words, a warning in red. Nothing else is needed, which is why a new protocol's notifications
are cards from its first connection.
- It may say more by attaching an embed to the line (`kind: "moderation"`, `"raid"`, `"subscribe"`,
`"gift-sub"`, `"stream"`, ...) with the subject in `author`, what happened in `title`, who did it in
`description` where that is known, and a Material Symbols glyph in `icon`. A penalty (`gavel`,
`timer_off`, `delete`, `block`) draws in the danger colour, anything else in the window's accent.
- It says each thing **once**. What the service already tells the whole room (a timeout, a mode change) is
not also written by the client that caused it; only what happens to this account alone is written
locally, as a card.
- A line that is not speech is **never** left as bare grey text, and a card is never used for a person
speaking: the divide is whether somebody said it.
A new protocol is not finished until its joins, parts, topic changes, moderation, mode changes and
service notices come out of it as cards. `scripts/shot-notification-cards.mjs` draws every kind of line
beside ordinary chat and a moderation card, to check that they are the same sort of thing.
## On-disk state
| Path | Contents |
@@ -209,6 +266,8 @@ called `subscribe` for that buffer.
| `~/.config/nobilis/scrollback.db` | SQLite scrollback |
| `~/.config/nobilis/matrix-crypto/` | Matrix E2EE device keys and Olm sessions |
| `~/.config/nobilis/voice.toml` | microphone, speakers, volumes and voice processing |
| `~/.config/nobilis/highlights.toml`, `ignores.toml` | the words that count as a mention, and who is ignored |
| `~/.config/nobilis/dcc.toml`, `irc-sts.toml` | DCC settings, and the IRC networks that have pinned TLS (STS) |
| `~/.config/nobilis/tor-state/`, `tor-cache/` | the embedded Tor client's state |
| `~/.cache/nobilis/` | re-derivable media caches (Matrix media, Discord thumbnails, stickers and sounds, Kick emotes, Sneedchat avatars and attachments), each held to a size and to thirty days, and fetched again when a missing file is looked at - see `nobilis/src/media_cache.rs` |
| `~/.cache/moho/moho.log` | the app's log, the daemon's included |
+263
View File
@@ -17,6 +17,56 @@ Each section has two parts:
that made it, including dependency updates
(`node scripts/changelog-deps.mjs <previous tag>` lists those).
## [1.2.0]
### Major features
- **Twitch, fully ported:** a streamer's chat beside the stream, in the same window as everything else. Read with no account at all, or sign in once and have chat, video, channel points, moderation and whispers from that single sign-in.
- **Twitch chat:** messages with name colours, badges, cheers with their pictures, replies and threads, Twitch's own emotes (animated ones move), first-time and returning chatters, and subscriptions, gifts, raids and announcements drawn as cards. The channels you follow are opened the first time you sign in, and the member list counts who is talking.
- **Twitch video:** the stream plays in the window the way Kick's does, in the conversation, in the corner or in a window of its own, with a resolution menu, captions where the streamer sends them, and Twitch's ad breaks said instead of left to look like a fault. A subscriber's or Turbo account plays without them.
- **Twitch emotes:** the picker shows the selected channel's whole set with the ones you cannot use greyed out and the usable ones first, then Twitch's own, then a section for each other channel that gives you emotes, each with the channel's picture. What you send leaves as its word and shows as its picture in your own line; an emote that lapses leaves the picker, and an emote that cannot be sent in a conversation is not offered as a recent one on any service.
- **Twitch channel points:** the balance on a plaque, one panel over the chat that lists a channel's rewards cheapest first and redeems them (Highlight My Message, Choose an Emote to Unlock, Unlock a Random Sub Emote, Modify a Single Emote, and the streamer's own, with the text a reward asks for), and watching a stream in moho now earns points because the player reports that it is being watched, once a minute and only while the picture is playing, with a switch in Settings.
- **Twitch polls, predictions and raids** as they happen, as cards: vote in a poll and bet on a prediction from the card, and go along with a raid.
- **Twitch moderation and whispers** from one sign-in: removing messages, timeouts, bans, the room's modes, announcements, shoutouts, blocking, and whispers as conversations of their own, from menus and from slash commands, for the channels where the account has the rights.
- **Twitch's own sections in the list:** Channels and Direct Messages are fixed headings, and a whisper is a conversation named for the person, with a badge to tell it from a channel.
- **Kick and Twitch share one rewards panel** for channel points, opened from the plaque over the chat like the quick switcher.
### Fixes, patches and changes
- Every notification a service sends is drawn as a card in the same style, and that is the rule for protocols to come ([moho `9ef8a67`](https://github.com/Moho-Chat/moho/commit/9ef8a67))
- IRC explains a channel's modes in words when you join and when one changes, so `+Cnrt` is not left for a new user to decode ([moho `34fcddf`](https://github.com/Moho-Chat/moho/commit/34fcddf), [nobilis `28481f3`](https://github.com/Moho-Chat/nobilis/commit/28481f3))
- Sneedchat greentext is drawn in 4chan's green, and a sent greentext line is matched with the site's coloured echo ([moho `bb2edf5`](https://github.com/Moho-Chat/moho/commit/bb2edf5))
- Recent emoji are offered only where they can be sent, in every conversation of every service ([moho `aacbde7`](https://github.com/Moho-Chat/moho/commit/aacbde7))
- The channel list, the member list, the stream above the conversation and the corner panel can be resized by dragging their edges ([moho `d1e39f1`](https://github.com/Moho-Chat/moho/commit/d1e39f1))
- Kick video plays about 3.5 seconds behind instead of 20, without rebuilding the picture on every new address, and has a resolution menu and live captions ([moho `3aff255`](https://github.com/Moho-Chat/moho/commit/3aff255), [nobilis `9f0ca86`](https://github.com/Moho-Chat/nobilis/commit/9f0ca86))
- Kick video no longer guesses prefetched segments for streams whose segments differ in length ([moho `a7ef798`](https://github.com/Moho-Chat/moho/commit/a7ef798))
- Kick shows threads from replies and removed messages, struck through, left out or replaced as chosen ([moho `8e3d110`](https://github.com/Moho-Chat/moho/commit/8e3d110), [nobilis `bb318cf`](https://github.com/Moho-Chat/nobilis/commit/bb318cf))
- Kick's recently-talking count keeps rising past the length of what is listed ([moho `987644e`](https://github.com/Moho-Chat/moho/commit/987644e), [nobilis `41e93b8`](https://github.com/Moho-Chat/nobilis/commit/41e93b8))
- Kick's points plaque follows its push, and badge pictures are drawn beside names ([moho `dd44e76`](https://github.com/Moho-Chat/moho/commit/dd44e76), [nobilis `4c17be0`](https://github.com/Moho-Chat/nobilis/commit/4c17be0))
## [1.1.0]
### Major features
- **Steam, fully ported:** friends and direct messages, group chats with their rooms, members, roles and permissions, voice rooms and one-to-one voice calls, all in the same window as everything else.
- **Steam friends:** the friends list with presence and what each person is playing, friend requests and invitations, blocking and unblocking, nicknames, and profile cards; Steam's own friend folders are the headings on the Steam account's own page, made, renamed, deleted and filled on Steam, and what people were last called is kept between runs so a start shows names and not numbers.
- **Steam messages:** emoticons, stickers and reactions in both directions, pictures and files (spoilers too) into friends and group rooms, mentions with `@name`, `@all` and `@online`, link cards, message history that stops asking once it reaches the start, and Steam's own wording for every refusal.
- **Steam group chats:** joining by link, a friend's invitation answered from the Invitations tile, leaving, bans, kicks, role giving, notification levels, a group's picture, and the group's voice rooms with who is in them.
- **Steam voice:** group voice rooms and one-to-one calls over the same relay the Steam client uses, with the speaking indicator, and a call that retries the way the client does when Steam fails it in passing.
- **Sign in to Steam two ways:** a password with Steam Guard, or a QR code scanned by the Steam phone app; the token Steam hands back is kept, not the password.
- **One Invitations tile** for everything that waits on a yes or a no, from every service: Matrix rooms, Steam friend requests and group invitations, Discord requests, and verification.
- **Direct messages sorted under a heading per account** ("Steam - Goons", "Discord - name"), made once and yours to rename, move and delete after that.
### Fixes, patches and changes
- Kick chat moved from Pusher to Kick's realtime service ([moho `042f7b6`](https://github.com/Moho-Chat/moho/commit/042f7b6), [nobilis `c26f968`](https://github.com/Moho-Chat/nobilis/commit/c26f968))
- Fixed a link at the end of a clause taking the clause's punctuation with it ([moho `0770478`](https://github.com/Moho-Chat/moho/commit/0770478))
- Fixed Sneedchat members having no pictures ([nobilis `d1d0770`](https://github.com/Moho-Chat/nobilis/commit/d1d0770))
- The profile card has a Status section ([moho `68684a4`](https://github.com/Moho-Chat/moho/commit/68684a4))
- Comfy message lists follow Discord's layout on every service, and Classic stays names ([moho `82665ee`](https://github.com/Moho-Chat/moho/commit/82665ee))
- Dropping a conversation on a category lights the whole category, not a line above it ([moho `0633a2c`](https://github.com/Moho-Chat/moho/commit/0633a2c))
- Fixed Steam invite links not copying: the page's own clipboard is refused, so they go through the main process like every other copy
## [1.0.0]
### Major features
@@ -27,9 +77,114 @@ Each section has two parts:
- **Per-account Tor and SOCKS5 routing:** Clearnet, Service tunnel or Strict - Strict sends every picture, link check and embed through the account's route too. Or send everything through Tor at once.
- **One mentions inbox across every service,** link previews, YouTube cards that play in place, voice messages, polls, and exporting a conversation to disk.
- **A background daemon** (nobilis) that keeps every connection alive with the window closed.
- **Discord forums:** forum and media channels appear in the channel list and open as a pane of posts - title, tags, the first message's words, replies, age, picture and top reaction - with search, sorting, older posts, and a New Post form
- **A redesigned interface:** Discord-shaped messages with date separators and an unread bar, red mention badges, a Ctrl+K quick switcher, keyboard navigation, a rail with mention counts and folders, a member list with faces, and motion throughout
- **Settings rebuilt:** one click from the gear, a grouped rail with a search over every setting, and new Appearance (layout, spacing, zoom, clock, reduce motion), Notifications and Keybinds pages with a hotkey recorder
- **A one-box message composer:** a + menu, Send only when there is something to send, Shift+Enter for a new line on every service, an attachment tray with spoilers, and dropped files going into the tray
- **Discord notices and permissions:** Discord's own safety notices drawn as the cards they are, and a banner in place of the message box wherever nothing can be written
- **A rebuilt tray:** an icon drawn for the system's colour scheme with the number of conversations waiting on it, a tooltip that says what is waiting, and a menu of what is new, Show/Hide, Status, Settings, Restart moho and Quit - Restart moho brings back both the window and the daemon
- **Status by service:** the plaque and the tray offer only the statuses each service has - Discord all four, IRC away and back, Matrix online and offline, Kick and Sneedchat none - and Do not disturb silences desktop notifications on every service, and is Discord's real mode there
- **GIFs on every service:** a linked GIF is drawn as the picture - silent, looping, without the player - whichever service it came from, and a GIF picker with trending, categories and search sends one from the composer. Klipy, Tenor and Giphy pages are never probed for what they are.
- **Idle detection:** moho notices when nothing has been used for a while, from the system where it can say and from its own windows where it cannot, and puts an account that has an idle on Idle and back. It has a setting for it and for how long, and the plaque says Idle (away) while keeping what you chose checked.
- **Link cards on every service:** a YouTube link gets a titled card with its channel on IRC, Matrix, Kick and Sneedchat as it does on Discord - including through Tor, which YouTube refuses the usual way - and one added in an edit, read back from history, or posted before cards existed gets its card too.
- **Kick events as cards:** KICKs sent to a streamer, gifted subscriptions with who received them, subscriptions, raids, redeemed rewards, bans and timeouts, and the stream going live or ending are each a card in moho's own theme, sitting where a person's message does in every layout.
- **Conversations open where you stopped reading,** as Discord's do: at the first unread when the run is short, otherwise at the end with a bar to go back by, and a return to a conversation no longer lands hundreds of messages up.
### Fixes, patches and changes
- Fixed Kick's KICKs never being heard - they arrive on a subscription that was never opened - and drew KICKs, gifted subscriptions with their recipients, raids and redeemed rewards as cards ([moho `7bae0ea`](https://github.com/Moho-Chat/moho/commit/7bae0ea), [nobilis `95ee3b1`](https://github.com/Moho-Chat/nobilis/commit/95ee3b1))
- Every other Kick event is a card too: subscriptions, bans, timeouts, unbans, a cleared chat, and the stream going live or ending ([moho `f118395`](https://github.com/Moho-Chat/moho/commit/f118395), [nobilis `23489f8`](https://github.com/Moho-Chat/nobilis/commit/23489f8))
- Kick cards start at the picture's edge with the name where a name is in the log, in comfy and bubbles, with the same air above as a message ([moho `64ade92`](https://github.com/Moho-Chat/moho/commit/64ade92))
- A conversation opens as Discord's does, and the line from a visit is dropped when the conversation is left ([moho `39dcba5`](https://github.com/Moho-Chat/moho/commit/39dcba5))
- Fixed an unpinned Kick message never leaving the screen ([nobilis `81a33a1`](https://github.com/Moho-Chat/nobilis/commit/81a33a1))
- Fixed cancelling an outgoing IRC file offer that nobody had accepted doing nothing for the three minutes the offer lasts ([nobilis `b3e92b5`](https://github.com/Moho-Chat/nobilis/commit/b3e92b5))
- Fixed the IRC capability list being cut short when a server sends it over several lines with the continuation mark in the other field ([nobilis `b3e92b5`](https://github.com/Moho-Chat/nobilis/commit/b3e92b5))
- Fixed a removed account leaving six lists behind in memory, among them its silenced channels and what its network said about MONITOR ([nobilis `b3e92b5`](https://github.com/Moho-Chat/nobilis/commit/b3e92b5))
- Fixed a raid of one viewer reading "1 viewers" ([nobilis `b3e92b5`](https://github.com/Moho-Chat/nobilis/commit/b3e92b5))
- Every test was read; those that could not fail now can, tests that checked a copy of the code now check the code, and the untested cases mutation testing found are covered ([moho `8a7e3e7`](https://github.com/Moho-Chat/moho/commit/8a7e3e7), [moho `b0e6775`](https://github.com/Moho-Chat/moho/commit/b0e6775), [moho `47653c5`](https://github.com/Moho-Chat/moho/commit/47653c5), [moho `09c060d`](https://github.com/Moho-Chat/moho/commit/09c060d), [nobilis `2477514`](https://github.com/Moho-Chat/nobilis/commit/2477514), [nobilis `b522fe6`](https://github.com/Moho-Chat/nobilis/commit/b522fe6), [nobilis `b3e92b5`](https://github.com/Moho-Chat/nobilis/commit/b3e92b5))
- Nothing in the code or documentation refers to the maintainer's own server any more ([moho `97611c8`](https://github.com/Moho-Chat/moho/commit/97611c8), [nobilis `1c70d32`](https://github.com/Moho-Chat/nobilis/commit/1c70d32))
- Documentation brought up to date with the settings pages, how a conversation opens, presence, link cards, Kick events and the daemon as it is ([moho `d99ad2b`](https://github.com/Moho-Chat/moho/commit/d99ad2b), [nobilis `6a1bb8e`](https://github.com/Moho-Chat/nobilis/commit/6a1bb8e))
- Added idle detection with a setting for it and for how long ([moho `23c87dd`](https://github.com/Moho-Chat/moho/commit/23c87dd), [nobilis `35ea0b7`](https://github.com/Moho-Chat/nobilis/commit/35ea0b7))
- Added GIFs drawn as pictures on every service, and a GIF picker ([moho `55248eb`](https://github.com/Moho-Chat/moho/commit/55248eb), [nobilis `046fbb4`](https://github.com/Moho-Chat/nobilis/commit/046fbb4))
- Fixed a Sneedchat picture from the forum's uploads host - an AVIF, or an MP4 - not drawing: its domain is taken off and it is fetched through the account's route, clearnet or Tor ([moho `07f4fc2`](https://github.com/Moho-Chat/moho/commit/07f4fc2), [nobilis `ca29a63`](https://github.com/Moho-Chat/nobilis/commit/ca29a63))
- Fixed the tray's number drifting from the client's: it is now counted by the window, by the rail's rule, and told to the tray whenever it changes ([moho `1e314f8`](https://github.com/Moho-Chat/moho/commit/1e314f8))
- Fixed YouTube cards never appearing for an account on Tor: YouTube answers every Tor exit with a 403 on its card lookup, so the video's own page is read when that happens ([moho `8064e79`](https://github.com/Moho-Chat/moho/commit/8064e79), [nobilis `340342b`](https://github.com/Moho-Chat/nobilis/commit/340342b))
- Fixed link cards being lost when a message was edited, and missing from Matrix history, Discord catch-up and older messages ([moho `638b02e`](https://github.com/Moho-Chat/moho/commit/638b02e), [nobilis `dab394f`](https://github.com/Moho-Chat/nobilis/commit/dab394f))
- Fixed scrolling through a long run of unread messages snapping back to where the "new messages" bar appeared ([moho `336335a`](https://github.com/Moho-Chat/moho/commit/336335a))
- The number beside a conversation is what is addressed to you - mentions in a channel, each unread message in a direct message - and the bold name is the sign of activity ([moho `987f31c`](https://github.com/Moho-Chat/moho/commit/987f31c))
- A muted channel keeps its mention count in the list, the rail, the switcher and the tray, and stops the popups; muting a Discord channel or direct message is now set on the account, so a phone is quiet too ([moho `7803cc3`](https://github.com/Moho-Chat/moho/commit/7803cc3), [nobilis `b0fa250`](https://github.com/Moho-Chat/nobilis/commit/b0fa250))
- Reworked the tray icon and menu, and dropped Restart daemon and Stop daemon from it ([moho `24106e5`](https://github.com/Moho-Chat/moho/commit/24106e5))
- Do not disturb now silences an account's desktop notifications and the taskbar flash; the tray no longer has a switch of its own for it ([moho `53fb0f3`](https://github.com/Moho-Chat/moho/commit/53fb0f3))
- Idle from the tray reaches Discord and IRC and leaves Matrix, Kick and Sneedchat as they were; a service is no longer asked for a status it does not have ([moho `c1bcb72`](https://github.com/Moho-Chat/moho/commit/c1bcb72), [nobilis `a8441ab`](https://github.com/Moho-Chat/nobilis/commit/a8441ab))
- Fixed Matrix's rate limit on changing status showing as M_LIMIT_EXCEEDED: a limited request now waits the time the server names, and a longer wait is applied when it is over ([moho `0dfff0d`](https://github.com/Moho-Chat/moho/commit/0dfff0d), [nobilis `43135c1`](https://github.com/Moho-Chat/nobilis/commit/43135c1))
- Fixed the window being pulled back to the open conversation's server whenever you chose another: the daemon link coming up is acted on once, a refresh no longer follows the open conversation, and the tray is told only what changed ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a))
- Matrix's invisible is called Offline in the plaque, its popout and the tray, which is what it sets ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a))
- A room muted on a Matrix account can now be unmuted from the menu, including a mute set from Element ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a), [nobilis `59db82c`](https://github.com/Moho-Chat/nobilis/commit/59db82c))
- Server rail tiles now count the mentions waiting in them rather than the messages, and a muted room counts for nothing, mentions included ([moho `0dc048c`](https://github.com/Moho-Chat/moho/commit/0dc048c))
- Fixed a room unmuted on a Matrix account still being drawn as muted; a mute or unmute the server did not take is now reported with its reason, and Matrix requests to a server that does not answer give up ([moho `95f1784`](https://github.com/Moho-Chat/moho/commit/95f1784), [nobilis `d1bbcaf`](https://github.com/Moho-Chat/nobilis/commit/d1bbcaf))
- Discord requests now give up on a connection that has gone quiet, a history catch-up always releases its place, and the gateway logs what it receives and why a message is dropped ([moho `b151ef8`](https://github.com/Moho-Chat/moho/commit/b151ef8), [nobilis `a34821e`](https://github.com/Moho-Chat/nobilis/commit/a34821e))
- Fixed Discord reconnecting every few seconds after being told it is rate limited: it now waits out the minute Discord asks for ([nobilis `59db82c`](https://github.com/Moho-Chat/nobilis/commit/59db82c))
- Fixed Sneedchat pictures posted back to back by a script merging into one unusable link ([moho `6edae7a`](https://github.com/Moho-Chat/moho/commit/6edae7a))
- Discord files staged together now go as one message, with an upload ring that fills as it uploads ([moho `9888c80`](https://github.com/Moho-Chat/moho/commit/9888c80), [nobilis `9690905`](https://github.com/Moho-Chat/nobilis/commit/9690905))
- Fixed the release mirror script printing a traceback while the release did not exist yet ([moho `a8d9f41`](https://github.com/Moho-Chat/moho/commit/a8d9f41))
- Defined the design tokens the stylesheet used but never had, and applied one set across it ([moho `9db25c8`](https://github.com/Moho-Chat/moho/commit/9db25c8), [moho `3cdfe37`](https://github.com/Moho-Chat/moho/commit/3cdfe37))
- Added a screenshot harness for before-and-after pictures of the interface ([moho `88375ba`](https://github.com/Moho-Chat/moho/commit/88375ba))
- Unread channels now read as unread, mentions are red, and every badge is one style ([moho `4ca37dd`](https://github.com/Moho-Chat/moho/commit/4ca37dd))
- Added date separators to the message list ([moho `b2464e4`](https://github.com/Moho-Chat/moho/commit/b2464e4))
- Added Mark as read on channels, categories and servers, and Escape to read what you are in ([moho `2745a2f`](https://github.com/Moho-Chat/moho/commit/2745a2f))
- Added a Ctrl+K quick switcher and keyboard navigation between channels ([moho `1eef37e`](https://github.com/Moho-Chat/moho/commit/1eef37e))
- Voice panel: a horizontal microphone bar, the connection's real latency, and a share button ([moho `ad0f0c0`](https://github.com/Moho-Chat/moho/commit/ad0f0c0), [nobilis `e0f6169`](https://github.com/Moho-Chat/nobilis/commit/e0f6169))
- Replies jump to the message they answer, and names and pictures open the profile ([moho `bf45147`](https://github.com/Moho-Chat/moho/commit/bf45147))
- Added Copy and Copy text on every message, and Copy message link on Discord ([moho `0033955`](https://github.com/Moho-Chat/moho/commit/0033955))
- The hover toolbar fades in, can be reached by keyboard, and offers Edit, Copy and your recent reactions ([moho `57e1856`](https://github.com/Moho-Chat/moho/commit/57e1856))
- Fixed six interface bugs: a nested voice button, folded categories hiding unread, muted channels hiding mentions, overlapping corners, the typing strip moving the log, and pinned rows missing their service mark ([moho `6598043`](https://github.com/Moho-Chat/moho/commit/6598043))
- Pending sends dim only their text, failed ones can be deleted, and Up in an empty box edits your last message in a multi-line editor ([moho `6a82c32`](https://github.com/Moho-Chat/moho/commit/6a82c32))
- Server rail: mention counts, folders that say what is waiting and stay open, an instant name flyout and a red close button ([moho `8072b24`](https://github.com/Moho-Chat/moho/commit/8072b24))
- Member list: faces, presence dots, and a click for the profile; Discord's member pictures now come with the list ([moho `c912ed3`](https://github.com/Moho-Chat/moho/commit/c912ed3), [nobilis `4099305`](https://github.com/Moho-Chat/nobilis/commit/4099305))
- Voice channel list: faces, muted and deafened marks, a speaking ring and a collapsible heading ([moho `843288d`](https://github.com/Moho-Chat/moho/commit/843288d))
- Comfy messages in Discord's shape, amber mention rows, one code and quote style, and marked system lines ([moho `3a7677e`](https://github.com/Moho-Chat/moho/commit/3a7677e))
- Buttons are primary, subtle or the one danger style, and the restarts ask first ([moho `2c8609c`](https://github.com/Moho-Chat/moho/commit/2c8609c))
- Escape closes only the top layer, and full-page panels close on Escape ([moho `c5e7619`](https://github.com/Moho-Chat/moho/commit/c5e7619))
- Thread panel reads the display settings, groups replies and follows the newest ([moho `33129f9`](https://github.com/Moho-Chat/moho/commit/33129f9))
- Toasts slide in and out, come in three kinds, pause on hover and fold repeats ([moho `2d1b8be`](https://github.com/Moho-Chat/moho/commit/2d1b8be))
- Context menu: keyboard navigation, checked items, shortcut hints, and placement that holds while it animates ([moho `4065963`](https://github.com/Moho-Chat/moho/commit/4065963), [moho `fcb7df5`](https://github.com/Moho-Chat/moho/commit/fcb7df5))
- A conversation opens at its first unread, with a bar saying how many are above ([moho `55fd192`](https://github.com/Moho-Chat/moho/commit/55fd192))
- Added hover fades, turning fold arrows, arriving messages, a reaction pop and growing overlays ([moho `6d8a050`](https://github.com/Moho-Chat/moho/commit/6d8a050))
- Every dialog is built from one shared frame with a close button, kept focus and a scrim below the title bar ([moho `5cb30ce`](https://github.com/Moho-Chat/moho/commit/5cb30ce), [moho `420a2f7`](https://github.com/Moho-Chat/moho/commit/420a2f7), [moho `6a329c6`](https://github.com/Moho-Chat/moho/commit/6a329c6))
- Shift+Enter makes a new line on every service and pasted line breaks stay; IRC sends them as a batch or separate lines, Sneedchat as [br], Kick as one line ([moho `9ba630c`](https://github.com/Moho-Chat/moho/commit/9ba630c), [nobilis `ccc8de6`](https://github.com/Moho-Chat/nobilis/commit/ccc8de6))
- Reply bar: its own surface, the author's face, Esc to cancel, and an @ switch to answer without pinging on Discord ([moho `f48185e`](https://github.com/Moho-Chat/moho/commit/f48185e), [nobilis `918fcfa`](https://github.com/Moho-Chat/nobilis/commit/918fcfa))
- One style for the name, command and emoji lists, with member pictures and :shortcode emoji completion ([moho `28d8b3c`](https://github.com/Moho-Chat/moho/commit/28d8b3c))
- Lightbox: step through a conversation's pictures, wheel and pinch zoom with panning, and Copy picture ([moho `817cb1e`](https://github.com/Moho-Chat/moho/commit/817cb1e))
- Emoji picker: walk it with the arrow keys, Enter picks the top result, and skin tones are remembered ([moho `99fd0d3`](https://github.com/Moho-Chat/moho/commit/99fd0d3))
- Attachment tray cards with name, size and a Discord spoiler toggle; dropped files go into the tray instead of being sent at once ([moho `15e7061`](https://github.com/Moho-Chat/moho/commit/15e7061), [nobilis `0f5669b`](https://github.com/Moho-Chat/nobilis/commit/0f5669b))
- Composer: one box with the + on the left and emoji, stickers, voice and Send on the right ([moho `a59c1ab`](https://github.com/Moho-Chat/moho/commit/a59c1ab))
- Several pictures in one message lay out as a grid, and SPOILER_ files are blurred until clicked ([moho `87ea3b8`](https://github.com/Moho-Chat/moho/commit/87ea3b8))
- Pinned messages show as a banner over the log for Discord and Matrix, with a count on the header ([moho `3a74cf0`](https://github.com/Moho-Chat/moho/commit/3a74cf0))
- Reactions say what they are and who gave them on hover ([moho `506140d`](https://github.com/Moho-Chat/moho/commit/506140d), [nobilis `1da3ad8`](https://github.com/Moho-Chat/nobilis/commit/1da3ad8))
- User footer: muted and deafened are red, voice buttons stay on every service, and the status is a popout with a custom status for Discord and Matrix and Apply to all accounts ([moho `c026609`](https://github.com/Moho-Chat/moho/commit/c026609), [nobilis `f280563`](https://github.com/Moho-Chat/nobilis/commit/f280563))
- The footer's join button is gone; the rail's + is the one way in, pinned under a rule above the settings cog while the server list scrolls ([moho `b4c1ced`](https://github.com/Moho-Chat/moho/commit/b4c1ced), [moho `dad5543`](https://github.com/Moho-Chat/moho/commit/dad5543))
- Header: one 48px row across every column, filled toggles, and the channel topic from IRC, Matrix and Discord ([moho `2ef9e35`](https://github.com/Moho-Chat/moho/commit/2ef9e35), [nobilis `44d1611`](https://github.com/Moho-Chat/nobilis/commit/44d1611))
- "(edited)" says when on hover, for edits seen live ([moho `7f4c356`](https://github.com/Moho-Chat/moho/commit/7f4c356), [nobilis `e51f139`](https://github.com/Moho-Chat/nobilis/commit/e51f139))
- Incoming Discord and Matrix calls share one card style and stack ([moho `59d4893`](https://github.com/Moho-Chat/moho/commit/59d4893))
- Settings: the gear opens it directly, with Accounts and Downloads as pages, a search, one switch for every boolean and a saved tick ([moho `66a0c0f`](https://github.com/Moho-Chat/moho/commit/66a0c0f), [moho `cc4c9f6`](https://github.com/Moho-Chat/moho/commit/cc4c9f6))
- Settings: added Appearance, Notifications and Keybinds pages, the last with a recorder for the global hotkey ([moho `fe6ad97`](https://github.com/Moho-Chat/moho/commit/fe6ad97), [moho `adca84d`](https://github.com/Moho-Chat/moho/commit/adca84d), [moho `82a0bdf`](https://github.com/Moho-Chat/moho/commit/82a0bdf))
- Accounts open by default with few accounts, Remove sits in a danger zone, and Join picks its account and says Join ([moho `f8dcccd`](https://github.com/Moho-Chat/moho/commit/f8dcccd))
- Mentions page: service and unread filters, day groups, two-line bodies and put-away ([moho `078fe41`](https://github.com/Moho-Chat/moho/commit/078fe41))
- Room settings is a tabbed dialog instead of a popover ([moho `f8936c6`](https://github.com/Moho-Chat/moho/commit/f8936c6))
- Quiet buttons such as Cancel have their own fill, so they no longer vanish into a dialog ([moho `50aaa97`](https://github.com/Moho-Chat/moho/commit/50aaa97))
- Fixed browsing an IRC network's channels freezing or blanking the window: the daemon holds the list and sends a page, it times out after a minute, and a refusal is reported ([moho `2b41c5e`](https://github.com/Moho-Chat/moho/commit/2b41c5e), [nobilis `152fdf1`](https://github.com/Moho-Chat/nobilis/commit/152fdf1))
- Channels in the list no longer jump to the top as messages arrive ([moho `9f03d6b`](https://github.com/Moho-Chat/moho/commit/9f03d6b))
- Fixed Discord's notices showing only a stand-in title: they are read as cards, and a banner replaces the message box in them and in channels this account cannot write in ([moho `ff7d313`](https://github.com/Moho-Chat/moho/commit/ff7d313), [moho `d7c4215`](https://github.com/Moho-Chat/moho/commit/d7c4215), [moho `2a4f797`](https://github.com/Moho-Chat/moho/commit/2a4f797), [nobilis `1b270be`](https://github.com/Moho-Chat/nobilis/commit/1b270be), [nobilis `97ac6bc`](https://github.com/Moho-Chat/nobilis/commit/97ac6bc), [nobilis `7561b88`](https://github.com/Moho-Chat/nobilis/commit/7561b88))
- A window whose page dies is reloaded and the reason logged, instead of being left empty ([moho `6899694`](https://github.com/Moho-Chat/moho/commit/6899694), [moho `06bb812`](https://github.com/Moho-Chat/moho/commit/06bb812))
- Discord forums, with live and archived posts read together ([moho `dd0c25e`](https://github.com/Moho-Chat/moho/commit/dd0c25e), [nobilis `d02bba0`](https://github.com/Moho-Chat/nobilis/commit/d02bba0), [nobilis `8363003`](https://github.com/Moho-Chat/nobilis/commit/8363003), [nobilis `3cf1a5b`](https://github.com/Moho-Chat/nobilis/commit/3cf1a5b), [nobilis `6abcb8e`](https://github.com/Moho-Chat/nobilis/commit/6abcb8e))
- Added Rust crate tokio-util 0.7.19 ([nobilis `9690905`](https://github.com/Moho-Chat/nobilis/commit/9690905))
- Channel rows in the sidebar now show a single `#` instead of two ([moho `ccdf090`](https://github.com/Moho-Chat/moho/commit/ccdf090))
- Typing anywhere in the window now goes into the message box, without clicking it first ([moho `b5f920f`](https://github.com/Moho-Chat/moho/commit/b5f920f))
- Added a formatting strip over selected text in the message box, offering the formats each service can carry ([moho `9ca477c`](https://github.com/Moho-Chat/moho/commit/9ca477c))
- Added a right-click menu to every text field, with spelling suggestions, Undo, Cut, Copy, Paste and Select all ([moho `b2a6210`](https://github.com/Moho-Chat/moho/commit/b2a6210))
- Fixed YouTube cards not appearing on Sneedchat when YouTube refused a Tor exit, so most links there had none ([moho `0368796`](https://github.com/Moho-Chat/moho/commit/0368796), [nobilis `71e6036`](https://github.com/Moho-Chat/nobilis/commit/71e6036))
- Fixed live and premiere YouTube videos vanishing because they have no thumbnail yet ([moho `916abd4`](https://github.com/Moho-Chat/moho/commit/916abd4))
- Fixed YouTube links with the video id after other parameters, `music.youtube.com` and `youtube-nocookie.com` links getting no card ([nobilis `f8409fc`](https://github.com/Moho-Chat/nobilis/commit/f8409fc))
- Patched a flaw that let a message's content make moho load a local file ([moho `7138201`](https://github.com/Moho-Chat/moho/commit/7138201), [nobilis `f972611`](https://github.com/Moho-Chat/nobilis/commit/f972611))
- Strict routing now sends a Tor or proxy account's pictures, links and embeds through its route instead of directly ([moho `cee8604`](https://github.com/Moho-Chat/moho/commit/cee8604), [nobilis `a3897ba`](https://github.com/Moho-Chat/nobilis/commit/a3897ba))
- Discord voice and soundboard audio is now checked before it reaches the C decoder ([nobilis `8eca16e`](https://github.com/Moho-Chat/nobilis/commit/8eca16e))
@@ -101,3 +256,111 @@ Each section has two parts:
- Added npm package lottie-web 5.13.0 ([moho `f67c753`](https://github.com/Moho-Chat/moho/commit/f67c753))
- Added npm package happy-dom 15.11.7 ([moho `53a6f92`](https://github.com/Moho-Chat/moho/commit/53a6f92))
- Added npm package vitest 2.1.9 ([moho `53a6f92`](https://github.com/Moho-Chat/moho/commit/53a6f92))
## [1.0.0-rc.5]
### Major features
- **GIFs on every service:** a linked GIF is drawn as the picture - silent, looping, without the player - whichever service it came from, and a GIF picker with trending, categories and search sends one from the composer. Klipy, Tenor and Giphy pages are never probed for what they are.
- **Idle detection:** moho notices when nothing has been used for a while, from the system where it can say and from its own windows where it cannot, and puts an account that has an idle on Idle and back. It has a setting for it and for how long, and the plaque says Idle (away) while keeping what you chose checked.
- **Link cards on every service:** a YouTube link gets a titled card with its channel on IRC, Matrix, Kick and Sneedchat as it does on Discord - including through Tor, which YouTube refuses the usual way - and one added in an edit, read back from history, or posted before cards existed gets its card too.
### Fixes, patches and changes
- Added idle detection with a setting for it and for how long ([moho `23c87dd`](https://github.com/Moho-Chat/moho/commit/23c87dd), [nobilis `35ea0b7`](https://github.com/Moho-Chat/nobilis/commit/35ea0b7))
- Added GIFs drawn as pictures on every service, and a GIF picker ([moho `55248eb`](https://github.com/Moho-Chat/moho/commit/55248eb), [nobilis `046fbb4`](https://github.com/Moho-Chat/nobilis/commit/046fbb4))
- Fixed a Sneedchat picture from the forum's uploads host - an AVIF, or an MP4 - not drawing: its domain is taken off and it is fetched through the account's route, clearnet or Tor ([moho `07f4fc2`](https://github.com/Moho-Chat/moho/commit/07f4fc2), [nobilis `ca29a63`](https://github.com/Moho-Chat/nobilis/commit/ca29a63))
- Fixed the tray's number drifting from the client's: it is now counted by the window, by the rail's rule, and told to the tray whenever it changes ([moho `1e314f8`](https://github.com/Moho-Chat/moho/commit/1e314f8))
- Fixed YouTube cards never appearing for an account on Tor: YouTube answers every Tor exit with a 403 on its card lookup, so the video's own page is read when that happens ([moho `8064e79`](https://github.com/Moho-Chat/moho/commit/8064e79), [nobilis `340342b`](https://github.com/Moho-Chat/nobilis/commit/340342b))
- Fixed link cards being lost when a message was edited, and missing from Matrix history, Discord catch-up and older messages ([moho `638b02e`](https://github.com/Moho-Chat/moho/commit/638b02e), [nobilis `dab394f`](https://github.com/Moho-Chat/nobilis/commit/dab394f))
- Fixed scrolling through a long run of unread messages snapping back to where the "new messages" bar appeared ([moho `336335a`](https://github.com/Moho-Chat/moho/commit/336335a))
- The number beside a conversation is what is addressed to you - mentions in a channel, each unread message in a direct message - and the bold name is the sign of activity ([moho `987f31c`](https://github.com/Moho-Chat/moho/commit/987f31c))
- A muted channel keeps its mention count in the list, the rail, the switcher and the tray, and stops the popups; muting a Discord channel or direct message is now set on the account, so a phone is quiet too ([moho `7803cc3`](https://github.com/Moho-Chat/moho/commit/7803cc3), [nobilis `b0fa250`](https://github.com/Moho-Chat/nobilis/commit/b0fa250))
## [1.0.0-rc.4]
### Major features
- **A rebuilt tray:** an icon drawn for the system's colour scheme with the number of conversations waiting on it, a tooltip that says what is waiting, and a menu of what is new, Show/Hide, Status, Settings, Restart moho and Quit - Restart moho brings back both the window and the daemon
- **Status by service:** the plaque and the tray offer only the statuses each service has - Discord all four, IRC away and back, Matrix online and offline, Kick and Sneedchat none - and Do not disturb silences desktop notifications on every service, and is Discord's real mode there
### Fixes, patches and changes
- Reworked the tray icon and menu, and dropped Restart daemon and Stop daemon from it ([moho `24106e5`](https://github.com/Moho-Chat/moho/commit/24106e5))
- Do not disturb now silences an account's desktop notifications and the taskbar flash; the tray no longer has a switch of its own for it ([moho `53fb0f3`](https://github.com/Moho-Chat/moho/commit/53fb0f3))
- Idle from the tray reaches Discord and IRC and leaves Matrix, Kick and Sneedchat as they were; a service is no longer asked for a status it does not have ([moho `c1bcb72`](https://github.com/Moho-Chat/moho/commit/c1bcb72), [nobilis `a8441ab`](https://github.com/Moho-Chat/nobilis/commit/a8441ab))
- Fixed Matrix's rate limit on changing status showing as M_LIMIT_EXCEEDED: a limited request now waits the time the server names, and a longer wait is applied when it is over ([moho `0dfff0d`](https://github.com/Moho-Chat/moho/commit/0dfff0d), [nobilis `43135c1`](https://github.com/Moho-Chat/nobilis/commit/43135c1))
- Fixed the window being pulled back to the open conversation's server whenever you chose another: the daemon link coming up is acted on once, a refresh no longer follows the open conversation, and the tray is told only what changed ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a))
- Matrix's invisible is called Offline in the plaque, its popout and the tray, which is what it sets ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a))
- A room muted on a Matrix account can now be unmuted from the menu, including a mute set from Element ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a), [nobilis `59db82c`](https://github.com/Moho-Chat/nobilis/commit/59db82c))
- Server rail tiles now count the mentions waiting in them rather than the messages, and a muted room counts for nothing, mentions included ([moho `0dc048c`](https://github.com/Moho-Chat/moho/commit/0dc048c))
- Fixed a room unmuted on a Matrix account still being drawn as muted; a mute or unmute the server did not take is now reported with its reason, and Matrix requests to a server that does not answer give up ([moho `95f1784`](https://github.com/Moho-Chat/moho/commit/95f1784), [nobilis `d1bbcaf`](https://github.com/Moho-Chat/nobilis/commit/d1bbcaf))
- Discord requests now give up on a connection that has gone quiet, a history catch-up always releases its place, and the gateway logs what it receives and why a message is dropped ([moho `b151ef8`](https://github.com/Moho-Chat/moho/commit/b151ef8), [nobilis `a34821e`](https://github.com/Moho-Chat/nobilis/commit/a34821e))
- Fixed Discord reconnecting every few seconds after being told it is rate limited: it now waits out the minute Discord asks for ([nobilis `59db82c`](https://github.com/Moho-Chat/nobilis/commit/59db82c))
## [1.0.0-rc.3]
### Major features
- **Discord forums:** forum and media channels appear in the channel list and open as a pane of posts - title, tags, the first message's words, replies, age, picture and top reaction - with search, sorting, older posts, and a New Post form
- **A redesigned interface:** Discord-shaped messages with date separators and an unread bar, red mention badges, a Ctrl+K quick switcher, keyboard navigation, a rail with mention counts and folders, a member list with faces, and motion throughout
- **Settings rebuilt:** one click from the gear, a grouped rail with a search over every setting, and new Appearance (layout, spacing, zoom, clock, reduce motion), Notifications and Keybinds pages with a hotkey recorder
- **A one-box message composer:** a + menu, Send only when there is something to send, Shift+Enter for a new line on every service, an attachment tray with spoilers, and dropped files going into the tray
- **Discord notices and permissions:** Discord's own safety notices drawn as the cards they are, and a banner in place of the message box wherever nothing can be written
### Fixes, patches and changes
- Fixed Sneedchat pictures posted back to back by a script merging into one unusable link ([moho `6edae7a`](https://github.com/Moho-Chat/moho/commit/6edae7a))
- Discord files staged together now go as one message, with an upload ring that fills as it uploads ([moho `9888c80`](https://github.com/Moho-Chat/moho/commit/9888c80), [nobilis `9690905`](https://github.com/Moho-Chat/nobilis/commit/9690905))
- Fixed the release mirror script printing a traceback while the release did not exist yet ([moho `a8d9f41`](https://github.com/Moho-Chat/moho/commit/a8d9f41))
- Defined the design tokens the stylesheet used but never had, and applied one set across it ([moho `9db25c8`](https://github.com/Moho-Chat/moho/commit/9db25c8), [moho `3cdfe37`](https://github.com/Moho-Chat/moho/commit/3cdfe37))
- Added a screenshot harness for before-and-after pictures of the interface ([moho `88375ba`](https://github.com/Moho-Chat/moho/commit/88375ba))
- Unread channels now read as unread, mentions are red, and every badge is one style ([moho `4ca37dd`](https://github.com/Moho-Chat/moho/commit/4ca37dd))
- Added date separators to the message list ([moho `b2464e4`](https://github.com/Moho-Chat/moho/commit/b2464e4))
- Added Mark as read on channels, categories and servers, and Escape to read what you are in ([moho `2745a2f`](https://github.com/Moho-Chat/moho/commit/2745a2f))
- Added a Ctrl+K quick switcher and keyboard navigation between channels ([moho `1eef37e`](https://github.com/Moho-Chat/moho/commit/1eef37e))
- Voice panel: a horizontal microphone bar, the connection's real latency, and a share button ([moho `ad0f0c0`](https://github.com/Moho-Chat/moho/commit/ad0f0c0), [nobilis `e0f6169`](https://github.com/Moho-Chat/nobilis/commit/e0f6169))
- Replies jump to the message they answer, and names and pictures open the profile ([moho `bf45147`](https://github.com/Moho-Chat/moho/commit/bf45147))
- Added Copy and Copy text on every message, and Copy message link on Discord ([moho `0033955`](https://github.com/Moho-Chat/moho/commit/0033955))
- The hover toolbar fades in, can be reached by keyboard, and offers Edit, Copy and your recent reactions ([moho `57e1856`](https://github.com/Moho-Chat/moho/commit/57e1856))
- Fixed six interface bugs: a nested voice button, folded categories hiding unread, muted channels hiding mentions, overlapping corners, the typing strip moving the log, and pinned rows missing their service mark ([moho `6598043`](https://github.com/Moho-Chat/moho/commit/6598043))
- Pending sends dim only their text, failed ones can be deleted, and Up in an empty box edits your last message in a multi-line editor ([moho `6a82c32`](https://github.com/Moho-Chat/moho/commit/6a82c32))
- Server rail: mention counts, folders that say what is waiting and stay open, an instant name flyout and a red close button ([moho `8072b24`](https://github.com/Moho-Chat/moho/commit/8072b24))
- Member list: faces, presence dots, and a click for the profile; Discord's member pictures now come with the list ([moho `c912ed3`](https://github.com/Moho-Chat/moho/commit/c912ed3), [nobilis `4099305`](https://github.com/Moho-Chat/nobilis/commit/4099305))
- Voice channel list: faces, muted and deafened marks, a speaking ring and a collapsible heading ([moho `843288d`](https://github.com/Moho-Chat/moho/commit/843288d))
- Comfy messages in Discord's shape, amber mention rows, one code and quote style, and marked system lines ([moho `3a7677e`](https://github.com/Moho-Chat/moho/commit/3a7677e))
- Buttons are primary, subtle or the one danger style, and the restarts ask first ([moho `2c8609c`](https://github.com/Moho-Chat/moho/commit/2c8609c))
- Escape closes only the top layer, and full-page panels close on Escape ([moho `c5e7619`](https://github.com/Moho-Chat/moho/commit/c5e7619))
- Thread panel reads the display settings, groups replies and follows the newest ([moho `33129f9`](https://github.com/Moho-Chat/moho/commit/33129f9))
- Toasts slide in and out, come in three kinds, pause on hover and fold repeats ([moho `2d1b8be`](https://github.com/Moho-Chat/moho/commit/2d1b8be))
- Context menu: keyboard navigation, checked items, shortcut hints, and placement that holds while it animates ([moho `4065963`](https://github.com/Moho-Chat/moho/commit/4065963), [moho `fcb7df5`](https://github.com/Moho-Chat/moho/commit/fcb7df5))
- A conversation opens at its first unread, with a bar saying how many are above ([moho `55fd192`](https://github.com/Moho-Chat/moho/commit/55fd192))
- Added hover fades, turning fold arrows, arriving messages, a reaction pop and growing overlays ([moho `6d8a050`](https://github.com/Moho-Chat/moho/commit/6d8a050))
- Every dialog is built from one shared frame with a close button, kept focus and a scrim below the title bar ([moho `5cb30ce`](https://github.com/Moho-Chat/moho/commit/5cb30ce), [moho `420a2f7`](https://github.com/Moho-Chat/moho/commit/420a2f7), [moho `6a329c6`](https://github.com/Moho-Chat/moho/commit/6a329c6))
- Shift+Enter makes a new line on every service and pasted line breaks stay; IRC sends them as a batch or separate lines, Sneedchat as [br], Kick as one line ([moho `9ba630c`](https://github.com/Moho-Chat/moho/commit/9ba630c), [nobilis `ccc8de6`](https://github.com/Moho-Chat/nobilis/commit/ccc8de6))
- Reply bar: its own surface, the author's face, Esc to cancel, and an @ switch to answer without pinging on Discord ([moho `f48185e`](https://github.com/Moho-Chat/moho/commit/f48185e), [nobilis `918fcfa`](https://github.com/Moho-Chat/nobilis/commit/918fcfa))
- One style for the name, command and emoji lists, with member pictures and :shortcode emoji completion ([moho `28d8b3c`](https://github.com/Moho-Chat/moho/commit/28d8b3c))
- Lightbox: step through a conversation's pictures, wheel and pinch zoom with panning, and Copy picture ([moho `817cb1e`](https://github.com/Moho-Chat/moho/commit/817cb1e))
- Emoji picker: walk it with the arrow keys, Enter picks the top result, and skin tones are remembered ([moho `99fd0d3`](https://github.com/Moho-Chat/moho/commit/99fd0d3))
- Attachment tray cards with name, size and a Discord spoiler toggle; dropped files go into the tray instead of being sent at once ([moho `15e7061`](https://github.com/Moho-Chat/moho/commit/15e7061), [nobilis `0f5669b`](https://github.com/Moho-Chat/nobilis/commit/0f5669b))
- Composer: one box with the + on the left and emoji, stickers, voice and Send on the right ([moho `a59c1ab`](https://github.com/Moho-Chat/moho/commit/a59c1ab))
- Several pictures in one message lay out as a grid, and SPOILER_ files are blurred until clicked ([moho `87ea3b8`](https://github.com/Moho-Chat/moho/commit/87ea3b8))
- Pinned messages show as a banner over the log for Discord and Matrix, with a count on the header ([moho `3a74cf0`](https://github.com/Moho-Chat/moho/commit/3a74cf0))
- Reactions say what they are and who gave them on hover ([moho `506140d`](https://github.com/Moho-Chat/moho/commit/506140d), [nobilis `1da3ad8`](https://github.com/Moho-Chat/nobilis/commit/1da3ad8))
- User footer: muted and deafened are red, voice buttons stay on every service, and the status is a popout with a custom status for Discord and Matrix and Apply to all accounts ([moho `c026609`](https://github.com/Moho-Chat/moho/commit/c026609), [nobilis `f280563`](https://github.com/Moho-Chat/nobilis/commit/f280563))
- The footer's join button is gone; the rail's + is the one way in, pinned under a rule above the settings cog while the server list scrolls ([moho `b4c1ced`](https://github.com/Moho-Chat/moho/commit/b4c1ced), [moho `dad5543`](https://github.com/Moho-Chat/moho/commit/dad5543))
- Header: one 48px row across every column, filled toggles, and the channel topic from IRC, Matrix and Discord ([moho `2ef9e35`](https://github.com/Moho-Chat/moho/commit/2ef9e35), [nobilis `44d1611`](https://github.com/Moho-Chat/nobilis/commit/44d1611))
- "(edited)" says when on hover, for edits seen live ([moho `7f4c356`](https://github.com/Moho-Chat/moho/commit/7f4c356), [nobilis `e51f139`](https://github.com/Moho-Chat/nobilis/commit/e51f139))
- Incoming Discord and Matrix calls share one card style and stack ([moho `59d4893`](https://github.com/Moho-Chat/moho/commit/59d4893))
- Settings: the gear opens it directly, with Accounts and Downloads as pages, a search, one switch for every boolean and a saved tick ([moho `66a0c0f`](https://github.com/Moho-Chat/moho/commit/66a0c0f), [moho `cc4c9f6`](https://github.com/Moho-Chat/moho/commit/cc4c9f6))
- Settings: added Appearance, Notifications and Keybinds pages, the last with a recorder for the global hotkey ([moho `fe6ad97`](https://github.com/Moho-Chat/moho/commit/fe6ad97), [moho `adca84d`](https://github.com/Moho-Chat/moho/commit/adca84d), [moho `82a0bdf`](https://github.com/Moho-Chat/moho/commit/82a0bdf))
- Accounts open by default with few accounts, Remove sits in a danger zone, and Join picks its account and says Join ([moho `f8dcccd`](https://github.com/Moho-Chat/moho/commit/f8dcccd))
- Mentions page: service and unread filters, day groups, two-line bodies and put-away ([moho `078fe41`](https://github.com/Moho-Chat/moho/commit/078fe41))
- Room settings is a tabbed dialog instead of a popover ([moho `f8936c6`](https://github.com/Moho-Chat/moho/commit/f8936c6))
- Quiet buttons such as Cancel have their own fill, so they no longer vanish into a dialog ([moho `50aaa97`](https://github.com/Moho-Chat/moho/commit/50aaa97))
- Fixed browsing an IRC network's channels freezing or blanking the window: the daemon holds the list and sends a page, it times out after a minute, and a refusal is reported ([moho `2b41c5e`](https://github.com/Moho-Chat/moho/commit/2b41c5e), [nobilis `152fdf1`](https://github.com/Moho-Chat/nobilis/commit/152fdf1))
- Channels in the list no longer jump to the top as messages arrive ([moho `9f03d6b`](https://github.com/Moho-Chat/moho/commit/9f03d6b))
- Fixed Discord's notices showing only a stand-in title: they are read as cards, and a banner replaces the message box in them and in channels this account cannot write in ([moho `ff7d313`](https://github.com/Moho-Chat/moho/commit/ff7d313), [moho `d7c4215`](https://github.com/Moho-Chat/moho/commit/d7c4215), [moho `2a4f797`](https://github.com/Moho-Chat/moho/commit/2a4f797), [nobilis `1b270be`](https://github.com/Moho-Chat/nobilis/commit/1b270be), [nobilis `97ac6bc`](https://github.com/Moho-Chat/nobilis/commit/97ac6bc), [nobilis `7561b88`](https://github.com/Moho-Chat/nobilis/commit/7561b88))
- A window whose page dies is reloaded and the reason logged, instead of being left empty ([moho `6899694`](https://github.com/Moho-Chat/moho/commit/6899694), [moho `06bb812`](https://github.com/Moho-Chat/moho/commit/06bb812))
- Discord forums, with live and archived posts read together ([moho `dd0c25e`](https://github.com/Moho-Chat/moho/commit/dd0c25e), [nobilis `d02bba0`](https://github.com/Moho-Chat/nobilis/commit/d02bba0), [nobilis `8363003`](https://github.com/Moho-Chat/nobilis/commit/8363003), [nobilis `3cf1a5b`](https://github.com/Moho-Chat/nobilis/commit/3cf1a5b), [nobilis `6abcb8e`](https://github.com/Moho-Chat/nobilis/commit/6abcb8e))
- Added Rust crate tokio-util 0.7.19 ([nobilis `9690905`](https://github.com/Moho-Chat/nobilis/commit/9690905))
+1 -1
View File
@@ -1,7 +1,7 @@
pkgname=moho-git
pkgver=r1.268751e
pkgrel=1
pkgdesc="Unified desktop chat client for IRC, Discord, Sneedchat and Matrix"
pkgdesc="Unified desktop chat client for IRC, Discord, Matrix, Kick, Sneedchat and Steam"
arch=('x86_64')
url="https://github.com/Moho-Chat/moho"
license=('GPL3')
+42 -37
View File
@@ -1,45 +1,44 @@
# moho
**Every conversation you're in, in one window.**
<img width="1545" height="912" alt="showcase-screenshot" src="https://github.com/user-attachments/assets/b9dabddb-bb3c-4a71-b872-9ad167ecd400" />
moho is a desktop chat client that speaks IRC, Discord, Matrix, Kick and Sneedchat at the
same time. One buffer list down the side, one log in the middle, one set of habits — whether
the room is a twenty-year-old IRC channel, a Discord server, an encrypted Matrix room, or a
stream's chat going past at forty lines a minute.
It isn't five web apps in a trenchcoat. Underneath sits a small Rust daemon that holds the
connections; the window is just what draws them. Close it and you stay connected. Open it
again and everything is where you left it.
moho is a desktop chat client that speaks IRC, Discord, Matrix, Kick, Twitch, Sneedchat, Steam and many more protocols at the
same time. Whether the room is a twenty-year-old IRC channel, a Discord server, an encrypted Matrix room, or a
stream's chat going past at forty lines a minute you're able to be part of the conversation.
## What it does
- **Five networks, one place.** IRC, Discord, Matrix, Kick and Sneedchat, side by side, with
- **Many networks, one place.** IRC, Discord, Matrix, Kick, Twitch, Sneedchat, Steam and many more protocols soon, side by side, with
the same reply, edit, react, search and tag-someone gestures on each — as far as each
service actually supports them.
- **A log, not a feed.** Compact IRC-style scrollback with nick colours, markdown and BBCode,
spoilers, code blocks, inline images and video, and custom emoji. There's a roomier mode
with avatars if you prefer one.
- **It stays connected.** The daemon keeps IRC registered, Matrix syncing and Tor circuits
open while the window is shut. Quitting says a proper goodbye rather than dropping the
socket, so you don't come back to a ghosted nick.
- **Calls.** Voice, camera and screen sharing on Discord and on Matrix, including the group
calls Element holds on a media server. On Discord, Go Live streams both ways with sound,
- **Calls.** Voice, camera and screen sharing on Discord and on Matrix, voice rooms and calls on Steam, including the group
calls Element holds on a media server. On Discord, Go Live, streams both ways with sound,
echo cancellation, volume per person, the soundboard and stage channels. A call can sit in
the corner or pop out into a window of its own.
- **Matrix, properly encrypted.** End-to-end encryption with device verification by emoji or
QR, cross-signing, key backup, and encrypted attachments.
- **Sneedchat, and Tor where you want it.** The site's proof-of-work gate and its login
captcha both handled, and every room you've joined connected at once. Any account on any of
the five can go through an embedded Tor client or a SOCKS5 proxy instead of the open internet.
- **Kick streams in the window.** Watch the stream beside its chat, in the corner or in a
window of its own, with VODs, clips, 7TV and BTTV emotes, polls and predictions.
- **No captchas on Discord, on purpose.** Discord puts joining a server, adding a friend and
making a server behind a captcha when they come from anything but its own client, and
answering one gets an account flagged for spam. moho opens discord.com for those three
instead of putting your account at risk.
- **Kick and Twitch streams in the window.** Watch the stream beside its chat, in the corner or in a
window of its own, polls, predictions and other interactive methods are functional and at your fingertips.
- **Yours.** GPL-3.0, no telemetry, no account with us — your history is a SQLite file on
your own disk.
## Supported protocols
| Protocol | State | What you get |
| --- | --- | --- |
| **IRC** | Supported | Channels and queries on any number of networks, with SASL, TLS, DCC file transfers and the IRCv3 capabilities servers offer |
| **Matrix** | Supported | Rooms, spaces and direct messages, end-to-end encryption with device verification and key backup, Element Call, and signing in by QR code |
| **Discord** | Supported | Servers, direct messages, forums and threads, voice, video, screen sharing and Go Live with end-to-end encryption, stages and the soundboard |
| **Kick** | Supported | A streamer's chat beside the stream, with polls, predictions and its events drawn as cards |
| **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 |
Every protocol gets the same window, mentions inbox, search and per-account Tor or SOCKS5 routing.
How much of each service's own feature set a protocol has is as far as its service lets a
third-party client go; more protocols are planned.
## Get it
Prebuilt Linux and Windows downloads are on the
@@ -47,10 +46,6 @@ Prebuilt Linux and Windows downloads are on the
changed is in [CHANGELOG.md](CHANGELOG.md). moho tells you in its mentions inbox when a newer
version is out.
Versions are MAJOR.FEATURE.FIX: a major release, then protocols added and major features,
then bug and security fixes - 1.1.0 would add a protocol, 1.0.5 is the fifth fix release
of 1.0.
The AppImage needs `libfuse2` on recent distributions; if it refuses to start, either install
that or run it with `APPIMAGE_EXTRACT_AND_RUN=1`.
@@ -85,13 +80,18 @@ npm install-scripts approve electron esbuild
## Test it
```bash
npm test # the window's unit tests (Vitest)
npm run test:ui # layout checks against the real app - popups must stay inside the window
npm test # the window's unit tests (Vitest)
npm run test:ui # layout checks against the real app - popups must stay inside the window
npm run test:tray # the tray: its icon, tooltip, menu, statuses and restart
npm run test:gif # GIFs drawn as pictures, and the GIF picker
npm run test:scroll # scrolling and where a conversation opens
npm run test:kick # where Kick event cards sit in each layout
cd nobilis && cargo test --release
```
`test:ui` builds the window, runs it against a scratch home and a fake IRC server, and needs the
daemon built first. On a machine with no display, run it under `xvfb-run`.
The `test:` scripts other than `test` build the window, run it against a scratch home and a
fake IRC server, and need the daemon built first. On a machine with no display, run them under
`xvfb-run`. `npm run shots` photographs the interface scene by scene, for looking at changes.
## Run it
@@ -107,9 +107,14 @@ npm run deploy
```
Other ways out of the box: `npm run pack:dir` for a self-contained `dist/linux-unpacked/`
directory to run in place, `npm run pack:win` for the Windows installer (cross-built with
mingw-w64; packaging it also needs wine with 32-bit support), and `makepkg -si` against the
bundled `PKGBUILD` to install it as an Arch package under `/opt/moho`.
directory to run in place, `npm run pack:deb` for a Debian package, `npm run pack:win` for the
Windows installer (cross-built with mingw-w64; packaging it also needs wine with 32-bit
support), and `makepkg -si` against the bundled `PKGBUILD` to install it as an Arch package
under `/opt/moho`.
Packaging leaves a lot behind - an AppImage per build, and Rust build output that Cargo never
cleans up. `npm run deploy` first runs `npm run tidy`, which keeps the newest two AppImages and
empties `nobilis/target/` once it passes 8 GB.
---
+6 -6
View File
@@ -1,8 +1,9 @@
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
// @ts-expect-error - a plain .mjs script, shared with the daemon build scripts
import { versionLabel } from './scripts/version-label.mjs'
/**
* The commit this build came from, stamped in at build time.
@@ -30,11 +31,10 @@ function buildCommit(): string {
const define = {
__BUILD_COMMIT__: JSON.stringify(buildCommit()),
__BUILD_DATE__: JSON.stringify(new Date().toISOString()),
// Read from the manifest rather than exposed over IPC: it is a constant
// known at build time, and a round trip to ask for it would be one.
__APP_VERSION__: JSON.stringify(
JSON.parse(readFileSync(resolve('package.json'), 'utf8')).version as string
)
// The release's version for a release build, "development" for anything
// else - see scripts/version-label.mjs. Known at build time, so stamped in
// rather than asked for over IPC.
__APP_VERSION__: JSON.stringify(versionLabel())
}
export default defineConfig({
+1 -1
Submodule nobilis updated: 081821ac3a...2355fcb6ac
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "moho",
"version": "1.0.0-rc.1",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "moho",
"version": "1.0.0-rc.1",
"version": "1.2.0",
"license": "GPL-3.0-or-later",
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
+10 -4
View File
@@ -1,8 +1,8 @@
{
"name": "moho",
"version": "1.0.0-rc.1",
"version": "1.2.0",
"description": "Unified desktop chat client for IRC, Discord, Sneedchat and Matrix",
"author": "Salastil",
"author": "Salastil <[email protected]>",
"license": "GPL-3.0-or-later",
"homepage": "https://github.com/Moho-Chat/moho",
"repository": {
@@ -21,12 +21,18 @@
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
"test": "vitest run",
"test:ui": "electron-vite build && node scripts/ui-check.mjs",
"test:tray": "electron-vite build && node scripts/tray-check.mjs",
"test:gif": "electron-vite build && node scripts/gif-check.mjs",
"test:scroll": "electron-vite build && node scripts/scroll-check.mjs",
"test:kick": "electron-vite build && node scripts/kick-check.mjs",
"shots": "electron-vite build && node scripts/ui-shots.mjs",
"pack": "npm run daemon && npm run build && MOHO_BUILD=$(git rev-parse --short HEAD)$(git diff --quiet HEAD || echo -dirty) electron-builder --linux AppImage",
"pack:deb": "npm run daemon && npm run build && MOHO_BUILD=$(git rev-parse --short HEAD)$(git diff --quiet HEAD || echo -dirty) electron-builder --linux deb",
"pack:linux": "npm run daemon && npm run build && MOHO_BUILD=$(git rev-parse --short HEAD)$(git diff --quiet HEAD || echo -dirty) electron-builder --linux AppImage deb",
"pack:dir": "npm run daemon && npm run build && MOHO_BUILD=$(git rev-parse --short HEAD)$(git diff --quiet HEAD || echo -dirty) electron-builder --linux dir",
"daemon": "./scripts/build-daemon.sh",
"deploy": "npm run pack && ./scripts/install-desktop.sh",
"tidy": "./scripts/tidy.sh",
"deploy": "./scripts/tidy.sh && npm run pack && ./scripts/install-desktop.sh",
"daemon:win": "./scripts/build-daemon-win.sh",
"pack:win": "npm run daemon:win && npm run build && MOHO_BUILD=$(git rev-parse --short HEAD)$(git diff --quiet HEAD || echo -dirty) electron-builder --win nsis"
},
@@ -73,7 +79,7 @@
"AppImage",
"deb"
],
"maintainer": "Salastil <salastil@users.noreply.github.com>",
"maintainer": "Salastil <salastil@cock.li>",
"category": "Network",
"synopsis": "Unified desktop chat client",
"icon": "resources/icons/moho.png",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 797 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 771 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

+5
View File
@@ -22,6 +22,11 @@ MANIFEST="$SRC_DIR/nobilis/Cargo.toml"
. "$SRC_DIR/scripts/find-cargo.sh"
echo "building the Windows daemon with $CARGO"
# Whether this is a release build, decided by moho's checkout - the
# submodule's own cannot tell (in CI it is a bare commit, on no branch). The
# daemon then reports its version, or "development".
NOBILIS_CHANNEL="$(node "$SRC_DIR/scripts/version-label.mjs" --channel)"
export NOBILIS_CHANNEL
# From inside nobilis/, for its .cargo/config.toml - see build-daemon.sh.
cd "$SRC_DIR/nobilis"
# --locked: see build-daemon.sh.
+5
View File
@@ -9,6 +9,11 @@ SRC_DIR="$(cd "$(dirname "$0")/.." && pwd)"
. "$SRC_DIR/scripts/find-cargo.sh"
echo "building the daemon with $CARGO"
# Whether this is a release build, decided by moho's checkout - the
# submodule's own cannot tell (in CI it is a bare commit, on no branch). The
# daemon then reports its version, or "development".
NOBILIS_CHANNEL="$(node "$SRC_DIR/scripts/version-label.mjs" --channel)"
export NOBILIS_CHANNEL
# From inside nobilis/: cargo reads .cargo/config.toml from the directory it
# runs in, not from the manifest's, and nobilis's names the version a bundled
# libopus reports (#251).
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env node
/**
* The edges that can be dragged, in a scratch moho with injected state (nothing is contacted): the channel list
* and the member list change width and keep it, double-click puts them back, a stream above the conversation
* changes height by its bottom edge, and the corner panel changes size from every edge and corner.
*
* node scripts/check-resize.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
const shot = async (name) => {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
}
const results = []
const check = (what, ok, detail = '') => {
results.push(ok)
console.log(ok ? ' ok ' : ' FAIL', what, detail)
}
const mouse = (type, x, y) => page.call('Input.dispatchMouseEvent', { type, x, y, button: 'left', buttons: type === 'mouseReleased' ? 0 : 1, clickCount: 1 })
const drag = async (from, to) => {
await mouse('mouseMoved', from.x, from.y)
await mouse('mousePressed', from.x, from.y)
for (let i = 1; i <= 6; i++) await mouse('mouseMoved', from.x + ((to.x - from.x) * i) / 6, from.y + ((to.y - from.y) * i) / 6)
await mouse('mouseReleased', to.x, to.y)
await sleep(300)
}
const rect = (sel) => ev(`(() => { const r = document.querySelector(${JSON.stringify(sel)})?.getBoundingClientRect(); return r ? { x: r.left, y: r.top, w: r.width, h: r.height, r: r.right, b: r.bottom } : null })()`)
const centre = (r) => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 })
try {
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
await ev(`(() => {
const s = window.__mohoShots.store
const acct = { id: 'twitch:me', service: 'twitch', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, nick: 'me' }
const mk = (name) => ({ id: 'twitch:me|' + name, accountId: 'twitch:me', kind: 'channel', name, lastActivityTs: 5, category: 'Channels', groupId: 'account:twitch:me' })
const t = Math.floor(Date.now() / 1000) - 600
const messages = Array.from({ length: 8 }, (_, i) => ({ id: 'm' + i, bufferId: 'twitch:me|xqc', from: 'user' + i, body: 'hello ' + i, ts: t + i * 20, kind: 'chat', isAction: false, isHighlight: false, isOwn: false, edited: false }))
s.set({ accounts: [acct], groups: [{ id: 'account:twitch:me', accountId: 'twitch:me', service: 'twitch', kind: 'account', name: 'me', position: 0 }], buffers: [mk('xqc'), mk('other')], messagesByBuffer: { 'twitch:me|xqc': messages }, activeBufferId: 'twitch:me|xqc' })
s.selectGroup('account:twitch:me')
})()`)
await sleep(1200)
console.log('Channel list')
let side = await rect('.sidebar')
const divider = await rect('.divider-v.resizer')
check('the divider beside the channel list can be taken', !!divider, JSON.stringify(divider))
const before = side.w
await drag(centre(divider), { x: centre(divider).x + 90, y: centre(divider).y })
side = await rect('.sidebar')
check('dragging it wider widens the channel list', Math.abs(side.w - (before + 90)) <= 2, `${before} -> ${side.w}`)
await drag(centre(await rect('.divider-v.resizer')), { x: 5, y: 300 })
side = await rect('.sidebar')
check('it stops at its narrowest', side.w >= 158 && side.w <= 162, String(side.w))
await ev(`document.querySelector('.divider-v.resizer').dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))`)
await sleep(300)
side = await rect('.sidebar')
check('double-click puts it back to its usual width', Math.abs(side.w - 220) <= 2, String(side.w))
console.log('Member list')
await ev(`window.__mohoShots.store.set({ roster: {} })`)
const nick = await rect('.nicklist-pane')
if (nick) {
const handles = await ev(`[...document.querySelectorAll('.divider-v.resizer')].map((d) => { const r = d.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 } })`)
const right = handles[handles.length - 1]
const w0 = nick.w
await drag(right, { x: right.x - 70, y: right.y })
const w1 = (await rect('.nicklist-pane')).w
check('dragging the member list divider left widens the member list', Math.abs(w1 - (w0 + 70)) <= 2, `${w0} -> ${w1}`)
} else check('the member list is shown for a channel', false)
await shot('columns.png')
console.log('Stream above the conversation')
await ev(`(() => {
const s = window.__mohoShots.store
s.set({ watching: { bufferId: 'twitch:me|xqc', accountId: 'twitch:me', title: 'xqc' }, kickStreams: { 'twitch:me|xqc': { bufferId: 'twitch:me|xqc', live: true, title: 'a stream', viewers: 5, playbackUrl: '' } } })
})()`)
await sleep(800)
const edge = await rect('.call-stage:not(.pip) .stage-resize')
check('the stream has a bottom edge to take', !!edge, JSON.stringify(edge))
if (edge) {
const h0 = (await rect('.video-stage')).h
await drag(centre(edge), { x: centre(edge).x, y: centre(edge).y - 60 })
const h1 = (await rect('.video-stage')).h
check('dragging it up makes the picture shorter', h1 < h0 - 40, `${h0} -> ${h1}`)
await shot('stream-inline.png')
}
console.log('Corner panel')
await ev(`window.__mohoShots.store.selectBuffer('twitch:me|other')`)
await sleep(800)
let pip = await rect('.call-stage.pip')
check('the stream goes to a corner when another channel is open', !!pip, JSON.stringify(pip))
if (pip) {
for (const [dir, dx, dy, bigger] of [['l', -80, 0, true], ['t', 0, -60, true], ['tl', -60, -40, true], ['r', 60, 0, true], ['b', 0, 50, true]]) {
const was = (await rect('.call-stage.pip')).w
const handle = await rect(`.call-stage.pip .edge-${dir}`)
if (!handle) { check(`the ${dir} edge can be taken`, false); continue }
const start = centre(handle)
await drag(start, { x: start.x + dx, y: start.y + dy })
const now = await rect('.call-stage.pip')
check(`dragging the ${dir} edge changes the corner panel's size`, bigger ? now.w > was + 20 : now.w < was - 20, `${Math.round(was)} -> ${Math.round(now.w)}`)
check(`...and it stays inside the window (${dir})`, now.x >= 0 && now.r <= 1280 && now.y >= 0 && now.b <= 800, JSON.stringify(now))
}
await shot('pip.png')
}
} finally {
await stop()
console.log(results.every(Boolean) ? 'ALL OK' : 'SOME FAILED')
process.exitCode = results.every(Boolean) ? 0 : 1
}
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env node
/**
* The channel points panel over the chat, for Twitch and for Kick, in a scratch moho with the daemon's answers
* stood in for (nothing is contacted, nothing spent): it opens from the plaque, lists the rewards with their
* costs, says why one cannot be pressed, needs a second press to spend, asks for the text a reward wants, and
* closes on Escape.
*
* node scripts/check-rewards-panel.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
const shot = async (name) => {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
}
const results = []
const check = (what, ok, detail = '') => {
results.push(ok)
console.log(ok ? ' ok ' : ' FAIL', what, detail)
}
const key = (k) => page.call('Input.dispatchKeyEvent', { type: 'keyDown', key: k, code: k }).then(() => page.call('Input.dispatchKeyEvent', { type: 'keyUp', key: k, code: k }))
try {
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1200, height: 800, deviceScaleFactor: 1, mobile: false })
await ev(`(() => {
window.__redeemed = []
window.__rpcFixture = {
listTwitchRewards: () => ({ points: 600, name: 'Sloths', rewards: [
{ id: 'r1', title: 'Drink water', cost: 500, description: null, enabled: true, paused: false, needsInput: false },
{ id: 'r2', title: 'Say a name', cost: 100, description: 'Who?', enabled: true, paused: false, needsInput: true },
{ id: 'r3', title: 'Dance', cost: 50, description: null, enabled: true, paused: true, needsInput: false },
{ id: 'r4', title: 'Costly', cost: 9000, description: null, enabled: true, paused: false, needsInput: false },
{ id: 'a1', title: 'Choose an Emote to Unlock', cost: 480, description: 'Pick a subscriber emote to unlock for 24 hours', enabled: true, paused: false, needsInput: false, choices: [{ id: 'e1', label: 'binxbWut' }, { id: 'e2', label: 'binxbYAP' }] },
{ id: 'a3', title: 'Modify a Single Emote', cost: 550, description: null, enabled: true, paused: false, needsInput: false, choices: [{ id: 'e1_BW', label: 'binxbWut, black and white', group: 'binxbWut', groupImage: null }, { id: 'e1_SG', label: 'binxbWut, with sunglasses', group: 'binxbWut', groupImage: null }, { id: 'e2_SG', label: 'binxbYAP, with sunglasses', group: 'binxbYAP', groupImage: null }] },
{ id: 'a2', title: 'Sub-only', cost: 350, description: null, enabled: true, paused: false, needsInput: false, unavailable: 'Send this one from twitch.tv chat box' } ] }),
redeemTwitchReward: (p) => { window.__redeemed.push(p); return { points: 600 } },
listKickRewards: () => ({ points: 1200, rewards: [ { id: 'k1', title: 'Hydrate', cost: 300, enabled: true, paused: false, needsInput: false }, { id: 'k2', title: 'Type something', cost: 100, enabled: true, paused: false, needsInput: true } ] }),
redeemKickReward: (p) => { window.__redeemed.push(p); return { points: 900 } },
twitchPoints: () => ({ points: 600, name: 'Sloths' }),
kickPoints: () => ({ points: 1200 })
}
const s = window.__mohoShots.store
const pic = 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#7b5cd6"/></svg>')
const accts = [
{ id: 'twitch:me', service: 'twitch', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, webSession: true, nick: 'me' },
{ id: 'kick:me', service: 'kick', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, nick: 'me' }
]
const bufs = [
{ id: 'twitch:me|xqc', accountId: 'twitch:me', kind: 'channel', name: 'xqc', lastActivityTs: 5, avatarUrl: pic, groupId: 'account:twitch:me' },
{ id: 'kick:me|spreen', accountId: 'kick:me', kind: 'channel', name: 'spreen', lastActivityTs: 5, avatarUrl: pic, groupId: 'account:kick:me' }
]
s.set({ accounts: accts, groups: accts.map((a, i) => ({ id: 'account:' + a.id, accountId: a.id, service: a.service, kind: 'account', name: 'me', position: i })), buffers: bufs, activeBufferId: 'twitch:me|xqc', twitchPoints: { 'twitch:me|xqc': { points: 600, name: 'Sloths' } }, kickPointsByBuffer: { 'kick:me|spreen': 1200 } })
s.selectGroup('account:twitch:me')
})()`)
await sleep(1500)
await ev(`window.__errs = []; window.addEventListener('error', (e) => window.__errs.push(String(e.message))); window.addEventListener('unhandledrejection', (e) => window.__errs.push('rej ' + String(e.reason)))`)
console.log('Twitch')
const plaque = `document.querySelector('.kick-points-plaque')`
check('the plaque shows the balance with the channel\'s name for its points', await ev(`${plaque}?.innerText.includes('600 Sloths')`))
await ev(`${plaque}.click()`)
await sleep(600)
check('a panel opens over the chat', await ev(`!!document.querySelector('.switcher.rewards')`))
check('it lists the four rewards, cheapest first within the ones that can be had, then the ones that cannot', await ev(`JSON.stringify([...document.querySelectorAll('.reward-row')].map((r) => r.querySelector('.reward-cost').innerText)) === JSON.stringify(['100', '480', '500', '550', '50', '350', '9,000'])`))
check('under two headings', await ev(`JSON.stringify([...document.querySelectorAll('.reward-group')].map((h) => h.innerText.toLowerCase())) === JSON.stringify(['available', 'unavailable'])`))
check('a paused reward and an unaffordable one are disabled with the reason', await ev(`(() => { const r = [...document.querySelectorAll('.reward-row')]; return r.slice(0, 4).every((x) => !x.disabled) && r[4].disabled && r[4].innerText.includes('Paused') && r[5].disabled && r[5].innerText.includes('twitch.tv chat box') && r[6].disabled && r[6].innerText.includes('You have 600 of 9,000') })()`))
await shot('rewards-twitch.png')
await ev(`document.querySelectorAll('.reward-row')[2].click()`)
await sleep(300)
check('the first press arms it and spends nothing', await ev(`!!document.querySelector('.reward-confirm') && window.__redeemed.length === 0`))
await ev(`document.querySelector('.reward-confirm .button.primary').click()`)
await sleep(500)
check('Redeem spends it', await ev(`window.__redeemed.length === 1 && window.__redeemed[0].rewardId === 'r1'`))
await ev(`document.querySelectorAll('.reward-row')[0].click()`)
await sleep(300)
check('a reward that asks for text shows its field, and Redeem waits for it', await ev(`!!document.querySelector('.reward-confirm input') && document.querySelector('.reward-confirm .button.primary').disabled`))
await page.call('Input.insertText', { text: 'Greg' })
await sleep(200)
await ev(`document.querySelector('.reward-confirm .button.primary').click()`)
await sleep(500)
check('what was typed goes with it', await ev(`window.__redeemed[1]?.input === 'Greg'`))
await ev(`document.querySelectorAll('.reward-row')[1].click()`)
await sleep(300)
check('an emote unlock shows the emotes to press, with a search, and Redeem waits for one', await ev(`!document.querySelector('.reward-confirm select') && document.querySelectorAll('.reward-confirm .reward-emote').length === 2 && !!document.querySelector('.reward-confirm input[placeholder="Search emotes"]') && document.querySelector('.reward-confirm .button.primary').disabled`))
await ev(`document.querySelector('.reward-confirm .reward-emote[title="binxbYAP"]').click()`)
await sleep(200)
check('pressing one picks it', await ev(`!document.querySelector('.reward-confirm .button.primary').disabled && document.querySelector('.reward-emote.selected')?.title === 'binxbYAP'`))
await ev(`document.querySelector('.reward-confirm .button.primary').click()`)
await sleep(500)
check('the emote picked goes with it', await ev(`window.__redeemed[2]?.rewardId === 'a1' && window.__redeemed[2]?.choice === 'e2'`))
await ev(`[...document.querySelectorAll('.reward-row')].find((r) => r.innerText.includes('Modify a Single Emote')).click()`)
await sleep(300)
check('a change to an emote shows the emotes first', await ev(`[...document.querySelectorAll('.reward-confirm .reward-emote')].map((b) => b.title).join() === 'binxbWut,binxbYAP'`))
await ev(`document.querySelector('.reward-confirm .reward-emote[title="binxbWut"]').click()`)
await sleep(200)
check('then what can be done to the one picked', await ev(`[...document.querySelectorAll('.reward-confirm .reward-emote')].map((b) => b.title).join() === 'binxbWut, black and white,binxbWut, with sunglasses'`))
await ev(`document.querySelector('.reward-confirm .reward-emote[title="binxbWut, with sunglasses"]').click()`)
await sleep(200)
await ev(`document.querySelector('.reward-confirm .button.primary').click()`)
await sleep(500)
check('the change picked goes with it', await ev(`window.__redeemed[3]?.rewardId === 'a3' && window.__redeemed[3]?.choice === 'e1_SG'`))
await key('Escape')
await sleep(300)
check('Escape closes the panel', await ev(`!document.querySelector('.switcher.rewards')`))
console.log('Kick')
await ev(`window.__mohoShots.store.selectGroup('account:kick:me'); window.__mohoShots.store.selectBuffer('kick:me|spreen', true)`)
await sleep(1200)
await ev(`document.querySelector('.kick-points-plaque')?.click()`)
await sleep(600)
check('Kick opens the same panel', await ev(`!!document.querySelector('.switcher.rewards') && document.querySelectorAll('.reward-row').length === 2`))
check('a Kick reward that asks for text is disabled with a reason', await ev(`(() => { const r = document.querySelectorAll('.reward-row')[1]; return r.disabled && r.innerText.includes('cannot do') })()`))
await shot('rewards-kick.png')
} finally {
await stop()
}
console.log(results.every(Boolean) ? 'ALL OK' : `${results.filter((r) => !r).length} FAILED`)
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env node
/**
* Makes a Twitch integrity token the way the app does when Twitch asks for one - in a hidden page on
* twitch.tv, from main - and then uses it for a stream's playback token from outside the page, to show the
* token is the kind Twitch's GraphQL takes.
*
* node scripts/check-twitch-integrity.mjs
*/
import { cdp, freePort, launchApp, sleep } from './lib/harness.mjs'
const inspectPort = await freePort()
const { stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' }, args: [`--inspect=${inspectPort}`] })
try {
let main = null
for (let i = 0; i < 60 && !main; i++) {
try {
const list = await (await fetch(`http://127.0.0.1:${inspectPort}/json/list`)).json()
const target = list.find((t) => t.webSocketDebuggerUrl)
if (target) main = cdp(target.webSocketDebuggerUrl)
} catch {
/* not up yet */
}
await sleep(500)
}
if (!main) throw new Error('main never offered its inspector')
for (let i = 0; i < 40 && !(await main.evaluate('!!globalThis.__mohoTwitchIntegrity').catch(() => false)); i++) await sleep(500)
const device = `check${Math.random().toString(16).slice(2, 14)}`
const t0 = Date.now()
const made = await main.evaluate(`globalThis.__mohoTwitchIntegrity.mint(${JSON.stringify(device)})`)
console.log(`made in ${((Date.now() - t0) / 1000).toFixed(1)}s:`, { prefix: made.token.slice(0, 9), length: made.token.length, goodForHours: +((made.expiration - Date.now()) / 3600000).toFixed(1) })
// From outside any page: the player's own token query, with the integrity token and the device it was made for.
const res = await fetch('https://gql.twitch.tv/gql', {
method: 'POST',
headers: { 'Client-ID': 'kimne78kx3ncx6brgo4mv6wki5h1ko', 'Content-Type': 'text/plain;charset=UTF-8', 'X-Device-Id': device, 'Client-Integrity': made.token },
body: JSON.stringify({ operationName: 'PlaybackAccessToken', variables: { isLive: true, login: 'xqc', isVod: false, vodID: '', playerType: 'site', platform: 'web' }, extensions: { persistedQuery: { version: 1, sha256Hash: 'ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9' } } })
})
const body = await res.json()
const token = body?.data?.streamPlaybackAccessToken
console.log('playback token call', res.status, token ? `token for device ${JSON.parse(token.value).device_id}` : JSON.stringify(body).slice(0, 200))
} finally {
await stop()
}
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env node
/**
* Drives the sign-in window of a scratch moho without anybody signing in: after the window opens, a made-up
* `auth-token` cookie stands in for the one a real sign-in leaves, and the window is watched to see that it is
* taken to Twitch's approval page and kept there when something sends it back to the front page. Nothing real
* is typed or approved; the sign-in is cancelled at the end.
*
* node scripts/check-twitch-signin-window.mjs
*/
import { cdp, launchApp, sleep } from './lib/harness.mjs'
const { page, stop, port } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
const ev = (code) => page.evaluate(code)
const targets = async () => (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()).filter((t) => t.type === 'page' && /twitch\.tv/.test(t.url))
const clickText = (selector, text) => ev(`(() => { const b = [...document.querySelectorAll(${JSON.stringify(selector)})].find((x) => x.textContent.trim().startsWith(${JSON.stringify(text)})); if (!b) return false; b.click(); return true })()`)
try {
await sleep(2500)
await ev(`window.__mohoShots.store.setActivePanel('accounts')`)
await sleep(600)
await clickText('.service-chip', 'Twitch')
await sleep(500)
say('sign in', await clickText('button', 'Sign in with Twitch'))
let t
for (let i = 0; i < 40 && !t; i++) {
await sleep(500)
t = (await targets())[0]
}
say('window at', t?.url)
const win = cdp(t.webSocketDebuggerUrl)
await sleep(3000)
await win.call('Network.enable')
await win.call('Network.setCookie', { name: 'auth-token', value: 'madeupmadeupmadeup', domain: '.twitch.tv', path: '/', secure: true })
say('cookie set')
let at = ''
for (let i = 0; i < 40 && !/activate/.test(at); i++) {
await sleep(500)
at = (await targets())[0]?.url ?? ''
}
say('window now at', at)
// Something sends it back to the front page, as a late redirect would.
await win.evaluate(`location.href = 'https://www.twitch.tv/'`).catch(() => {})
const seen = []
for (let i = 0; i < 24; i++) {
await sleep(250)
const u = (await targets())[0]?.url ?? ''
const where = new URL(u).pathname
if (seen[seen.length - 1] !== where) seen.push(where)
}
say('the window went', seen.join(' -> '))
await sleep(6000)
say('page text', JSON.stringify(await win.evaluate(`document.body.innerText.slice(0, 300)`)))
say('inputs', JSON.stringify(await win.evaluate(`[...document.querySelectorAll('input')].map((i) => ({ type: i.type, name: i.name, id: i.id, ph: i.placeholder, max: i.maxLength, value: i.value, shown: i.getClientRects().length > 0 }))`)))
win.close()
} finally {
await stop()
}
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* The Twitch parts of the window, in a scratch moho with injected state (nothing is contacted): the list's fixed
* headings, whispers' badge, Settings -> Twitch, Ctrl+K finding a Twitch channel, readable name colours, emotes
* and cheers drawn as pictures, links, and a signed-in account's composer with no GIF button.
*
* node scripts/check-twitch-ui.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
const shot = async (name) => {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
}
const results = []
const check = (what, ok, detail = '') => {
results.push(ok)
console.log(ok ? ' ok ' : ' FAIL', what, detail)
}
const key = (k, mods = 0) => page.call('Input.dispatchKeyEvent', { type: 'keyDown', key: k, code: k.length === 1 ? 'Key' + k.toUpperCase() : k, modifiers: mods, windowsVirtualKeyCode: k.length === 1 ? k.toUpperCase().charCodeAt(0) : 0 }).then(() => page.call('Input.dispatchKeyEvent', { type: 'keyUp', key: k, modifiers: mods }))
try {
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1200, height: 800, deviceScaleFactor: 1, mobile: false })
await ev(`(() => {
const s = window.__mohoShots.store
const acct = { id: 'twitch:me', service: 'twitch', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, webSession: true, moderation: true, nick: 'me' }
const pic = 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#7b5cd6"/></svg>')
const mk = (name, kind, t, cat) => ({ id: 'twitch:me|' + name, accountId: 'twitch:me', kind, name, lastActivityTs: t, avatarUrl: pic, category: cat, groupId: 'account:twitch:me' })
const buffers = [mk('xqc', 'channel', 5, 'Channels'), mk('skitzy_va', 'channel', 4, 'Channels'), mk('@friend', 'dm', 6, 'Direct Messages')]
const t = Math.floor(Date.now() / 1000) - 600
const m = (i, from, body, extra = {}) => ({ id: 'm' + i, bufferId: 'twitch:me|xqc', from, body, ts: t + i * 20, kind: 'chat', isAction: false, isHighlight: false, isOwn: false, edited: false, ...extra })
const messages = [
m(1, 'darkblue', 'a very dark colour name', { senderColor: '#0000FF' }),
m(2, 'pinkie', 'with an emote [twitch:emote:25:Kappa] and a moving [twitch:aemote:emotesv2_abc:Dance] one'),
m(3, 'whale', 'cheered [twitch:cheer:cheer:100] for the stream', {}),
m(4, 'linker', 'see https://example.com/page for details'),
m(5, 'newbie', 'first message here', { firstMessage: true })
]
s.set({ accounts: [acct], groups: [{ id: 'account:twitch:me', accountId: 'twitch:me', service: 'twitch', kind: 'account', name: 'me', position: 0 }], buffers, messagesByBuffer: { 'twitch:me|xqc': messages }, activeBufferId: 'twitch:me|xqc' })
s.selectGroup('account:twitch:me')
})()`)
await sleep(1500)
console.log('Channel list')
const rows = await ev(`[...document.querySelectorAll('.category, .buffer-row')].map((r) => r.innerText.replace(/\\n/g, ' ').trim())`)
check('fixed headings with the whisper under Direct Messages', JSON.stringify(rows).includes('DIRECT MESSAGES') && JSON.stringify(rows).includes('CHANNELS'), JSON.stringify(rows))
check('the whisper row has the mail badge', await ev(`!!document.querySelector('.avatar-badge')`), await ev(`[...document.querySelectorAll('.buffer-row')].map((r) => r.innerHTML.slice(0, 200)).join(' || ')`))
console.log('Messages')
check('a very dark name colour is made readable', await ev(`(() => { const e = [...document.querySelectorAll('.message-sender, .message-from, .sender')].find((x) => x.textContent.includes('darkblue')); if (!e) return false; const m = getComputedStyle(e).color.match(/\\d+/g).map(Number); return (0.2126 * m[0] + 0.7152 * m[1] + 0.0722 * m[2]) > 90 })()`))
check('emotes are pictures', (await ev(`document.querySelectorAll('.message-row img[src*="emoticons"], .message-row img.custom-emoji').length`)) >= 2, String(await ev(`[...document.querySelectorAll('.message-row img')].map((i) => i.src.slice(0, 60)).join(' | ')`)))
check('a cheer is drawn with its amount', await ev(`document.body.innerText.includes('100')`))
check('a link is a link', await ev(`!!document.querySelector('.message-row a[href^="https://example.com"], .message-row .link')`))
await shot('twitch-messages.png')
console.log('Composer')
check('the signed-in composer has an emoji button and no GIF button', await ev(`!!document.querySelector('button[title="Emoji"]') && !document.querySelector('button[title="GIFs"]')`))
console.log('Ctrl+K')
await key('k', 2)
await sleep(600)
const opened = await ev(`!!document.querySelector('input[placeholder*="Where"], .switcher, [role="dialog"] input')`)
check('the quick switcher opens', opened)
if (opened) {
await ev(`(() => { const i = document.querySelector('.switcher input, [role="dialog"] input, input[placeholder*="Where"]'); i.focus() })()`)
await page.call('Input.insertText', { text: 'skitz' })
await sleep(500)
check('it finds a Twitch channel by name', await ev(`document.body.innerText.includes('skitzy_va')`))
await key('Escape')
await sleep(300)
}
console.log('Settings')
await ev(`window.__mohoShots.store.setActivePanel('settings')`)
await sleep(800)
const clicked = await ev(`(() => { const b = [...document.querySelectorAll('button, [role=tab], a')].find((x) => x.textContent.trim() === 'Twitch'); if (!b) return false; b.click(); return true })()`)
check('Settings has a Twitch page', clicked)
await sleep(600)
const text = await ev(`document.body.innerText`)
for (const want of ['What to show besides chat', 'Badges', 'Watching', 'Polls, predictions and raids', 'Recent messages']) check(`Settings -> Twitch has "${want}"`, text.includes(want))
await shot('twitch-settings.png')
} finally {
await stop()
}
console.log(results.every(Boolean) ? 'ALL OK' : `${results.filter((r) => !r).length} FAILED`)
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env node
/**
* Checks GIFs against the running app: that one a message carries is drawn as
* the picture and not as its address, that a link to a page nothing could read
* is still a link, and that the picker finds, filters and sends.
*
* What it stands in for, and why: Discord is not here, so the picker's list is
* left for it (a fixture only a harness window reads) and the embeds are put on
* messages directly. What is real is everything the window does with them, and
* the send, which goes through the daemon to a fake IRC server and is read back
* off the wire.
*
* npm run test:gif (builds first; needs the daemon built too)
*/
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fakeIrc, launchApp, openIrcChannel, sleep, until, click } from './lib/harness.mjs'
let failures = 0
const check = (ok, what, detail = '') => {
console.log(`${ok ? 'ok ' : 'FAIL'} ${what}${ok ? '' : detail ? ` - ${detail}` : ''}`)
if (!ok) failures++
}
setTimeout(() => {
console.log('FAIL timed out')
process.exit(1)
}, 120000).unref()
// A GIF and a video to be served, so what is drawn has something to load: made
// with ffmpeg, a second of a flat colour. The window only plays https, blob and
// data media, so they are handed to it as the last two.
const media = fs.mkdtempSync(path.join(os.tmpdir(), 'moho-gif-'))
execFileSync('ffmpeg', ['-v', 'error', '-f', 'lavfi', '-i', 'color=c=0x3a8fe0:s=96x54:d=1:r=10', '-pix_fmt', 'yuv420p', '-y', path.join(media, 'cat.mp4')])
execFileSync('ffmpeg', ['-v', 'error', '-f', 'lavfi', '-i', 'color=c=0xe0a43a:s=96x54:d=1:r=10', '-y', path.join(media, 'giphy.gif')])
const MP4 = fs.readFileSync(path.join(media, 'cat.mp4')).toString('base64')
const GIF = fs.readFileSync(path.join(media, 'giphy.gif')).toString('base64')
const sent = []
const irc = await fakeIrc({
people: ['bob'],
onJoin: (channel, _nick, send) => send(`:bob!b@h PRIVMSG ${channel} :hello there`),
onLine: (line) => {
if (/^PRIVMSG/i.test(line)) sent.push(line)
}
})
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
let home = ''
try {
home = await page.evaluate('window.moho.diagnostics?.home ?? ""').catch(() => '')
await until(page, 'window.__mohoShots', 'the harness hook')
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#lobby'], waitFor: 'hello there' })
// The same GIF, as a message that carries it.
const SIZE = (a, b, label) =>
'data:image/svg+xml;utf8,' +
encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="480" height="270"><rect width="480" height="270" fill="${a}"/><text x="240" y="150" font-size="60" fill="${b}" text-anchor="middle">${label}</text></svg>`)
await page.evaluate(`(() => {
const shots = window.__mohoShots
const s = shots.state()
const id = s.activeBufferId
const now = Math.floor(Date.now() / 1000)
const bytes = Uint8Array.from(atob('${MP4}'), (c) => c.charCodeAt(0))
const MP4_URL = URL.createObjectURL(new Blob([bytes], { type: 'video/mp4' }))
const GIF_URL = 'data:image/gif;base64,${GIF}'
const msg = (n, body, embeds) => ({ id: 'g' + n, bufferId: id, from: 'bob', senderId: 'bob', body, ts: now - (10 - n), isAction: false, isHighlight: false, kind: 'chat', isOwn: false, ...(embeds ? { embeds } : {}) })
shots.patch({
accounts: [...s.accounts, { id: 'discord:gifcheck', service: 'discord', displayName: 'x', state: 'connected', autojoin: '', currentNick: 'x', status: 'online', useTor: false, ssl: true }],
messagesByBuffer: { ...s.messagesByBuffer, [id]: [...(s.messagesByBuffer[id] || []),
msg(1, 'https://tenor.com/view/cat-gif-123', [{ kind: 'gif', url: 'https://tenor.com/view/cat-gif-123', mediaUrl: MP4_URL, mediaKind: 'video', width: 480, height: 270, provider: 'Tenor' }]),
msg(2, 'look at this https://giphy.com/gifs/dog-Z7wFi5b2VEAr6 so good', [{ kind: 'gif', url: 'https://giphy.com/gifs/dog-Z7wFi5b2VEAr6', mediaUrl: GIF_URL, mediaKind: 'image', provider: 'Giphy' }]),
msg(3, 'https://klipy.com/gifs/friends-joey-53')
] }
})
window.__gifFixture = {
categories: [{ name: 'reaction', src: '' }, { name: 'happy', src: '' }],
gifs: [
{ id: 'a', title: 'cat dancing', url: 'https://tenor.com/view/cat-dancing-gif-1', src: 'http://127.0.0.1:9/a.mp4', gifSrc: 'http://127.0.0.1:9/a.gif', preview: '', width: 480, height: 270 },
{ id: 'b', title: 'dog running', url: 'https://tenor.com/view/dog-running-gif-2', src: 'http://127.0.0.1:9/b.mp4', gifSrc: 'http://127.0.0.1:9/b.gif', preview: '', width: 270, height: 480 },
{ id: 'c', title: 'cat sleeping', url: 'https://tenor.com/view/cat-sleeping-gif-3', src: 'http://127.0.0.1:9/c.mp4', gifSrc: 'http://127.0.0.1:9/c.gif', preview: '', width: 480, height: 480 }
]
}
return true
})()`)
await sleep(800)
const row = (n) => `[...document.querySelectorAll('.message-row')].find((r) => r.innerText.includes('${n}') || r.querySelector('[data-id="g${n}"]'))`
// A message that carries a GIF shows the GIF, and not the address it came from.
const rows = await page.evaluate(`[...document.querySelectorAll('.message-row')].map((r) => ({ text: r.innerText, videos: [...r.querySelectorAll('video')].map((v) => v.getAttribute('src')), plays: [...r.querySelectorAll('video')].map((v) => ({ muted: v.muted, loop: v.loop, controls: v.controls, autoplay: v.autoplay })), images: [...r.querySelectorAll('img')].map((i) => i.getAttribute('src')) }))`)
if (process.env.GIF_DEBUG) console.log(JSON.stringify(rows.slice(-4), null, 1))
const tenor = rows.find((r) => r.videos.some((v) => v?.startsWith('blob:')))
check(!!tenor, 'a GIF message is drawn as a video')
check(!!tenor && !/tenor\.com/.test(tenor.text), 'and its page address is not drawn as well', tenor?.text)
const play = tenor?.plays?.[0]
check(!!play && play.muted && play.loop && !play.controls && play.autoplay, 'played as a GIF: silent, going round, with no player to work', JSON.stringify(play))
const giphy = rows.find((r) => r.text.includes('look at this'))
check(!!giphy && /look at this/.test(giphy.text) && /so good/.test(giphy.text) && !/giphy\.com/.test(giphy.text), 'words around a GIF link stay, and the link goes', giphy?.text)
check(!!giphy && (giphy.images.some((i) => i?.startsWith('data:image/gif'))), 'and a GIF file is drawn as a picture')
const klipy = rows.find((r) => r.text.includes('klipy.com'))
check(!!klipy && klipy.videos.length === 0, 'a Klipy page nothing could read stays a link', JSON.stringify(klipy))
// The picker.
check(await page.evaluate('!!document.querySelector(\'button[title="GIFs"]\')'), 'a GIF button is offered where there is a Discord account')
await page.evaluate(click('button[title="GIFs"]'))
await until(page, 'document.querySelector(".gif-picker")', 'the picker')
check((await page.evaluate('document.querySelectorAll(".gif-cell").length')) === 3, 'it lists what it was given')
check((await page.evaluate('document.querySelectorAll(".gif-category").length')) === 2, 'with categories to start from')
const inView = await page.evaluate(`(() => { const r = document.querySelector('.gif-picker').getBoundingClientRect(); return r.left >= 0 && r.top >= 0 && r.right <= window.innerWidth && r.bottom <= window.innerHeight })()`)
check(inView, 'inside the window')
await page.evaluate(`(() => { const i = document.querySelector('.gif-search input'); const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; set.call(i, 'cat'); i.dispatchEvent(new Event('input', { bubbles: true })); return true })()`)
await sleep(700)
check((await page.evaluate('document.querySelectorAll(".gif-cell").length')) === 2, 'a search narrows it')
// Choosing one sends it - to IRC, the file, which anything can show as a link.
await page.evaluate(`document.querySelector('.gif-cell[title="cat dancing"]').click()`)
await sleep(1200)
check(sent.some((l) => /PRIVMSG #lobby :?http:\/\/127\.0\.0\.1:9\/a\.gif/.test(l)), 'choosing one sends the file to a service that is not Discord', JSON.stringify(sent))
check(!(await page.evaluate('!!document.querySelector(".gif-picker")')), 'and the picker closes')
} catch (e) {
console.log('FAIL', e.message)
failures++
} finally {
await stop().catch(() => {})
irc.close()
fs.rmSync(media, { recursive: true, force: true })
}
console.log(failures ? `${failures} failed` : 'all passed')
process.exit(failures ? 1 : 0)
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env node
/**
* Checks where a Kick event card sits in the log, in each layout.
*
* A card is what a person did, so it takes the column a person's picture would
* (and not the mark a join gets), what is written in it starts where a name
* does, and it has the air above it that a message and an embed have. These
* are measured against a real chat line beside it rather than against numbers,
* so they hold when the layout's own numbers change.
*
* npm run test:kick (builds first; needs the daemon built too)
*/
import { fakeIrc, launchApp, openIrcChannel, sleep, until, click } from './lib/harness.mjs'
let failures = 0
const check = (ok, what, detail = '') => {
console.log(`${ok ? 'ok ' : 'FAIL'} ${what}${ok ? '' : detail ? ` - ${detail}` : ''}`)
if (!ok) failures++
}
setTimeout(() => {
console.log('FAIL timed out')
process.exit(1)
}, 120000).unref()
const irc = await fakeIrc({ people: ['bob'], onJoin: (channel, _nick, send) => send(`:bob!b@h PRIVMSG ${channel} :hello there`) })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const stage = `(() => {
const shots = window.__mohoShots
const s = shots.state()
const id = s.activeBufferId
const now = Math.floor(Date.now() / 1000)
const row = (n, kind, body, embed, from) => ({ id: 'k' + n, bufferId: id, from, senderId: from, body, ts: now - 600 + n * 70, isAction: false, isHighlight: false, kind, isOwn: false, embeds: embed ? [embed] : [] })
shots.patch({ messagesByBuffer: { ...s.messagesByBuffer, [id]: [
row(1, 'chat', 'that was a clean run', null, 'bob'),
row(2, 'chat', 'and then it got worse', null, 'carol'),
row(3, 'kicks', 'Currington sent 1 Kick', { kind: 'kicks', author: 'Currington', title: '1', color: 0x53fc18 }, 'kicker'),
row(4, 'sub', 'ItsDez gifted 2 subscriptions', { kind: 'gift-sub', author: 'ItsDez', title: '2', description: 'a\\nb' }, 'kicker'),
row(5, 'reward', 'poo_nana redeemed MUTE', { kind: 'redeem', author: 'poo_nana', title: 'MUTE (user) 30 MINUTES', description: 'O_C_T_O', color: 0xff4b1f }, 'kicker'),
row(6, 'raid', 'x raided with 25 viewers', { kind: 'raid', author: 'kreationX', title: '25' }, 'kicker'),
row(8, 'sub', 'bigkhernandez subscribed - 21 months', { kind: 'subscribe', author: 'bigkhernandez', title: '21' }, 'kicker'),
row(9, 'moderation', 'troll was timed out by mod', { kind: 'moderation', author: 'troll', title: 'was timed out', description: 'mod' }, 'kicker'),
row(10, 'stream', 'went live', { kind: 'stream', author: 'streamer', title: 'went live', icon: 'live' }, 'kicker'),
row(11, 'chat', 'thanks everybody', null, 'dave')
] } })
})()`
const measure = `(() => {
const x = (el) => (el ? Math.round(el.getBoundingClientRect().left) : null)
const rows = [...document.querySelectorAll('.message-row')]
const chat = rows.find((r) => !r.classList.contains('has-kick-event') && r.querySelector('.message-from'))
const cards = rows.filter((r) => r.classList.contains('has-kick-event'))
const margin = (r) => parseFloat(getComputedStyle(r).marginTop)
return {
marks: cards.filter((r) => r.querySelector('.system-mark')).length,
cards: cards.length,
chatEdge: x(chat.querySelector('.message-avatar img, .message-avatar .avatar-fallback') || chat.querySelector('.message-content')),
chatName: x(chat.querySelector('.message-from')),
cardEdge: cards.map((r) => x(r.querySelector('.kick-event'))),
cardName: cards.map((r) => x(r.querySelector('.kick-event-name'))),
gapAbove: cards.map(margin),
chatGap: margin(chat) || 0,
wide: cards.map((r) => Math.round(r.querySelector('.kick-event').getBoundingClientRect().width))
}
})()`
try {
await until(page, 'window.__mohoShots', 'the harness hook')
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#lobby'], waitFor: 'hello there' })
for (const mode of ['comfy', 'bubbles', 'classic']) {
await page.evaluate(`window.moho.prefs.set('display.messageMode', ${JSON.stringify(mode)})`)
await page.call('Page.reload')
await until(page, 'window.__mohoShots && document.querySelector("nav[aria-label=Servers] [aria-label]")', 'the window after a reload')
await sleep(500)
await page.evaluate(click('nav[aria-label=Servers] [aria-label*="localhost"]'))
await sleep(300)
await page.evaluate(click('.buffer-row', '#lobby'))
await sleep(500)
await page.evaluate(stage)
await sleep(700)
const m = await page.evaluate(measure)
console.log(`${mode}: ${JSON.stringify(m)}`)
check(m.cards === 7, `${mode}: every kind of card is drawn`, `${m.cards}`)
check(m.marks === 0, `${mode}: no join mark beside a card`, `${m.marks}`)
if (mode !== 'classic') {
// The card starts at the picture's edge.
check(m.cardEdge.every((e) => Math.abs(e - m.chatEdge) <= 2), `${mode}: a card starts where a person's picture does`, `${m.cardEdge} vs ${m.chatEdge}`)
// And what is written in it starts where a name does.
check(m.cardName.every((n) => Math.abs(n - m.chatName) <= 2), `${mode}: the name in a card is where a name is in the log`, `${m.cardName} vs ${m.chatName}`)
}
check(m.gapAbove.every((g) => g >= (mode === 'comfy' ? m.chatGap : 4) - 0.5), `${mode}: a card has air above it`, `${m.gapAbove} vs ${m.chatGap}`)
check(m.wide.every((w) => w > 200), `${mode}: and is not squeezed or centred away`, `${m.wide}`)
}
} finally {
await stop()
irc.close()
}
console.log(failures ? `${failures} FAILED` : 'all passed')
process.exit(failures ? 1 : 0)
+310
View File
@@ -0,0 +1,310 @@
/**
* What the scripts that drive the real app share: a small fake IRC server to
* give it something to show, the app launched against a throwaway home, and a
* line to its window over the DevTools protocol.
*
* The app never touches a real account or the daemon of a moho already
* running: HOME, the XDG directories and - because the daemon's socket lives
* there - XDG_RUNTIME_DIR are all its own.
*/
import { spawn, execFileSync } from 'node:child_process'
import fs from 'node:fs'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import electronPath from 'electron'
export const root = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))))
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
// --- a fake IRC server ---------------------------------------------------
/**
* A server that registers anybody and, when they join a channel, says
* whatever `onJoin` says there.
*
* `caps` are offered and granted whole. `server-time` among them lets a line
* carry when it was said (`at(date)` builds the tag), which is how history
* from other days reaches the client.
*/
export function fakeIrc({ people = [], caps = [], onJoin, onLine } = {}) {
const server = net.createServer((socket) => {
let nick = 'checker'
let negotiating = false
let registered = false
const send = (line) => socket.write(line + '\r\n')
const welcome = () => {
if (registered) return
registered = true
send(`:fake 001 ${nick} :Welcome`)
send(`:fake 376 ${nick} :End of MOTD`)
}
let buffered = ''
socket.on('error', () => {})
socket.on('data', (chunk) => {
buffered += chunk.toString()
let at
while ((at = buffered.indexOf('\n')) >= 0) {
const line = buffered.slice(0, at).replace(/\r$/, '')
buffered = buffered.slice(at + 1)
onLine?.(line, send)
const [command, ...args] = line.split(' ')
switch (command.toUpperCase()) {
case 'CAP':
if (args[0] === 'LS') {
negotiating = true
send(`:fake CAP * LS :${caps.join(' ')}`)
} else if (args[0] === 'REQ') {
send(`:fake CAP * ACK ${args.slice(1).join(' ')}`)
} else if (args[0] === 'END') {
negotiating = false
welcome()
}
break
case 'NICK':
nick = args[0]
break
case 'USER':
if (!negotiating) welcome()
break
case 'PING':
send(`:fake PONG fake ${args.join(' ')}`)
break
case 'JOIN':
for (const channel of args[0].split(',')) {
send(`:${nick}!u@h JOIN ${channel}`)
send(`:fake 353 ${nick} = ${channel} :${[nick, ...people].join(' ')}`)
send(`:fake 366 ${nick} ${channel} :End of NAMES`)
onJoin?.(channel, nick, send)
}
break
}
}
})
})
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server)))
}
/** The server-time tag for a moment. */
export const at = (date) => `@time=${date.toISOString()} `
// --- the app, and a line to it --------------------------------------------
export async function freePort() {
const probe = net.createServer()
await new Promise((r) => probe.listen(0, '127.0.0.1', r))
const { port } = probe.address()
await new Promise((r) => probe.close(r))
return port
}
async function pageTarget(port) {
for (let i = 0; i < 120; i++) {
try {
const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
const page = list.find((t) => t.type === 'page' && !t.url.startsWith('devtools:'))
if (page) return page
} catch {
/* not up yet */
}
await sleep(500)
}
throw new Error('the app never opened a window')
}
export function cdp(url) {
const socket = new WebSocket(url)
let id = 0
const waiting = new Map()
const listeners = new Map()
socket.addEventListener('message', (e) => {
const msg = JSON.parse(e.data)
if (msg.id && waiting.has(msg.id)) {
const { resolve, reject } = waiting.get(msg.id)
waiting.delete(msg.id)
if (msg.error) reject(new Error(msg.error.message))
else resolve(msg.result)
} else if (msg.method) {
for (const fn of listeners.get(msg.method) ?? []) fn(msg.params)
}
})
const ready = new Promise((r) => socket.addEventListener('open', r))
const call = async (method, params = {}) => {
await ready
const n = ++id
socket.send(JSON.stringify({ id: n, method, params }))
return new Promise((resolve, reject) => waiting.set(n, { resolve, reject }))
}
const evaluate = async (expression) => {
const r = await call('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text)
return r.result.value
}
const on = (method, fn) => {
if (!listeners.has(method)) listeners.set(method, new Set())
listeners.get(method).add(fn)
return () => listeners.get(method).delete(fn)
}
return { call, evaluate, on, close: () => socket.close() }
}
/** Waits until an expression in the page is truthy. */
export async function until(page, expression, what, ms = 30000) {
const end = Date.now() + ms
while (Date.now() < end) {
if (await page.evaluate(`!!(${expression})`).catch(() => false)) return
await sleep(250)
}
throw new Error(`timed out waiting for ${what}`)
}
/** Clicks the first element matching `selector` whose text or title holds `text`. */
export const click = (selector, text) => `(() => {
const el = [...document.querySelectorAll(${JSON.stringify(selector)})]
.find((e) => ${JSON.stringify(text ?? '')} === '' || (e.innerText + ' ' + (e.title || '')).includes(${JSON.stringify(text ?? '')}))
if (!el) return false
el.click()
return true
})()`
/**
* On Hyprland, out of the way: a window the harness opens should not land on
* top of what the person running it is doing. Anywhere else, left alone.
*/
function moveAside(pid) {
if (!process.env.HYPRLAND_INSTANCE_SIGNATURE) return
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j'], { encoding: 'utf8' }))
const descendants = new Set([pid])
// The window belongs to Electron's browser process, a child of the one
// spawned; walking /proc is enough to find it.
for (const entry of fs.readdirSync('/proc')) {
if (!/^\d+$/.test(entry)) continue
try {
const stat = fs.readFileSync(`/proc/${entry}/stat`, 'utf8')
const ppid = Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1])
if (descendants.has(ppid)) descendants.add(Number(entry))
} catch {
/* gone */
}
}
for (const c of clients) {
if (!descendants.has(c.pid)) continue
execFileSync('hyprctl', [
'dispatch',
`hl.dsp.window.move({ window = "address:${c.address}", workspace = "${process.env.MOHO_HARNESS_WORKSPACE ?? '2'}", silent = true })`
])
}
} catch {
/* not this compositor's dialect; leave it */
}
}
/**
* The built app, against a scratch home, with a debugging port. Returns the
* page and a `stop()` that takes everything down again.
*/
export async function launchApp({ env = {}, args = [], seed } = {}) {
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'moho-ui-'))
const run = path.join(scratch, 'run')
fs.mkdirSync(run, { mode: 0o700 })
// Files the app should find on its first start, written into its own home.
seed?.(scratch)
const port = await freePort()
// A CI runner's kernel refuses Chromium's sandbox (Ubuntu 24.04 restricts
// the unprivileged namespaces it needs); there it runs without, as an
// ordinary Chromium in a container does.
const flags = [`--remote-debugging-port=${port}`, ...(process.env.CI ? ['--no-sandbox'] : []), ...args]
const app = spawn(electronPath, [root, ...flags], {
cwd: root,
detached: true,
stdio: process.env.MOHO_UI_VERBOSE ? 'inherit' : 'ignore',
env: {
...process.env,
...env,
HOME: scratch,
XDG_CONFIG_HOME: path.join(scratch, 'config'),
XDG_CACHE_HOME: path.join(scratch, 'cache'),
XDG_DATA_HOME: path.join(scratch, 'data'),
XDG_STATE_HOME: path.join(scratch, 'state'),
XDG_RUNTIME_DIR: run,
// The runtime directory is moved so the daemon's socket is this run's
// own - which also moves where a Wayland client looks for the
// compositor. An absolute path keeps it pointing at the real one.
...(process.env.WAYLAND_DISPLAY && process.env.XDG_RUNTIME_DIR && !path.isAbsolute(process.env.WAYLAND_DISPLAY)
? { WAYLAND_DISPLAY: path.join(process.env.XDG_RUNTIME_DIR, process.env.WAYLAND_DISPLAY) }
: {})
}
})
const stop = async () => {
page?.close()
try {
process.kill(-app.pid, 'SIGTERM')
} catch {
/* already gone */
}
await sleep(1500)
try {
process.kill(-app.pid, 'SIGKILL')
} catch {
/* already gone */
}
// Whatever a crash left behind that is still pointed at this run's
// directory: Chromium's helpers can outlive the group they were started
// in, and a harness that leaves windows and daemons running is worse than
// one that fails.
for (const entry of fs.readdirSync('/proc')) {
if (!/^\d+$/.test(entry)) continue
try {
if (Number(entry) === process.pid) continue
const cmd = fs.readFileSync(`/proc/${entry}/cmdline`, 'utf8')
// The daemon is started by the app with only the environment to say
// whose it is.
const env = cmd.includes(scratch) ? '' : fs.readFileSync(`/proc/${entry}/environ`, 'utf8')
if (cmd.includes(scratch) || env.includes(`XDG_RUNTIME_DIR=${scratch}`)) process.kill(Number(entry), 'SIGKILL')
} catch {
/* gone, or not ours to read */
}
}
fs.rmSync(scratch, { recursive: true, force: true })
}
let page
try {
page = cdp((await pageTarget(port)).webSocketDebuggerUrl)
moveAside(app.pid)
await until(page, 'window.moho && document.querySelector("#root > *")', 'the window to load')
} catch (e) {
await stop()
throw e
}
return { page, stop, port, scratch }
}
/**
* An IRC account on the fake server, its channels joined, and the window
* showing the first of them.
*/
export async function openIrcChannel(page, { port, nick, channels, waitFor }) {
await until(page, 'window.moho.rpc("listAccounts", {}).then(() => true)', 'the daemon')
await page.evaluate(
`window.moho.rpc("addAccount", { nick: ${JSON.stringify(nick)}, host: "localhost", port: ${port}, ssl: false, autojoin: ${JSON.stringify(channels.join(','))} })`
)
await until(
page,
`window.moho.rpc("listBuffers", {}).then((b) => ${JSON.stringify(channels)}.every((c) => b.some((x) => x.id.endsWith("|" + c))))`,
'the channels'
)
await page.call('Page.reload')
await sleep(1000)
await until(page, 'document.querySelector("nav[aria-label=Servers] [aria-label*=\\"localhost\\"]")', 'the account in the rail')
await page.evaluate(click('nav[aria-label=Servers] [aria-label*="localhost"]'))
await until(page, `[...document.querySelectorAll(".buffer-row")].some((r) => (r.title || r.innerText).includes(${JSON.stringify(channels[0])}))`, 'the channel row')
await page.evaluate(click('.buffer-row', channels[0]))
await until(page, `[...document.querySelectorAll(".message-row")].some((r) => r.innerText.includes(${JSON.stringify(waitFor)}))`, 'the messages')
}
+99
View File
@@ -0,0 +1,99 @@
// The page the Kick video probe plays in: one hls.js player per configuration, measured while it plays.
import Hls from 'hls.js'
import { ivsAbr, ivsLoader } from '../../src/renderer/src/lib/ivsplaylist'
interface Config {
name: string
url: string
seconds: number
prefetch: number
hls: Record<string, unknown>
cap?: number
}
declare global {
interface Window {
runProbe: (config: Config) => Promise<Record<string, unknown>>
}
}
window.runProbe = async (cfg) => {
// How far this clock is from the server's, from the time the playlist says it was made at.
const sent = Date.now()
const text = await (await fetch(cfg.url)).text()
const received = Date.now()
const server = Number(/SERVER-TIME",VALUE="([0-9.]+)"/.exec(text)?.[1] ?? NaN)
const offset = server * 1000 - (sent + received) / 2
return new Promise((resolve) => {
const video = document.getElementById('v') as HTMLVideoElement
const hls = new Hls({ ...cfg.hls, ...(cfg.prefetch > 0 ? { loader: ivsLoader(cfg.prefetch), abrController: ivsAbr() } : {}) } as never)
const latency: number[] = []
const levels = new Set<string>()
const fatal: string[] = []
let stalls = 0
let waits = 0
let switches = 0
let waitStart = 0
let waited = 0
let firstFrame = 0
const began = Date.now()
video.muted = true
video.onwaiting = (): void => {
waits++
if (!waitStart && firstFrame) waitStart = Date.now()
}
video.onplaying = (): void => {
if (!firstFrame) firstFrame = Date.now() - began
if (waitStart) {
waited += Date.now() - waitStart
waitStart = 0
}
}
hls.on(Hls.Events.ERROR, (_e, d) => {
if (d.details === 'bufferStalledError') stalls++
if (d.fatal) fatal.push(d.details)
})
hls.on(Hls.Events.LEVEL_SWITCHED, (_e, d) => {
switches++
levels.add(`${hls.levels[d.level]?.height}p`)
})
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (cfg.cap) {
const best = hls.levels.reduce((b, l, i) => (l.height <= (cfg.cap ?? 720) && l.height >= (hls.levels[b]?.height ?? 0) ? i : b), -1)
if (best >= 0) hls.autoLevelCapping = best
}
void video.play().catch(() => {})
})
hls.attachMedia(video)
hls.loadSource(cfg.url)
const timer = setInterval(() => {
const at = hls.playingDate
if (at && !video.paused && firstFrame) latency.push((Date.now() + offset - at.getTime()) / 1000)
if (Date.now() - began < cfg.seconds * 1000) return
clearInterval(timer)
const q = video.getVideoPlaybackQuality()
const sorted = [...latency].sort((a, b) => a - b)
const pick = (p: number): number => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] ?? NaN
const settled = sorted.slice(Math.floor(sorted.length * 0.2))
const mean = settled.length ? settled.reduce((a, b) => a + b, 0) / settled.length : NaN
hls.destroy()
resolve({
name: cfg.name,
startedInMs: firstFrame,
samples: latency.length,
latencyMin: +Math.min(...latency).toFixed(2),
latencyMax: +Math.max(...latency).toFixed(2),
latencyMedian: +pick(0.5).toFixed(2),
latencyP95: +pick(0.95).toFixed(2),
latencyMeanAfterSettling: +mean.toFixed(2),
rebufferSeconds: +(waited / 1000).toFixed(2),
waits,
stallErrors: stalls,
dropped: `${q.droppedVideoFrames}/${q.totalVideoFrames}`,
levelSwitches: switches,
levels: [...levels].join(','),
fatal: fatal.join(',')
})
}, 1000)
})
}
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env node
// Draws the tray icons into resources/icons/tray-*.png.
//
// A tray cannot be recoloured by the desktop the way a themed icon can, so
// there is one set for a dark panel (a light bubble) and one for a light panel
// (a dark bubble), and main picks by the system's colour scheme. Each set is
// the bubble alone, and the bubble with a red badge carrying the number of
// conversations waiting - 1 to 9, then 9+ - because a tray is the one place
// still in view when a notification has faded, and "something" is a poorer
// answer than "three things".
//
// Run when the artwork changes; the PNGs are committed. Needs rsvg-convert.
import { execFileSync } from 'node:child_process'
import { mkdirSync, writeFileSync, rmSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const out = join(dirname(fileURLToPath(import.meta.url)), '..', 'resources', 'icons')
const SIZE = 64
// The app's mention red, so the badge is the same colour as the one in the window.
const BADGE = '#e5484d'
const BUBBLE = 'M14 8h36a8 8 0 0 1 8 8v22a8 8 0 0 1-8 8H34L22 58V46h-8a8 8 0 0 1-8-8V16a8 8 0 0 1 8-8z'
function svg(ink, label) {
const badge = label !== null
// The badge sits in a gap cut from the bubble, so it reads against any panel
// without a ring of a colour that panel may not be.
// Without one, three dots are cut from it, which is what makes the shape a
// conversation and not a label.
const mask = badge
? `<mask id="m"><rect width="64" height="64" fill="#fff"/><circle cx="46" cy="18" r="19" fill="#000"/></mask>`
: `<mask id="m"><rect width="64" height="64" fill="#fff"/><circle cx="21" cy="27" r="4" fill="#000"/><circle cx="32" cy="27" r="4" fill="#000"/><circle cx="43" cy="27" r="4" fill="#000"/></mask>`
const size = label && label.length > 1 ? 24 : 30
const text = badge
? `<circle cx="46" cy="18" r="16" fill="${BADGE}"/><text x="46" y="${18 + size * 0.36}" font-family="DejaVu Sans" font-weight="bold" font-size="${size}" text-anchor="middle" fill="#fff">${label}</text>`
: ''
return `<svg xmlns="http://www.w3.org/2000/svg" width="${SIZE}" height="${SIZE}" viewBox="0 0 64 64">${mask}<path d="${BUBBLE}" fill="${ink}" mask="url(#m)"/>${text}</svg>`
}
mkdirSync(out, { recursive: true })
const labels = [null, '1', '2', '3', '4', '5', '6', '7', '8', '9', '9+']
for (const [name, ink] of [['light', '#f1f3f5'], ['dark', '#25282e']]) {
for (const label of labels) {
const file = join(out, `tray-${name}${label === null ? '' : `-${label === '9+' ? 'more' : label}`}.png`)
const tmp = `${file}.svg`
writeFileSync(tmp, svg(ink, label))
execFileSync('rsvg-convert', ['-w', String(SIZE), '-h', String(SIZE), '-o', file, tmp])
rmSync(tmp)
}
}
console.log('tray icons written to', out)
-101
View File
@@ -1,101 +0,0 @@
#!/usr/bin/env bash
# Mirrors a published GitHub release of moho to Gitea, and makes nobilis's
# release of the same version on both hosts. Run here, by hand, once the
# release workflow has published the tag on GitHub - Gitea releases are never
# made by Actions.
#
# scripts/mirror-release.sh v1.0.0
#
# moho on Gitea: the same title, notes, pre-release flag and files as GitHub.
# nobilis, on GitHub and Gitea: tagged at the commit moho's tag points to,
# named after the same version, its notes the version's major features and
# the nobilis lines of the changelog, with the two daemon binaries the
# release's run built.
#
# Safe to run again: a release that exists is updated rather than repeated.
set -euo pipefail
TAG="${1:?usage: scripts/mirror-release.sh vX.Y.Z}"
VERSION="${TAG#v}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
GITEA="https://git.salastil.com/api/v1"
TOKEN="$(cat "$HOME/.config/gitea/token")"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
gitea() { # method path [curl args...]
local method="$1" path="$2"; shift 2
curl -fsS -X "$method" -H "Authorization: token $TOKEN" "$GITEA$path" "$@"
}
# Creates or updates a Gitea release and makes its files exactly those given.
gitea_release() { # repo title notes-file prerelease file...
local repo="$1" title="$2" notes="$3" pre="$4"; shift 4
local body id
body="$(python3 -c 'import json,sys; print(json.dumps({"tag_name": sys.argv[1], "name": sys.argv[2], "body": open(sys.argv[3]).read(), "prerelease": sys.argv[4] == "true"}))' "$TAG" "$title" "$notes" "$pre")"
if id="$(gitea GET "/repos/$repo/releases/tags/$TAG" 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"; then
gitea PATCH "/repos/$repo/releases/$id" -H 'Content-Type: application/json' -d "$body" >/dev/null
for asset in $(gitea GET "/repos/$repo/releases/$id/assets" | python3 -c 'import json,sys; print(" ".join(str(a["id"]) for a in json.load(sys.stdin)))'); do
gitea DELETE "/repos/$repo/releases/$id/assets/$asset" >/dev/null
done
else
id="$(gitea POST "/repos/$repo/releases" -H 'Content-Type: application/json' -d "$body" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
fi
for f in "$@"; do
gitea POST "/repos/$repo/releases/$id/assets?name=$(basename "$f")" -F "attachment=@$f" >/dev/null
echo " uploaded $(basename "$f") -> Gitea $repo"
done
}
# --- moho -------------------------------------------------------------------
echo "moho $TAG: GitHub -> Gitea"
read -r PRE TITLE < <(gh release view "$TAG" --repo Moho-Chat/moho --json isPrerelease,name -q '"\(.isPrerelease) \(.name)"')
gh release view "$TAG" --repo Moho-Chat/moho --json body -q .body > "$WORK/moho-notes.md"
mkdir -p "$WORK/moho"
gh release download "$TAG" --repo Moho-Chat/moho --dir "$WORK/moho"
git -C "$ROOT" push -q origin "refs/tags/$TAG" 2>/dev/null || true
gitea_release Salastil/moho "$TITLE" "$WORK/moho-notes.md" "$PRE" "$WORK"/moho/*
# --- nobilis ------------------------------------------------------------------
NOBILIS_SHA="$(git -C "$ROOT" rev-parse "$TAG:nobilis")"
echo "nobilis $TAG at ${NOBILIS_SHA:0:7}"
if ! git -C "$ROOT/nobilis" rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
git -C "$ROOT/nobilis" tag -a "$TAG" "$NOBILIS_SHA" -m "nobilis $VERSION"
fi
git -C "$ROOT/nobilis" push -q github "refs/tags/$TAG"
git -C "$ROOT/nobilis" push -q origin "refs/tags/$TAG"
# Its notes: the version's major features, and the nobilis lines only.
SECTION="$VERSION"
grep -q "^## \[$VERSION\]" "$ROOT/CHANGELOG.md" || SECTION="${VERSION%%-*}"
awk -v v="$SECTION" '
index($0, "## [" v "]") == 1 { on = 1; next }
on && /^## \[/ { exit }
!on { next }
/^### / { fixes = ($0 ~ /^### Fixes/); print; next }
/^- / { if (!fixes || /\[nobilis /) print; next }
{ print }
' "$ROOT/CHANGELOG.md" > "$WORK/nobilis-notes.md"
printf '\nnobilis is the daemon inside moho %s; these are its own builds, for running it on its own.\n' "$VERSION" >> "$WORK/nobilis-notes.md"
# The daemon binaries, from the run that built this release.
RUN="$(gh run list --repo Moho-Chat/moho --workflow release.yml --branch "$TAG" --status success --limit 1 --json databaseId -q '.[0].databaseId')"
[ -n "$RUN" ] || { echo "no successful release run for $TAG" >&2; exit 1; }
mkdir -p "$WORK/nobilis"
gh run download "$RUN" --repo Moho-Chat/moho -n daemon-linux -D "$WORK/d-linux"
gh run download "$RUN" --repo Moho-Chat/moho -n daemon-windows -D "$WORK/d-win"
install -m 755 "$WORK/d-linux/nobilis" "$WORK/nobilis/nobilis-$VERSION-linux-x86_64"
install -m 644 "$WORK/d-win/nobilis.exe" "$WORK/nobilis/nobilis-$VERSION-windows-x86_64.exe"
NOBILIS_FLAGS=(--latest)
[ "$PRE" = "true" ] && NOBILIS_FLAGS=(--prerelease --latest=false)
if gh release view "$TAG" --repo Moho-Chat/nobilis >/dev/null 2>&1; then
gh release edit "$TAG" --repo Moho-Chat/nobilis --notes-file "$WORK/nobilis-notes.md"
gh release upload "$TAG" --repo Moho-Chat/nobilis "$WORK"/nobilis/* --clobber
else
gh release create "$TAG" --repo Moho-Chat/nobilis --verify-tag --title "nobilis $VERSION" \
--notes-file "$WORK/nobilis-notes.md" "${NOBILIS_FLAGS[@]}" "$WORK"/nobilis/*
fi
echo " GitHub Moho-Chat/nobilis $TAG"
gitea_release Salastil/nobilis "nobilis $VERSION" "$WORK/nobilis-notes.md" "$PRE" "$WORK"/nobilis/*
echo "done: moho and nobilis $TAG on GitHub and Gitea"
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* Plays a live Kick stream with several hls.js configurations, one after the other, in the app's own
* Chromium, and says for each how far behind the broadcaster it ran, how often it stopped to wait,
* and how many frames it dropped. Nothing is signed in: the playlist is public.
*
* node scripts/probe-kick-video.mjs [channel] [seconds] (defaults: spreen, 75)
*/
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const channel = process.argv[2] ?? 'spreen'
const seconds = Number(process.argv[3] ?? 75)
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..')
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'kick-video-'))
execFileSync(path.join(root, 'node_modules/.bin/esbuild'), [path.join(root, 'scripts/lib/hlsprobe.ts'), '--bundle', '--format=iife', `--outfile=${path.join(work, 'probe.js')}`, '--log-level=error'])
fs.writeFileSync(path.join(work, 'probe.html'), '<!doctype html><body style="margin:0"><video id="v" width="1280" height="720" autoplay muted playsinline></video><script src="probe.js"></script></body>')
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
const playlist = async () => {
const r = await fetch(`https://kick.com/api/v2/channels/${channel}/playback-url`, { headers: { 'User-Agent': UA, Accept: 'application/json' } })
return (await r.json()).data
}
const base = { capLevelToPlayerSize: true }
const configs = [
{ name: 'as it is now (3 target durations, no prefetch)', prefetch: 0, hls: { ...base, liveSyncDurationCount: 3 } },
{ name: 'seconds: sync 4s, no prefetch', prefetch: 0, hls: { ...base, liveSyncDuration: 4, liveMaxLatencyDuration: 10 } },
{ name: 'prefetch 2, sync 4s', prefetch: 2, hls: { ...base, liveSyncDuration: 4, liveMaxLatencyDuration: 10 } },
{ name: 'prefetch 2, sync 3s, catch-up 1.1', prefetch: 2, hls: { ...base, liveSyncDuration: 3, liveMaxLatencyDuration: 8, maxLiveSyncPlaybackRate: 1.1 } },
{ name: 'prefetch 1, sync 3s, catch-up 1.1', prefetch: 1, hls: { ...base, liveSyncDuration: 3, liveMaxLatencyDuration: 8, maxLiveSyncPlaybackRate: 1.1 } },
{ name: 'prefetch 2, sync 5s, catch-up 1.1', prefetch: 2, hls: { ...base, liveSyncDuration: 5, liveMaxLatencyDuration: 10, maxLiveSyncPlaybackRate: 1.1 } }
]
if (process.env.LIST === 'steady') {
configs.length = 0
configs.push(
{ name: 'prefetch 2, sync 3s', prefetch: 2, hls: { ...base, liveSyncDuration: 3, liveMaxLatencyDuration: 10 } },
{ name: 'prefetch 2, sync 4s', prefetch: 2, hls: { ...base, liveSyncDuration: 4, liveMaxLatencyDuration: 10 } },
{ name: 'prefetch 2, sync 5s', prefetch: 2, hls: { ...base, liveSyncDuration: 5, liveMaxLatencyDuration: 10 } }
)
}
const only = process.env.ONLY ? process.env.ONLY.split(',').map(Number) : null
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
try {
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
await sleep(2500)
await page.call('Page.navigate', { url: `file://${path.join(work, 'probe.html')}` })
await sleep(1500)
for (const [i, cfg] of configs.entries()) {
if (only && !only.includes(i)) continue
const url = await playlist()
const out = await page.evaluate(`window.runProbe(${JSON.stringify({ ...cfg, url, seconds, cap: 720 })})`)
console.log(JSON.stringify(out))
}
} finally {
await stop()
}
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env node
/**
* A one-to-one Steam call with somebody on the official client: a scratch app signed in as
* the throwaway account (never the real one) with a fake microphone joins the group's voice room
* and reports what it hears every ten seconds, until ~/.local/share/moho-dev/stop exists.
*
* MODE=call node scripts/probe-steam-call.mjs (or MODE=wait, which answers the first ring)
*
* npx electron-vite build && node scripts/probe-steam-voice.mjs
*
* Needs the throwaway account's block in ~/.config/nobilis/accounts.toml. The app is the
* one built in out/, with its own home and runtime directory, and signs the account in
* for as long as this runs - so nothing else may be signed in as it.
*/
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const live = fs.readFileSync(path.join(os.homedir(), '.config/nobilis/accounts.toml'), 'utf8')
const block = live.split(/\n(?=\[)/).find((b) => b.startsWith('[[steam_account]]') && /username\s*=\s*"mohotester"/.test(b))
if (!block) throw new Error('the throwaway account is not in accounts.toml')
const { page, stop, scratch } = await launchApp({
env: { MOHO_UI_SHOTS: '1' },
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', `--use-file-for-fake-audio-capture=${os.homedir()}/.local/share/moho-dev/speech.wav`],
seed: (home) => {
fs.mkdirSync(path.join(home, 'config/nobilis'), { recursive: true })
fs.writeFileSync(path.join(home, 'config/nobilis/accounts.toml'), block + '\n')
}
})
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
const ev = (code) => page.evaluate(code)
try {
// The window onto workspace 2, where the user does not look.
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
let account = ''
for (let i = 0; i < 90 && !account; i++) {
account = await ev(`(() => { const a = window.__mohoShots.state().accounts.find((a) => a.id.startsWith('steam:') && a.state === 'connected'); return a ? a.id : '' })()`)
if (!account) await sleep(1000)
}
say('account', account || 'NOT CONNECTED')
if (!account) throw new Error('not connected')
const rpc = (m, p) => page.evaluate(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
page.on('console', (m) => say('console', m.type(), m.text().slice(0, 300)))
const mode = process.env.MODE || 'call'
const stopFile = path.join(os.homedir(), '.local/share/moho-dev/stop')
const state = (expr) => ev(`(() => { const s = window.__mohoShots.state(); return ${expr} })()`)
if (mode === 'call') {
let buffer = ''
for (let i = 0; i < 60 && !buffer; i++) {
buffer = await state(`(s.buffers.find((b) => b.accountId === ${JSON.stringify(account)} && b.kind === 'dm' && b.name === 'Salastil') || {}).id || ''`)
if (!buffer) await sleep(1000)
}
say('calling', buffer)
await ev(`window.__mohoShots.store.callBuffer(${JSON.stringify(buffer)})`)
} else {
say('waiting for a ring')
}
let answered = false
let phase = ''
for (let t = 0; t < 1200 && !fs.existsSync(stopFile); t += 3) {
if (mode === 'wait' && !answered) {
const ring = await state(`s.steamRings && s.steamRings.length ? JSON.stringify(s.steamRings[0]) : ''`)
if (ring) {
say('ringing', ring)
await ev(`window.__mohoShots.store.answerSteamCall(window.__mohoShots.state().steamRings[0])`)
answered = true
}
}
const now = await state(`JSON.stringify({ phase: s.steamVoiceNow ? s.steamVoiceNow.phase : 'idle', toasts: s.toasts.map((t) => t.message || t.text || JSON.stringify(t)) })`)
if (now !== phase) { phase = now; say('state', now) }
const report = await ev(`(async () => {
const v = window.__mohoShots.store.steamVoiceLive
if (!v || !v.pc) return { none: true }
const stats = []
const rep = await v.pc.getStats()
rep.forEach((s) => { if (['outbound-rtp', 'inbound-rtp', 'media-source'].includes(s.type)) stats.push({ type: s.type, packetsSent: s.packetsSent, packetsReceived: s.packetsReceived, audioLevel: s.audioLevel }) })
return { phase: v.phase, rtt: v.rttMs, ice: v.pc.iceConnectionState, stats }
})().catch((e) => ({ error: String(e) }))`)
say('report', JSON.stringify(report))
await sleep(3000)
}
await ev(`window.__mohoShots.store.leaveVoice(${JSON.stringify(account)})`)
await sleep(2000)
say('left the call')
} finally {
await stop()
}
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env node
/**
* The groups Steam says an account is in, after a fresh sign-in: a scratch app signed in as
* the throwaway account (never the real one) with a fake microphone joins the group's voice room
* and reports what it hears every ten seconds, until ~/.local/share/moho-dev/stop exists.
*
* NAME='Biggest Newest Moho Test' WAY=rail node scripts/probe-steam-leave.mjs (WAY=rail or settings)
*
* npx electron-vite build && node scripts/probe-steam-voice.mjs
*
* Needs the throwaway account's block in ~/.config/nobilis/accounts.toml. The app is the
* one built in out/, with its own home and runtime directory, and signs the account in
* for as long as this runs - so nothing else may be signed in as it.
*/
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const live = fs.readFileSync(path.join(os.homedir(), '.config/nobilis/accounts.toml'), 'utf8')
const block = live.split(/\n(?=\[)/).find((b) => b.startsWith('[[steam_account]]') && /username\s*=\s*"mohotester"/.test(b))
if (!block) throw new Error('the throwaway account is not in accounts.toml')
const { page, stop, scratch } = await launchApp({
env: { MOHO_UI_SHOTS: '1' },
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', `--use-file-for-fake-audio-capture=${os.homedir()}/.local/share/moho-dev/speech.wav`],
seed: (home) => {
fs.mkdirSync(path.join(home, 'config/nobilis'), { recursive: true })
fs.writeFileSync(path.join(home, 'config/nobilis/accounts.toml'), block + '\n')
}
})
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
const ev = (code) => page.evaluate(code)
try {
// The window onto workspace 2, where the user does not look.
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
let account = ''
for (let i = 0; i < 90 && !account; i++) {
account = await ev(`(() => { const a = window.__mohoShots.state().accounts.find((a) => a.id.startsWith('steam:') && a.state === 'connected'); return a ? a.id : '' })()`)
if (!account) await sleep(1000)
}
say('account', account || 'NOT CONNECTED')
if (!account) throw new Error('not connected')
const rpc = (m, p) => page.evaluate(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
page.on('console', (m) => say('console', m.type(), m.text().slice(0, 300)))
await sleep(5000)
say('window groups', await ev(`JSON.stringify(window.__mohoShots.state().groups.filter((g) => g.accountId === ${JSON.stringify(account)}).map((g) => g.name))`))
say('daemon groups', JSON.stringify(((await rpc('steamChatGroups', { accountId: account })) ?? []).map((g) => g.name)))
say('steam channels', await ev(`JSON.stringify(window.__mohoShots.state().buffers.filter((b) => b.accountId === ${JSON.stringify(account)} && b.kind === 'channel').map((b) => b.name))`))
} finally {
await stop()
}
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env node
/**
* Leaving a Steam group through the window, the way a person does: a scratch app signed in as
* the throwaway account (never the real one) with a fake microphone joins the group's voice room
* and reports what it hears every ten seconds, until ~/.local/share/moho-dev/stop exists.
*
* NAME='Biggest Newest Moho Test' WAY=rail node scripts/probe-steam-leave.mjs (WAY=rail or settings)
*
* npx electron-vite build && node scripts/probe-steam-voice.mjs
*
* Needs the throwaway account's block in ~/.config/nobilis/accounts.toml. The app is the
* one built in out/, with its own home and runtime directory, and signs the account in
* for as long as this runs - so nothing else may be signed in as it.
*/
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const live = fs.readFileSync(path.join(os.homedir(), '.config/nobilis/accounts.toml'), 'utf8')
const block = live.split(/\n(?=\[)/).find((b) => b.startsWith('[[steam_account]]') && /username\s*=\s*"mohotester"/.test(b))
if (!block) throw new Error('the throwaway account is not in accounts.toml')
const { page, stop, scratch } = await launchApp({
env: { MOHO_UI_SHOTS: '1' },
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', `--use-file-for-fake-audio-capture=${os.homedir()}/.local/share/moho-dev/speech.wav`],
seed: (home) => {
fs.mkdirSync(path.join(home, 'config/nobilis'), { recursive: true })
fs.writeFileSync(path.join(home, 'config/nobilis/accounts.toml'), block + '\n')
}
})
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
const ev = (code) => page.evaluate(code)
try {
// The window onto workspace 2, where the user does not look.
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
let account = ''
for (let i = 0; i < 90 && !account; i++) {
account = await ev(`(() => { const a = window.__mohoShots.state().accounts.find((a) => a.id.startsWith('steam:') && a.state === 'connected'); return a ? a.id : '' })()`)
if (!account) await sleep(1000)
}
say('account', account || 'NOT CONNECTED')
if (!account) throw new Error('not connected')
const rpc = (m, p) => page.evaluate(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
page.on('console', (m) => say('console', m.type(), m.text().slice(0, 300)))
const name = process.env.NAME ?? ''
const way = process.env.WAY ?? 'rail'
const state = (expr) => ev(`(() => { const s = window.__mohoShots.state(); return ${expr} })()`)
if (process.env.CREATE) say('created', JSON.stringify(await rpc('steamChatGroupAction', { accountId: account, action: 'create', name })))
let group = null
for (let i = 0; i < 60 && !group; i++) {
group = await state(`(s.groups.find((g) => g.accountId === ${JSON.stringify(account)} && g.name === ${JSON.stringify(name)}) || null)`)
if (!group) await sleep(1000)
}
say('group', JSON.stringify(group))
if (process.env.VOICE && group) {
// In the group's voice room when it is left.
const number = group.id.split('steamgroup:')[1]
say('voice room', JSON.stringify(await rpc('steamChatGroupAction', { accountId: account, action: 'createRoom', groupId: number, name: 'talk', voice: true })))
let rooms = []
for (let i = 0; i < 20 && !rooms.length; i++) {
rooms = await rpc('steamVoiceRooms', { accountId: account, groupId: number })
if (!rooms.length) await sleep(500)
}
await ev(`window.__mohoShots.store.joinSteamVoice(${JSON.stringify(account)}, ${JSON.stringify(number)}, ${JSON.stringify(rooms[0].roomId)})`)
for (let i = 0; i < 30; i++) {
if ((await state(`s.steamVoiceNow ? s.steamVoiceNow.phase : 'idle'`)) === 'connected') break
await sleep(500)
}
say('in voice', await state(`s.steamVoiceNow ? s.steamVoiceNow.phase : 'idle'`))
await sleep(3000)
}
if (!group) throw new Error('the group is not in the window')
await sleep(4000)
const click = (text) => ev(`(() => { const e = [...document.querySelectorAll('button, [role=menuitem]')].find((b) => b.textContent.trim().endsWith(${JSON.stringify(text)})); if (!e) return false; e.click(); return true })()`)
if (way === 'room') {
// A room's own menu: what it offers is leaving the group.
await ev(`window.__mohoShots.store.selectGroup(${JSON.stringify(group.id)})`)
let room = null
for (let i = 0; i < 30 && !room; i++) {
room = await state(`(s.buffers.find((b) => b.groupId === ${JSON.stringify(group.id)} && b.kind === 'channel') || null)`)
if (!room) await sleep(500)
}
say('room', JSON.stringify(room && room.name))
await sleep(800)
say('menu opened', await ev(`(() => { const row = [...document.querySelectorAll('.buffer-row')][0]; if (!row) return false; const r = row.getBoundingClientRect(); row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: r.x + 20, clientY: r.y + 10 })); return true })()`))
await sleep(400)
say('menu', JSON.stringify(await ev(`[...document.querySelectorAll('.context-menu button, .context-menu [role=menuitem]')].map((e) => e.textContent.trim())`)))
say('clicked', await click('Leave group…'))
} else if (way === 'rail') {
say('menu opened', await ev(`(() => { const tile = [...document.querySelectorAll('.rail-tile')].find((t) => t.getAttribute('aria-label') === ${JSON.stringify(name)}); if (!tile) return false; const r = tile.getBoundingClientRect(); tile.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: r.x + 10, clientY: r.y + 10 })); return true })()`))
await sleep(400)
say('menu', JSON.stringify(await ev(`[...document.querySelectorAll('.context-menu button, .context-menu [role=menuitem]')].map((e) => e.textContent.trim())`)))
say('clicked Leave server', await click('Leave server'))
} else {
say('menu opened', await ev(`(() => { const tile = [...document.querySelectorAll('.rail-tile')].find((t) => t.getAttribute('aria-label') === ${JSON.stringify(name)}); if (!tile) return false; const r = tile.getBoundingClientRect(); tile.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: r.x + 10, clientY: r.y + 10 })); return true })()`))
await sleep(400)
say('clicked Group settings', await click('Group settings'))
await sleep(800)
say('clicked Leave group', await click('Leave group'))
}
await sleep(600)
say('buttons now', JSON.stringify(await ev(`[...document.querySelectorAll('button')].map((b) => b.textContent.trim()).filter((t) => /leave/i.test(t))`)))
// The confirmation counts down before it will leave ("Leave in 10"): waited out, as a person has to.
for (let i = 0; i < 40; i++) {
if (await ev(`[...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Leave' && !b.disabled)`)) break
await sleep(500)
}
// The dialog's own Leave (or the inline confirm's).
say('confirmed', await ev(`(() => { const all = [...document.querySelectorAll('button')].filter((b) => b.textContent.trim() === 'Leave'); const e = all[all.length - 1]; if (!e) return false; e.click(); return true })()`))
for (let i = 0; i < 30; i++) {
const still = await state(`s.groups.some((g) => g.id === ${JSON.stringify(group.id)})`)
if (!still) break
await sleep(500)
}
say('still in the window', await state(`s.groups.some((g) => g.id === ${JSON.stringify(group.id)})`))
say('toasts', await state(`JSON.stringify(s.toasts.map((t) => t.message || t.text || JSON.stringify(t)))`))
say('voice after', await state(`s.steamVoiceNow ? s.steamVoiceNow.phase : 'idle'`))
const held = await rpc('steamChatGroups', { accountId: account })
say('daemon still holds', JSON.stringify((held ?? []).map((g) => g.name)))
} finally {
await stop()
}
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* Signing in to Steam by scanning a QR code with the phone app, through the window as a person
* does it: a scratch app with no accounts, Accounts -> Steam -> "Sign in with a QR code". The code is
* photographed to <output directory>/qr-N.png each time Steam replaces it, for somebody to scan,
* and the run ends when the account is signed in or when STOP exists. The scratch home is thrown
* away, so the token Steam hands back is not kept.
*
* node scripts/probe-steam-qr.mjs <output directory>
*/
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const stopFile = path.join(os.homedir(), '.local/share/moho-dev/stop')
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
const ev = (code) => page.evaluate(code)
const shot = async (name) => {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
}
try {
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 900, height: 900, deviceScaleFactor: 1, mobile: false })
// Everything the daemon says about the sign-in, kept as it comes.
await ev(`window.__events = []; window.moho.onEvent((e) => { if (String(e.event).startsWith('steamLogin') || e.event === 'accountsChanged') window.__events.push({ at: Date.now(), event: e.event, data: JSON.stringify(e.data).slice(0, 300) }) })`)
await ev(`window.__mohoShots.store.setActivePanel('accounts')`)
await sleep(800)
const click = (text) => ev(`(() => { const e = [...document.querySelectorAll('button')].find((b) => b.textContent.trim().includes(${JSON.stringify(text)})); if (!e) return false; e.click(); return true })()`)
say('add form', JSON.stringify(await ev(`[...document.querySelectorAll('button')].map((b) => b.textContent.trim()).filter(Boolean).slice(0, 30)`)))
say('clicked Steam', await click('Steam'))
await sleep(600)
say('clicked QR', await click('Sign in with a QR code'))
let last = ''
let n = 0
for (let t = 0; t < 600 && !fs.existsSync(stopFile); t += 2) {
const src = await ev(`document.querySelector('img.qr-image')?.src ?? ''`)
if (src && src !== last) {
last = src
n++
await sleep(300)
await shot(`qr-${n}.png`)
say('QR', n, 'drawn', src.slice(-40))
}
const seen = await ev(`window.__events.splice(0)`).catch(() => [])
for (const e of seen) say('event', e.event, e.data)
const accounts = await ev(`JSON.stringify(window.__mohoShots.state().accounts.filter((a) => a.service === 'steam').map((a) => [a.id, a.state]))`)
if (accounts !== '[]') {
say('signed in', accounts)
break
}
const toasts = await ev(`JSON.stringify(window.__mohoShots.state().toasts.map((x) => x.message || x.text || JSON.stringify(x)))`)
if (toasts !== '[]' && toasts !== (globalThis.__t ?? '')) { globalThis.__t = toasts; say('toasts', toasts) }
await sleep(2000)
}
await sleep(4000)
for (const e of (await ev(`window.__events.splice(0)`).catch(() => []))) say('event', e.event, e.data)
say('accounts after', await ev(`JSON.stringify(window.__mohoShots.state().accounts.filter((a) => a.service === 'steam').map((a) => [a.id, a.state, a.displayName]))`))
await shot('qr-after.png')
} finally {
await stop()
}
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env node
/**
* Steam voice in a group that already exists, with somebody on the official client in the room too: a scratch app signed in as
* the throwaway account (never the real one) with a fake microphone joins the group's voice room
* and reports what it hears every ten seconds, until ~/.local/share/moho-dev/stop exists.
*
* GROUP=78995476 ROOM=316282066 node scripts/probe-steam-voice-join.mjs
*
* npx electron-vite build && node scripts/probe-steam-voice.mjs
*
* Needs the throwaway account's block in ~/.config/nobilis/accounts.toml. The app is the
* one built in out/, with its own home and runtime directory, and signs the account in
* for as long as this runs - so nothing else may be signed in as it.
*/
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const live = fs.readFileSync(path.join(os.homedir(), '.config/nobilis/accounts.toml'), 'utf8')
const block = live.split(/\n(?=\[)/).find((b) => b.startsWith('[[steam_account]]') && /username\s*=\s*"mohotester"/.test(b))
if (!block) throw new Error('the throwaway account is not in accounts.toml')
const { page, stop, scratch } = await launchApp({
env: { MOHO_UI_SHOTS: '1' },
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', `--use-file-for-fake-audio-capture=${os.homedir()}/.local/share/moho-dev/speech.wav`],
seed: (home) => {
fs.mkdirSync(path.join(home, 'config/nobilis'), { recursive: true })
fs.writeFileSync(path.join(home, 'config/nobilis/accounts.toml'), block + '\n')
}
})
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
const ev = (code) => page.evaluate(code)
try {
// The window onto workspace 2, where the user does not look.
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
let account = ''
for (let i = 0; i < 90 && !account; i++) {
account = await ev(`(() => { const a = window.__mohoShots.state().accounts.find((a) => a.id.startsWith('steam:') && a.state === 'connected'); return a ? a.id : '' })()`)
if (!account) await sleep(1000)
}
say('account', account || 'NOT CONNECTED')
if (!account) throw new Error('not connected')
const rpc = (m, p) => page.evaluate(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
page.on('console', (m) => say('console', m.type(), m.text().slice(0, 300)))
const group = process.env.GROUP
const roomId = process.env.ROOM
const stopFile = path.join(os.homedir(), '.local/share/moho-dev/stop')
for (let i = 0; i < 40; i++) {
const rooms = await rpc('steamVoiceRooms', { accountId: account, groupId: group })
if (Array.isArray(rooms) && rooms.length) break
await sleep(500)
}
await ev(`window.__mohoShots.store.joinSteamVoice(${JSON.stringify(account)}, ${JSON.stringify(group)}, ${JSON.stringify(roomId)})`)
let phase = ''
let lastToast = ''
for (let i = 0; i < 80; i++) {
const now = await ev(`(() => { const s = window.__mohoShots.state(); return JSON.stringify({ phase: s.steamVoiceNow ? s.steamVoiceNow.phase : 'idle', toasts: s.toasts.map((t) => t.message || t.text || JSON.stringify(t)) }) })()`)
if (now !== lastToast) { lastToast = now; say('fast', now) }
await sleep(250)
}
for (let t = 0; t < 1200 && !fs.existsSync(stopFile); t += 10) {
const now = await ev(`(() => { const s = window.__mohoShots.state(); return s.steamVoiceNow ? s.steamVoiceNow.phase : 'idle' })()`)
if (now !== phase) { phase = now; say('phase', now) }
const report = await ev(`(async () => {
const v = window.__mohoShots.store.steamVoiceLive
if (!v) return { none: true }
const stats = []
const rep = await v.pc.getStats()
rep.forEach((s) => { if (['outbound-rtp', 'inbound-rtp', 'media-source', 'remote-inbound-rtp'].includes(s.type)) stats.push({ type: s.type, packetsSent: s.packetsSent, bytesSent: s.bytesSent, packetsReceived: s.packetsReceived, packetsLost: s.packetsLost, ssrc: s.ssrc, audioLevel: s.audioLevel, energy: s.totalAudioEnergy, rtt: s.roundTripTime }) })
return { statuses: v.statuses ? [...v.statuses.entries()] : null, session: v.session, owners: v.owners ? [...v.owners.entries()] : null, track: v.mic ? v.mic.getAudioTracks().map((t) => [t.enabled, t.muted, t.readyState]) : null, phase: v.phase, rtt: v.rttMs, ice: v.pc.iceConnectionState, speakers: window.__mohoShots.state().steamVoiceNow?.speakers, stats }
})().catch((e) => ({ error: String(e) }))`)
say('report', JSON.stringify(report))
say('toasts', await ev(`JSON.stringify(window.__mohoShots.state().toasts.map((t) => t.message || t.text || t))`))
say('rooms', JSON.stringify(await rpc('steamVoiceRooms', { accountId: account, groupId: group })))
await sleep(3000)
}
await ev(`window.__mohoShots.store.leaveVoice(${JSON.stringify(account)})`)
await sleep(2000)
say('left voice; the group is left in place')
} finally {
await stop()
}
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env node
/**
* Steam voice against real Steam, through the real window: a scratch app signed in as
* the throwaway account (never the real one) with a fake microphone makes a group with a
* voice room, joins its voice, reports how it went, leaves, and leaves the group.
*
* npx electron-vite build && node scripts/probe-steam-voice.mjs
*
* Needs the throwaway account's block in ~/.config/nobilis/accounts.toml. The app is the
* one built in out/, with its own home and runtime directory, and signs the account in
* for as long as this runs - so nothing else may be signed in as it.
*/
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const live = fs.readFileSync(path.join(os.homedir(), '.config/nobilis/accounts.toml'), 'utf8')
const block = live.split(/\n(?=\[)/).find((b) => b.startsWith('[[steam_account]]') && /username\s*=\s*"mohotester"/.test(b))
if (!block) throw new Error('the throwaway account is not in accounts.toml')
const { page, stop, scratch } = await launchApp({
env: { MOHO_UI_SHOTS: '1' },
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'],
seed: (home) => {
fs.mkdirSync(path.join(home, 'config/nobilis'), { recursive: true })
fs.writeFileSync(path.join(home, 'config/nobilis/accounts.toml'), block + '\n')
}
})
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
const ev = (code) => page.evaluate(code)
try {
// The window onto workspace 2, where the user does not look.
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
let account = ''
for (let i = 0; i < 90 && !account; i++) {
account = await ev(`(() => { const a = window.__mohoShots.state().accounts.find((a) => a.id.startsWith('steam:') && a.state === 'connected'); return a ? a.id : '' })()`)
if (!account) await sleep(1000)
}
say('account', account || 'NOT CONNECTED')
if (!account) throw new Error('not connected')
const rpc = (m, p) => page.evaluate(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
const made = await rpc('steamChatGroupAction', { accountId: account, action: 'create', name: 'voice probe' })
say('created', JSON.stringify(made))
const group = String(made.groupId).split('steamgroup:')[1]
say('room', JSON.stringify(await rpc('steamChatGroupAction', { accountId: account, action: 'createRoom', groupId: group, name: 'talk', voice: true })))
let rooms = []
for (let i = 0; i < 20 && !rooms.length; i++) {
rooms = await rpc('steamVoiceRooms', { accountId: account, groupId: group })
if (!rooms.length) await sleep(500)
}
say('voice rooms', JSON.stringify(rooms))
const roomId = rooms[0]?.roomId
await sleep(1500)
await ev(`window.__mohoShots.store.joinSteamVoice(${JSON.stringify(account)}, ${JSON.stringify(group)}, ${JSON.stringify(roomId)})`)
let phase = ''
const seen = []
for (let i = 0; i < 120; i++) {
const now = await ev(`(() => { const s = window.__mohoShots.state(); return s.steamVoiceNow ? s.steamVoiceNow.phase : 'idle' })()`)
if (now !== phase) { phase = now; seen.push(now); say('phase', now) }
if (now === 'connected') break
await sleep(500)
}
await sleep(4000)
const report = await ev(`(async () => {
const v = window.__mohoShots.store.steamVoiceLive
const stats = []
const rep = await v.pc.getStats()
rep.forEach((s) => { if (['outbound-rtp', 'inbound-rtp', 'candidate-pair'].includes(s.type) && (s.type !== 'candidate-pair' || s.nominated)) stats.push({ type: s.type, kind: s.kind, packetsSent: s.packetsSent, bytesSent: s.bytesSent, packetsReceived: s.packetsReceived, rtt: s.currentRoundTripTime, state: s.state, ssrc: s.ssrc }) })
return { phase: v.phase, level: v.level, rtt: v.rttMs, ice: v.pc.iceConnectionState, voiceChatId: v.voiceChatId, session: v.session, stats, mic: !!v.mic, sessions: window.__mohoShots.state().voiceSessions }
})().catch((e) => ({ error: String(e) }))`)
say('report', JSON.stringify(report, null, 1))
say('rooms while in', JSON.stringify(await rpc('steamVoiceRooms', { accountId: account, groupId: group })))
await ev(`window.__mohoShots.store.leaveVoice(${JSON.stringify(account)})`)
await sleep(2000)
say('rooms after leaving', JSON.stringify(await rpc('steamVoiceRooms', { accountId: account, groupId: group })))
say('now', await ev(`JSON.stringify(window.__mohoShots.state().steamVoiceNow)`))
say('left group', JSON.stringify(await rpc('leaveGroup', { groupId: made.groupId })))
} finally {
await stop()
}
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Makes nobilis's release of the same version as one of moho's.
#
# Run by the maintainer once the release workflow has published moho's tag:
# the daemon is its own repository with its own releases, and the job that
# builds it holds a token that can only release moho.
#
# scripts/release-nobilis.sh v1.0.0
# scripts/release-nobilis.sh v1.0.0 --keep DIR leave the notes and the two
# daemon binaries in DIR
#
# nobilis is tagged at the commit moho's tag points to, named after the same
# version, its notes the version's major features and the nobilis lines of the
# changelog, with the two daemon binaries the release's run built. Safe to run
# again: a release that exists is updated rather than repeated.
set -euo pipefail
TAG="${1:?usage: scripts/release-nobilis.sh vX.Y.Z [--keep DIR]}"
KEEP=""
[ "${2:-}" = "--keep" ] && KEEP="${3:?--keep needs a directory}"
VERSION="${TAG#v}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
read -r PRE < <(gh release view "$TAG" --repo Moho-Chat/moho --json isPrerelease -q .isPrerelease)
NOBILIS_SHA="$(git -C "$ROOT" rev-parse "$TAG:nobilis")"
echo "nobilis $TAG at ${NOBILIS_SHA:0:7}"
if ! git -C "$ROOT/nobilis" rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
git -C "$ROOT/nobilis" tag -a "$TAG" "$NOBILIS_SHA" -m "nobilis $VERSION"
fi
git -C "$ROOT/nobilis" push -q github "refs/tags/$TAG"
# Its notes: the version's major features, and the nobilis lines only.
SECTION="$VERSION"
grep -q "^## \[$VERSION\]" "$ROOT/CHANGELOG.md" || SECTION="${VERSION%%-*}"
awk -v v="$SECTION" '
index($0, "## [" v "]") == 1 { on = 1; next }
on && /^## \[/ { exit }
!on { next }
/^### / { fixes = ($0 ~ /^### Fixes/); print; next }
/^- / { if (!fixes || /\[nobilis /) print; next }
{ print }
' "$ROOT/CHANGELOG.md" > "$WORK/nobilis-notes.md"
printf '\nnobilis is the daemon inside moho %s; these are its own builds, for running it on its own.\n' "$VERSION" >> "$WORK/nobilis-notes.md"
# The daemon binaries, from the run that built this release.
RUN="$(gh run list --repo Moho-Chat/moho --workflow release.yml --branch "$TAG" --status success --limit 1 --json databaseId -q '.[0].databaseId')"
[ -n "$RUN" ] || { echo "no successful release run for $TAG" >&2; exit 1; }
mkdir -p "$WORK/nobilis"
gh run download "$RUN" --repo Moho-Chat/moho -n daemon-linux -D "$WORK/d-linux"
gh run download "$RUN" --repo Moho-Chat/moho -n daemon-windows -D "$WORK/d-win"
install -m 755 "$WORK/d-linux/nobilis" "$WORK/nobilis/nobilis-$VERSION-linux-x86_64"
install -m 644 "$WORK/d-win/nobilis.exe" "$WORK/nobilis/nobilis-$VERSION-windows-x86_64.exe"
NOBILIS_FLAGS=(--latest)
[ "$PRE" = "true" ] && NOBILIS_FLAGS=(--prerelease --latest=false)
if gh release view "$TAG" --repo Moho-Chat/nobilis >/dev/null 2>&1; then
gh release edit "$TAG" --repo Moho-Chat/nobilis --notes-file "$WORK/nobilis-notes.md"
gh release upload "$TAG" --repo Moho-Chat/nobilis "$WORK"/nobilis/* --clobber
else
gh release create "$TAG" --repo Moho-Chat/nobilis --verify-tag --title "nobilis $VERSION" \
--notes-file "$WORK/nobilis-notes.md" "${NOBILIS_FLAGS[@]}" "$WORK"/nobilis/*
fi
echo " GitHub Moho-Chat/nobilis $TAG"
if [ -n "$KEEP" ]; then
mkdir -p "$KEEP"
cp "$WORK/nobilis-notes.md" "$KEEP/"
cp -r "$WORK/nobilis" "$KEEP/"
fi
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env node
/**
* Checks that a reader scrolling through a long unread run is not put back.
*
* The "N new messages since" bar sits over the top of the log. What the list
* remembers about where the reader is came from asking the page what is at its
* top edge - which, while the bar is up, is the bar. So the place stopped being
* kept up to date the moment the bar appeared, and the next thing to change
* size (a card arriving, a picture loading) put the view back where the
* reader had been when it appeared.
*
* npm run test:scroll (builds first; needs the daemon built too)
*/
import { fakeIrc, launchApp, openIrcChannel, sleep, until } from './lib/harness.mjs'
let failures = 0
const check = (ok, what, detail = '') => {
console.log(`${ok ? 'ok ' : 'FAIL'} ${what}${ok ? '' : detail ? ` - ${detail}` : ''}`)
if (!ok) failures++
}
setTimeout(() => {
console.log('FAIL timed out')
process.exit(1)
}, 90000).unref()
const irc = await fakeIrc({
people: ['bob'],
onJoin: (channel, _nick, send) => send(`:bob!b@h PRIVMSG ${channel} :hello there`)
})
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
try {
await until(page, 'window.__mohoShots', 'the harness hook')
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#lobby', '#other'], waitFor: 'hello there' })
// Three hundred messages, the last two hundred of them unread.
await page.evaluate(`(() => {
const shots = window.__mohoShots
const s = shots.state()
const id = s.activeBufferId
const now = Math.floor(Date.now() / 1000)
const list = Array.from({ length: 300 }, (_, n) => ({
id: 'm' + n, bufferId: id, from: n % 3 ? 'bob' : 'carol', senderId: n % 3 ? 'bob' : 'carol',
body: 'message number ' + n + ' with a little text in it', ts: now - 40000 + n * 60,
isAction: false, isHighlight: false, kind: 'chat', isOwn: false
}))
shots.patch({
messagesByBuffer: { ...s.messagesByBuffer, [id]: list },
dividerTsByBuffer: { ...s.dividerTsByBuffer, [id]: list[99].ts }
})
})()`)
// Two hundred unread is more than a screenful: opened at its end, as
// Discord does, with the bar saying how much is above and not the log
// dropped hundreds of messages up.
await until(page, `!!document.querySelector('.unread-bar')`, 'the unread bar')
await sleep(600)
const opened = await page.evaluate(`(() => {
const el = document.querySelector('.messagelist-scroll')
return { fromBottom: el.scrollHeight - el.scrollTop - el.clientHeight, line: !!document.querySelector('.new-divider'), bar: document.querySelector('.unread-bar-text')?.textContent }
})()`)
check(opened.fromBottom < 8 && !opened.line, 'a long unread run opens at the end of the conversation', JSON.stringify(opened))
check(/^200 new messages/.test(opened.bar ?? ''), 'and the bar says how much there is', opened.bar)
await page.evaluate(`document.querySelector('.unread-bar-text').click()`)
await until(page, `!!document.querySelector('.new-divider')`, 'the New line after pressing the bar')
await sleep(600)
// Reading down past the line, the way a wheel does: the bar comes up.
await page.evaluate(`(async () => {
const el = document.querySelector('.messagelist-scroll')
el.dispatchEvent(new WheelEvent('wheel', { deltaY: 100, bubbles: true }))
for (let i = 0; i < 8; i++) {
el.scrollTop += 120
el.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true }))
await new Promise((r) => setTimeout(r, 60))
}
})()`)
await until(page, `!!document.querySelector('.unread-bar')`, 'the unread bar')
// Further down, with the bar over the top of the log the whole way.
await page.evaluate(`(async () => {
const el = document.querySelector('.messagelist-scroll')
for (let i = 0; i < 12; i++) {
el.scrollTop += 150
el.dispatchEvent(new WheelEvent('wheel', { deltaY: 150, bubbles: true }))
await new Promise((r) => setTimeout(r, 60))
}
})()`)
await sleep(300)
const before = await page.evaluate(`document.querySelector('.messagelist-scroll').scrollTop`)
// Something above the reader settles taller - a card arriving, a picture
// loading. The row under the reader's eye is what must stay put.
const moved = await page.evaluate(`(async () => {
const el = document.querySelector('.messagelist-scroll')
const box = el.getBoundingClientRect()
const rows = [...el.querySelectorAll('[data-msg-id]')]
const watched = rows.find((r) => r.getBoundingClientRect().top > box.top + 60)
const was = watched.getBoundingClientRect().top - box.top
const above = rows.find((r) => r.getBoundingClientRect().bottom < box.top - 50)
above.style.minHeight = (above.getBoundingClientRect().height + 240) + 'px'
await new Promise((r) => setTimeout(r, 400))
return { id: watched.dataset.msgId, drift: watched.getBoundingClientRect().top - box.top - was }
})()`)
check(Math.abs(moved.drift) < 2, 'a row above growing leaves the row being read where it was', `moved ${moved.drift}px`)
// And the position itself did not fall back towards where the bar appeared.
const after = await page.evaluate(`document.querySelector('.messagelist-scroll').scrollTop`)
check(after >= before - 4, 'the view did not go back up the log', `${before} -> ${after}`)
// Leaving, and coming back after more has been said. The line was kept from
// the first visit, so the return went up the log to a place nothing was
// unread any more.
const ids = await page.evaluate(`(() => {
const s = window.__mohoShots.state()
return { lobby: s.activeBufferId, other: s.buffers.find((b) => b.name.endsWith('#other')).id }
})()`)
const said = (count) => `(() => {
const shots = window.__mohoShots
const s = shots.state()
const id = ${JSON.stringify(ids.lobby)}
const base = Math.max(...s.messagesByBuffer[id].map((m) => m.ts)) + 1
const more = Array.from({ length: ${count} }, (_, n) => ({
id: 'later' + Date.now() + '-' + n, bufferId: id, from: 'bob', senderId: 'bob', body: 'said while away ' + n, ts: base + n,
isAction: false, isHighlight: false, kind: 'chat', isOwn: false
}))
shots.patch({
messagesByBuffer: { ...s.messagesByBuffer, [id]: [...s.messagesByBuffer[id], ...more] },
buffers: s.buffers.map((b) => (b.id === id ? { ...b, unread: ${count} } : b))
})
})()`
const away = async (count) => {
await page.evaluate(`window.__mohoShots.store.selectBuffer(${JSON.stringify(ids.other)}, false)`)
await sleep(500)
await page.evaluate(said(count))
await page.evaluate(`window.__mohoShots.store.selectBuffer(${JSON.stringify(ids.lobby)}, false)`)
await sleep(1200)
return page.evaluate(`(() => {
const el = document.querySelector('.messagelist-scroll')
const line = document.querySelector('.new-divider')
const rows = [...el.querySelectorAll('[data-msg-id]')]
const after = line ? rows.filter((r) => line.compareDocumentPosition(r) & Node.DOCUMENT_POSITION_FOLLOWING).length : -1
return { fromBottom: el.scrollHeight - el.scrollTop - el.clientHeight, line: !!line, after, bar: document.querySelector('.unread-bar-text')?.textContent ?? '' }
})()`)
}
// A few unread: opens at the first of them, and the line is this visit's -
// thirty rows under it, not the two hundred of the visit before.
const few = await away(30)
check(few.line && few.after === 30, 'a short unread run opens at its first message, with the line where this visit began', JSON.stringify(few))
// A great many: opens at the end, with the bar to go back by.
const many = await away(100)
check(many.fromBottom < 8, 'a long one opens at the end and not hundreds of messages up', JSON.stringify(many))
check(/^100 new messages/.test(many.bar), 'with the bar to go back by', many.bar)
// Nothing unread: the end.
await page.evaluate(`window.__mohoShots.store.selectBuffer(${JSON.stringify(ids.other)}, false)`)
await sleep(500)
await page.evaluate(`window.__mohoShots.store.selectBuffer(${JSON.stringify(ids.lobby)}, false)`)
await sleep(1000)
const nothing = await page.evaluate(`(() => { const el = document.querySelector('.messagelist-scroll'); return { fromBottom: el.scrollHeight - el.scrollTop - el.clientHeight, line: !!document.querySelector('.new-divider') } })()`)
check(nothing.fromBottom < 8 && !nothing.line, 'a conversation left read and returned to is at its end', JSON.stringify(nothing))
} finally {
await stop()
irc.close()
}
console.log(failures ? `${failures} FAILED` : 'all passed')
process.exit(failures ? 1 : 0)
+19
View File
@@ -0,0 +1,19 @@
import fs from 'node:fs'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (c) => page.evaluate(c)
try {
try { const cl = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString()); for (const c of cl.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`]) } catch {}
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
const a = await rpc('addKickAccount', {})
await rpc('joinBuffer', { accountId: a.accountId, name: 'spreen' })
const id = `${a.accountId}|spreen`
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
await sleep(20000)
console.log(await ev(`JSON.stringify({ images: document.querySelectorAll('.sender-badge-image').length, loaded: [...document.querySelectorAll('.sender-badge-image')].filter((i) => i.complete && i.naturalWidth > 0).length, words: [...document.querySelectorAll('.sender-badge')].map((b) => b.textContent).slice(0, 8), titles: [...new Set([...document.querySelectorAll('.sender-badge-image')].map((i) => i.title))].slice(0, 8) })`))
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(process.env.HOME + '/.local/share/moho-dev/shots/kick-badges.png', Buffer.from(data, 'base64'))
} finally { await stop() }
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* Watches a real live Kick stream in a scratch moho with no account signed in, and checks what the
* player does: that it plays, how far behind it runs, that the resolution menu changes the picture, that
* the CC button shows the captions, and that the player is built once and not again as the address is
* renewed. Photographs the stream, each menu and the captions.
*
* node scripts/shot-kick-stream.mjs <output directory> [channel] [idle seconds]
*/
import fs from 'node:fs'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
const channel = process.argv[3] ?? 'spreen'
const idle = Number(process.argv[4] ?? 60)
fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
const ev = (code) => page.evaluate(code)
const shot = async (name) => {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
}
const click = (selector) => ev(`(() => { const b = document.querySelector(${JSON.stringify(selector)}); if (!b || b.disabled) return false; b.click(); return true })()`)
const video = (expr) => ev(`(() => { const v = document.querySelector('video.video-tile-picture'); return v ? (${expr}) : null })()`)
try {
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
const added = await rpc('addKickAccount', {})
say('account', JSON.stringify(added))
say('join', JSON.stringify(await rpc('joinBuffer', { accountId: added.accountId, name: channel })))
const id = `${added.accountId}|${channel}`
let live = null
for (let i = 0; i < 40 && !live?.playbackUrl; i++) {
await sleep(1000)
live = await ev(`window.__mohoShots.state().kickStreams[${JSON.stringify(id)}] || null`)
}
say('stream', JSON.stringify({ live: live?.live, hasUrl: !!live?.playbackUrl, expires: live?.playbackExpires, livestreamId: live?.livestreamId }))
await ev(`(async () => { await window.__mohoShots.store.selectBuffer(${JSON.stringify(id)}); window.__mohoShots.store.watchStream(${JSON.stringify(id)}) })()`)
for (let i = 0; i < 40; i++) {
if ((await video('v.readyState >= 3 && v.currentTime > 1')) === true) break
await sleep(500)
}
say('playing', await video('({ t: v.currentTime, w: v.videoWidth, h: v.videoHeight, paused: v.paused })').then(JSON.stringify))
await sleep(4000)
await shot('stream-playing.png')
// Resolution.
say('quality menu', await click('button[title^="Resolution"]'))
await sleep(400)
say('quality choices', JSON.stringify(await ev(`[...document.querySelectorAll('.stream-menu .context-menu-item')].map((b) => b.textContent.trim())`)))
await shot('stream-quality-menu.png')
for (const want of ['480p', 'Auto']) {
await ev(`(() => { const b = [...document.querySelectorAll('.stream-menu .context-menu-item')].find((x) => x.textContent.trim().startsWith(${JSON.stringify(want)})); b && b.click() })()`)
await sleep(8000)
say(`after choosing ${want}`, JSON.stringify(await video('({ h: v.videoHeight, t: +v.currentTime.toFixed(1) })')), JSON.stringify(await ev(`document.querySelector('button[title^="Resolution"]')?.title`)))
if (want === '480p') await click('button[title^="Resolution"]')
}
// Captions.
say('captions button', await ev(`JSON.stringify({ title: document.querySelector('button[title*="aptions"]')?.title, disabled: document.querySelector('button[title*="aptions"]')?.disabled })`))
say('captions menu', await click('button[title*="aptions"]'))
await sleep(400)
say('caption choices', JSON.stringify(await ev(`[...document.querySelectorAll('.stream-menu .context-menu-item')].map((b) => b.textContent.trim())`)))
await shot('stream-captions-menu.png')
await ev(`(() => { const b = [...document.querySelectorAll('.stream-menu .context-menu-item')].find((x) => x.textContent.trim() === 'English'); b && b.click() })()`)
await sleep(15000)
say('text tracks', await video(`JSON.stringify([...v.textTracks].map((t) => ({ lang: t.language, mode: t.mode, cues: t.cues ? t.cues.length : 0 })))`))
await shot('stream-captions-on.png')
say('captions button now', await ev(`document.querySelector('button[title*="aptions"]')?.title`))
// Built once.
const sources = new Set()
const began = Date.now()
while (Date.now() - began < idle * 1000) {
sources.add(await video('v.src'))
await sleep(1000)
}
const later = await ev(`window.__mohoShots.state().kickStreams[${JSON.stringify(id)}]`)
say(`over ${idle}s: ${sources.size} player source(s); address expires`, later?.playbackExpires, 'changed:', later?.playbackUrl !== live?.playbackUrl)
say('quality', JSON.stringify(await video('(() => { const q = v.getVideoPlaybackQuality(); return { dropped: q.droppedVideoFrames, total: q.totalVideoFrames } })()')))
} finally {
await stop()
}
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* Reads a busy live Kick channel in a scratch moho and checks what replies and removals look like: that a
* reply carries its thread, that the thread opens from it, and that a removed message is struck through in
* red, left out or replaced by a notice according to Settings -> Kick. Removal is the one thing it cannot
* wait for, so one is injected through the store the way the daemon's event would arrive.
*
* node scripts/shot-kick-threads.mjs <output directory> [channel]
*/
import fs from 'node:fs'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
const channel = process.argv[3] ?? 'xqc'
fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
const ev = (code) => page.evaluate(code)
const shot = async (name) => {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
}
try {
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
const added = await rpc('addKickAccount', {})
say('join', JSON.stringify(await rpc('joinBuffer', { accountId: added.accountId, name: channel })))
const id = `${added.accountId}|${channel}`
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
// Replies, as the daemon read them: each should name the thread it belongs to.
let replies = []
for (let i = 0; i < 120 && replies.length < 2; i++) {
await sleep(1000)
replies = JSON.parse(await ev(`JSON.stringify((window.__mohoShots.state().messagesByBuffer[${JSON.stringify(id)}] || []).filter((m) => m.replyTo).map((m) => ({ id: m.id, to: m.replyTo.id, root: m.replyTo.threadRoot, thread: m.replyTo.thread })))`))
}
say('replies seen', replies.length, JSON.stringify(replies.slice(0, 4)))
say('every reply has a thread root', replies.every((r) => r.root))
await sleep(1500)
await shot('kick-replies.png')
await ev(`document.querySelector('.reply-preview')?.scrollIntoView({ block: 'center' })`)
await sleep(500)
await shot('kick-reply-row.png')
// Open a thread from a reply.
say('thread button', await ev(`(() => { const b = document.querySelector('.reply-thread-open'); if (!b) return false; b.click(); return true })()`))
await sleep(1200)
say('thread panel', await ev(`JSON.stringify({ open: !!document.querySelector('.thread-pane'), rows: document.querySelectorAll('.thread-pane .message-row').length, title: document.querySelector('.thread-header')?.textContent })`))
await shot('kick-thread.png')
await ev(`window.__mohoShots.store.closeThreadPanel()`)
await sleep(300)
// A removal, as the daemon would announce it, in each of the three ways.
const victim = await ev(`(() => { const ms = window.__mohoShots.state().messagesByBuffer[${JSON.stringify(id)}] || []; const m = [...ms].reverse().find((x) => x.kind === 'chat' && x.body && !x.replyTo); return m && m.id })()`)
await ev(`window.__mohoShots.store.handleEvent({ event: 'messageModerated', data: { bufferId: ${JSON.stringify(id)}, id: ${JSON.stringify(victim)}, moderated: { ai: true, rules: ['hate'] } } })`)
await sleep(500)
await ev(`document.querySelector('.message-row.removed')?.scrollIntoView({ block: 'center' })`)
await sleep(500)
say('removed row (default: strike)', await ev(`(() => { const r = document.querySelector('.message-row.removed .message-body'); if (!r) return null; const s = getComputedStyle(r); return JSON.stringify({ text: r.textContent.slice(0, 40), color: s.color, line: s.textDecorationLine, title: r.title }) })()`))
await shot('kick-removed-strike.png')
for (const [label, name] of [['Replace with notice', 'replace'], ['Delete', 'remove'], ['Strikethrough', 'strike']]) {
await ev(`window.__mohoShots.store.setActivePanel('settings')`)
await sleep(500)
await ev(`[...document.querySelectorAll('.settings-rail-item, .settings-rail button')].find((b) => b.textContent.trim().startsWith('Kick'))?.click()`)
await sleep(500)
say('setting', label, await ev(`(() => { const b = [...document.querySelectorAll('.setting-segmented button')].find((x) => x.textContent.trim() === ${JSON.stringify(label)}); if (!b) return false; b.click(); return true })()`))
await sleep(300)
if (name === 'replace') await shot('kick-settings.png')
await ev(`window.__mohoShots.store.setActivePanel('')`)
await sleep(500)
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
await sleep(700)
say(name, await ev(`JSON.stringify({ removedRows: document.querySelectorAll('.message-row.removed').length, text: document.querySelector('.message-row.removed .message-body')?.textContent.slice(0, 50) })`))
await ev(`document.querySelector('.message-row.removed')?.scrollIntoView({ block: 'center' })`)
await sleep(300)
if (name !== 'strike') await shot(`kick-removed-${name}.png`)
}
} finally {
await stop()
}
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
/**
* Every kind of line a service writes rather than somebody saying it, beside ordinary chat and a moderation
* card, in each layout: they must all be the same sort of card. Messages are injected; nothing is contacted.
*
* node scripts/shot-notification-cards.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
try {
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1000, height: 760, deviceScaleFactor: 2, mobile: false })
await ev(`(() => {
const s = window.__mohoShots.store
const acct = { id: 'irc:test', service: 'irc', name: 'test', host: 'irc.example', port: 6667, state: 'connected', hasPassword: false, nick: 'me' }
const b = { id: 'irc:test|#room', accountId: 'irc:test', kind: 'channel', name: '#room', lastActivityTs: 5, groupId: 'account:irc:test' }
const t = Math.floor(Date.now() / 1000) - 600
const m = (i, kind, from, body, extra = {}) => ({ id: 'm' + i, bufferId: b.id, from, body, ts: t + i * 20, kind, isAction: false, isHighlight: false, isOwn: false, edited: false, ...extra })
const messages = [
m(1, 'join', 'alice', 'alice joined #room'),
m(2, 'chat', 'alice', 'hello everyone'),
m(3, 'topic', 'bob', 'bob changed the topic to: Release day'),
m(4, 'mode', 'ChanServ', 'ChanServ sets mode +o alice'),
m(5, 'kick', 'bob', 'bob kicked carol (spamming)'),
m(6, 'part', 'carol', 'carol left #room'),
m(7, 'system', 'moderation', 'This room is now in slow mode.'),
m(8, 'moderation', 'room', 'Slow mode is on: one message every 10 seconds', { embeds: [{ kind: 'moderation', author: 'Slow mode', title: 'is on: one message every 10 seconds', icon: 'timer' }] }),
m(9, 'chat', 'bob', 'back to normal chat')
]
s.set({ accounts: [acct], groups: [{ id: 'account:irc:test', accountId: 'irc:test', service: 'irc', kind: 'account', name: 'test', position: 0 }], buffers: [b], messagesByBuffer: { [b.id]: messages }, activeBufferId: b.id })
s.selectGroup('account:irc:test')
})()`)
await sleep(1200)
for (const mode of ['comfy', 'classic', 'bubbles']) {
await ev(`window.moho.rpc && 0; window.__mohoShots.store.setPref && window.__mohoShots.store.setPref('ui.messageLayout', ${JSON.stringify(mode)})`).catch(() => {})
await sleep(500)
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, `notification-cards-${mode}.png`), Buffer.from(data, 'base64'))
}
console.log('cards', await ev(`document.querySelectorAll('.kick-event.notification').length`), 'moderation', await ev(`document.querySelectorAll('.kick-event.moderation').length`))
} finally {
await stop()
}
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env node
/**
* Sneedchat lines that start with ">" in a scratch moho: the site's own coloured echo and a plain one must both
* draw as 4chan's greentext (#789922) and not as a quote block. Messages are injected; nothing is contacted.
*
* node scripts/shot-sneedchat-greentext.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
try {
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 900, height: 600, deviceScaleFactor: 2, mobile: false })
await ev(`(() => {
const s = window.__mohoShots.store
const acct = { id: 'sneedchat:me', service: 'sneedchat', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, nick: 'me' }
const b = { id: 'sneedchat:me|#room', accountId: 'sneedchat:me', kind: 'channel', name: '#room', lastActivityTs: 5, groupId: 'account:sneedchat:me' }
const t = Math.floor(Date.now() / 1000) - 300
const m = (i, from, body) => ({ id: 'm' + i, bufferId: b.id, from, body, ts: t + i * 20, kind: 'chat', isAction: false, isHighlight: false, isOwn: false, edited: false })
const messages = [
m(1, 'alice', 'an ordinary line of chat'),
m(2, 'bob', '[color=#72ff72]>archlinuxwoman'),
m(3, 'carol', '>be me\\nwalk into a room\\nnobody notices'),
m(4, 'bob', 'back to normal')
]
s.set({ accounts: [acct], groups: [{ id: 'account:sneedchat:me', accountId: 'sneedchat:me', service: 'sneedchat', kind: 'account', name: 'me', position: 0 }], buffers: [b], messagesByBuffer: { [b.id]: messages }, activeBufferId: b.id })
s.selectGroup('account:sneedchat:me')
})()`)
await sleep(1200)
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, 'sneedchat-greentext.png'), Buffer.from(data, 'base64'))
console.log('green lines', await ev(`[...document.querySelectorAll('.greentext')].map((e) => e.textContent + ' => ' + getComputedStyle(e).color)`))
console.log('quote blocks', await ev(`document.querySelectorAll('.quote-block').length`))
} finally {
await stop()
}
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env node
/**
* Photographs a Steam chat group as the window shows it: a server on the rail
* with its rooms as channels and its members listed, then the group's settings
* dialog on each of its tabs. Made-up state on a fake IRC server, in a scratch
* home; the daemon's answers for the group are stood in for here.
*
* node scripts/shot-steam-chatgroups.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const shot = async (name) => {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
}
try {
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
await page.evaluate(`(() => {
const group = {
id: '7', railId: 'steam:me|steamgroup:7', name: 'Raiders', tagline: 'Friday nights, 8pm', owner: '76561197960265729', myRank: 50, myRankWord: 'Owner',
mayModerate: true, mayManage: true, mayInvite: true, mayChangeRoles: true, muted: false, notify: 2,
permissions: [{ key: 'chat', label: 'Write messages' }, { key: 'kick', label: 'Remove people' }, { key: 'ban', label: 'Ban people' }, { key: 'invite', label: 'Invite people and make links' }],
rooms: [{ id: '70', name: '', voice: false }, { id: '71', name: 'loot', voice: false }],
roles: [{ id: '90', name: 'Member' }, { id: '91', name: 'Raid leaders' }],
members: [
{ steamid: '76561197960265729', name: 'Gabe', rank: 50, rankWord: 'Owner', roles: ['90'] },
{ steamid: '76561197960265730', name: 'Alyx', rank: 30, rankWord: 'Moderator', roles: ['90', '91'] },
{ steamid: '76561197960265731', name: 'Eli', rank: 20, rankWord: 'Member', roles: ['90'] }
],
kicked: []
}
window.__rpcFixture = {
steamChatGroups: () => [group],
steamChatGroupAction: (params) => {
if (params.action === 'links') return { links: [{ code: 'Ga3kODnC', link: 'https://s.team/chat/Ga3kODnC', expires: 1800000000 }] }
if (params.action === 'roleActions') return { chat: true, kick: params.roleId === '91', ban: false, invite: true }
if (params.action === 'bans') return { bans: [{ steamid: '76561197960265800', name: 'Spammer' }] }
return {}
}
}
const s = window.__mohoShots
const st = s.state()
const irc = st.accounts[0]
const account = { ...irc, id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
const room = (name, id, position) => ({ ...channel, id: 'steam:me|' + name, accountId: 'steam:me', name, kind: 'channel', remoteId: '7:' + id, groupId: 'steam:me|steamgroup:7', position, unread: 0, highlight: false, mentions: 0 })
const general = room('Raiders/#General', '70', 1)
const loot = { ...room('Raiders/#loot', '71', 2), unread: 3 }
s.patch({
accounts: [...st.accounts, account],
groups: [...st.groups, { id: 'steam:me|steamgroup:7', accountId: 'steam:me', service: 'steam', kind: 'guild', name: 'Raiders', position: 3 }],
buffers: [...st.buffers, general, loot],
activeBufferId: general.id, activeGroupId: 'steam:me|steamgroup:7',
presenceByBuffer: { ...st.presenceByBuffer, [general.id]: [
{ nick: 'Gabe', status: 'online', userId: '76561197960265729', prefix: '👑', roles: ['Owner'], powerLevel: 50 },
{ nick: 'Alyx', status: 'online', userId: '76561197960265730', prefix: '🛡', roles: ['Moderator', 'Raid leaders'], powerLevel: 30, activity: { kind: 'game', text: 'Half-Life: Alyx' } },
{ nick: 'Eli', status: 'offline', away: true, userId: '76561197960265731', powerLevel: 20 }
] }
})
})()`)
await sleep(1500)
await shot('steam-chatgroup.png')
console.log('channels:', JSON.stringify(await page.evaluate(`[...document.querySelectorAll('.sidebar')].map((s) => s.innerText).join('|')`)))
// The rail entry's menu has the settings.
await page.evaluate(`(() => { const tile = [...document.querySelectorAll('.rail-tile')].find((t) => t.getAttribute('aria-label') === 'Raiders' || t.textContent.includes('Ra')); tile.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 40, clientY: 200 })) })()`)
await sleep(400)
const entries = await page.evaluate(`[...document.querySelectorAll('.context-menu button, .context-menu [role=menuitem]')].map((e) => e.textContent.trim())`)
console.log('menu:', JSON.stringify(entries))
await page.evaluate(`(() => { const item = [...document.querySelectorAll('.context-menu button, .context-menu [role=menuitem]')].find((e) => e.textContent.includes('Group settings')); item && item.click() })()`)
await sleep(600)
for (const tab of ['General', 'Rooms', 'People', 'Roles', 'Invites']) {
await page.evaluate(`(() => { const t = [...document.querySelectorAll('.steam-group .room-settings-tab')].find((e) => e.textContent.trim() === ${JSON.stringify(tab)}); t && t.click() })()`)
await sleep(500)
if (tab === 'Roles') await page.evaluate(`(() => { const b = [...document.querySelectorAll('.steam-group button')].find((e) => e.textContent.trim() === 'Edit' ); b && b.click() })()`)
await sleep(300)
await shot(`steam-chatgroup-${tab.toLowerCase()}.png`)
const box = await page.evaluate(`(() => { const r = document.querySelector('.steam-group')?.getBoundingClientRect(); return r ? [Math.round(r.left), Math.round(r.top), Math.round(r.right), Math.round(r.bottom)] : null })()`)
console.log(tab, 'dialog box', JSON.stringify(box))
}
} finally {
await stop()
irc.close()
}
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env node
/**
* Photographs a Steam account's own page with its friends under the account's folders, then moves a
* friend to another folder by dragging and says which changes the window asked Steam for. Made-up
* state on a fake IRC server, in a scratch home; the daemon's answers are stood in for here.
*
* node scripts/shot-steam-folders.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const shot = async (name) => {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
}
try {
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
await page.evaluate(`(() => {
window.__calls = []
const folders = [
{ id: 1, name: 'Steam - ISRAD', members: ['76561197960265704'] },
{ id: 3, name: 'Steam - Friends', members: ['76561197960265701', '76561197960265702'] },
{ id: 5, name: 'Steam - PL', members: ['76561197960265705'] },
{ id: 7, name: 'Steam - Goons', members: ['76561197960265703'] }
]
window.__folders = folders
window.__rpcFixture = { steamFriendGroups: () => folders, steamGroupChange: (p) => { window.__calls.push(p); return {} } }
const s = window.__mohoShots
const st = s.state()
const irc = st.accounts[0]
const ircGroup = st.groups.find((g) => g.kind === 'account')
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
const account = { ...irc, id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
const group = { ...ircGroup, id: 'account:steam:me', accountId: 'steam:me', service: 'steam', name: 'Steam - me' }
const friend = (name, id, category) => ({ ...channel, id: 'steam:me|' + name, accountId: 'steam:me', name, kind: 'dm', remoteId: id, groupId: group.id, category: category ?? null, position: 0, unread: 0, highlight: false, mentions: 0 })
const buffers = [
friend('Dandalf', '76561197960265701', 'Friends'), friend('GetCreped', '76561197960265702', 'Friends'),
friend('Shaod', '76561197960265703', 'Goons'), friend('WigglyQuimble', '76561197960265704', 'ISRAD'),
friend('Hujoppi', '76561197960265705', 'PL'), friend('Stranger', '76561197960265706', null)
]
s.patch({
accounts: [...st.accounts, account],
groups: [...st.groups, group],
buffers: [...st.buffers, ...buffers],
steamGroups: { 'steam:me': folders },
activeGroupId: group.id, activeBufferId: buffers[0].id
})
})()`)
await sleep(1500)
// The daemon answered the window's own question about the made-up account with nothing; say it again.
await page.evaluate(`window.__mohoShots.patch({ steamGroups: { 'steam:me': window.__folders } })`)
await sleep(500)
await shot('steam-folders.png')
console.log('classes:', JSON.stringify(await page.evaluate(`[...document.querySelectorAll('.bufferlist [class*=category]')].filter((e) => e.textContent.trim() === 'expand_morePL').map((e) => e.className + ' ' + e.tagName + ' ' + e.draggable)`)))
await page.evaluate(`(() => { const st = window.__mohoShots.store; window.__ranAll = []; const orig = st.moveSteamFriend.bind(st); st.moveSteamFriend = (...a) => { window.__moved = a; return orig(...a) }; const run = st.runSteamGroupCalls.bind(st); st.runSteamGroupCalls = (c) => { window.__ran = c; window.__ranAll.push(...c); return run(c) } })()`)
// Drag Shaod (Goons) onto the PL heading.
const moved = await page.evaluate(`(async () => {
const row = [...document.querySelectorAll('.bufferlist [draggable=true]')].find((e) => e.textContent.trim().endsWith('Shaod') && e.textContent.trim().length < 12)
const head = [...document.querySelectorAll('.bufferlist [class*=category]')].find((e) => e.textContent.trim() === 'expand_morePL')
if (!row || !head) return { row: !!row, head: !!head }
const dt = new DataTransfer()
const wait = () => new Promise((r) => setTimeout(r, 150))
row.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer: dt }))
await wait()
head.dispatchEvent(new DragEvent('dragenter', { bubbles: true, cancelable: true, dataTransfer: dt }))
head.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt }))
await wait()
window.__finish = () => { head.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt })) }
return { dragged: true }
})()`)
console.log('drag:', JSON.stringify(moved))
await shot('steam-folders-drag.png')
await page.evaluate(`window.__finish && window.__finish()`)
await sleep(500)
console.log('state:', JSON.stringify(await page.evaluate(`(() => { const st = window.__mohoShots.state(); const b = st.buffers.find((x) => x.id === 'steam:me|Shaod'); return { b: b && [b.remoteId, b.category, b.accountId], g: st.steamGroups } })()`)))
console.log('moved:', JSON.stringify(await page.evaluate(`[window.__moved, window.__ran]`)))
console.log('calls:', JSON.stringify(await page.evaluate(`window.__calls`)))
// A new folder: named, and made on Steam.
await page.evaluate(`(() => { const t = [...document.querySelectorAll('.bufferlist button')].find((b) => b.textContent.includes('Steam - me')); t && t.click() })()`)
await sleep(300)
await page.evaluate(`(() => { const i = [...document.querySelectorAll('.context-menu button, .context-menu [role=menuitem]')].find((e) => e.textContent.includes('New category')); i && i.click() })()`)
await sleep(400)
await shot('steam-folders-naming.png')
await page.evaluate(`(() => { const inp = document.querySelector('.reason-prompt-box'); if (!inp) return; const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; set.call(inp, 'Raiders'); inp.dispatchEvent(new Event('input', { bubbles: true })) })()`)
await sleep(300)
await page.evaluate(`(() => { const inp = document.querySelector('.reason-prompt-box'); inp && inp.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) })()`)
await sleep(500)
console.log('all calls:', JSON.stringify(await page.evaluate(`window.__ranAll`)))
} finally {
await stop()
irc.close()
}
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env node
/**
* Photographs the direct-messages page when a Steam account has friend groups:
* each group a heading, its friends' conversations under it. Then changes one
* as Steam would, and reads where the conversation went. Made-up state on a fake
* IRC server, in a scratch home.
*
* node scripts/shot-steam-groups.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
try {
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
await page.call('Emulation.setDeviceMetricsOverride', { width: 1000, height: 700, deviceScaleFactor: 1, mobile: false })
await page.evaluate(`(() => {
const s = window.__mohoShots
const st = s.state()
const irc = st.accounts[0]
const account = { ...irc, id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
const dm = (name, remoteId) => ({ ...channel, id: 'steam:me|' + name, accountId: 'steam:me', name, kind: 'dm', remoteId, unread: 0, highlight: false, mentions: 0 })
s.patch({
accounts: [...st.accounts, account],
buffers: [...st.buffers, dm('Gabe', '76561197960265729'), dm('Alyx', '76561197960287930'), dm('Eli', '76561197960265730')],
activeBufferId: 'steam:me|Gabe', activeGroupId: '~dms',
steamGroups: { 'steam:me': [{ id: 7, name: 'Raiders', members: ['76561197960265729', '76561197960265730'] }, { id: 8, name: 'Work', members: [] }] }
})
})()`)
await sleep(1500)
const headings = () => page.evaluate(`[...document.querySelectorAll('.sidebar')].map((s) => s.innerText).join('|')`)
console.log('page:', JSON.stringify(await headings()))
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, 'steam-groups.png'), Buffer.from(data, 'base64'))
// Steam moves Alyx into Work.
await page.evaluate(`(() => { const s = window.__mohoShots; s.patch({ steamGroups: { 'steam:me': [{ id: 7, name: 'Raiders', members: ['76561197960265729', '76561197960265730'] }, { id: 8, name: 'Work', members: ['76561197960287930'] }] } }) })()`)
await sleep(1200)
console.log('after Steam moved Alyx into Work:', JSON.stringify(await headings()))
} finally {
await stop()
irc.close()
}
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env node
/**
* Photographs the Invitations tile with a Steam group invitation on it, as the window shows it.
* Made-up state on a fake IRC server, in a scratch home.
*
* node scripts/shot-steam-invite.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
try {
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
await page.evaluate(`(() => {
const s = window.__mohoShots
const st = s.state()
const account = { ...st.accounts[0], id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
s.patch({
accounts: [...st.accounts, account],
matrixInvites: { 'steam:me': [{ roomId: '79003375', name: 'Biggest Newest Moho Test', inviter: 'Salastil', avatarUrl: null, isDirect: false }] },
activeGroupId: '~invitations'
})
})()`)
await sleep(1200)
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, 'steam-invite.png'), Buffer.from(data, 'base64'))
console.log(await page.evaluate(`document.querySelector('.invite-panel')?.innerText`))
} finally {
await stop()
irc.close()
}
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env node
/**
* Photographs a Steam conversation Steam has warned about: the red banner over
* it and the red line in the log, then the banner dismissed. Made-up state on a
* fake IRC server, in a scratch home.
*
* node scripts/shot-steam-notice.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
try {
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
await page.call('Emulation.setDeviceMetricsOverride', { width: 1000, height: 620, deviceScaleFactor: 1, mobile: false })
await page.evaluate(`(() => {
const s = window.__mohoShots
const st = s.state()
const irc = st.accounts[0]
const account = { ...irc, id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
const notice = { kind: 'suspicious', text: 'Steam warns that this person may not be who they appear to be. Be careful with links, offers and requests for anything - and do not give out your account details.' }
const dm = { ...channel, id: 'steam:me|Gabe', accountId: 'steam:me', name: 'Gabe', kind: 'dm', remoteId: '76561197960265829', unread: 0, highlight: false, mentions: 0, notice }
const now = Math.floor(Date.now() / 1000)
const m = (id, from, body, kind = 'chat') => ({ id, bufferId: dm.id, from, body, ts: now - 600 + Number(id.split(':')[1] || 0), isAction: false, isHighlight: false, kind })
s.patch({
accounts: [...st.accounts, account], buffers: [...st.buffers, dm], activeBufferId: dm.id, activeGroupId: '~dms',
messagesByBuffer: { ...st.messagesByBuffer, [dm.id]: [
m('1:1', 'Gabe', 'hey, long time'),
m('2:2', 'Steam', notice.text, 'warning'),
m('3:3', 'Gabe', 'can you check this link for me')
] }
})
})()`)
await sleep(1000)
const shot = async (name) => {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
console.log('wrote', name)
}
await shot('steam-notice-before.png')
console.log('banner:', await page.evaluate(`!!document.querySelector('.notice-banner')`), '| warning lines:', await page.evaluate(`document.querySelectorAll('.message-row.warning').length`))
await page.evaluate(`document.querySelector('.notice-banner-dismiss').click()`)
await sleep(600)
console.log('banner after dismiss:', await page.evaluate(`!!document.querySelector('.notice-banner')`), '| warning lines:', await page.evaluate(`document.querySelectorAll('.message-row.warning').length`))
await shot('steam-notice-after.png')
} finally {
await stop()
irc.close()
}
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env node
/**
* Photographs the message box of a Steam conversation with its emoji picker
* open: no GIF button, and a picker with no Unicode emoji. Made-up state, on a
* fake IRC server, in a scratch home.
*
* node scripts/shot-steam-picker.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
try {
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
await page.call('Emulation.setDeviceMetricsOverride', { width: 1000, height: 700, deviceScaleFactor: 1, mobile: false })
const shot = async (name) => {
await sleep(600)
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
console.log('wrote', name)
}
// A Steam account and a conversation with a friend, selected.
await page.evaluate(`(() => {
const s = window.__mohoShots
const st = s.state()
const irc = st.accounts[0]
const account = { ...irc, id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
const dm = { ...channel, id: 'steam:me|Gabe', accountId: 'steam:me', name: 'Gabe', kind: 'dm', remoteId: '76561197960265829', unread: 0, highlight: false, mentions: 0 }
s.patch({ accounts: [...st.accounts, account], buffers: [...st.buffers, dm], activeBufferId: dm.id, activeGroupId: '~dms' })
})()`)
await shot('steam-composer.png')
await page.evaluate(`document.querySelector('button[title="Emoji"]').click()`)
await shot('steam-picker.png')
const gif = await page.evaluate(`!!document.querySelector('button[title="GIFs"]')`)
const sticker = await page.evaluate(`!!document.querySelector('button[title="Stickers"]')`)
console.log('GIF button:', gif, '| Stickers button:', sticker)
} finally {
await stop()
irc.close()
}
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* Photographs the column about a Steam friend, beside their conversation, and
* checks the conversation's right-click menu offers it. Made-up state on a fake
* IRC server, in a scratch home.
*
* node scripts/shot-steam-profile.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
try {
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
await page.call('Emulation.setDeviceMetricsOverride', { width: 1100, height: 700, deviceScaleFactor: 1, mobile: false })
await page.evaluate(`(() => {
const s = window.__mohoShots
const st = s.state()
const irc = st.accounts[0]
const account = { ...irc, id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
const dm = { ...channel, id: 'steam:me|Gabe', accountId: 'steam:me', name: 'Gabe', kind: 'dm', remoteId: '76561197960265829', unread: 0, highlight: false, mentions: 0 }
const now = Math.floor(Date.now() / 1000)
const m = (id, body) => ({ id, bufferId: dm.id, from: 'Gabe', body, ts: now - 600 + Number(id.split(':')[1]), isAction: false, isHighlight: false, kind: 'chat' })
s.patch({
accounts: [...st.accounts, account], buffers: [...st.buffers, dm], activeBufferId: dm.id, activeGroupId: '~dms',
messagesByBuffer: { ...st.messagesByBuffer, [dm.id]: [m('1:1', 'want to play later?')] },
presenceByBuffer: { ...st.presenceByBuffer, [dm.id]: [{ nick: 'Gabe', status: 'online', userId: '76561197960265829', activity: { kind: 'game', text: 'Counter-Strike 2' }, client: 'desktop', rich: 'Competitive - Dust II' }] }
})
})()`)
await sleep(600)
// The right-click menu of the row, and what it offers.
const rect = await page.evaluate(`(() => { const r = [...document.querySelectorAll('.buffer-row, .buffer')].find((e) => e.textContent.includes('Gabe')); if (!r) return null; const b = r.getBoundingClientRect(); return { x: b.x + 20, y: b.y + 10 } })()`)
console.log('row at', JSON.stringify(rect))
await page.call('Input.dispatchMouseEvent', { type: 'mousePressed', x: rect.x, y: rect.y, button: 'right', clickCount: 1 })
await page.call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: rect.x, y: rect.y, button: 'right', clickCount: 1 })
await sleep(400)
console.log('menu:', JSON.stringify(await page.evaluate(`[...document.querySelectorAll('[role=menuitem], .context-menu button')].map((e) => e.textContent.trim()).slice(0, 6)`)))
// Chosen from the menu, as a person would.
console.log('clicked Profile:', await page.evaluate(`(() => { const e = [...document.querySelectorAll('[role=menuitem], .context-menu button')].find((x) => x.textContent.trim() === 'Profile'); if (!e) return false; e.click(); return true })()`))
await sleep(500)
// What Steam said, as the daemon would send it: the card every service opens.
await page.evaluate(`window.__mohoShots.patch({ profile: {
service: 'steam', accountId: 'steam:me', id: '76561197960265729', name: 'Gabe', createdTs: 1300000000,
status: 'online', activity: { kind: 'game', text: 'Counter-Strike 2' },
nameHistory: [{ name: 'Gabe Newell', when: '3 Mar, 2019 @ 1:00am' }, { name: 'Valve Gabe', when: '12 Jun, 2012 @ 6:40pm' }],
extra: [{ label: 'Steam level', value: '42' }, { label: 'Real name', value: 'Gabe N' }, { label: 'Location', value: 'Bellevue, United States' }, { label: 'On', value: 'desktop' }, { label: 'Doing', value: 'Competitive - Dust II' }, { label: 'Headline', value: 'Hello' }]
} })`)
await sleep(500)
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, 'steam-profile-card.png'), Buffer.from(data, 'base64'))
console.log('wrote steam-profile-card.png; card:', await page.evaluate(`!!document.querySelector('.profile-card')`), '| previous names:', await page.evaluate(`document.querySelectorAll('.profile-name-history li').length`))
// Offline: last seen instead of playing.
await page.evaluate(`(() => { const s = window.__mohoShots; const st = s.state(); s.patch({ profile: { ...st.profile, status: 'offline', activity: null, lastActiveTs: Math.floor(Date.now() / 1000) - 7200 } }) })()`)
await sleep(400)
console.log('offline card:', JSON.stringify(await page.evaluate(`document.querySelector('.profile-card')?.innerText.replace(/\\n+/g, ' | ').slice(0, 300)`)))
} finally {
await stop()
irc.close()
}
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* Photographs a Steam conversation holding a picture and a video, each plain
* and each sent as a spoiler. Made-up state on a fake IRC server in a scratch
* home; Steam's addresses are answered from a generated picture and clip.
*
* node scripts/shot-steam-spoiler.mjs <output directory> <picture.png> <clip.webm>
*/
import fs from 'node:fs'
import path from 'node:path'
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
const [out = '.', picture, clip] = process.argv.slice(2)
fs.mkdirSync(out, { recursive: true })
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
try {
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
await page.call('Emulation.setDeviceMetricsOverride', { width: 1000, height: 900, deviceScaleFactor: 1, mobile: false })
// Steam's addresses answered here: the picture and the clip, whatever the path.
await page.call('Fetch.enable', { patterns: [{ urlPattern: '*steamusercontent.com*' }, { urlPattern: '*steamstatic.com*' }, { urlPattern: '*example.com*' }] })
page.on('Fetch.requestPaused', async (e) => {
const video = e.request.url.includes('/cdn-video/') || e.request.url.includes('cdn.steamusercontent.com')
const body = fs.readFileSync(video ? clip : picture).toString('base64')
await page.call('Fetch.fulfillRequest', {
requestId: e.requestId,
responseCode: 200,
responseHeaders: [{ name: 'Content-Type', value: video ? 'video/webm' : 'image/png' }, { name: 'Access-Control-Allow-Origin', value: '*' }],
body
})
})
const img = (n) => `https://images.steamusercontent.com/ugc/${n}/AAAA/`
const vid = (n) => `https://cdn.steamusercontent.com/ugc/${n}/BBBB/`
await page.evaluate(`(() => {
const s = window.__mohoShots
const st = s.state()
const irc = st.accounts[0]
const account = { ...irc, id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
const dm = { ...channel, id: 'steam:me|Gabe', accountId: 'steam:me', name: 'Gabe', kind: 'dm', remoteId: '76561197960265829', unread: 0, highlight: false, mentions: 0 }
const m = (id, body) => ({ id, bufferId: dm.id, from: 'Gabe', body, ts: Math.floor(Date.now() / 1000) - 600 + Number(id), isAction: false, isHighlight: false, kind: 'chat' })
s.patch({
accounts: [...st.accounts, account], buffers: [...st.buffers, dm], activeBufferId: dm.id, activeGroupId: '~dms',
messagesByBuffer: { ...st.messagesByBuffer, [dm.id]: [
m('1', 'a plain picture'), m('2', '\\n[steam:image:${img(1)}]\\n'),
m('3', 'a picture sent as a spoiler'), m('4', '||\\n[steam:image:${img(2)}]\\n||'),
m('5', 'a plain video'), m('6', '\\n[steam:video:${vid(3)}]\\n'),
m('7', 'a video sent as a spoiler'), m('8', '||\\n[steam:video:${vid(4)}]\\n||'),
m('9', 'an emoticon [steam:emote:steamhappy] in a line, then a sticker'), m('10', '[steam:sticker:Nyan GLaDOS]'),
m('11', 'a link with words: [look at this](https://links.invalid/a?x=1&y=2) and a bare one https://links.invalid/b, then a picture https://example.com/p.png'),
{ ...m('12', '[x.salastil.com](http://x.salastil.com)'), embeds: [{ url: 'http://x.salastil.com', title: 'Home', description: 'A privacy-focused Twitter/X frontend', provider: 'x.salastil.com', imageUrl: 'https://community.steamstatic.com/chat/image/abc/share_image' }] }
] }
})
})()`)
await sleep(1500)
const shot = async (name) => {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
console.log('wrote', name)
}
await shot('steam-spoiler-before.png')
console.log('links:', JSON.stringify(await page.evaluate(`[...document.querySelectorAll('.message-body a, .message-text a, .message-row a')].map((a) => [a.textContent, a.getAttribute('href')]).filter((l) => l[1] && !l[1].startsWith('spoiler'))`)))
console.log('covers:', await page.evaluate(`document.querySelectorAll('.spoiler-cover').length`), '| embeds:', await page.evaluate(`document.querySelectorAll('.media-embed-wrap').length`))
await page.evaluate(`document.querySelector('.spoiler-cover')?.click()`)
await sleep(500)
console.log('covers after one click:', await page.evaluate(`document.querySelectorAll('.spoiler-cover').length`))
await shot('steam-spoiler-after.png')
} finally {
await stop()
irc.close()
}
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env node
/**
* Photographs Steam voice as the window shows it: a group's voice room with people in
* it, the panel for the call you are in, and a call ringing. Made-up state on a fake IRC
* server, in a scratch home.
*
* node scripts/shot-steam-voice.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
try {
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
await page.evaluate(`(() => {
const s = window.__mohoShots
const st = s.state()
const account = { ...st.accounts[0], id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
const room = (name, id, position) => ({ ...channel, id: 'steam:me|' + name, accountId: 'steam:me', name, kind: 'channel', remoteId: '7:' + id, groupId: 'steam:me|steamgroup:7', position, unread: 0, highlight: false, mentions: 0 })
const dm = { ...channel, id: 'steam:me|Gabe', accountId: 'steam:me', name: 'Gabe', kind: 'dm', remoteId: '76561197960265729', unread: 0, highlight: false, mentions: 0 }
const general = room('Raiders/#General', '70', 1)
const talk = room('Raiders/#Lounge', '71', 2)
const live = s.store.steamVoiceLive
live.speakers = new Set(['76561197960265730'])
live.statuses = new Map([['76561197960265731', { micMuted: true, outputMuted: false, noMic: false }]])
live.level = 0.4
live.rttMs = 23
s.patch({
accounts: [...st.accounts, account],
groups: [...st.groups, { id: 'steam:me|steamgroup:7', accountId: 'steam:me', service: 'steam', kind: 'guild', name: 'Raiders', position: 3 }],
buffers: [...st.buffers, general, talk, dm],
activeBufferId: general.id, activeGroupId: 'steam:me|steamgroup:7',
steamVoiceRooms: { 'steam:me|7': [{ roomId: '71', bufferId: talk.id, name: 'Lounge', members: [
{ userId: '76561197960265729', nick: 'Gabe' },
{ userId: '76561197960265730', nick: 'Alyx' },
{ userId: '76561197960265731', nick: 'Eli' },
{ userId: '76561197960287930', nick: 'me', isSelf: true }
] }] },
steamVoiceNow: { accountId: 'steam:me', phase: 'connected', target: { kind: 'room', groupId: '7', roomId: '71', bufferId: talk.id, name: 'Lounge', groupName: 'Raiders', railId: 'steam:me|steamgroup:7' } },
voiceSessions: [{ accountId: 'steam:me', channelId: '71', channelName: 'Lounge', isDirect: false, bufferId: talk.id, railId: 'steam:me|steamgroup:7' }],
steamRings: [{ accountId: 'steam:me', steamid: '76561197960265729', voiceChatId: '99' }]
})
})()`)
// The window asks the daemon who is in the voice rooms when the group opens, and the
// daemon knows no such group: the made-up answer goes in after that.
await sleep(1200)
await page.evaluate(`(() => { const s = window.__mohoShots; s.patch({ steamVoiceRooms: { 'steam:me|7': [{ roomId: '71', bufferId: 'steam:me|Raiders/#Lounge', name: 'Lounge', members: [{ userId: '76561197960265729', nick: 'Gabe' }, { userId: '76561197960265730', nick: 'Alyx' }, { userId: '76561197960265731', nick: 'Eli' }, { userId: '76561197960287930', nick: 'me', isSelf: true }] }] } }) })()`)
await sleep(800)
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, 'steam-voice.png'), Buffer.from(data, 'base64'))
console.log('panel:', JSON.stringify(await page.evaluate(`document.querySelector('.voice-panel')?.innerText`)))
console.log('ring:', JSON.stringify(await page.evaluate(`document.querySelector('.incoming-call')?.innerText`)))
} finally {
await stop()
irc.close()
}
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env node
/**
* Reads a busy live Twitch channel in a scratch moho with no sign-in, and checks what the window shows:
* that the channel joins, messages arrive with their colours, badges and emote pictures, replies carry
* their quote, the room's modes are in the header and the chatters are counted.
*
* node scripts/shot-twitch-chat.mjs <output directory> [channel] [seconds]
*/
import fs from 'node:fs'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
const channel = process.argv[3] ?? 'xqc'
const seconds = Number(process.argv[4] ?? 40)
fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
const ev = (code) => page.evaluate(code)
try {
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
// HISTORY=1 turns on the third-party recent messages, which is off until a person says so.
if (process.env.HISTORY) await ev(`window.moho.prefs.set('twitch.recentHistory', true)`)
const added = await rpc('addTwitchAccount', {})
say('account', JSON.stringify(added))
await sleep(2500)
say('accounts', await ev(`JSON.stringify(window.__mohoShots.state().accounts.map((a) => ({ id: a.id, service: a.service, state: a.state, name: a.displayName, hasPassword: a.hasPassword })))`))
say('join', JSON.stringify(await rpc('joinBuffer', { accountId: added.accountId, name: channel })))
const id = `${added.accountId}|${channel}`
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
say('a channel that does not exist', JSON.stringify(await rpc('joinBuffer', { accountId: added.accountId, name: 'zzzz_no_such_channel_zzzz' })))
if (process.env.HISTORY) {
await sleep(6000)
say('rows six seconds after opening, with the past', await ev(`(window.__mohoShots.state().messagesByBuffer[${JSON.stringify(id)}] || []).length`))
}
await sleep(seconds * 1000)
say('buffers', await ev(`JSON.stringify(window.__mohoShots.state().buffers.filter((b) => b.accountId === ${JSON.stringify(added.accountId)}).map((b) => ({ id: b.id, modes: b.channelModes })))`))
say('page', await ev(`JSON.stringify({
rows: document.querySelectorAll('.message-row').length,
emotes: document.querySelectorAll('.message-row img.custom-emoji').length,
emotesLoaded: [...document.querySelectorAll('.message-row img.custom-emoji')].filter((i) => i.complete && i.naturalWidth > 0).length,
badges: [...new Set([...document.querySelectorAll('.message-row .sender-badge')].map((b) => b.textContent))],
badgePictures: document.querySelectorAll('.message-row .sender-badge-image').length,
badgePicturesLoaded: [...document.querySelectorAll('.message-row .sender-badge-image')].filter((i) => i.complete && i.naturalWidth > 0).length,
badgeTitles: [...new Set([...document.querySelectorAll('.message-row .sender-badge-image')].map((i) => i.title))].slice(0, 6),
emoteSources: [...new Set([...document.querySelectorAll('.message-row img.custom-emoji')].map((i) => new URL(i.src).hostname))],
replies: document.querySelectorAll('.message-row .reply-preview').length,
header: document.querySelector('.nicklist-header span')?.textContent,
coloured: document.querySelectorAll('.message-row .message-from[style*="color"]').length
})`))
await shot('twitch-chat.png')
async function shot(name) {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
}
} finally {
await stop()
}
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env node
/**
* Opens the Accounts pane in a scratch moho and starts a Twitch sign-in: checks that one window of twitch.tv's
* login opens (nothing is typed into it), then closes that window as a person giving up would, and checks the
* form comes back with nothing saved. No account is signed in.
*
* node scripts/shot-twitch-signin.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const { page, stop, port } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
const ev = (code) => page.evaluate(code)
const shot = async (name) => {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
}
const clickText = (selector, text) => ev(`(() => { const b = [...document.querySelectorAll(${JSON.stringify(selector)})].find((x) => x.textContent.trim().startsWith(${JSON.stringify(text)})); if (!b) return false; b.click(); return true })()`)
try {
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1100, height: 800, deviceScaleFactor: 1, mobile: false })
await ev(`window.__mohoShots.store.setActivePanel('accounts')`)
await sleep(600)
say('twitch chip', await clickText('.service-chip', 'Twitch'))
await sleep(500)
await shot('twitch-signin-form.png')
say('sign in', await clickText('button', 'Sign in with Twitch'))
// The sign-in window is another page of the same app, so the debugging port lists it.
const targets = async () => (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()).filter((t) => t.type === 'page' && /twitch\.tv/.test(t.url))
let found = []
for (let i = 0; i < 40 && found.length === 0; i++) {
await sleep(500)
found = await targets()
}
say('sign-in window', JSON.stringify(found.map((t) => t.url)))
say('waiting text', await ev(`document.querySelector('.add-form')?.textContent.slice(0, 120)`))
await shot('twitch-signin-waiting.png')
// Giving up, as a person does by closing it.
for (const t of found) await fetch(`http://127.0.0.1:${port}/json/close/${t.id}`)
await sleep(2000)
say('window gone', (await targets()).length === 0)
say('back to the form', await ev(`!!([...document.querySelectorAll('button')].find((b) => b.textContent.trim() === 'Sign in with Twitch'))`))
say('accounts saved', await ev(`window.__mohoShots.state().accounts.filter((a) => a.service === 'twitch').length`))
} finally {
await stop()
}
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env node
/**
* A Twitch channel and a whisper conversation side by side in the channel list, to see that they can be told
* apart. Injects the two buffers; nothing is contacted.
*
* node scripts/shot-twitch-whisper.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
try {
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1100, height: 700, deviceScaleFactor: 2, mobile: false })
await ev(`(() => {
const s = window.__mohoShots.store
const acct = { id: 'twitch:me', service: 'twitch', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, nick: 'me' }
const pic = 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#7b5cd6"/><circle cx="32" cy="26" r="12" fill="#fff"/><rect x="14" y="42" width="36" height="16" rx="8" fill="#fff"/></svg>')
const buffers = [
{ id: 'twitch:me|skitzy_va', accountId: 'twitch:me', kind: 'channel', name: 'skitzy_va', lastActivityTs: 5, avatarUrl: pic, category: 'Channels', groupId: 'account:twitch:me' },
{ id: 'twitch:me|kotbarsik_twt', accountId: 'twitch:me', kind: 'channel', name: 'kotbarsik_twt', lastActivityTs: 4, avatarUrl: pic, category: 'Channels', groupId: 'account:twitch:me' },
{ id: 'twitch:me|@anon7of10', accountId: 'twitch:me', kind: 'dm', name: '@anon7of10', lastActivityTs: 6, avatarUrl: pic, category: 'Direct Messages', groupId: 'account:twitch:me' }
]
s.set({ accounts: [acct], groups: [{ id: 'account:twitch:me', accountId: 'twitch:me', service: 'twitch', kind: 'account', name: 'me', position: 0 }], buffers, activeBufferId: 'twitch:me|skitzy_va' })
s.selectGroup('account:twitch:me')
})()`)
await sleep(800)
await sleep(400)
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, 'twitch-whisper-vs-channel.png'), Buffer.from(data, 'base64'))
console.log('rows', await ev(`[...document.querySelectorAll('.buffer-row')].map((r) => r.innerText.replace(/\\n/g, ' ') + (r.querySelector('.avatar-badge') ? ' [badge]' : ''))`))
} finally {
await stop()
}
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* Photographs the member list and the direct-message rows in comfy and in
* classic, with made-up presence on them (a game, a track, words somebody
* wrote), so the two looks can be compared by eye.
*
* node scripts/shot-userlist.mjs <output directory>
*
* Runs the built app against a scratch home with the screenshot hook on, and
* a fake IRC server for something to be in.
*/
import fs from 'node:fs'
import path from 'node:path'
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const PEOPLE = ['Crujix', 'Currington', 'dakota', 'Gremlin_magos', 'onkell', 'Salastil', 'Taylaigh']
const MEMBERS = [
{ nick: 'Crujix', status: 'idle', activity: { kind: 'game', text: 'Songs of Syx Demo' } },
{ nick: 'Currington', status: 'online', activity: { kind: 'game', text: 'Philippians 2' } },
{ nick: 'dakota', status: 'online' },
{ nick: 'Gremlin_magos', status: 'dnd', activity: { kind: 'custom', text: 'ask questions' } },
{ nick: 'onkell', status: 'online', activity: { kind: 'game', text: 'Down to Dungeon' } },
{ nick: 'Salastil', status: 'online', activity: { kind: 'music', text: 'Ramones' } },
{ nick: 'Taylaigh', status: 'offline', away: true }
]
const irc = await fakeIrc({
people: PEOPLE,
onJoin: (channel, _nick, send) => send(`:Crujix!u@h PRIVMSG ${channel} :hello there`)
})
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
try {
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
await page.call('Emulation.setDeviceMetricsOverride', { width: 1100, height: 760, deviceScaleFactor: 1, mobile: false })
const shot = async (name) => {
await sleep(600)
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
console.log('wrote', name)
}
const dress = async () => {
await page.evaluate(`(() => {
const s = window.__mohoShots
const st = s.state()
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
const dm = (name, extra = {}) => ({ ...channel, id: 'irc:x|' + name, name, kind: 'dm', unread: 0, highlight: false, mentions: 0, ...extra })
const gamer = dm('k. mardam bae')
const quiet = dm('Alexcellence')
s.patch({
buffers: [...st.buffers, gamer, quiet],
presenceByBuffer: {
...st.presenceByBuffer,
[channel.id]: ${JSON.stringify(MEMBERS)},
[gamer.id]: [{ nick: 'k. mardam bae', status: 'idle', activity: { kind: 'game', text: "Assassin's Creed Valhalla" } }],
[quiet.id]: [{ nick: 'Alexcellence', status: 'online' }]
}
})
})()`)
}
await dress()
await page.evaluate(`window.moho.prefs.set('display.messageMode', 'comfy')`)
await shot('comfy-channel.png')
await page.evaluate(`(() => {
const s = window.__mohoShots
const channel = s.state().buffers.find((b) => b.id.endsWith('|#ui'))
s.patch({ profile: { service: 'irc', accountId: channel.accountId, name: 'onkell', handle: 'onkell', status: 'online', extra: [{ label: 'Client', value: 'moho' }] } })
})()`)
await shot('profile-card.png')
await page.evaluate(`(() => { window.__mohoShots.patch({ profile: null }) })()`)
await page.evaluate(`(() => { const s = window.__mohoShots; s.store.selectGroup('~dms') })()`)
await shot('comfy-dms.png')
// A window is not told about its own preference change, so the new mode is
// set, the page reloaded to read it, and the made-up state put back.
await page.evaluate(`window.moho.prefs.set('display.messageMode', 'classic')`)
await sleep(300)
await page.call('Page.reload', {})
await sleep(2500)
await dress()
await page.evaluate(`(() => { const s = window.__mohoShots; s.store.selectGroup('~dms') })()`)
await shot('classic-dms.png')
await page.evaluate(`(() => { const s = window.__mohoShots; const b = s.state().buffers.find((b) => b.id.endsWith('|#ui')); s.store.selectBuffer(b.id) })()`)
await shot('classic-channel.png')
} finally {
await stop()
irc.close()
}
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env sh
# Keeps a development checkout from filling the disk.
#
# Everything this removes is build output that the next build makes again, and
# none of it is the project: it is what grew to a hundred gigabytes without
# anybody choosing it.
#
# - dist/ got one AppImage per deploy and never lost one. Only the newest few
# are kept; the installed copy lives in ~/.local/bin.
# - Cargo never removes what a build no longer uses. Every change to a
# dependency, a feature or the compiler leaves the old build beside the new
# one, so nobilis/target held fifty copies of each library. Past a size it
# is emptied, and the build that follows starts it again with only what is
# current. Run before a build, never after one: after, it would throw away
# the thing just made.
# - Debug builds and the Windows cross-build are not what a deploy uses and
# are made again when somebody asks for them.
#
# scripts/tidy.sh (also run first by `npm run deploy`)
set -eu
SRC_DIR="$(cd "$(dirname "$0")/.." && pwd)"
KEEP_APPIMAGES="${MOHO_KEEP_APPIMAGES:-2}"
# In megabytes: a clean release build of the daemon is about two gigabytes.
TARGET_LIMIT_MB="${MOHO_TARGET_LIMIT_MB:-8000}"
size_mb() { du -sm "$1" 2>/dev/null | cut -f1; }
# What is in dist/ besides the newest AppImages: older ones, Windows and
# Debian packages, the unpacked trees and their block maps.
if [ -d "$SRC_DIR/dist" ]; then
keep="$(ls -t "$SRC_DIR"/dist/*.AppImage 2>/dev/null | head -n "$KEEP_APPIMAGES")"
for f in "$SRC_DIR"/dist/* "$SRC_DIR"/dist/.[!.]*; do
[ -e "$f" ] || continue
case "$(basename "$f")" in builder-debug.yml|builder-effective-config.yaml|latest*.yml) continue ;; esac
printf '%s\n' "$keep" | grep -qxF "$f" && continue
rm -rf "$f"
done
fi
# A build directory left over from before the daemon lived in nobilis/.
rm -rf "$SRC_DIR/target"
TARGET="$SRC_DIR/nobilis/target"
if [ -d "$TARGET" ]; then
rm -rf "$TARGET/debug" "$TARGET"/x86_64-pc-windows-* "$TARGET"/aarch64-*
used="$(size_mb "$TARGET")"
if [ "${used:-0}" -gt "$TARGET_LIMIT_MB" ]; then
echo "tidy: nobilis/target is ${used} MB, over ${TARGET_LIMIT_MB} MB - emptying it; the next build starts it fresh"
rm -rf "$TARGET"
fi
fi
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env node
/**
* Checks the tray against the running app: what its icon, tooltip and menu say
* for what is waiting, what its entries do, and that "Restart moho" brings back
* moho and a daemon of its own.
*
* A tray cannot be photographed, so this reads what main last gave it through a
* hook that exists only under MOHO_UI_SHOTS, and presses its entries the same
* way. Main is reached over the Node inspector the app is started with.
*
* npm run test:tray (builds first; needs the daemon built too)
*/
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import { cdp, fakeIrc, freePort, launchApp, openIrcChannel, sleep, until } from './lib/harness.mjs'
let failures = 0
const check = (ok, what, detail = '') => {
console.log(`${ok ? 'ok ' : 'FAIL'} ${what}${ok ? '' : detail ? ` - ${detail}` : ''}`)
if (!ok) failures++
}
async function mainProcess(inspectPort) {
for (let i = 0; i < 60; i++) {
try {
const list = await (await fetch(`http://127.0.0.1:${inspectPort}/json/list`)).json()
const target = list.find((t) => t.webSocketDebuggerUrl)
if (target) return cdp(target.webSocketDebuggerUrl)
} catch {
/* not up yet */
}
await sleep(500)
}
throw new Error('main never offered its inspector')
}
/** The nobilis processes that belong to this run, by the runtime directory in their environment. */
function daemons(scratch) {
const out = []
for (const entry of fs.readdirSync('/proc')) {
if (!/^\d+$/.test(entry)) continue
try {
const exe = fs.readlinkSync(`/proc/${entry}/exe`)
if (!exe.endsWith('/nobilis')) continue
if (fs.readFileSync(`/proc/${entry}/environ`, 'utf8').includes(`XDG_RUNTIME_DIR=${scratch}/run`)) out.push(Number(entry))
} catch {
/* gone, or not ours */
}
}
return out
}
// A run that hangs is a failure, and must not leave an app behind it.
setTimeout(() => {
console.log('FAIL timed out')
process.exit(1)
}, 150000).unref()
const inspectPort = await freePort()
const { page, stop, port } = await launchApp({ env: { MOHO_UI_SHOTS: '1' }, args: [`--inspect=${inspectPort}`] })
let scratchDir = ''
try {
const main = await mainProcess(inspectPort)
const tray = (expr) => main.evaluate(`(() => { const t = globalThis.__mohoTray; return ${expr} })()`)
await until(main, 'globalThis.__mohoTray', 'the tray')
scratchDir = await main.evaluate('process.env.HOME')
// Nothing waiting.
await tray('t.update({ unread: 0, dms: 0, mentions: 0 })'.replace('t.', 't.'))
check(/^tray-(light|dark)\.png$/.test(await tray('t.icon()')), 'idle: the plain bubble', await tray('t.icon()'))
check((await tray('t.menu()[0].label')) === 'Nothing new', 'idle: the menu says nothing is new')
check((await tray('t.tooltip()')) === 'moho', 'idle: the tooltip is just moho')
// Something waiting: the count is on the icon, and the menu says what.
await tray('t.update({ unread: 3, dms: 2, mentions: 1 })')
check(/^tray-(light|dark)-3\.png$/.test(await tray('t.icon()')), 'waiting: the icon carries the count', await tray('t.icon()'))
check((await tray('t.tooltip()')) === 'moho - 2 direct messages, 1 mention', 'waiting: the tooltip says what', await tray('t.tooltip()'))
check((await tray('t.menu()[0].label')) === '2 direct messages, 1 mention - open', 'waiting: the menu says what', await tray('t.menu()[0].label'))
await tray('t.update({ unread: 40, dms: 40, mentions: 0 })')
check(/-more\.png$/.test(await tray('t.icon()')), 'a great many: the icon says 9+')
await tray('t.update({ unread: 0, dms: 0, mentions: 0 })')
// The entries, and that none of them is daemon plumbing.
const labels = await tray('t.menu().map((i) => i.label ?? "-")')
check(!labels.some((l) => /daemon/i.test(l)), 'the menu offers no daemon plumbing', labels.join(' | '))
check(labels.includes('Restart moho'), 'the menu offers Restart moho')
// Settings opens in the window.
await tray('t.click(["Settings"])')
await until(page, 'window.__mohoShots.state().activePanel === "settings"', 'Settings to open')
check(true, 'Settings opens the settings page')
// A status goes to the window to carry out.
await page.evaluate('window.__trayGot = []; window.moho.onTrayCommand((c, a) => window.__trayGot.push([c, a])); true')
await tray('t.click(["Status", "Do not disturb"])')
await until(page, 'window.__trayGot.length', 'the status to reach the window')
check(JSON.stringify(await page.evaluate('window.__trayGot[0]')) === '["status","dnd"]', 'a status is handed to the window')
// There is no notifications switch of its own: Do not disturb is that.
check(!labels.some((l) => /notification/i.test(l)), 'the menu has no notifications switch of its own', labels.join(' | '))
// With an IRC account connected - which has away and back, and no invisible -
// the menu offers what IRC can be set to, and the daemon takes it.
const aways = []
const irc = await fakeIrc({
people: ['bob'],
onJoin: (channel, _nick, send) => send(`:bob!b@h PRIVMSG ${channel} :hello there`),
onLine: (line) => {
if (/^AWAY/i.test(line)) aways.push(line)
}
})
try {
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#lobby'], waitFor: 'hello there' })
await until(main, 'globalThis.__mohoTray.menu().find((i) => i.label === "Status").submenu.length === 3', 'the menu to offer only IRC\'s statuses')
const offered = await tray('t.menu().find((i) => i.label === "Status").submenu.map((i) => i.label)')
check(JSON.stringify(offered) === '["Online","Idle","Do not disturb"]', 'IRC alone: no Invisible is offered', JSON.stringify(offered))
const statusOf = () => page.evaluate('window.moho.rpc("listAccounts", {}).then((a) => a.find((x) => x.id.startsWith("checker")).status)')
await tray('t.click(["Status", "Idle"])')
await until(page, 'window.moho.rpc("listAccounts", {}).then((a) => a.find((x) => x.id.startsWith("checker")).status === "idle")', 'IRC to go idle')
check(true, 'Idle from the tray sets the IRC account idle')
await tray('t.click(["Status", "Do not disturb"])')
await until(page, 'window.moho.rpc("listAccounts", {}).then((a) => a.find((x) => x.id.startsWith("checker")).status === "dnd")', 'IRC to go to Do not disturb')
check((await statusOf()) === 'dnd', 'Do not disturb from the tray sets it')
// Going idle when the person steps away, and only when they are online.
const shown = () => page.evaluate('window.moho.rpc("listAccounts", {}).then((a) => { const x = a.find((y) => y.id.startsWith("checker")); return { status: x.status, auto: !!x.autoIdle } })')
const waitFor = (expr, what) => until(page, `window.moho.rpc("listAccounts", {}).then((a) => { const x = a.find((y) => y.id.startsWith("checker")); return ${expr} })`, what)
await tray('t.click(["Status", "Online"])')
await waitFor('x.status === "online"', 'online again')
await tray('t.idle(true)')
await waitFor('x.status === "idle" && x.autoIdle === true', 'an online account to go idle when its person steps away')
check(true, 'stepping away turns an online account idle, as automatic')
await sleep(500)
check(aways.some((l) => /AWAY :?Idle/i.test(l)), 'and IRC is told it is away', JSON.stringify(aways))
await tray('t.idle(false)')
await waitFor('x.status === "online" && !x.autoIdle', 'it to come back')
check(aways.some((l) => /^AWAY\s*$/i.test(l)), 'coming back puts it right without anybody touching it', JSON.stringify(aways))
// What was chosen is never moved.
await tray('t.click(["Status", "Do not disturb"])')
await waitFor('x.status === "dnd"', 'Do not disturb')
await tray('t.idle(true)')
await sleep(800)
let now = await shown()
check(now.status === 'dnd' && !now.auto, 'Do not disturb is kept when they step away', JSON.stringify(now))
await tray('t.idle(false)')
await sleep(500)
now = await shown()
check(now.status === 'dnd', 'and when they come back', JSON.stringify(now))
await tray('t.click(["Status", "Idle"])')
await waitFor('x.status === "idle" && !x.autoIdle', 'a chosen Idle')
await tray('t.idle(true)')
await tray('t.idle(false)')
await sleep(500)
now = await shown()
check(now.status === 'idle' && !now.auto, 'a chosen Idle stays until Online is chosen', JSON.stringify(now))
await tray('t.click(["Status", "Online"])')
await waitFor('x.status === "online"', 'online again')
// The tray's number is the window's: it follows what is waiting, by the rail's rule.
const stage = (patch) => page.evaluate(`(() => { const s = window.__mohoShots.state(); const id = s.buffers.find((b) => b.kind === 'channel' && b.id.startsWith('checker')).id; window.__chan = id; window.__mohoShots.patch({ buffers: s.buffers.map((b) => (b.id === id ? { ...b, ...(${JSON.stringify(patch)}) } : b)) }); return id })()`)
const counted = async (want, what) => {
await until(main, `(() => { const s = globalThis.__mohoTray.state(); return s.mentions === ${want.mentions} && s.dms === ${want.dms} })()`, what)
check(true, what)
}
await tray('t.update({ unread: 0, dms: 0, mentions: 0 })')
const chan = await stage({ unread: 33, mentions: 2, highlight: true })
await counted({ mentions: 2, dms: 0 }, 'two mentions in a busy channel make the tray say two, not thirty-three')
check((await tray('t.state().latest')) === chan, 'and the menu opens that channel')
// Said the way another window would say it, through main.
const chanId = await page.evaluate('window.__chan')
await tray(`t.pref('mutedBuffers', [${JSON.stringify(chanId)}])`)
await counted({ mentions: 2, dms: 0 }, 'muting it leaves its mentions on the tray - a mute stops the popups, not the count')
await stage({ unread: 40, mentions: 0, highlight: false })
await counted({ mentions: 0, dms: 0 }, 'and what else is said in it counts for nothing')
await tray("t.pref('mutedBuffers', [])")
await stage({ unread: 33, mentions: 2, highlight: true })
await counted({ mentions: 2, dms: 0 }, 'unmuting changes nothing about the number')
await stage({ unread: 0, mentions: 0, highlight: false })
await counted({ mentions: 0, dms: 0 }, 'reading it somewhere else clears the tray as it clears the window')
await page.evaluate(`(() => { const s = window.__mohoShots.state(); window.__mohoShots.patch({ buffers: [...s.buffers, { id: 'checker@localhost|bobdm', accountId: s.accounts[0].id, kind: 'dm', name: 'bob', lastActivityTs: 1, unread: 3, highlight: false, mentions: 0 }] }); return true })()`)
await counted({ mentions: 0, dms: 3 }, 'a direct message counts for each unread message in it')
check(/3 direct messages/.test(await tray('t.tooltip()')), 'and the tooltip says so', await tray('t.tooltip()'))
await page.evaluate(`(() => { const s = window.__mohoShots.state(); window.__mohoShots.patch({ buffers: s.buffers.filter((b) => b.id !== 'checker@localhost|bobdm') }); return true })()`)
await counted({ mentions: 0, dms: 0 }, 'and the tray is empty again')
} finally {
irc.close()
}
// The tray being redrawn - which it is whenever something unread changes - must
// not move the window: somebody who has chosen another server stays on it. The
// redraw tells the window the link is up, and taking each for a reconnection
// sent the pane back to the server of whatever conversation was open.
const away = await page.evaluate('window.__mohoShots.state().activeGroupId')
await page.evaluate('window.__mohoShots.store.selectGroup("~mentions")')
for (let i = 0; i < 6; i++) {
await tray(`t.update({ unread: ${i}, dms: ${i}, mentions: 0 })`)
await sleep(200)
}
await sleep(1500)
const stayed = await page.evaluate('window.__mohoShots.state().activeGroupId')
check(stayed === '~mentions', 'a redrawn tray leaves the chosen server chosen', `${away} -> ${stayed}`)
await tray('t.update({ unread: 0, dms: 0, mentions: 0 })')
// Using a window is heard by main - and only that it happened.
const heardBefore = await tray('t.lastActivity()')
await sleep(6000)
await page.call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 300, y: 300, button: 'none', clickCount: 0 })
await sleep(500)
const heardAfter = await tray('t.lastActivity()')
check(heardAfter > heardBefore, 'moving the pointer in a window is heard as being here', `${heardBefore} -> ${heardAfter}`)
// Restart brings back moho and a daemon of its own.
const before = daemons(scratchDir)
const pidBefore = await main.evaluate('process.pid')
check(before.length === 1, 'one daemon is running', String(before))
// Not waited for: the app quits under this call, and the inspector with it.
void tray('t.click(["Restart moho"])').catch(() => {})
// And the inspector let go, because a process that is being inspected waits
// for the inspector to disconnect before it will exit.
await sleep(500)
main.close()
let pidAfter = pidBefore
let again = null
for (let i = 0; i < 80 && pidAfter === pidBefore; i++) {
await sleep(500)
try {
again = await mainProcess(inspectPort)
pidAfter = await again.evaluate('process.pid')
} catch {
/* between the two */
}
}
check(pidAfter !== pidBefore, 'moho came back as a new process', `${pidBefore} -> ${pidAfter}`)
await sleep(4000)
const after = daemons(scratchDir)
check(after.length === 1 && !before.includes(after[0]), 'with a daemon of its own, the old one gone', `${before} -> ${after}`)
// Whatever it started has to go with this run.
try {
for (const pid of daemons(scratchDir)) process.kill(pid, 'SIGTERM')
} catch {
/* gone */
}
} catch (e) {
console.log('FAIL', e.message)
failures++
} finally {
// The relaunched copy is not the one the harness started, so it is not in the
// group stop() ends. It is found by the debugging port it was told to use -
// it shows no environment to be found by - and what it started goes when it
// does. Ended first, and twice since it may still be starting, because it
// writes into the directory stop() is about to remove.
for (let i = 0; i < 2; i++) {
await sleep(1500)
try {
execFileSync('pkill', ['-9', '-f', `remote-debugging-port=${port}`])
} catch {
/* none left */
}
for (const entry of fs.readdirSync('/proc')) {
if (!/^\d+$/.test(entry) || Number(entry) === process.pid || !scratchDir) continue
try {
if (fs.readFileSync(`/proc/${entry}/environ`, 'utf8').includes(`HOME=${scratchDir}\0`)) process.kill(Number(entry), 'SIGKILL')
} catch {
/* gone, or not ours to read */
}
}
}
await sleep(1500)
await stop().catch(() => {})
if (scratchDir) fs.rmSync(scratchDir, { recursive: true, force: true })
}
console.log(failures ? `${failures} failed` : 'all passed')
process.exit(failures ? 1 : 0)
+14 -189
View File
@@ -23,15 +23,7 @@
* On a machine with no display, run it under xvfb-run.
*/
import { spawn } from 'node:child_process'
import fs from 'node:fs'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import electronPath from 'electron'
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
import { click, fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
/** When each popup is measured after it is opened, in milliseconds. */
const SAMPLES = [80, 400, 1500, 3500]
@@ -45,119 +37,8 @@ const SIZES = [
/** Everything that opens on top of the UI. */
const POPUPS = '.context-menu, .emoji-picker, .header-popover, .profile-card, [role="dialog"], [role="menu"]'
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
// --- a fake IRC server ---------------------------------------------------
const PEOPLE = ['alice', 'bob', 'carol', 'dave_the_longer_named', 'eve']
function fakeIrc() {
const server = net.createServer((socket) => {
let nick = 'checker'
const send = (line) => socket.write(line + '\r\n')
let buffered = ''
socket.on('error', () => {})
socket.on('data', (chunk) => {
buffered += chunk.toString()
let at
while ((at = buffered.indexOf('\n')) >= 0) {
const line = buffered.slice(0, at).replace(/\r$/, '')
buffered = buffered.slice(at + 1)
const [command, ...args] = line.split(' ')
switch (command.toUpperCase()) {
case 'CAP':
if (args[0] === 'LS') send(':fake CAP * LS :')
break
case 'NICK':
nick = args[0]
break
case 'USER':
send(`:fake 001 ${nick} :Welcome`)
send(`:fake 376 ${nick} :End of MOTD`)
break
case 'PING':
send(`:fake PONG fake ${args.join(' ')}`)
break
case 'JOIN': {
const channel = args[0].split(',')[0]
send(`:${nick}!u@h JOIN ${channel}`)
send(`:fake 353 ${nick} = ${channel} :${[nick, ...PEOPLE].join(' ')}`)
send(`:fake 366 ${nick} ${channel} :End of NAMES`)
for (let i = 0; i < 12; i++) {
const who = PEOPLE[i % PEOPLE.length]
send(`:${who}!u@h PRIVMSG ${channel} :line ${i + 1} from ${who}, long enough to wrap on a narrow window`)
}
break
}
}
}
})
})
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server)))
}
// --- the app, and a line to it --------------------------------------------
async function freePort() {
const probe = net.createServer()
await new Promise((r) => probe.listen(0, '127.0.0.1', r))
const { port } = probe.address()
await new Promise((r) => probe.close(r))
return port
}
async function pageTarget(port) {
for (let i = 0; i < 120; i++) {
try {
const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
const page = list.find((t) => t.type === 'page' && !t.url.startsWith('devtools:'))
if (page) return page
} catch {
/* not up yet */
}
await sleep(500)
}
throw new Error('the app never opened a window')
}
function cdp(url) {
const socket = new WebSocket(url)
let id = 0
const waiting = new Map()
socket.addEventListener('message', (e) => {
const msg = JSON.parse(e.data)
if (msg.id && waiting.has(msg.id)) {
const { resolve, reject } = waiting.get(msg.id)
waiting.delete(msg.id)
if (msg.error) reject(new Error(msg.error.message))
else resolve(msg.result)
}
})
const ready = new Promise((r) => socket.addEventListener('open', r))
const call = async (method, params = {}) => {
await ready
const n = ++id
socket.send(JSON.stringify({ id: n, method, params }))
return new Promise((resolve, reject) => waiting.set(n, { resolve, reject }))
}
const evaluate = async (expression) => {
const r = await call('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text)
return r.result.value
}
return { call, evaluate, close: () => socket.close() }
}
/** Waits until an expression in the page is truthy. */
async function until(page, expression, what, ms = 30000) {
const end = Date.now() + ms
while (Date.now() < end) {
if (await page.evaluate(`!!(${expression})`).catch(() => false)) return
await sleep(250)
}
throw new Error(`timed out waiting for ${what}`)
}
// --- the checks -------------------------------------------------------------
/** Where every open popup is, against the window. */
@@ -180,26 +61,19 @@ const MEASURE = `(() => {
/** A right-click at the window's far corner, on whatever element is named. */
const rightClickInCorner = (selector, text) => `(() => {
const el = [...document.querySelectorAll(${JSON.stringify(selector)})]
.find((e) => ${JSON.stringify(text ?? '')} === '' || e.innerText.includes(${JSON.stringify(text ?? '')}))
.find((e) => ${JSON.stringify(text ?? '')} === '' || (e.innerText + " " + (e.title || "")).includes(${JSON.stringify(text ?? '')}))
if (!el) return false
el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: innerWidth - 2, clientY: innerHeight - 2, button: 2 }))
return true
})()`
const click = (selector, text) => `(() => {
const el = [...document.querySelectorAll(${JSON.stringify(selector)})]
.find((e) => ${JSON.stringify(text ?? '')} === '' || (e.innerText + ' ' + (e.title || '')).includes(${JSON.stringify(text ?? '')}))
if (!el) return false
el.click()
return true
})()`
const SCENARIOS = [
{ name: 'emoji picker', open: click('button[title="Emoji"]') },
{ name: 'channel list menu, opened in the corner', open: rightClickInCorner('.buffer-row', '#ui') },
{ name: 'message menu, opened in the corner', open: rightClickInCorner('.message-row', 'line 12') },
{ name: 'member menu, opened in the corner', open: rightClickInCorner('.nick-row', 'alice') },
{ name: 'mentions inbox', open: click('button[title="Mentions"]') }
{ name: 'mentions inbox', open: click('button[title="Mentions"]') },
{ name: 'status popout', open: click('.user-identity-button') }
]
async function closePopups(page) {
@@ -217,58 +91,21 @@ async function closePopups(page) {
}
async function main() {
const irc = await fakeIrc()
const ircPort = irc.address().port
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'moho-ui-'))
const run = path.join(scratch, 'run')
fs.mkdirSync(run, { mode: 0o700 })
const port = await freePort()
// A CI runner's kernel refuses Chromium's sandbox (Ubuntu 24.04 restricts
// the unprivileged namespaces it needs); there it runs without, as an
// ordinary Chromium in a container does.
const flags = [`--remote-debugging-port=${port}`, ...(process.env.CI ? ['--no-sandbox'] : [])]
const app = spawn(electronPath, [root, ...flags], {
cwd: root,
detached: true,
stdio: process.env.MOHO_UI_VERBOSE ? 'inherit' : 'ignore',
env: {
...process.env,
HOME: scratch,
XDG_CONFIG_HOME: path.join(scratch, 'config'),
XDG_CACHE_HOME: path.join(scratch, 'cache'),
XDG_DATA_HOME: path.join(scratch, 'data'),
XDG_STATE_HOME: path.join(scratch, 'state'),
XDG_RUNTIME_DIR: run,
// The runtime directory is moved so the daemon's socket is this run's
// own - which also moves where a Wayland client looks for the
// compositor. An absolute path keeps it pointing at the real one.
...(process.env.WAYLAND_DISPLAY && process.env.XDG_RUNTIME_DIR && !path.isAbsolute(process.env.WAYLAND_DISPLAY)
? { WAYLAND_DISPLAY: path.join(process.env.XDG_RUNTIME_DIR, process.env.WAYLAND_DISPLAY) }
: {})
const irc = await fakeIrc({
people: PEOPLE,
onJoin: (channel, _nick, send) => {
for (let i = 0; i < 12; i++) {
const who = PEOPLE[i % PEOPLE.length]
send(`:${who}!u@h PRIVMSG ${channel} :line ${i + 1} from ${who}, long enough to wrap on a narrow window`)
}
}
})
const failures = []
let checked = 0
let page
const { page, stop } = await launchApp()
try {
page = cdp((await pageTarget(port)).webSocketDebuggerUrl)
await until(page, 'window.moho && document.querySelector("#root > *")', 'the window to load')
// An account on the fake server, with a channel to open popups over.
await until(page, 'window.moho.rpc("listAccounts", {}).then(() => true)', 'the daemon')
await page.evaluate(
`window.moho.rpc("addAccount", { nick: "checker", host: "localhost", port: ${ircPort}, ssl: false, autojoin: "#ui" })`
)
await until(page, 'window.moho.rpc("listBuffers", {}).then((b) => b.some((x) => x.id.endsWith("|#ui")))', 'the channel')
await page.call('Page.reload')
await sleep(1000)
await until(page, 'document.querySelector("[title*=\\"localhost\\"]")', 'the account in the rail')
await page.evaluate(click('[title*="localhost"]'))
await until(page, `[...document.querySelectorAll(".buffer-row")].some((r) => r.innerText.includes("#ui"))`, 'the channel row')
await page.evaluate(click('.buffer-row', '#ui'))
await until(page, `[...document.querySelectorAll(".message-row")].some((r) => r.innerText.includes("line 12"))`, 'the messages')
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'line 12' })
for (const size of SIZES) {
await page.call('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: false })
@@ -302,20 +139,8 @@ async function main() {
}
}
} finally {
page?.close()
try {
process.kill(-app.pid, 'SIGTERM')
} catch {
/* already gone */
}
await sleep(1500)
try {
process.kill(-app.pid, 'SIGKILL')
} catch {
/* already gone */
}
await stop()
irc.close()
fs.rmSync(scratch, { recursive: true, force: true })
}
if (failures.length > 0) {
+1568
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
// What a build calls itself: its version, or "development".
//
// The version - package.json's - only for a build of a release: CI building a
// pushed v<version> tag, a checkout sitting exactly on that tag, or the master
// branch. Anything else, the development branch above all, is "development":
// a build from in-between commits is not the release its package.json names,
// and showing that number in Settings > About said it was.
//
// node scripts/version-label.mjs -> "1.0.0" or "development"
// node scripts/version-label.mjs --channel -> "release" or "development"
//
// Imported by electron.vite.config.ts; run by the daemon build scripts, which
// hand the answer to nobilis (NOBILIS_CHANNEL), whose own checkout cannot
// tell - CI checks a submodule out as a bare commit, on no branch.
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const git = (...args) => {
try {
return execFileSync('git', ['-C', ROOT, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
} catch {
return ''
}
}
export function isReleaseBuild(version) {
const tag = `v${version}`
// CI, building the tag it was pushed for.
if (process.env.GITHUB_REF_TYPE === 'tag' && process.env.GITHUB_REF_NAME === tag) return true
if (git('tag', '--points-at', 'HEAD').split('\n').includes(tag)) return true
return git('rev-parse', '--abbrev-ref', 'HEAD') === 'master'
}
export function versionLabel() {
const version = JSON.parse(readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version
return isReleaseBuild(version) ? version : 'development'
}
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
const label = versionLabel()
console.log(process.argv.includes('--channel') ? (label === 'development' ? 'development' : 'release') : label)
}
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env node
/**
* Reads a busy live Kick channel in a scratch moho and reports how the chatters count grows against the
* names listed, and whether drawing a long list holds the window up.
*
* node scripts/watch-kick-roster.mjs [channel] [seconds]
*/
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const channel = process.argv[2] ?? 'spreen'
const seconds = Number(process.argv[3] ?? 150)
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
try {
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
await sleep(2500)
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
const added = await rpc('addKickAccount', {})
await rpc('joinBuffer', { accountId: added.accountId, name: channel })
const id = `${added.accountId}|${channel}`
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
await ev(`(() => { window.__long = 0; window.__longMs = 0; new PerformanceObserver((l) => { for (const e of l.getEntries()) { window.__long++; window.__longMs += e.duration } }).observe({ entryTypes: ['longtask'] }) })()`)
const began = Date.now()
while (Date.now() - began < seconds * 1000) {
await sleep(15000)
console.log(await ev(`JSON.stringify({ header: document.querySelector('.nicklist-header span')?.textContent, rows: document.querySelectorAll('.nicklist .member-row, .nicklist-scroll > div > div:not(.nicklist-group)').length, state: (() => { const s = window.__mohoShots.state(); return { listed: (s.presenceByBuffer[${JSON.stringify(id)}] || []).length, total: s.presenceTotalByBuffer[${JSON.stringify(id)}] } })(), longTasks: window.__long, longMs: Math.round(window.__longMs) })`))
}
} finally {
await stop()
}
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env node
/**
* Watches a live Kick stream in a scratch moho and reports every time the picture waits for data: when,
* for how long, and how much was buffered ahead of the playhead just before.
*
* node scripts/watch-kick-stalls.mjs [channel] [seconds]
*/
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const channel = process.argv[2] ?? 'spreen'
const seconds = Number(process.argv[3] ?? 90)
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
try {
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
await sleep(2500)
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
const added = await rpc('addKickAccount', {})
await rpc('joinBuffer', { accountId: added.accountId, name: channel })
const id = `${added.accountId}|${channel}`
for (let i = 0; i < 40; i++) {
await sleep(1000)
if (await ev(`!!window.__mohoShots.state().kickStreams[${JSON.stringify(id)}]?.playbackUrl`)) break
}
await ev(`(async () => { await window.__mohoShots.store.selectBuffer(${JSON.stringify(id)}); window.__mohoShots.store.watchStream(${JSON.stringify(id)}) })()`)
for (let i = 0; i < 40; i++) {
if (await ev(`!!document.querySelector('video.video-tile-picture')`)) break
await sleep(500)
}
await ev(`(() => {
const v = document.querySelector('video.video-tile-picture')
const log = (window.__stalls = { events: [], samples: [], t0: performance.now() })
const at = () => +((performance.now() - log.t0) / 1000).toFixed(1)
const ahead = () => { const b = v.buffered; return b.length ? +(b.end(b.length - 1) - v.currentTime).toFixed(2) : 0 }
for (const n of ['waiting', 'stalled', 'playing', 'seeking', 'seeked', 'ratechange']) v.addEventListener(n, () => log.events.push({ t: at(), n, ahead: ahead(), rate: v.playbackRate, ct: +v.currentTime.toFixed(2) }))
setInterval(() => log.samples.push({ t: at(), ahead: ahead(), ct: +v.currentTime.toFixed(2), rate: v.playbackRate }), 500)
})()`)
await sleep(seconds * 1000)
const r = JSON.parse(await ev('JSON.stringify(window.__stalls)'))
const waits = r.events.filter((e) => e.n === 'waiting')
console.log(`${waits.length} waiting events in ${seconds}s`)
for (const e of r.events) console.log(JSON.stringify(e))
const a = r.samples.map((s) => s.ahead)
console.log('buffer ahead: min', Math.min(...a), 'max', Math.max(...a), 'mean', +(a.reduce((x, y) => x + y, 0) / a.length).toFixed(2))
console.log('rates seen', [...new Set(r.samples.map((s) => s.rate))])
console.log('quality', await ev(`JSON.stringify((() => { const q = document.querySelector('video.video-tile-picture').getVideoPlaybackQuality(); return { dropped: q.droppedVideoFrames, total: q.totalVideoFrames } })())`))
} finally {
await stop()
}
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env node
/**
* Watches a live stream of either service in a scratch moho and reports how it plays: every time the
* picture waits for data, how much was buffered ahead of the playhead, how far behind the stream's own
* clock it runs, what rendition it settled on, and whether an ad break was marked.
*
* node scripts/watch-stream-stalls.mjs <kick|twitch> <channel> [seconds]
*/
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const service = process.argv[2] ?? 'twitch'
const channel = process.argv[3] ?? 'xqc'
const seconds = Number(process.argv[4] ?? 90)
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
try {
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
await sleep(2500)
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
const added = await rpc(service === 'twitch' ? 'addTwitchAccount' : 'addKickAccount', {})
const accountId = added.accountId
console.log('join', JSON.stringify(await rpc('joinBuffer', { accountId, name: channel })))
const id = `${accountId}|${channel}`
for (let i = 0; i < 60; i++) {
await sleep(1000)
if (await ev(`!!window.__mohoShots.state().kickStreams[${JSON.stringify(id)}]?.playbackUrl`)) break
}
const stream = JSON.parse(await ev(`JSON.stringify((({ live, title, category, viewers, playbackExpires, adFree, playbackError }) => ({ live, title, category, viewers, playbackExpires, adFree, playbackError }))(window.__mohoShots.state().kickStreams[${JSON.stringify(id)}] || {}))`))
console.log('stream', JSON.stringify(stream))
if (!stream.live) { console.log('not live; nothing to watch'); process.exit(0) }
await ev(`(async () => { await window.__mohoShots.store.selectBuffer(${JSON.stringify(id)}); window.__mohoShots.store.watchStream(${JSON.stringify(id)}) })()`)
for (let i = 0; i < 40; i++) {
if (await ev(`!!document.querySelector('video.video-tile-picture') && window.__mohoHls?.levels?.length > 0`)) break
await sleep(500)
}
// The stream's own clock against ours, from the playlist's session data.
const offset = await ev(`(async () => { const u = window.__mohoShots.state().kickStreams[${JSON.stringify(id)}].playbackUrl; const t0 = Date.now(); const r = await fetch(u); const m = /SERVER-TIME",VALUE="([0-9.]+)"/.exec(await r.text()); return m ? Number(m[1]) * 1000 - (t0 + Date.now()) / 2 : null })().catch(() => null)`)
console.log('clock offset ms', offset)
await ev(`(() => {
const v = document.querySelector('video.video-tile-picture')
const log = (window.__stalls = { events: [], samples: [], t0: performance.now() })
const at = () => +((performance.now() - log.t0) / 1000).toFixed(1)
const ahead = () => { const b = v.buffered; return b.length ? +(b.end(b.length - 1) - v.currentTime).toFixed(2) : 0 }
for (const n of ['waiting', 'stalled', 'seeking', 'ratechange']) v.addEventListener(n, () => log.events.push({ t: at(), n, ahead: ahead(), ct: +v.currentTime.toFixed(2) }))
setInterval(() => {
const h = window.__mohoHls
log.samples.push({ t: at(), ahead: ahead(), latency: h?.playingDate && ${offset === null ? 'null' : offset} !== null ? +((Date.now() + ${offset ?? 0} - h.playingDate.getTime()) / 1000).toFixed(1) : null, level: h?.currentLevel, height: v.videoHeight, ad: !!document.querySelector('.stream-ad') })
}, 500)
})()`)
if (process.env.SHOT_DIR) {
// Photographs the stage, then its resolution menu, part-way through.
const fs = await import('node:fs')
const shot = async (name) => {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(`${process.env.SHOT_DIR}/${name}`, Buffer.from(data, 'base64'))
}
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
await sleep(Math.min(20, seconds / 2) * 1000)
await shot(`${service}-stream.png`)
await ev(`document.querySelector('button[title^="Resolution"]')?.click()`)
await sleep(500)
console.log('quality choices', await ev(`JSON.stringify([...document.querySelectorAll('.stream-menu .context-menu-item')].map((b) => b.textContent.trim()))`))
await shot(`${service}-stream-quality.png`)
await ev(`document.querySelector('.stream-menu-scrim')?.click()`)
await sleep((seconds - Math.min(20, seconds / 2)) * 1000 - 500)
} else {
await sleep(seconds * 1000)
}
const r = JSON.parse(await ev('JSON.stringify(window.__stalls)'))
const waits = r.events.filter((e) => e.n === 'waiting' && e.t > 6)
console.log(`${waits.length} waiting events after the first six seconds, over ${seconds}s`)
for (const e of r.events.filter((e) => e.t > 6)) console.log(JSON.stringify(e))
const a = r.samples.map((s) => s.ahead)
const lat = r.samples.map((s) => s.latency).filter((x) => x !== null)
console.log('buffer ahead: min', Math.min(...a), 'max', Math.max(...a), 'mean', +(a.reduce((x, y) => x + y, 0) / a.length).toFixed(2))
if (lat.length) console.log('behind the stream: min', Math.min(...lat), 'max', Math.max(...lat), 'mean', +(lat.reduce((x, y) => x + y, 0) / lat.length).toFixed(1))
console.log('heights', [...new Set(r.samples.map((s) => s.height))], 'ad breaks seen', r.samples.some((s) => s.ad))
console.log('quality', await ev(`JSON.stringify((() => { const q = document.querySelector('video.video-tile-picture').getVideoPlaybackQuality(); return { dropped: q.droppedVideoFrames, total: q.totalVideoFrames } })())`))
} finally {
await stop()
}
+78 -5
View File
@@ -30,7 +30,16 @@ export type Capture =
/** A request header the page starts sending - Discord's Authorization. */
| { kind: 'header'; name: string }
/** A cookie the service sets - the usual shape for forum software. */
| { kind: 'cookie'; name: string; url: string }
| {
kind: 'cookie'
name: string
url: string
/**
* Where the same value is to be had if the named cookie is not there: another cookie holding JSON with
* the value under a key. Twitch's page-readable `twilight-user` carries the token its `auth-token` cookie does.
*/
fallback?: { name: string; key: string }
}
/** A parameter on a redirect the service lands on - SSO, mostly. */
| { kind: 'query'; param: string }
/**
@@ -126,6 +135,17 @@ export const LOGIN_FLOWS: Record<string, LoginFlow> = {
"(document.querySelector('.p-navgroup-user-linkText') || document.querySelector('.p-navgroup-link--user .p-navgroup-linkText'))?.textContent?.trim() || null"
}
},
twitch: {
label: 'Twitch',
url: 'https://www.twitch.tv/login',
// Nothing: the credential is a cookie the site sets, not a request to read.
watch: [],
// The website's own session token, which is what Twitch's player calls accept for playing a stream as the
// person who signed in - their Turbo, their subscriptions - and which a token from moho's own sign-in
// (made for chat and the official API) is not. It goes straight to the daemon, for the account named.
capture: { kind: 'cookie', name: 'auth-token', url: 'https://www.twitch.tv', fallback: { name: 'twilight-user', key: 'authToken' } },
finish: { method: 'setTwitchWebToken', param: 'token' }
},
kick: {
label: 'Kick',
url: 'https://kick.com/',
@@ -165,7 +185,20 @@ let openWindow: BrowserWindow | null = null
* be signing in, and the second would inherit the first's half-finished
* session.
*/
export async function browserLogin(service: string): Promise<LoginOutcome> {
export async function browserLogin(
service: string,
options?: {
/**
* Keeps the window open once the credential is captured, for a flow that goes on in it: called with the
* credential and the window, and the window closes when what it returns settles. Twitch's: signing in on
* twitch.tv and then, in the same window and the same session, approving the code that gives the chat
* connection its token.
*/
hold?: (value: string, win: BrowserWindow) => Promise<void>
/** Said at each step, without any credential in it, for the log. */
trace?: (message: string) => void
}
): Promise<LoginOutcome> {
const flow = LOGIN_FLOWS[service]
if (!flow) return { ok: false, error: `No browser sign-in is defined for ${service}` }
if (openWindow && !openWindow.isDestroyed()) {
@@ -219,6 +252,17 @@ export async function browserLogin(service: string): Promise<LoginOutcome> {
e.preventDefault()
retitle()
})
if (options?.trace) {
const trace = options.trace
win.webContents.on('did-navigate', (_e, url) => {
try {
const u = new URL(url)
trace(`the window is at ${u.host}${u.pathname}`)
} catch {
/* not a URL */
}
})
}
win.webContents.on('did-navigate', retitle)
win.webContents.on('did-navigate-in-page', retitle)
retitle()
@@ -244,7 +288,19 @@ export async function browserLogin(service: string): Promise<LoginOutcome> {
TIMEOUT_MS
)
let holding = false
watch(ses, win, flow, (value) => {
if (options?.hold) {
// The credential shows up more than once as cookies change; the flow is told once.
if (holding) return
holding = true
options.trace?.('signed in on twitch.tv; the session was found')
void options
.hold(value, win)
.then(() => finish({ ok: true, value }))
.catch((e: Error) => finish({ ok: false, error: e.message }))
return
}
// Read before finishing, because finishing destroys the window - and
// best-effort, because this is a nicety and the credential is not.
const also = flow.alsoRead
@@ -320,14 +376,31 @@ function watch(ses: Session, win: BrowserWindow, flow: LoginFlow, found: (value:
}
if (flow.capture.kind === 'cookie') {
const { name, url } = flow.capture
const { name, url, fallback } = flow.capture
const check = (): void => {
void ses.cookies.get({ url, name }).then((cookies) => {
const value = cookies[0]?.value
void ses.cookies.get({ url }).then((cookies) => {
let value = cookies.find((c) => c.name === name)?.value
if (!value && fallback) {
const holder = cookies.find((c) => c.name === fallback.name)?.value
if (holder) {
try {
const parsed = JSON.parse(decodeURIComponent(holder)) as Record<string, unknown>
const token = parsed[fallback.key]
if (typeof token === 'string') value = token
} catch {
/* not JSON yet; the next change will be */
}
}
}
if (value) found(value)
})
}
ses.cookies.on('changed', check)
// Also after each page, in case the sign-in ended in a redirect that the cookie jar's own events did not announce.
if (!win.isDestroyed()) {
win.webContents.on('did-navigate', check)
win.webContents.on('did-finish-load', check)
}
check()
return
}
+17
View File
@@ -99,6 +99,23 @@ export function localPath(source: string): string | null {
return null
}
/**
* The bytes of one piece of media, wherever it is: a file nobilis cached, or a
* link (including the app's own moho-media one). For putting a picture on the
* clipboard, where saving it first would leave a file nobody asked for.
*/
export async function readMedia(
source: string,
fetchRemote: (url: string) => Promise<{ ok: boolean; status: number; bytes: () => Promise<Uint8Array> }>
): Promise<Uint8Array> {
const local = localPath(source)
if (local) return fsp.readFile(local)
if (!/^(https?|moho-media):\/\//i.test(source)) throw new Error('unsupported link')
const res = await fetchRemote(source)
if (!res.ok) throw new Error(`server returned ${res.status}`)
return res.bytes()
}
export interface SaveResult {
path?: string
error?: string
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest'
import { IdleWatcher } from './idle'
const MIN = 60_000
/** A watcher on a clock the test holds, with the system's idle time a figure it sets. */
function watcher(over: { enabled?: boolean; minutes?: number } = {}): {
w: IdleWatcher
clock: { now: number; system: number; enabled: boolean }
said: boolean[]
} {
const clock = { now: 1_000_000, system: 0, enabled: over.enabled ?? true }
const said: boolean[] = []
const w = new IdleWatcher({
now: () => clock.now,
systemIdleSeconds: () => clock.system,
enabled: () => clock.enabled,
thresholdMs: () => (over.minutes ?? 10) * MIN,
onChange: (idle) => said.push(idle)
})
return { w, clock, said }
}
describe('being away, by moho\'s own windows', () => {
it('is away after the threshold with nothing touched, and says so once', () => {
const { w, clock, said } = watcher()
clock.now += 9 * MIN
w.evaluate()
expect(w.isIdle()).toBe(false)
clock.now += 2 * MIN
w.evaluate()
w.evaluate()
expect(w.isIdle()).toBe(true)
expect(said).toEqual([true])
})
it('is back the moment anything is used', () => {
const { w, clock, said } = watcher()
clock.now += 11 * MIN
w.evaluate()
w.activity()
expect(w.isIdle()).toBe(false)
expect(said).toEqual([true, false])
})
it('counts use as starting the clock again', () => {
const { w, clock } = watcher()
clock.now += 9 * MIN
w.activity()
clock.now += 9 * MIN
w.evaluate()
expect(w.isIdle()).toBe(false)
})
it('does nothing at all when it is switched off', () => {
const { w, clock, said } = watcher({ enabled: false })
clock.now += 60 * MIN
w.evaluate()
expect(said).toEqual([])
})
it('comes back when it is switched off while away', () => {
const { w, clock, said } = watcher()
clock.now += 11 * MIN
w.evaluate()
clock.enabled = false
w.evaluate()
expect(said).toEqual([true, false])
})
})
describe('being away, by the system\'s idle time', () => {
it('does not take a zero that never changes for somebody who never stops', () => {
// A platform that cannot say reports zero for ever; moho's own windows decide.
const { w, clock } = watcher()
clock.system = 0
clock.now += 11 * MIN
w.evaluate()
expect(w.isIdle()).toBe(true)
})
it('believes the system once it has been seen to be anything but zero, and counts use elsewhere as being here', () => {
const { w, clock } = watcher()
clock.system = 3
w.evaluate()
// Eleven minutes of not touching moho, but the keyboard was used a minute ago.
clock.now += 11 * MIN
clock.system = 60
w.evaluate()
expect(w.isIdle()).toBe(false)
// And then it really is eleven minutes everywhere.
clock.system = 11 * 60
w.evaluate()
expect(w.isIdle()).toBe(true)
})
})
describe('locking', () => {
it('is away at once when the screen locks and back when it unlocks', () => {
const { w, said } = watcher()
w.lock()
expect(w.isIdle()).toBe(true)
w.unlock()
expect(w.isIdle()).toBe(false)
expect(said).toEqual([true, false])
})
})
+97
View File
@@ -0,0 +1,97 @@
/**
* Whether the person has stepped away, which is all this knows.
*
* Discord's own client and every IRC client that sets itself away work from the
* same one fact - how long since anything was touched - and nothing else: not
* what is on screen, not what was typed, not which program is in front. This is
* kept to that, and is deliberately small: a clock, two sources of "how long",
* and a threshold.
*
* The two sources are the system's own idle time, where the platform will say
* (Windows, macOS, X11), and moho's own windows being used, which every
* platform can say. Some Wayland desktops give Chromium no idle time at all and
* report zero for ever, which looks exactly like somebody who never stops
* typing; so the system's figure is only believed once it has been seen to be
* anything but zero, and until then - and where it never is - moho goes by its
* own windows.
*/
export interface IdleOptions {
now: () => number
/** Seconds since the last keyboard or mouse input anywhere; 0 where the platform cannot say. */
systemIdleSeconds: () => number
enabled: () => boolean
thresholdMs: () => number
/** Said when the answer changes, and only then. */
onChange: (idle: boolean) => void
}
export class IdleWatcher {
private lastActivity: number
private systemKnown = false
private away = false
/** The screen is locked or the machine asleep: away at once, whatever the clock says. */
private locked = false
private forced: boolean | null = null
constructor(private o: IdleOptions) {
this.lastActivity = o.now()
}
isIdle(): boolean {
return this.away
}
/** When something was last used in a moho window, for the harness to see that it was heard. */
lastActivityAt(): number {
return this.lastActivity
}
/** Something was used in a moho window - including coming back to one. */
activity(): void {
this.lastActivity = this.o.now()
this.locked = false
this.evaluate()
}
lock(): void {
this.locked = true
this.evaluate()
}
unlock(): void {
this.locked = false
this.lastActivity = this.o.now()
this.evaluate()
}
/** For the harness: an answer that the clock does not get to change. */
forceForTesting(idle: boolean | null): void {
this.forced = idle
this.evaluate()
}
/** Asks the clocks, and says so if the answer is different. Run on a timer, and when a setting moves. */
evaluate(): void {
this.set(this.decide())
}
private decide(): boolean {
if (this.forced !== null) return this.forced
if (!this.o.enabled()) return false
if (this.locked) return true
const system = this.o.systemIdleSeconds()
if (system > 0) this.systemKnown = true
const own = this.o.now() - this.lastActivity
// Whichever is shorter: a keystroke in another program counts as being
// here once the system can say so, and one in moho counts either way.
const idleFor = this.systemKnown ? Math.min(own, system * 1000) : own
return idleFor >= this.o.thresholdMs()
}
private set(idle: boolean): void {
if (idle === this.away) return
this.away = idle
this.o.onChange(idle)
}
}
+386 -70
View File
@@ -6,30 +6,38 @@ import {
app,
BrowserWindow,
clipboard,
ClipboardItem,
dialog,
globalShortcut,
ipcMain,
nativeImage,
nativeTheme,
net,
powerMonitor,
session,
Menu,
screen,
shell,
Tray
Tray,
type MenuItemConstructorOptions
} from 'electron'
import { electronApp, is, optimizer } from '@electron-toolkit/utils'
import { defaultSocketPath, NobilisClient } from './nobilis-client'
import { NobilisProcess } from './nobilis-process'
import { Prefs } from './prefs'
import { Notifier } from './notifications'
import { IdleWatcher } from './idle'
import { NOTHING_WAITING, trayIconFile, trayMenu, trayTooltip, type TrayState, type TrayStatus } from './tray'
import { browserLogin, LOGIN_FLOWS } from './browser-login'
import { IPC, POPOUT_FLAG, type PopoutState } from '../shared/ipc'
import { feedTwitchSignIn, twitchSignIn } from './twitch-signin'
import { answerIntegrity, mintIntegrity } from './twitch-integrity'
import { EDIT_ACTIONS, IPC, POPOUT_FLAG, UI_SHOTS_FLAG, type EditAction, type EditMenuRequest, type PopoutState } from '../shared/ipc'
import { clearnetLinks } from '../shared/clearnet'
import { readCapped, pictureNamedIn } from './imagepage'
import { DEEP_LINK_SCHEMES, isDeepLink } from '../shared/deeplink'
import { allowPickedFile, allowRoot, installMediaHandler, registerMediaScheme } from './media-protocol'
import { installScreenShare } from './screenshare'
import { defaultDownloadDir, saveMedia } from './downloads'
import { defaultDownloadDir, readMedia, saveMedia } from './downloads'
import type { Buffer as ChatBuffer } from '../shared/wire'
import { log } from './log'
import { applyPolicy, applyTunnel } from './tunnel'
@@ -130,6 +138,7 @@ let prefs: Prefs
let nobilis: NobilisProcess
let client: NobilisClient
let notifier: Notifier
let idleWatcher: IdleWatcher
let registeredHotkey: string | null = null
/**
@@ -189,6 +198,52 @@ function sniffImage(head: Buffer): string | null {
}
/** Every window with a renderer in it, main and popped-out conversations alike. */
/**
* Sets a preference and tells whoever holds a copy of it.
*
* `except` is the window that made the change, which already knows and has
* drawn it. The tray's own entries come through here too, so a setting flipped
* from the tray moves its switch in Settings.
*/
function applyPref(key: string, value: unknown, except?: Electron.WebContents): void {
prefs.set(key, value)
// The badge and the flash are decided from these as they stand.
if (key === 'notifications.badge' || key === 'notifications.flash') updateTray(lastTray)
// Every window keeps its own cache of these, so a setting changed in one
// is stale in the others until they are told. That is not cosmetic once
// there are several windows: muting a conversation, or switching the log
// to compact, would apply to whichever window happened to be asked.
for (const w of liveWindows()) {
if (w.webContents !== except) w.webContents.send(IPC.prefsChanged, key, value)
}
// Pins and mutes feed the tray/notification rules, which live here.
if (key === 'pinnedBuffers' || key === 'mutedBuffers') notifier.publish()
if (key === 'hotkey.toggle') applyHotkey(String(value))
if (key === 'presence.autoIdle' || key === 'presence.autoIdleMinutes') idleWatcher?.evaluate()
}
/**
* Brings up a conversation: a click on its notification, or on the tray's
* "open" entry.
*
* If it has a window of its own, that window is what the click asked for.
* Raising the main one and switching it would move somebody away from whatever
* they were reading in order to show them a conversation that was already open
* on their screen.
*/
function openConversation(bufferId: string): void {
const popout = popouts.get(bufferId)
if (popout && !popout.isDestroyed()) {
if (popout.isMinimized()) popout.restore()
popout.show()
popout.focus()
return
}
mainWindow?.show()
mainWindow?.focus()
mainWindow?.webContents.send(IPC.activateBuffer, bufferId)
}
function liveWindows(): BrowserWindow[] {
const all = mainWindow ? [mainWindow, ...popouts.values()] : [...popouts.values()]
return all.filter((w) => !w.isDestroyed())
@@ -225,6 +280,47 @@ function callerWindow(event: Electron.IpcMainInvokeEvent): BrowserWindow | null
* The gap between them is a fraction of a second of the background colour,
* which is a far better failure than no window at all.
*/
/**
* What to do when a window's page dies under it.
*
* Nothing used to be done, and nothing said: the renderer process would be
* gone - out of memory, killed, crashed - and the window stayed on screen as an
* empty shell with no page in it, which is what "the window just went
* invisible" was. Now the reason is written to the log, where it outlives the
* process, and the page is loaded again so the window comes back with the
* conversation list and everything else intact: the daemon is a separate
* process and kept running all along.
*
* Not when the app is quitting, and not more than a few times a minute: a page that dies as it loads would otherwise be reloaded for
* ever.
*/
function recoverFromCrash(win: BrowserWindow, name: string): void {
const recent: number[] = []
win.webContents.on('render-process-gone', (_e, details) => {
log.error(
`[window] ${name}: the page process is gone - ${details.reason} (exit code ${details.exitCode}) at ${win.isDestroyed() ? 'a destroyed window' : win.webContents.getURL()}`
)
// A page that exits cleanly while its window is still there is no window
// being closed - that would have taken the window with it - and left the
// window empty just the same. Only the app quitting is let go.
if (win.isDestroyed() || quitting) return
const now = Date.now()
while (recent.length > 0 && now - recent[0] > 60_000) recent.shift()
recent.push(now)
if (recent.length > 3) {
log.error(`[window] ${name}: it keeps dying; leaving it`)
return
}
setTimeout(() => {
// Loaded afresh rather than reloaded: a page that had navigated away
// would otherwise reload wherever it had got to.
if (!win.isDestroyed()) loadRenderer(win)
}, 500)
})
win.webContents.on('unresponsive', () => log.warn(`[window] ${name}: the page has stopped responding`))
win.webContents.on('responsive', () => log.warn(`[window] ${name}: the page is responding again`))
}
function revealOnce(win: BrowserWindow, focus = false): void {
let done = false
const reveal = (): void => {
@@ -237,6 +333,37 @@ function revealOnce(win: BrowserWindow, focus = false): void {
win.webContents.once('did-finish-load', reveal)
}
/**
* Right click where text is typed. The page cannot know what Chromium's
* spellchecker thinks of the word under the pointer, so Chromium's answer is
* handed to the window, which draws the menu in the app's own style.
*/
function wireEditMenu(win: BrowserWindow): void {
win.webContents.on('context-menu', (_e, params) => {
if (!params.isEditable) return
const f = params.editFlags
win.webContents.send(IPC.editMenu, {
x: params.x,
y: params.y,
word: params.misspelledWord,
suggestions: params.dictionarySuggestions.slice(0, 5),
can: {
undo: f.canUndo,
redo: f.canRedo,
cut: f.canCut,
copy: f.canCopy,
paste: f.canPaste,
delete: f.canDelete,
selectAll: f.canSelectAll
},
hasSelection: params.selectionText.length > 0
} satisfies EditMenuRequest)
})
}
/** The screenshot harness's flag, passed on to every window when it asked for it. */
const shotsArguments = process.env.MOHO_UI_SHOTS === '1' ? [UI_SHOTS_FLAG] : []
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1100,
@@ -262,11 +389,14 @@ function createWindow(): void {
// is allowed, and reads its popout flag from process.argv, which stays
// readable. See the note in whenReady for the hosts that cannot honour
// this.
sandbox: true
sandbox: true,
additionalArguments: shotsArguments
}
})
revealOnce(mainWindow)
recoverFromCrash(mainWindow, 'main')
wireEditMenu(mainWindow)
// A link that arrived before this window existed - from the click that
// launched moho - goes over once the renderer is listening for it. Cleared
@@ -277,6 +407,9 @@ function createWindow(): void {
pendingDeepLink = null
mainWindow?.webContents.send(IPC.deepLink, url)
})
// The tray's first action is to show or hide this window, and says which.
mainWindow.on('show', refreshTrayMenu)
mainWindow.on('hide', refreshTrayMenu)
mainWindow.on('maximize', () => mainWindow?.webContents.send(IPC.maximizeChanged, true))
mainWindow.on('unmaximize', () => mainWindow?.webContents.send(IPC.maximizeChanged, false))
mainWindow.on('closed', () => {
@@ -430,7 +563,7 @@ function openPopout(bufferId: string, title?: string): void {
nodeIntegration: false,
// As the main window - a popout draws the same conversations.
sandbox: true,
additionalArguments: [`${POPOUT_FLAG}${bufferId}`]
additionalArguments: [`${POPOUT_FLAG}${bufferId}`, ...shotsArguments]
}
})
popouts.set(bufferId, win)
@@ -438,6 +571,8 @@ function openPopout(bufferId: string, title?: string): void {
// Focused as well as shown: unlike the main window at startup, this one was
// asked for just now.
revealOnce(win, true)
recoverFromCrash(win, `popout ${bufferId}`)
wireEditMenu(win)
// Sent to this window rather than broadcast: every window draws its own
// title bar, and they are not maximised together.
@@ -484,46 +619,112 @@ function toggleWindow(): void {
}
}
function trayIcon(hasAlert: boolean): Electron.NativeImage {
const file = resourcePath('icons', hasAlert ? 'tray-alert.png' : 'tray.png')
const img = nativeImage.createFromPath(file)
function trayIcon(unread: number): Electron.NativeImage {
lastTrayIcon = trayIconFile(nativeTheme.shouldUseDarkColors, unread)
const img = nativeImage.createFromPath(resourcePath('icons', lastTrayIcon))
// A missing icon file would otherwise produce an invisible tray entry the
// user can never click; fall back to the app icon so the entry still exists.
return img.isEmpty() ? nativeImage.createFromPath(resourcePath('icons', 'moho.png')) : img
}
function createTray(): void {
tray = new Tray(trayIcon(false))
tray.setToolTip('moho')
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: 'Show/hide', click: toggleWindow },
{ label: 'Restart daemon', click: () => nobilis.restart() },
{
label: 'Stop daemon',
// Deliberately separate from Quit: stopping the daemon disconnects
// every account, which is worth asking for explicitly rather than
// making it a side effect of closing a window.
click: () => {
void stopDaemon()
}
},
{ type: 'separator' },
{
label: 'Quit',
click: () => {
app.quit()
}
}
])
)
tray.on('click', toggleWindow)
/** What the window last said each connected account's status is. */
let accountStatus: Record<string, string> = {}
/** The status the menu shows as checked, and the ones it offers: what the connected accounts can be set to. */
let trayStatus: TrayStatus | null = null
let trayOffered: TrayStatus[] = []
let trayInvisibleName = 'Invisible'
const isTrayStatus = (v: unknown): v is TrayStatus => v === 'online' || v === 'idle' || v === 'dnd' || v === 'invisible'
/**
* Asks the window to do something, showing it first: the tray's entries that
* are about moho itself - settings, and a status set across every account -
* are the window's to carry out, since it is what holds the accounts.
*/
function tellWindow(command: 'settings' | 'status', arg?: string): void {
if (!mainWindow) createWindow()
if (command === 'settings') {
mainWindow?.show()
mainWindow?.focus()
}
mainWindow?.webContents.send(IPC.trayCommand, command, arg)
}
function updateTray(unreadCount: number, hasAlert: boolean): void {
/**
* Starts moho again, daemon and all.
*
* Quitting already takes the daemon down cleanly - QUIT to every network, then
* gone - and the new process starts its own, so restarting is quit-and-relaunch.
* An AppImage has to be relaunched by the path it was started from: its
* executable is a temporary mount that disappears when it exits.
*/
function restartMoho(): void {
const appImage = process.env.APPIMAGE
app.relaunch(appImage ? { execPath: appImage, args: process.argv.slice(1) } : undefined)
app.quit()
}
/** What the tray last offered and showed, kept for the UI harness to read. */
let lastTrayMenu: MenuItemConstructorOptions[] = []
let lastTrayIcon = ''
let lastTrayTooltip = 'moho'
function refreshTrayMenu(): void {
if (!tray) return
tray.setImage(trayIcon(hasAlert))
tray.setToolTip(unreadCount > 0 ? `moho - ${unreadCount} unread` : 'moho')
lastTrayMenu = trayMenu(
{
state: lastTray,
windowVisible: !!mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible(),
status: trayStatus,
offered: trayOffered,
invisibleName: trayInvisibleName
},
{
toggleWindow,
openLatest: () => {
const id = lastTray.latest ?? notifier.latestUnread()
if (id) openConversation(id)
},
openSettings: () => tellWindow('settings'),
setStatus: (status) => {
trayStatus = status
tellWindow('status', status)
},
restart: restartMoho,
quit: () => app.quit()
}
)
tray.setContextMenu(Menu.buildFromTemplate(lastTrayMenu))
}
function createTray(): void {
tray = new Tray(trayIcon(0))
tray.setToolTip('moho')
refreshTrayMenu()
tray.on('click', toggleWindow)
// The icon is drawn for the panel it sits on, so it is drawn again when the
// system changes colour scheme.
nativeTheme.on('updated', () => updateTray(lastTray))
}
/** What the notifier last said, kept so a change of setting can redraw without waiting for the next message. */
let lastTray: TrayState = NOTHING_WAITING
function updateTray(state: TrayState): void {
lastTray = state
// The unread badge: the tray's count and the launcher's count where the
// desktop has one. Off, the tray is just the tray.
const badge = prefs.get<boolean>('notifications.badge', true)
app.setBadgeCount(badge ? state.unread : 0)
// The taskbar flash: asking for attention while the window is not in front.
const flash = prefs.get<boolean>('notifications.flash', true)
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.flashFrame(flash && state.unread > 0 && trayStatus !== 'dnd' && !mainWindow.isFocused())
}
refreshTrayMenu()
if (!tray) return
tray.setImage(trayIcon(badge ? state.unread : 0))
lastTrayTooltip = trayTooltip(state, badge)
tray.setToolTip(lastTrayTooltip)
send(IPC.link, client.linkUp)
}
@@ -555,6 +756,12 @@ function applyHotkey(accelerator: string): void {
}
}
app.on('child-process-gone', (_e, details) => {
// The graphics process or a utility: a window that stops painting with its
// page still alive is this.
log.error(`[process] ${details.type} process gone - ${details.reason} (exit code ${details.exitCode})`)
})
function wireIpc(): void {
ipcMain.handle(IPC.rpc, async (_e, method: string, params: Record<string, unknown>) => {
try {
@@ -568,19 +775,27 @@ function wireIpc(): void {
})
ipcMain.handle(IPC.prefsGetAll, () => prefs.all())
ipcMain.handle(IPC.prefsSet, (e, key: string, value: unknown) => {
prefs.set(key, value)
// Every window keeps its own cache of these, so a setting changed in one
// is stale in the others until they are told. That is not cosmetic once
// there are several windows: muting a conversation, or switching the log
// to compact, would apply to whichever window happened to be asked.
// Not echoed to the window that set it - it already knows, and has drawn.
for (const w of liveWindows()) {
if (w.webContents !== e.sender) w.webContents.send(IPC.prefsChanged, key, value)
}
// Pins and mutes feed the tray/notification rules, which live here.
if (key === 'pinnedBuffers' || key === 'mutedBuffers') notifier.publish()
if (key === 'hotkey.toggle') applyHotkey(String(value))
ipcMain.handle(IPC.prefsSet, (e, key: string, value: unknown) => applyPref(key, value, e.sender))
// The window says what status its accounts are at, for the tray's menu to check.
ipcMain.on(IPC.trayStatus, (_e, statuses: Record<string, string>, offered: string[], shown: string | null, invisibleName: string) => {
accountStatus = statuses && typeof statuses === 'object' ? statuses : {}
trayOffered = Array.isArray(offered) ? offered.filter(isTrayStatus) : []
trayStatus = isTrayStatus(shown) ? shown : null
trayInvisibleName = invisibleName === 'Offline' ? 'Offline' : 'Invisible'
refreshTrayMenu()
// Do not disturb is asking for quiet, including from the taskbar.
updateTray(lastTray)
})
ipcMain.on(IPC.activity, () => idleWatcher.activity())
// What is waiting, as the window counts it. The tray's number is the window's
// own: counted here from notifications it drifted from what the window showed.
ipcMain.on(IPC.trayUnread, (_e, count: { dms: number; mentions: number; latest: string | null }) => {
const dms = Math.max(0, Number(count?.dms) || 0)
const mentions = Math.max(0, Number(count?.mentions) || 0)
updateTray({ unread: dms + mentions, dms, mentions, latest: typeof count?.latest === 'string' ? count.latest : null })
})
ipcMain.handle(IPC.markBufferRead, (_e, bufferId: string) => notifier.clear(bufferId))
@@ -689,10 +904,19 @@ function wireIpc(): void {
return res.canceled || !res.filePath ? null : res.filePath
})
ipcMain.handle(IPC.pickFile, async (e) => {
ipcMain.handle(IPC.pickFile, async (e, extensions?: string[]) => {
const parent = callerWindow(e) ?? mainWindow
if (!parent) return null
const res = await dialog.showOpenDialog(parent, { properties: ['openFile'] })
// Where a service takes only some kinds of file, the dialog shows only
// those and has no "all files" to widen it: a file that would be refused
// is not offered. Extensions only, as the dialog wants them - no dots.
const taken = Array.isArray(extensions)
? extensions.filter((x): x is string => typeof x === 'string' && /^[A-Za-z0-9]{1,8}$/.test(x))
: []
const res = await dialog.showOpenDialog(parent, {
properties: ['openFile'],
...(taken.length ? { filters: [{ name: 'Pictures and videos', extensions: taken }] } : {})
})
if (res.canceled || !res.filePaths[0]) return null
// The renderer draws a thumbnail of what was staged, and that goes back
// through the guarded media scheme like every other local file. Where
@@ -776,6 +1000,20 @@ function wireIpc(): void {
}
})
// The edit menu's verbs. Chromium does these to whatever has focus in the
// window that asked, which is what makes paste go through the box's own
// paste handling and cut and delete go through its undo history.
ipcMain.handle(IPC.editAction, (e, action: EditAction) => {
if (!EDIT_ACTIONS.includes(action)) return
e.sender[action]()
})
ipcMain.handle(IPC.replaceMisspelling, (e, word: string) => {
if (typeof word === 'string' && word) e.sender.replaceMisspelling(word)
})
ipcMain.handle(IPC.addToDictionary, (e, word: string) => {
if (typeof word === 'string' && word) e.sender.session.addWordToSpellCheckerDictionary(word)
})
ipcMain.handle(IPC.readClipboardImage, async () => {
// Electron 44 replaced the synchronous clipboard with the W3C shape:
// asynchronous, and addressed by MIME type rather than by readImage().
@@ -805,6 +1043,40 @@ function wireIpc(): void {
if (typeof text === 'string' && text.length > 0) clipboard.writeText(text)
})
// A picture on the clipboard, for pasting into another app. Taken from the
// page's own pixels when it could read them (that also covers formats the
// clipboard does not take as they are, such as WebP), and otherwise from
// wherever the picture lives.
ipcMain.handle(IPC.copyImage, async (_e, source: string, dataUrl?: string) => {
try {
const img =
typeof dataUrl === 'string' && dataUrl.startsWith('data:image/')
? nativeImage.createFromDataURL(dataUrl)
: nativeImage.createFromBuffer(
Buffer.from(
await readMedia(String(source), async (url) => {
const res = await net.fetch(url)
return { ok: res.ok, status: res.status, bytes: async () => new Uint8Array(await res.arrayBuffer()) }
})
)
)
if (img.isEmpty()) return { error: "this kind of picture can't be copied" }
await clipboard.write([new ClipboardItem({ 'image/png': new Blob([img.toPNG()], { type: 'image/png' }) })])
return {}
} catch (e) {
return { error: (e as Error).message }
}
})
ipcMain.handle(IPC.fileSize, async (_e, file: string) => {
try {
const st = await fsp.stat(String(file))
return st.isFile() ? st.size : null
} catch {
return null
}
})
ipcMain.handle(IPC.restartDaemon, () => nobilis.restart())
ipcMain.handle(IPC.daemonStatus, () => ({
binaryPath: nobilis.binaryPath,
@@ -840,6 +1112,13 @@ function wireIpc(): void {
}
})
// Twitch's sign-in, both halves in one window (see twitch-signin.ts). Only whether it worked comes back.
ipcMain.handle(IPC.twitchSignIn, (_e, options?: { useTor?: boolean }) =>
twitchSignIn((method, params) => client.request(method, params), {
useTor: typeof options?.useTor === 'boolean' ? options.useTor : undefined
})
)
ipcMain.handle(IPC.smiliesDir, () => smiliesPath())
// Chromium's HTTP cache, which Settings reports beside the daemon's.
@@ -988,32 +1267,38 @@ app.whenReady().then(() => {
nobilis = new NobilisProcess()
client = new NobilisClient()
// Going idle when the person steps away. What it says, and to whom, is
// decided in the daemon - which accounts are online, which are in a call -
// so all this says is whether they are here.
idleWatcher = new IdleWatcher({
now: () => Date.now(),
systemIdleSeconds: () => powerMonitor.getSystemIdleTime(),
enabled: () => prefs.get<boolean>('presence.autoIdle', true),
thresholdMs: () => (Number(prefs.get<unknown>('presence.autoIdleMinutes', '10')) || 10) * 60_000,
onChange: (idle) => void client.request('setUserIdle', { idle }).catch(() => {})
})
setInterval(() => idleWatcher.evaluate(), 10_000).unref()
app.on('browser-window-focus', () => idleWatcher.activity())
powerMonitor.on('lock-screen', () => idleWatcher.lock())
powerMonitor.on('suspend', () => idleWatcher.lock())
powerMonitor.on('unlock-screen', () => idleWatcher.unlock())
powerMonitor.on('resume', () => idleWatcher.unlock())
notifier = new Notifier(
prefs,
updateTray,
(bufferId) => {
// If this conversation has a window of its own, that window is what the
// click asked for. Raising the main one and switching it would move
// somebody away from whatever they were reading in order to show them a
// conversation that was already open on their screen.
const popout = popouts.get(bufferId)
if (popout && !popout.isDestroyed()) {
if (popout.isMinimized()) popout.restore()
popout.show()
popout.focus()
return
}
mainWindow?.show()
mainWindow?.focus()
mainWindow?.webContents.send(IPC.activateBuffer, bufferId)
},
// The tray's number is the window's, not this tally's - see IPC.trayUnread.
() => {},
openConversation,
() => mainWindow?.webContents ?? null,
(bufferId) => popoutState().watched.includes(bufferId)
(bufferId) => popoutState().watched.includes(bufferId),
(accountId) => accountStatus[accountId] === 'dnd'
)
client.on('link', (up) => {
send(IPC.link, up)
if (!up) return
// A daemon that has just started knows nothing of this.
if (idleWatcher.isIdle()) void client.request('setUserIdle', { idle: true }).catch(() => {})
// Before anything is fetched for the window: whether its own traffic goes
// through Tor or the proxy is the daemon's setting, asked of it here.
void applyTunnel((method, params) => client.request(method, params))
@@ -1044,6 +1329,13 @@ app.whenReady().then(() => {
} else if (frame.event === 'netSettings') {
void applyTunnel((method, params) => client.request(method, params))
resetRoute()
} else if (frame.event.startsWith('twitchLogin')) {
feedTwitchSignIn(frame as { event: string; data?: Record<string, unknown> })
} else if (frame.event === 'twitchIntegrityNeeded') {
// Twitch wants proof that a browser is asking for a stream; only a page of its own can make it.
void answerIntegrity(frame.data as { requestId?: string; deviceId?: string; authToken?: string }, (method, params) => client.request(method, params))
// Not forwarded: it carries the session, which only main and the daemon are to hold.
return
}
send(IPC.event, frame)
})
@@ -1055,6 +1347,30 @@ app.whenReady().then(() => {
createWindow()
createTray()
applyHotkey(prefs.get<string>('hotkey.toggle'))
// For the UI harness, which has no way to look inside a tray: what it shows
// and offers, and the entries themselves to be clicked. Only when it asks.
if (process.env.MOHO_UI_SHOTS === '1') {
;(globalThis as Record<string, unknown>).__mohoTwitchIntegrity = { mint: mintIntegrity }
;(globalThis as Record<string, unknown>).__mohoTray = {
update: updateTray,
idle: (v: boolean | null) => idleWatcher.forceForTesting(v),
lastActivity: () => idleWatcher.lastActivityAt(),
state: () => lastTray,
pref: (key: string, value: unknown) => applyPref(key, value),
icon: () => lastTrayIcon,
tooltip: () => lastTrayTooltip,
menu: () => lastTrayMenu,
click: (path: string[]) => {
let items: MenuItemConstructorOptions[] = lastTrayMenu
let item: MenuItemConstructorOptions | undefined
for (const label of path) {
item = items.find((i) => i.label === label || (i.label ?? '').startsWith(label))
items = (item?.submenu as MenuItemConstructorOptions[]) ?? []
}
item?.click?.({ checked: !item.checked } as never, undefined, {} as never)
}
}
}
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
+99
View File
@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const shown: { silent?: boolean }[] = []
vi.mock('electron', () => {
class Notification {
static isSupported(): boolean {
return true
}
opts: { silent?: boolean }
constructor(opts: { silent?: boolean }) {
this.opts = opts
}
on(): void {}
show(): void {
shown.push(this.opts)
}
}
return { Notification, nativeImage: {}, app: { getPath: () => '/tmp' } }
})
import { Notifier } from './notifications'
import type { TrayState } from './tray'
const payload = { accountId: 'a', bufferId: 'b', title: 'alice', body: 'hi' }
function notifier(settings: Record<string, unknown>, dnd: string[] = []): { n: Notifier; alerts: TrayState[] } {
const alerts: TrayState[] = []
const prefs = { get: <T>(key: string, fallback: T): T => (key in settings ? (settings[key] as T) : fallback) }
const n = new Notifier(prefs as never, (state) => alerts.push(state), () => {}, () => null, () => false, (account) => dnd.includes(account))
return { n, alerts }
}
describe('notification settings', () => {
beforeEach(() => {
shown.length = 0
})
it('shows a notification, with the system sound, by default', async () => {
await notifier({}).n.handle(payload)
expect(shown).toHaveLength(1)
expect(shown[0].silent).toBe(false)
})
it('shows none when desktop notifications are off, but still counts the conversation as unread', async () => {
const { n, alerts } = notifier({ 'notifications.desktop': false })
await n.handle(payload)
expect(shown).toHaveLength(0)
expect(alerts.at(-1)?.unread).toBe(1)
})
it('makes them silent when the sound is off', async () => {
await notifier({ 'notifications.sound': false }).n.handle(payload)
expect(shown[0].silent).toBe(true)
})
})
describe('what the tray is told is waiting', () => {
const buffer = (id: string, kind: string): never => ({ id, kind }) as never
it('counts direct messages and mentions apart, and lights for both', async () => {
const { n, alerts } = notifier({ 'notifications.desktop': false })
n.trackBuffer(buffer('dm', 'dm'), false)
n.trackBuffer(buffer('chan', 'channel'), false)
await n.handle({ ...payload, bufferId: 'dm' })
await n.handle({ ...payload, bufferId: 'chan' })
expect(alerts.at(-1)).toEqual({ unread: 2, dms: 1, mentions: 1 })
})
it('names the newest waiting conversation, and none once they are read', async () => {
const { n } = notifier({ 'notifications.desktop': false })
expect(n.latestUnread()).toBeNull()
await n.handle({ ...payload, bufferId: 'first' })
await n.handle({ ...payload, bufferId: 'second' })
expect(n.latestUnread()).toBe('second')
n.clear('second')
expect(n.latestUnread()).toBe('first')
n.clearAll()
expect(n.latestUnread()).toBeNull()
})
})
describe('Do not disturb', () => {
beforeEach(() => {
shown.length = 0
})
it('shows no notification for an account that is set to it, but still counts the conversation', async () => {
const { n, alerts } = notifier({}, ['a'])
await n.handle(payload)
expect(shown).toHaveLength(0)
expect(alerts.at(-1)?.unread).toBe(1)
})
it('leaves the other accounts alone', async () => {
const { n } = notifier({}, ['somebody-else'])
await n.handle(payload)
expect(shown).toHaveLength(1)
})
})
+36 -18
View File
@@ -4,6 +4,7 @@ import path from 'node:path'
import { Notification, nativeImage, net } from 'electron'
import type { WebContents } from 'electron'
import type { Prefs } from './prefs'
import type { TrayState } from './tray'
import type { Buffer as ChatBuffer } from '../shared/wire'
/**
@@ -48,7 +49,7 @@ export class Notifier {
constructor(
private prefs: Prefs,
private onAlertChange: (unreadCount: number, hasAlert: boolean) => void,
private onAlertChange: (state: TrayState) => void,
private onActivate: (bufferId: string) => void,
/** The renderer, borrowed only to decode formats nativeImage cannot. */
private renderer: () => WebContents | null = () => null,
@@ -60,7 +61,16 @@ export class Notifier {
* them what they can already see, and a tray badge counting it as unread
* is simply wrong.
*/
private isWatched: (bufferId: string) => boolean = () => false
private isWatched: (bufferId: string) => boolean = () => false,
/**
* Whether this account has been set to Do not disturb.
*
* That is the one switch for silence: an account that says so gets no popup
* and no sound, however its conversations behave. What it is sent still
* counts as unread - the tray and the badge keep their numbers, as Discord's
* own do - and is there to be read when it is looked at.
*/
private isDnd: (accountId: string) => boolean = () => false
) {}
trackBuffer(buffer: ChatBuffer, removed: boolean): void {
@@ -103,12 +113,19 @@ export class Notifier {
this.publish()
}
if (this.isDnd(payload.accountId)) return
// Switched off in Settings: the conversation still counts as unread above,
// it just does not pop up.
if (!this.prefs.get<boolean>('notifications.desktop', true)) return
if (!Notification.isSupported()) return
const icon = await this.notificationIcon(payload.avatarUrl)
const notification = new Notification({
title: payload.title || 'moho',
body: payload.body || '',
// The system's own sound for it, unless asked to be quiet.
silent: !this.prefs.get<boolean>('notifications.sound', true),
...(icon ? { icon } : {})
})
notification.on('click', () => this.onActivate(payload.bufferId))
@@ -283,24 +300,25 @@ export class Notifier {
}
/**
* Whether the tray should show something is waiting.
* What the tray should say is waiting.
*
* A direct message counts, and this is the change: the tray used to light up
* only for *pinned* buffers, on the reasoning that a DM had already had its
* desktop notification and the tray was for things important enough to pin.
* In practice that made the icon nearly inert - somebody messages you, and
* the one place still on screen after the notification has faded says
* nothing, unless you happened to have pinned that exact conversation.
*
* Pinned buffers still count, so a channel worth pinning can still raise it.
* There is no count here: a tray icon is around 22 pixels, which is room for
* "yes" and not for a number.
* Everything held here was worth a notification - a direct message, or
* somebody mentioning this account - so all of it lights the tray, and it
* says how many conversations and of which kind. It used to light only for
* direct messages and pinned conversations, on the reasoning that a mention
* had had its notification and the tray was for what mattered more; the
* effect was an icon that stayed dark while somebody was asking for you.
*/
publish(): void {
const pinned = this.prefs.get<string[]>('pinnedBuffers', [])
const hasAlert = [...this.unread].some(
(id) => this.buffers.get(id)?.kind === 'dm' || pinned.includes(id)
)
this.onAlertChange(this.unread.size, hasAlert)
let dms = 0
for (const id of this.unread) if (this.buffers.get(id)?.kind === 'dm') dms++
this.onAlertChange({ unread: this.unread.size, dms, mentions: this.unread.size - dms })
}
/** The conversation that has been waiting for you the shortest time. */
latestUnread(): string | null {
let latest: string | null = null
for (const id of this.unread) latest = id
return latest
}
}
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it, vi } from 'vitest'
import { trayIconFile, trayMenu, trayTooltip, waitingSummary, NOTHING_WAITING, type TrayActions } from './tray'
const acts = (): TrayActions => ({
toggleWindow: vi.fn(),
openLatest: vi.fn(),
openSettings: vi.fn(),
setStatus: vi.fn(),
restart: vi.fn(),
quit: vi.fn()
})
describe('the tray icon', () => {
it('is a light bubble on a dark system and a dark one on a light system', () => {
expect(trayIconFile(true, 0)).toBe('tray-light.png')
expect(trayIconFile(false, 0)).toBe('tray-dark.png')
})
it('carries the count to nine and says more after that', () => {
expect(trayIconFile(true, 1)).toBe('tray-light-1.png')
expect(trayIconFile(true, 9)).toBe('tray-light-9.png')
expect(trayIconFile(false, 10)).toBe('tray-dark-more.png')
expect(trayIconFile(false, 400)).toBe('tray-dark-more.png')
})
})
describe('what is said about what is waiting', () => {
it('names direct messages and mentions apart, and in the singular for one', () => {
expect(waitingSummary({ unread: 3, dms: 2, mentions: 1 })).toBe('2 direct messages, 1 mention')
expect(waitingSummary({ unread: 1, dms: 1, mentions: 0 })).toBe('1 direct message')
expect(waitingSummary({ unread: 4, dms: 0, mentions: 4 })).toBe('4 mentions')
})
it('leaves the tooltip plain with nothing waiting or the badge off', () => {
expect(trayTooltip(NOTHING_WAITING, true)).toBe('moho')
expect(trayTooltip({ unread: 2, dms: 1, mentions: 1 }, false)).toBe('moho')
expect(trayTooltip({ unread: 2, dms: 1, mentions: 1 }, true)).toBe('moho - 1 direct message, 1 mention')
})
})
describe('the menu', () => {
const model = { state: NOTHING_WAITING, windowVisible: true, status: 'idle' as const, offered: [], invisibleName: 'Invisible' }
const labels = (items: ReturnType<typeof trayMenu>): string[] => items.map((i) => i.label ?? '-')
it('offers no daemon plumbing, and a restart that is of moho as a whole', () => {
const l = labels(trayMenu(model, acts()))
expect(l).not.toContain('Restart daemon')
expect(l).not.toContain('Stop daemon')
expect(l).toContain('Restart moho')
})
it('says nothing is new, and offers nothing to open, when that is so', () => {
const [first] = trayMenu(model, acts())
expect(first.label).toBe('Nothing new')
expect(first.enabled).toBe(false)
})
it('says what is waiting and opens it', () => {
const a = acts()
const [first] = trayMenu({ ...model, state: { unread: 2, dms: 1, mentions: 1 } }, a)
expect(first.label).toBe('1 direct message, 1 mention - open')
first.click?.({} as never, undefined, {} as never)
expect(a.openLatest).toHaveBeenCalled()
})
it('has no notifications switch of its own: Do not disturb is that', () => {
expect(labels(trayMenu(model, acts())).join('|')).not.toMatch(/notification/i)
const status = trayMenu(model, acts()).find((i) => i.label === 'Status')
expect((status?.submenu as { label: string }[]).map((s) => s.label)).toContain('Do not disturb')
})
it('offers only what the connected accounts can be set to', () => {
const labelsOf = (offered: ('online' | 'idle' | 'dnd' | 'invisible')[]): string[] =>
(trayMenu({ ...model, offered }, acts()).find((i) => i.label === 'Status')?.submenu as { label: string }[]).map((s) => s.label)
expect(labelsOf(['online', 'dnd'])).toEqual(['Online', 'Do not disturb'])
expect(labelsOf(['online', 'idle', 'dnd', 'invisible'])).toHaveLength(4)
expect(labelsOf([])).toHaveLength(4)
})
it('calls invisible offline where only Matrix has it', () => {
const sub = trayMenu({ ...model, offered: ['online', 'invisible'], invisibleName: 'Offline' }, acts()).find((i) => i.label === 'Status')?.submenu as { label: string }[]
expect(sub.map((s) => s.label)).toEqual(['Online', 'Offline'])
})
it('asks for the window to be shown or hidden as it stands', () => {
expect(labels(trayMenu({ ...model, windowVisible: true }, acts()))).toContain('Hide moho')
expect(labels(trayMenu({ ...model, windowVisible: false }, acts()))).toContain('Show moho')
})
it('checks the status the accounts are at', () => {
const status = trayMenu(model, acts()).find((i) => i.label === 'Status')
const sub = status?.submenu as { label: string; checked: boolean }[]
expect(sub.filter((s) => s.checked).map((s) => s.label)).toEqual(['Idle'])
const none = trayMenu({ ...model, status: null }, acts()).find((i) => i.label === 'Status')?.submenu as { checked: boolean }[]
expect(none.some((s) => s.checked)).toBe(false)
})
})
+109
View File
@@ -0,0 +1,109 @@
/**
* What the tray says and offers, apart from the Electron objects that show it.
*
* Kept as plain functions of plain data so that what the icon, the tooltip and
* the menu say about a given set of unread conversations can be checked
* without a desktop to look at.
*/
import type { MenuItemConstructorOptions } from 'electron'
/** What is waiting, as the notifier counts it. */
export interface TrayState {
/** Conversations with something unread that was worth a notification. */
unread: number
/** Of those, direct messages. */
dms: number
/** Of those, everything else: somebody mentioning this account in a channel. */
mentions: number
/** The conversation the menu's first line opens, where the window has said. */
latest?: string | null
}
export const NOTHING_WAITING: TrayState = { unread: 0, dms: 0, mentions: 0 }
export type TrayStatus = 'online' | 'idle' | 'dnd' | 'invisible'
/**
* The icon file for a panel and a count.
*
* `dark` is the system's colour scheme, and a dark system wants the light
* bubble. The count is drawn into the icon up to nine; past that it says "9+",
* since a tray icon is about 22 pixels and has no room for a second digit.
*/
export function trayIconFile(dark: boolean, unread: number): string {
const ink = dark ? 'light' : 'dark'
if (unread <= 0) return `tray-${ink}.png`
return `tray-${ink}-${unread > 9 ? 'more' : unread}.png`
}
const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`
/** "2 direct messages, 1 mention" - what is waiting, in the words a person would use. */
export function waitingSummary(s: TrayState): string {
const parts: string[] = []
if (s.dms > 0) parts.push(plural(s.dms, 'direct message', 'direct messages'))
if (s.mentions > 0) parts.push(plural(s.mentions, 'mention', 'mentions'))
return parts.join(', ')
}
export function trayTooltip(s: TrayState, badge: boolean): string {
return badge && s.unread > 0 ? `moho - ${waitingSummary(s)}` : 'moho'
}
export interface TrayActions {
toggleWindow: () => void
openLatest: () => void
openSettings: () => void
setStatus: (status: TrayStatus) => void
restart: () => void
quit: () => void
}
export interface TrayModel {
state: TrayState
windowVisible: boolean
/** The status the accounts are at, where the window has said. */
status: TrayStatus | null
/** What the connected accounts can be set to; none reported yet means all of them. */
offered: TrayStatus[]
/** What invisible is called: Matrix, which has only offline presence, says offline. */
invisibleName: string
}
const STATUSES: { id: TrayStatus; label: string }[] = [
{ id: 'online', label: 'Online' },
{ id: 'idle', label: 'Idle' },
{ id: 'dnd', label: 'Do not disturb' },
{ id: 'invisible', label: 'Invisible' }
]
/**
* The right-click menu: what is new, how to get to moho, how to say what you
* are doing - which includes Do not disturb, the one switch for silencing it -
* and how to leave. Daemon plumbing - restart it, stop it - is
* deliberately not here; it is a thing for Settings, and "restart moho"
* covers what somebody reaching for it from a tray actually wants.
*/
export function trayMenu(model: TrayModel, act: TrayActions): MenuItemConstructorOptions[] {
const waiting = model.state.unread > 0
return [
waiting
? { label: `${waitingSummary(model.state)} - open`, click: act.openLatest }
: { label: 'Nothing new', enabled: false },
{ type: 'separator' },
{ label: model.windowVisible ? 'Hide moho' : 'Show moho', click: act.toggleWindow },
{
label: 'Status',
submenu: STATUSES.filter((s) => model.offered.length === 0 || model.offered.includes(s.id)).map((s) => ({
label: s.id === 'invisible' ? model.invisibleName : s.label,
type: 'radio' as const,
checked: model.status === s.id,
click: () => act.setStatus(s.id)
}))
},
{ label: 'Settings', click: act.openSettings },
{ type: 'separator' },
{ label: 'Restart moho', click: act.restart },
{ label: 'Quit', click: act.quit }
]
}
+81
View File
@@ -0,0 +1,81 @@
import { BrowserWindow, session } from 'electron'
import { routeSession } from './tunnel'
/**
* An integrity token for Twitch's GraphQL, which only a real page on twitch.tv can make.
*
* Twitch asks, now and then, for proof that the one asking for a stream is a browser and not a program: a
* token its own page computes with bot-detection script that nobody else can run. Not asked for today, and
* for a week in May 2023 it was. When it is asked for again the daemon cannot make one, so it says so (the
* `twitchIntegrityNeeded` event) and this makes it: a window nobody sees, on a throwaway session, that loads
* a twitch.tv page, waits for that script to be ready, and calls the same endpoint the page does. The token
* comes back valid for about sixteen hours and is for the device id the daemon names.
*
* Nothing is signed in to, nothing is kept (the session is memory-only and wiped), and it goes through the
* route like every other window does.
*/
/** The Client ID twitch.tv's own pages send; the integrity endpoint answers only to it. */
const SITE_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'
export interface Integrity {
token: string
/** When it stops being good, in Unix milliseconds. */
expiration: number
}
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
export async function mintIntegrity(deviceId: string, authToken = '', timeoutMs = 45_000): Promise<Integrity> {
const partition = `moho-twitch-integrity-${Date.now()}`
const ses = session.fromPartition(partition)
await routeSession(ses)
const win = new BrowserWindow({
show: false,
width: 900,
height: 700,
webPreferences: { partition, contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true }
})
try {
// A page with Twitch's own scripts on it: the bot check is one of them, and it patches fetch.
await win.loadURL('https://www.twitch.tv/directory')
const script = `(async () => {
const r = await fetch('https://gql.twitch.tv/integrity', { method: 'POST', headers: { 'Client-ID': ${JSON.stringify(SITE_CLIENT_ID)}, 'X-Device-Id': ${JSON.stringify(deviceId)}${authToken ? `, Authorization: ${JSON.stringify('OAuth ' + authToken)}` : ''} }, credentials: 'omit' })
if (r.status !== 200) throw new Error('Twitch answered ' + r.status)
const j = await r.json()
if (!j.token) throw new Error('Twitch sent no token')
return { token: j.token, expiration: j.expiration }
})()`
const deadline = Date.now() + timeoutMs
let last = ''
// The script that makes the call valid loads after the page does, so the first tries may be refused.
while (Date.now() < deadline) {
try {
const made = (await win.webContents.executeJavaScript(script, true)) as Integrity
if (made?.token) return made
} catch (e) {
last = (e as Error).message
}
await sleep(2000)
}
throw new Error(last || 'the page never became ready')
} finally {
if (!win.isDestroyed()) win.destroy()
void ses.clearStorageData()
}
}
/** Answers the daemon's request: makes the token and hands it over, or says why it could not. */
export async function answerIntegrity(
data: { requestId?: string; deviceId?: string; authToken?: string },
request: (method: string, params: Record<string, unknown>) => Promise<unknown>
): Promise<void> {
if (!data.requestId || !data.deviceId) return
try {
// As the account when it names one: Twitch takes for an account's call only a token made as that account.
const made = await mintIntegrity(data.deviceId, data.authToken ?? '')
await request('provideTwitchIntegrity', { requestId: data.requestId, token: made.token, expiration: made.expiration })
} catch (e) {
await request('provideTwitchIntegrity', { requestId: data.requestId, error: (e as Error).message }).catch(() => {})
}
}
+64
View File
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// The window itself is Electron's; what is under test is the order of things around it.
const login = vi.hoisted(() => ({ browserLogin: vi.fn() }))
vi.mock('./browser-login', () => login)
vi.mock('./log', () => ({ log: { info: () => undefined } }))
import { feedTwitchSignIn, twitchSignIn } from './twitch-signin'
describe('Twitch sign-in as one action', () => {
beforeEach(() => {
login.browserLogin.mockReset()
})
it('hands the session to the daemon, takes the same window to the code, and finishes when the code is approved', async () => {
const calls: string[] = []
const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
calls.push(method)
if (method === 'startTwitchLogin') {
// The code is heard in the time it takes the person to sign in.
setTimeout(() => feedTwitchSignIn({ event: 'twitchLoginCode', data: { loginId: 'L1', verificationUri: 'https://www.twitch.tv/activate?device-code=ABCD' } }), 0)
return { loginId: 'L1' }
}
if (method === 'setTwitchLoginWebToken') expect(params).toEqual({ loginId: 'L1', token: 'web-session' })
return {}
})
const loaded: string[] = []
login.browserLogin.mockImplementation(async (_service: string, options: { hold: (v: string, w: unknown) => Promise<void> }) => {
const win = { loadURL: async (u: string) => void loaded.push(u), webContents: { isLoading: () => false, on: () => undefined } }
const held = options.hold('web-session', win)
// Approving it on the page.
setTimeout(() => feedTwitchSignIn({ event: 'twitchLoginResult', data: { loginId: 'L1', success: true } }), 20)
await held
return { ok: true }
})
const outcome = await twitchSignIn(request, {})
expect(outcome).toEqual({ ok: true })
expect(calls).toEqual(['startTwitchLogin', 'setTwitchLoginWebToken'])
expect(loaded).toEqual(['https://www.twitch.tv/activate?device-code=ABCD'])
expect(request).toHaveBeenCalledWith('startTwitchLogin', {})
})
it('ends the daemon\'s wait when the window is closed before the code was approved', async () => {
const calls: string[] = []
const request = vi.fn(async (method: string) => {
calls.push(method)
return method === 'startTwitchLogin' ? { loginId: 'L2' } : {}
})
login.browserLogin.mockResolvedValue({ ok: false, error: 'cancelled' })
expect(await twitchSignIn(request, {})).toEqual({ ok: false, error: 'cancelled' })
expect(calls).toEqual(['startTwitchLogin', 'cancelTwitchLogin'])
})
it('does not run two at once, and says a refused sign-in', async () => {
let release: (v: { ok: boolean }) => void = () => {}
login.browserLogin.mockImplementation(() => new Promise((r) => (release = r)))
const request = vi.fn(async () => ({ loginId: 'L3' }))
const first = twitchSignIn(request, {})
await new Promise((r) => setTimeout(r, 5))
expect(await twitchSignIn(request, {})).toEqual({ ok: false, error: 'A Twitch sign-in is already under way' })
release({ ok: true })
await first
})
})
+189
View File
@@ -0,0 +1,189 @@
import { browserLogin } from './browser-login'
import { log } from './log'
/**
* Signing in to Twitch: one action that gives the account both things it needs.
*
* Twitch has two sign-ins that cannot be had from each other. Its official API (chat, sending, follows,
* moderation) takes a token made by approving a short code on twitch.tv; its player takes only the session
* of someone signed in to twitch.tv itself. Asking a person to do those as two flows was asking them to sign
* in twice. Here it is one window: the person signs in to twitch.tv (typed there, never seen by moho), and
* the same window, with the session it now holds, is taken to the page that approves the code. Approving it
* is Twitch's own consent screen - what moho will be allowed to do is listed there - and is left to the
* person; it is the one click that is theirs to make.
*
* The session found on the way goes straight to the daemon, to be saved with the account the code becomes,
* and is never returned to the page that asked.
*/
type Request = (method: string, params: Record<string, unknown>) => Promise<unknown>
interface Waiting {
loginId: string
userCode: string
gotCode: ((uri: string) => void) | null
codePromise: Promise<string>
result: ((r: { success: boolean; error?: string }) => void) | null
resultPromise: Promise<{ success: boolean; error?: string }>
/** The code was approved or the sign-in is over: the window may go where it likes. */
finished: boolean
}
let active: Waiting | null = null
/** Daemon events, offered here by main for the sign-in that is waiting on them. */
export function feedTwitchSignIn(frame: { event: string; data?: Record<string, unknown> }): void {
if (!active || !frame.event.startsWith('twitchLogin')) return
const d = frame.data ?? {}
// The first events can be heard before the daemon's answer naming the sign-in; nothing else is signing in.
if (active.loginId && d.loginId !== active.loginId) return
if (frame.event === 'twitchLoginCode' && typeof d.verificationUri === 'string') {
if (typeof d.userCode === 'string') active.userCode = d.userCode
active.gotCode?.(d.verificationUri)
} else if (frame.event === 'twitchLoginResult') {
active.result?.({ success: d.success === true, error: typeof d.error === 'string' ? d.error : undefined })
}
}
export interface SignInOutcome {
ok: boolean
error?: string
}
export async function twitchSignIn(request: Request, options: { useTor?: boolean }): Promise<SignInOutcome> {
if (active) return { ok: false, error: 'A Twitch sign-in is already under way' }
let gotCode: (uri: string) => void = () => {}
let result: (r: { success: boolean; error?: string }) => void = () => {}
const w: Waiting = {
loginId: '',
userCode: '',
gotCode: null,
codePromise: new Promise<string>((r) => (gotCode = r)),
result: null,
finished: false,
resultPromise: new Promise((r) => (result = r))
}
w.gotCode = gotCode
w.result = result
active = w
try {
const started = (await request('startTwitchLogin', {
...(typeof options.useTor === 'boolean' ? { useTor: options.useTor } : {})
})) as { loginId: string }
w.loginId = started.loginId
const outcome = await browserLogin('twitch', {
trace: (m) => log.info(`twitch sign-in: ${m}`),
hold: async (token, win) => {
// To the daemon, for the account the code is about to become. Never anywhere else.
await request('setTwitchLoginWebToken', { loginId: w.loginId, token })
let giveUp: NodeJS.Timeout | undefined
const uri = await Promise.race([
w.codePromise,
new Promise<string>((_, no) => (giveUp = setTimeout(() => no(new Error('Twitch sent no code')), 30_000)))
]).finally(() => clearTimeout(giveUp))
// Signed in. The page is still on its way somewhere (twitch.tv sends a new login to its front page), so
// that is let finish before the same window is taken to the page that approves the code - and brought
// back to it if the site's own redirect lands after, which would leave the person on the front page.
await settled(win)
const go = (): void => {
log.info('twitch sign-in: taking the window to the approval page')
win.loadURL(uri).catch(() => undefined)
}
go()
let tries = 0
const wander = (_e: unknown, url: string): void => {
let here = ''
try {
here = new URL(url).pathname
} catch {
return
}
if (!here.startsWith('/activate') && !w.finished && tries < 4) {
tries++
log.info(`twitch sign-in: the window went to ${here}; back to the approval page (${tries})`)
setTimeout(go, 600)
}
}
// Twitch's page does not take the code from the address, so it is typed into the page's field for the
// person, and nothing more: pressing Activate and then Authorize on Twitch's own screens is theirs.
const filled = (): void => {
if (w.finished || !w.userCode || !win.webContents.getURL().includes('/activate')) return
void win.webContents
.executeJavaScript(fillCode(w.userCode), true)
.then((how: unknown) => log.info(`twitch sign-in: the code field: ${String(how)}`))
.catch(() => undefined)
}
win.webContents.on('did-finish-load', filled)
win.webContents.on('did-navigate', wander)
win.webContents.on('did-navigate-in-page', wander)
const done = await w.resultPromise
w.finished = true
if (!done.success) throw new Error(done.error || 'Twitch sign-in failed')
}
})
if (!outcome.ok) {
// The window was closed or timed out before the code was approved: nobody is waiting for it any more.
await request('cancelTwitchLogin', { loginId: w.loginId }).catch(() => undefined)
return { ok: false, error: outcome.error }
}
return { ok: true }
} catch (e) {
if (w.loginId) await request('cancelTwitchLogin', { loginId: w.loginId }).catch(() => undefined)
return { ok: false, error: (e as Error).message }
} finally {
active = null
}
}
/** Waits for the window's page to finish loading and stay quiet for a moment. */
async function settled(win: { webContents: { isLoading(): boolean } }): Promise<void> {
const quiet = 1500
let quietFor = 0
for (let waited = 0; waited < 15_000 && quietFor < quiet; waited += 250) {
await new Promise((r) => setTimeout(r, 250))
quietFor = win.webContents.isLoading() ? 0 : quietFor + 250
}
}
/**
* A script that finds the code field on Twitch's "Activate your device" page and types the code in, the way a
* person would (so the page's own scripts see it). Waits for the page, which draws it after it loads; says what it
* found. It presses the page's Activate button, which only submits the code, and nothing after it.
*/
export function fillCode(code: string): string {
return `(async (code) => {
const type = (el, v) => {
const set = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value').set
el.focus()
set.call(el, v)
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
const shown = (el) => el.getClientRects().length > 0 && !el.disabled && !el.readOnly
const deadline = Date.now() + 20000
while (Date.now() < deadline) {
const inputs = [...document.querySelectorAll('input')].filter((i) => ['text', 'tel', 'search', ''].includes(i.type) && shown(i))
const boxes = inputs.filter((i) => i.maxLength === 1)
if (boxes.length >= code.length) {
code.split('').forEach((c, i) => type(boxes[i], c))
return 'filled ' + code.length + ' boxes'
}
const named = inputs.find((i) => /code/i.test([i.name, i.id, i.placeholder, i.getAttribute('aria-label')].join(' ')))
const field = named || (inputs.length === 1 ? inputs[0] : null)
if (field) {
if (field.value !== code) type(field, code)
// Submitting the code is the page's own "Activate" button; approving what moho may do, next, is not.
const go = field.form && [...field.form.querySelectorAll('button, input[type=submit]')].find((b) => /^activate$/i.test((b.innerText || b.value || '').trim()))
if (go && !window.__mohoSubmitted) {
window.__mohoSubmitted = true
go.click()
return 'filled one field and pressed Activate'
}
return 'filled one field'
}
await new Promise((r) => setTimeout(r, 400))
}
return 'no code field found'
})(${JSON.stringify(code)})`
}
+75 -3
View File
@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer, webUtils } from 'electron'
import { IPC, POPOUT_FLAG, type PopoutState, type ScreenSource } from '../shared/ipc'
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron'
import { IPC, POPOUT_FLAG, UI_SHOTS_FLAG, type EditAction, type EditMenuRequest, type PopoutState, type ScreenSource } from '../shared/ipc'
import type { NobilisEvent } from '../shared/wire'
/**
@@ -44,6 +44,11 @@ const api = {
return ipcRenderer.invoke(IPC.browserLogin, service, accountId, options)
},
/** Signs in to Twitch for chat and video at once, in one window. Resolves when it is done or given up. */
twitchSignIn(options: { useTor?: boolean }): Promise<{ ok: boolean; error?: string }> {
return ipcRenderer.invoke(IPC.twitchSignIn, options)
},
onEvent(cb: (frame: NobilisEvent) => void): () => void {
const handler = (_e: unknown, frame: NobilisEvent): void => cb(frame)
ipcRenderer.on(IPC.event, handler)
@@ -66,12 +71,44 @@ const api = {
return () => ipcRenderer.off(IPC.deepLink, handler)
},
/**
* A right click on a place text is typed - the box a message is written in,
* a settings field. Chromium knows what is misspelled there and what could
* replace it; the page does not, so it is told.
*/
onEditMenu(cb: (menu: EditMenuRequest) => void): () => void {
const handler = (_e: unknown, menu: EditMenuRequest): void => cb(menu)
ipcRenderer.on(IPC.editMenu, handler)
return () => ipcRenderer.off(IPC.editMenu, handler)
},
onActivateBuffer(cb: (bufferId: string) => void): () => void {
const handler = (_e: unknown, id: string): void => cb(id)
ipcRenderer.on(IPC.activateBuffer, handler)
return () => ipcRenderer.off(IPC.activateBuffer, handler)
},
/** The tray menu asking this window to open Settings, or to set a status on every account. */
onTrayCommand(cb: (command: 'settings' | 'status', arg?: string) => void): () => void {
const handler = (_e: unknown, command: 'settings' | 'status', arg?: string): void => cb(command, arg)
ipcRenderer.on(IPC.trayCommand, handler)
return () => ipcRenderer.off(IPC.trayCommand, handler)
},
/**
* Says what status each connected account is at, which statuses any of them
* can be set to, and the one to show as checked: for the tray menu, and for
* main to stay quiet for an account on Do not disturb.
*/
setAccountStatuses(statuses: Record<string, string>, offered: string[], shown: string | null, invisibleName: string): void {
ipcRenderer.send(IPC.trayStatus, statuses, offered, shown, invisibleName)
},
/** Says what is waiting, counted the way the rail counts it, for the tray's number. */
setTrayUnread(count: { dms: number; mentions: number; latest: string | null }): void {
ipcRenderer.send(IPC.trayUnread, count)
},
onMaximizeChange(cb: (maximized: boolean) => void): () => void {
const handler = (_e: unknown, v: boolean): void => cb(v)
ipcRenderer.on(IPC.maximizeChanged, handler)
@@ -110,6 +147,8 @@ const api = {
ipcRenderer.invoke(IPC.resolveImagePage, url),
/** Puts a line of text on the system clipboard. */
/** How large everything is drawn: 1 is as designed. */
setZoom: (factor: number): void => webFrame.setZoomFactor(factor),
copyText: (text: string): Promise<void> => ipcRenderer.invoke(IPC.writeClipboardText, text),
/**
* Where a dropped file actually lives.
@@ -122,7 +161,7 @@ const api = {
*/
pathForFile: (file: File): string => webUtils.getPathForFile(file),
pickFile: (): Promise<string | null> => ipcRenderer.invoke(IPC.pickFile),
pickFile: (extensions?: string[]): Promise<string | null> => ipcRenderer.invoke(IPC.pickFile, extensions),
/**
* Answers the main process when it needs a screen or window chosen.
*
@@ -145,9 +184,20 @@ const api = {
importGroupIcon: (groupId: string): Promise<{ path?: string; error?: string }> =>
ipcRenderer.invoke(IPC.importGroupIcon, groupId),
defaultDownloadDir: (): Promise<string> => ipcRenderer.invoke(IPC.defaultDownloadDir),
/**
* Puts a picture on the system clipboard: from its pixels where the page
* could read them, otherwise from where it lives.
*/
/** How big a file is, for the tray it is staged in; null for what cannot be read. */
fileSize: (path: string): Promise<number | null> => ipcRenderer.invoke(IPC.fileSize, path),
copyImage: (source: string, dataUrl?: string): Promise<{ error?: string }> =>
ipcRenderer.invoke(IPC.copyImage, source, dataUrl),
downloadMedia: (source: string, filename?: string): Promise<{ path?: string; error?: string }> =>
ipcRenderer.invoke(IPC.downloadMedia, source, filename),
readClipboardImage: (): Promise<string | null> => ipcRenderer.invoke(IPC.readClipboardImage),
editAction: (action: EditAction): Promise<void> => ipcRenderer.invoke(IPC.editAction, action),
replaceMisspelling: (word: string): Promise<void> => ipcRenderer.invoke(IPC.replaceMisspelling, word),
addToDictionary: (word: string): Promise<void> => ipcRenderer.invoke(IPC.addToDictionary, word),
restartDaemon: (): Promise<void> => ipcRenderer.invoke(IPC.restartDaemon),
daemonStatus: (): Promise<{ binaryPath: string; available: boolean; linkUp: boolean }> =>
ipcRenderer.invoke(IPC.daemonStatus),
@@ -168,6 +218,9 @@ const api = {
ipcRenderer.invoke(IPC.markBufferRead, bufferId),
/** Conversations in windows of their own. */
/** Whether the screenshot harness started this window - see UI_SHOTS_FLAG. */
uiShots: process.argv.includes(UI_SHOTS_FLAG),
popout: {
/** Set only in a popped-out window, naming the conversation it shows. */
bufferId: popoutBufferId,
@@ -185,6 +238,25 @@ const api = {
}
}
/**
* Tells main that somebody is using this window.
*
* Only that it happened - the event's kind and what it touched are not looked
* at, and nothing about them is sent. It is what lets moho know the person has
* stepped away, which is the one thing it wants to know, and it is sent at most
* every few seconds however much is going on.
*/
let lastActivityPing = 0
const noteActivity = (): void => {
const now = Date.now()
if (now - lastActivityPing < 5000) return
lastActivityPing = now
ipcRenderer.send(IPC.activity)
}
for (const kind of ['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart']) {
;(globalThis as unknown as EventTarget).addEventListener(kind, noteActivity, { capture: true, passive: true })
}
contextBridge.exposeInMainWorld('moho', api)
export type MohoApi = typeof api
+76 -21
View File
@@ -1,27 +1,31 @@
import { EditMenu } from './components/EditMenu'
import { useEffect, useRef, useState } from 'react'
import { TitleBar } from './components/TitleBar'
import { BufferList } from './components/BufferList'
import { ServerRail } from './components/ServerRail'
import { ForumPane } from './components/ForumPane'
import { MessageList } from './components/MessageList'
import { MentionsInbox } from './components/MentionsInbox'
import { MentionsPage } from './components/MentionsPage'
import { INVITE_PREFIX, MENTIONS_GROUP_ID } from './lib/groups'
import { INVITATIONS_GROUP_ID, MENTIONS_GROUP_ID } from './lib/groups'
import { trayCount } from './lib/traycount'
import { Composer } from './components/Composer'
import { MembershipGate } from './components/MembershipGate'
import { NickList } from './components/NickList'
import { ColumnResizer, useColumnWidth } from './components/ColumnResizer'
import { ThreadPanel } from './components/ThreadPanel'
import { InvitePanel } from './components/InvitePanel'
import { SteamFriendDialogs } from './components/SteamFriendDialogs'
import { VerificationDialog } from './components/VerificationDialog'
import { ProfileCard } from './components/ProfileCard'
import { ConversationTools } from './components/ConversationTools'
import { AccountsPanel } from './components/AccountsPanel'
import { SettingsPanel } from './components/settings/SettingsPanel'
import { DownloadsPanel } from './components/DownloadsPanel'
import { JoinPanel } from './components/JoinPanel'
import { PeekBar, PeekView } from './components/PeekView'
import { Toasts } from './components/Toasts'
import { IncomingCallPanel } from './components/IncomingCallPanel'
import { CallAudio, CallStage, IncomingMatrixCall, ScreenPicker } from './components/CallStage'
import { CallAudio, CallStage, ScreenPicker } from './components/CallStage'
import { CallWindowHost } from './components/stage/CallWindowHost'
import { StreamStage, StreamWindowHost } from './components/StreamStage'
import { EventCreatePanel, EventsPane } from './components/DiscordEvents'
@@ -32,14 +36,28 @@ import { Icon, IconButton } from './components/Icon'
import { ConversationMenu } from './components/ConversationMenu'
import { watchDelta, watchedKickBuffers } from './lib/kickwatch'
import { useActiveBuffer, useChat, usePref, usePrefsReady, useStore } from './state/hooks'
import { bufferDisplayName } from './lib/util'
import { useDmCategories } from './state/useDmCategories'
import { bufferDisplayName, setHourFormat } from './lib/util'
import type { BufferEntry } from './state/store'
import { loadLocalEmotes, recoverMissingEmotes } from './lib/emotecache'
import { restoreMissingMedia } from './lib/mediarestore'
import { useShortcuts } from './lib/shortcuts'
import { useEscapeLayer } from './lib/layers'
import { installDialogFocus } from './lib/dialogs'
import { QuickSwitcher } from './components/QuickSwitcher'
export default function App(): JSX.Element {
useShortcuts()
useEffect(() => installDialogFocus(), [])
// The full-page panels - Settings, Accounts, Downloads, Join - go away on
// Escape like everything else that opens over the window, but underneath
// whatever opened on top of them.
const panelOpen = useChat((s) => s.activePanel !== '')
useEscapeLayer(() => store.setActivePanel(''), panelOpen)
const store = useStore()
const prefsReady = usePrefsReady()
// Every account's direct messages under a heading of its own.
useDmCategories()
const [booted, setBooted] = useState(false)
const accounts = useChat((s) => s.accounts)
@@ -63,7 +81,39 @@ export default function App(): JSX.Element {
const streamElsewhere = !!watching && (watching.bufferId !== activeBufferId || activePanel !== '')
const [sidebarFolded, setSidebarFolded] = usePref<boolean>('ui.sidebarFolded', false)
// How the window looks, from the appearance settings: how large, how close
// together, whether things move, how the clock is written.
const [zoom] = usePref<number>('appearance.zoom', 1)
const [density] = usePref<string>('appearance.density', 'cozy')
const [reduceMotion] = usePref<boolean>('appearance.reduceMotion', false)
// The two side columns, as they were last dragged.
const [sidebarWidth, setSidebarWidth] = useColumnWidth('ui.sidebarWidth', 220)
const [nicklistWidth, setNicklistWidth] = useColumnWidth('ui.nicklistWidth', 180)
const [hourFormat] = usePref<string>('display.hourFormat', '24')
setHourFormat(hourFormat)
useEffect(() => window.moho.setZoom(zoom), [zoom])
useEffect(() => {
document.documentElement.dataset.density = density
if (reduceMotion) document.documentElement.dataset.reduceMotion = ''
else delete document.documentElement.dataset.reduceMotion
}, [density, reduceMotion])
const [userListFolded, setUserListFolded] = usePref<boolean>('ui.userListFolded', false)
// What the tray says is waiting, which is whatever the rail would add up to:
// the same rule, from the same lists, said to the main process whenever the
// answer changes. Told only when it has, since most changes to the buffers
// are not to what is waiting.
const [trayMuted] = usePref<string[]>('mutedBuffers', [])
const [trayHidden] = usePref<string[]>('hiddenBuffers', [])
const [trayMutedGroups] = usePref<string[]>('mutedGroups', [])
const lastTray = useRef('')
useEffect(() => {
const count = trayCount(allBuffers, trayMuted, trayHidden, trayMutedGroups)
const said = JSON.stringify(count)
if (said === lastTray.current) return
lastTray.current = said
window.moho.setTrayUnread(count)
}, [allBuffers, trayMuted, trayHidden, trayMutedGroups])
const [savedBufferId] = usePref<string>('ui.activeBufferId', '')
const [savedGroupId] = usePref<string>('ui.activeGroupId', '')
@@ -139,12 +189,8 @@ export default function App(): JSX.Element {
const headerTitle =
peeking
? `Looking · ${peek?.name || peek?.alias || ''}`
: activePanel === 'accounts'
? 'Accounts'
: activePanel === 'settings'
: activePanel === 'accounts' || activePanel === 'settings' || activePanel === 'downloads'
? 'Settings'
: activePanel === 'downloads'
? 'Downloads'
: activePanel === 'join'
? `Join · ${joinAccount?.displayName ?? ''}`
: onMentionsPage
@@ -154,7 +200,7 @@ export default function App(): JSX.Element {
: ''
return (
<div className="app">
<div className="app" style={{ '--sidebar-width': `${sidebarWidth}px`, '--nicklist-width': `${nicklistWidth}px` } as React.CSSProperties}>
<TitleBar />
<div className="app-body">
@@ -168,7 +214,7 @@ export default function App(): JSX.Element {
<div className="sidebar">
<BufferList />
</div>
<div className="divider-v" />
<ColumnResizer width={sidebarWidth} onWidth={setSidebarWidth} min={160} max={520} grows="right" label="Channel list width" />
</>
)}
@@ -209,8 +255,9 @@ export default function App(): JSX.Element {
{activePanel === '' && !onMentionsPage && !peeking && buffer?.kind === 'channel' && (
<IconButton
name={userListFolded ? 'group' : 'group_off'}
name="group"
title={userListFolded ? 'Show members' : 'Hide members'}
className={userListFolded ? undefined : 'active'}
onClick={() => setUserListFolded(!userListFolded)}
/>
)}
@@ -247,7 +294,8 @@ export default function App(): JSX.Element {
<>
{/* Above the box, saying why it will not work here yet. */}
{buffer && <MembershipGate buffer={buffer} />}
<Composer />
{/* A forum has no box: what is said in it is said in a post. */}
{!buffer?.forum && <Composer />}
</>
)}
</div>
@@ -264,7 +312,7 @@ export default function App(): JSX.Element {
{!openThread && showNickList && (
<>
<div className="divider-v" />
<ColumnResizer width={nicklistWidth} onWidth={setNicklistWidth} min={140} max={420} grows="left" label="Member list width" />
<div className="nicklist-pane">
<NickList />
</div>
@@ -280,12 +328,12 @@ export default function App(): JSX.Element {
room you share - and a dialog only the account panel can show is a
request nobody can answer. */}
<VerificationDialog />
<SteamFriendDialogs />
{/* Over everything: a call arrives while you are looking elsewhere. */}
<IncomingCallPanel />
{/* A Matrix call rings here too, but is answered by this window rather
than by the daemon - the media is the window's. */}
<IncomingMatrixCall />
<ScreenPicker />
{/* A form a Discord bot asked for. Over everything, because it is the
answer to something just pressed and it expires. */}
@@ -313,6 +361,8 @@ export default function App(): JSX.Element {
<TransferPanel />
<FileDrop />
<Toasts />
<QuickSwitcher />
<EditMenu />
</div>
)
}
@@ -351,13 +401,16 @@ function Body({
activeGroupId: string
peeking: boolean
}): JSX.Element {
if (activePanel === 'settings') return <SettingsPanel />
// Above the no-accounts case below: a finished download is still worth
// looking at on a machine whose accounts have since been removed.
if (activePanel === 'downloads') return <DownloadsPanel />
const activeBuffer = useActiveBuffer()
// Settings, with Accounts and Downloads as pages of it. Above the
// no-accounts case below: a finished download is still worth looking at on
// a machine whose accounts have since been removed.
if (activePanel === 'settings' || activePanel === 'accounts' || activePanel === 'downloads') {
return <SettingsPanel page={activePanel} />
}
// With no accounts at all, the accounts panel is the only useful thing to
// show - there is nothing to chat in yet.
if (activePanel === 'accounts' || !hasAccounts) return <AccountsPanel />
if (!hasAccounts) return <AccountsPanel />
if (activePanel === 'join') return <JoinPanel />
// A room being looked into: the pane's own view, with nothing to type into.
if (peeking) return <PeekView />
@@ -366,7 +419,9 @@ function Body({
if (activeGroupId === MENTIONS_GROUP_ID) return <MentionsPage />
// An invitation stands where the conversation would, because it is the
// conversation being offered.
if (activeGroupId.startsWith(INVITE_PREFIX)) return <InvitePanel groupId={activeGroupId} />
if (activeGroupId === INVITATIONS_GROUP_ID) return <InvitePanel />
// A forum is a list of posts, not a log.
if (hasBuffer && activeBuffer?.forum) return <ForumPane buffer={activeBuffer} />
if (hasBuffer) return <MessageList />
return <Placeholder icon="forum" text="Select or join a channel" />
}
+6 -2
View File
@@ -1,3 +1,4 @@
import { EditMenu } from './components/EditMenu'
import { useEffect, useState } from 'react'
import { TitleBar } from './components/TitleBar'
import { MessageList } from './components/MessageList'
@@ -8,7 +9,8 @@ import { ConversationTools } from './components/ConversationTools'
import { BufferFace } from './components/BufferFace'
import { FileDrop } from './components/FileDrop'
import { Toasts } from './components/Toasts'
import { CallStage, IncomingMatrixCall, ScreenPicker } from './components/CallStage'
import { CallStage, ScreenPicker } from './components/CallStage'
import { IncomingCallPanel } from './components/IncomingCallPanel'
import { StreamStage } from './components/StreamStage'
import { Icon, IconButton } from './components/Icon'
import { useActiveBuffer, useChat, usePref, usePrefsReady, useStore } from './state/hooks'
@@ -136,13 +138,15 @@ export function Popout({ bufferId }: { bufferId: string }): JSX.Element {
{/* A call rings wherever the client is, including here: this window has
its own connection to the daemon and its own media, so a call
answered here is answered here. */}
<IncomingMatrixCall />
{/* Silent: the main window is the one that rings. */}
<IncomingCallPanel silent />
<ScreenPicker />
{/* A popped-out conversation is one conversation, so a call that is not
this one is in the corner and this one is above the log. */}
{activeCall && activeCall.bufferId !== bufferId && <CallStage mode="pip" />}
{watching && watching.bufferId !== bufferId && <StreamStage mode="pip" />}
<Toasts />
<EditMenu />
</div>
)
}
+3450 -672
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<!-- Steam's mark, drawn as a plain silhouette: a disc, the small gear on its
arm, and the cut-out ring for the larger one. -->
<path fill="#fff" fill-rule="evenodd" d="M12 1.5A10.5 10.5 0 0 0 1.55 11.1l5.6 2.3a3 3 0 0 1 1.7-.5h.2l2.5-3.6v-.05a4 4 0 1 1 4 4h-.1l-3.55 2.55a3 3 0 0 1-5.95.5L1.8 14.9A10.5 10.5 0 1 0 12 1.5zm-4 17.1-1.3-.55a2.25 2.25 0 1 0 1.25-3.1l1.35.55a1.65 1.65 0 1 1-1.3 3.1zm7.05-6.2a2.65 2.65 0 1 0-2.65-2.65 2.65 2.65 0 0 0 2.65 2.65zm0-.95a1.7 1.7 0 1 1 1.7-1.7 1.7 1.7 0 0 1-1.7 1.7z"/>
</svg>

After

Width:  |  Height:  |  Size: 615 B

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

After

Width:  |  Height:  |  Size: 383 B

+455 -69
View File
@@ -1,7 +1,10 @@
import { useMediaUrl } from '../lib/route'
import { useEffect, useState } from 'react'
import { SwitchRow } from './Switch'
import { useEffect, useRef, useState } from 'react'
import { Icon, IconButton, ServiceMark } from './Icon'
import { MatrixAccountTools } from './MatrixAccountTools'
import { SteamAccountTools } from './SteamAccountTools'
import { TwitchSignIn } from './TwitchSignIn'
import { IgnoreList } from './IgnoreList'
import { TunnelAllSwitch } from './settings/Tunnel'
import { useChat, useConnectionDetail, useMapPref, usePref, useStore } from '../state/hooks'
@@ -22,7 +25,7 @@ import type { Account } from '../../../shared/wire'
* that gains a backend appears here as soon as it gains a form, and one the
* daemon stops offering disappears without this file being touched.
*/
const FORMS = ['irc', 'discord', 'sneedchat', 'matrix', 'kick'] as const
const FORMS = ['irc', 'discord', 'sneedchat', 'matrix', 'kick', 'steam', 'twitch'] as const
type AddableService = (typeof FORMS)[number]
/** What `listProtocols` answers with. */
@@ -117,7 +120,9 @@ export function AccountsPanel(): JSX.Element {
key={service}
type="button"
className={`service-chip${adding === service ? ' active' : ''}`}
onClick={() => setAdding(adding === service ? null : service)}
// Choosing the one already open leaves it open: the chip is a
// choice among services, and the way out is the Cancel below.
onClick={() => setAdding(service)}
>
<ServiceMark icon={icon} size={16} />
{nameFor(service)}
@@ -131,6 +136,13 @@ export function AccountsPanel(): JSX.Element {
{adding === 'sneedchat' && <SneedChatForm />}
{adding === 'matrix' && <MatrixForm />}
{adding === 'kick' && <KickForm onDone={() => setAdding(null)} />}
{adding === 'steam' && <SteamForm onDone={() => setAdding(null)} />}
{adding === 'twitch' && <TwitchForm onDone={() => setAdding(null)} />}
{adding && (
<button type="button" className="button subtle small" onClick={() => setAdding(null)}>
Cancel
</button>
)}
</div>
<div className="panel-section">
@@ -153,7 +165,10 @@ export function AccountsPanel(): JSX.Element {
// A service worth looking at even unasked: something in it is not
// working, or is on its way to working.
const troubled = mine.some((a) => TROUBLE_STATES.includes(a.state))
const shut = !(openGroups[service] ?? troubled)
// Open when there are few enough to see at once: a page that opens
// on a closed list makes the common case - one or two accounts -
// take clicks to reach what is there.
const shut = !(openGroups[service] ?? (troubled || accounts.length <= 4))
return (
<div key={service} className="account-group">
<button
@@ -445,6 +460,9 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
{account.service === 'matrix' && <MatrixAccountTools account={account} />}
{account.service === 'steam' && <SteamAccountTools account={account} />}
{account.service === 'twitch' && <TwitchAccountTools account={account} />}
{account.service === 'sneedchat' && (
<>
<RouteLevel account={account} onChange={(level) => call('setAccountRoute', { accountId: account.id, level })} />
@@ -463,6 +481,8 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
{/* Asked first, in place. Removing takes the saved sign-in, the
history kept here and - for Matrix - this device's keys, and
none of it comes back; it used to happen on the first click. */}
<div className="danger-zone">
<div className="danger-zone-title small">Danger zone</div>
{confirmingRemove ? (
<div className="remove-confirm" role="alertdialog" aria-label="Remove this account?">
<span className="small">
@@ -488,7 +508,7 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
>
<Icon name="delete" size={16} /> Remove
</button>
<button type="button" className="button" autoFocus onClick={() => setConfirmingRemove(false)}>
<button type="button" className="button subtle" autoFocus onClick={() => setConfirmingRemove(false)}>
Keep it
</button>
</div>
@@ -498,6 +518,7 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
<Icon name="delete" size={16} /> Remove account
</button>
)}
</div>
</div>
)}
</div>
@@ -548,20 +569,12 @@ function IrcSasl({
return (
<div className="sasl-block">
<label className="checkbox-row">
<input
type="checkbox"
checked={account.saslEnabled}
onChange={(e) => apply({ enabled: e.target.checked })}
/>
<span>
Authenticate with SASL
<span className="small muted">
{' '}
— identifies during connection, before any channel is joined
</span>
</span>
</label>
<SwitchRow
title="Authenticate with SASL"
description="Identifies during connection, before any channel is joined"
checked={account.saslEnabled}
onChange={(on) => apply({ enabled: on })}
/>
{account.saslEnabled && (
<>
@@ -634,47 +647,26 @@ function IrcSasl({
allowance decides nothing, and offering it there would be a
switch that appears to weaken something and does not. */}
{!account.ssl && (
<label className="checkbox-row">
<input
type="checkbox"
checked={account.allowPlaintextSasl}
onChange={(e) => {
setAllowPlaintext(e.target.checked)
apply({ allowPlaintextSasl: e.target.checked })
}}
/>
<span>
Send SASL credentials over this unencrypted connection
<span className="small muted">
{' '}
— SASL PLAIN is the password with base64 round it, not encryption. Without
this, connecting is refused rather than done quietly in the clear.
</span>
</span>
</label>
<SwitchRow
title="Send SASL credentials over this unencrypted connection"
description="SASL PLAIN is the password with base64 round it, not encryption. Without this, connecting is refused rather than done quietly in the clear."
checked={account.allowPlaintextSasl}
onChange={(on) => {
setAllowPlaintext(on)
apply({ allowPlaintextSasl: on })
}}
/>
)}
{/* The live login is tried first and needs no setting: this is only
the last resort, for a server that refuses it, and it costs a
moment out of every channel - hence off unless asked for. */}
<label className="checkbox-row">
<input
type="checkbox"
checked={account.reconnectForSasl}
onChange={(e) =>
call('setIrcReconnectForSasl', { accountId: account.id, enabled: e.target.checked })
}
/>
<span>
Reconnect to log in when services come back
<span className="small muted">
{' '}
— if the network's services were down when moho connected, it logs in as soon as
they return. A few servers only accept that login while connecting; this lets moho
reconnect for them, which briefly drops you from your channels.
</span>
</span>
</label>
<SwitchRow
title="Reconnect to log in when services come back"
description="If the network's services were down when moho connected, it logs in as soon as they return. A few servers only accept that login while connecting; this lets moho reconnect for them, which briefly drops you from your channels."
checked={account.reconnectForSasl}
onChange={(on) => call('setIrcReconnectForSasl', { accountId: account.id, enabled: on })}
/>
</>
)}
</div>
@@ -859,7 +851,7 @@ function IrcForm({ onDone }: { onDone: () => void }): JSX.Element {
for both would be asking somebody to type their password twice. */}
<div className="field">
<span className="small muted">Sign in</span>
<div className="field-row">
<div className="setting-segmented" role="radiogroup" aria-label="Sign in">
{(
[
['none', 'No account'],
@@ -867,15 +859,16 @@ function IrcForm({ onDone }: { onDone: () => void }): JSX.Element {
['nickserv', 'NickServ']
] as const
).map(([value, label]) => (
<label key={value} className="checkbox-row">
<input
type="radio"
name="irc-auth"
checked={auth === value}
onChange={() => setAuth(value)}
/>
<span>{label}</span>
</label>
<button
key={value}
type="button"
role="radio"
aria-checked={auth === value}
className={auth === value ? 'active' : undefined}
onClick={() => setAuth(value)}
>
{label}
</button>
))}
</div>
{auth !== 'none' && (
@@ -900,10 +893,7 @@ function IrcForm({ onDone }: { onDone: () => void }): JSX.Element {
</div>
{!chosen && (
<label className="checkbox-row">
<input type="checkbox" checked={ssl} onChange={(e) => setSsl(e.target.checked)} />
<span>Use TLS</span>
</label>
<SwitchRow title="Use TLS" checked={ssl} onChange={setSsl} />
)}
<TorSwitch service="irc" on={useTor} onChange={setUseTor} />
<button type="button" className="button" disabled={busy || !nick || !host} onClick={() => void submit()}>
@@ -1209,6 +1199,402 @@ function KickForm({ onDone }: { onDone: () => void }): JSX.Element {
)
}
/** What Steam can ask for to let a sign-in through; any one of them will do. */
interface SteamOption {
kind: 'deviceCode' | 'emailCode' | 'approval' | 'emailApproval'
hint?: string
}
/**
* Signing in to Steam.
*
* A conversation rather than a request, because Steam may want something from
* the person's phone or inbox: the daemon answers with a login id and the rest
* arrives as events (see nobilis backend/steam/login.rs). So the form has
* three shapes - the credentials, then whatever Steam is waiting for, then
* nothing, because the account exists and the form closes itself.
*
* The password is typed here, goes to the daemon once, and is forgotten when
* this form is: the account keeps the token Steam hands back, never the
* password.
*/
function SteamForm({ onDone }: { onDone: () => void }): JSX.Element {
const store = useStore()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [useTor, setUseTor] = useState(false)
const [busy, setBusy] = useState(false)
const [loginId, setLoginId] = useState('')
const [detail, setDetail] = useState('')
const [needs, setNeeds] = useState<{ options: SteamOption[]; error?: string } | null>(null)
const [code, setCode] = useState('')
// The picture of the code a phone scans, while a sign-in by scanning waits.
const [qr, setQr] = useState<{ path: string; at: number } | null>(null)
// The id lives in a ref as well as state, because the first event can be
// handled before the render that follows the answer to the request that
// started the sign-in. Nothing else is signing in from this form, so an
// event arriving before the id is known can only be this one's.
const current = useRef('')
const setId = (id: string): void => {
current.current = id
setLoginId(id)
}
useEffect(() => {
const off = window.moho.onEvent((e) => {
const d = e.data as {
loginId?: string
detail?: string
options?: SteamOption[]
error?: string
success?: boolean
path?: string
}
if (!e.event.startsWith('steamLogin')) return
if (current.current && d.loginId !== current.current) return
if (e.event === 'steamLoginQr' && d.path) {
// The same file is written again when the code is replaced, so the
// moment is part of what is drawn.
setQr({ path: d.path, at: Date.now() })
} else if (e.event === 'steamLoginStatus') {
setDetail(d.detail || '')
} else if (e.event === 'steamLoginNeeds') {
setNeeds({ options: d.options ?? [], error: d.error })
setCode('')
} else if (e.event === 'steamLoginResult') {
setNeeds(null)
setQr(null)
setId('')
setBusy(false)
setDetail('')
if (d.success) {
setPassword('')
void store.refreshAccounts()
onDone()
} else {
store.toast('error', d.error || 'Steam sign-in failed')
}
}
})
return off
}, [onDone, store])
// Closing the form on a sign-in that is still waiting tells the daemon
// nobody is, so it stops asking Steam and frees what it was holding.
useEffect(
() => () => {
if (current.current) void window.moho.rpc('cancelSteamLogin', { loginId: current.current }).catch(() => {})
},
[]
)
const begin = (): void => {
setBusy(true)
setDetail('')
setNeeds(null)
current.current = ''
void window.moho
.rpc<{ loginId: string }>('addSteamAccount', { username, password, useTor })
.then((r) => setId(r.loginId))
.catch((e: Error) => {
setBusy(false)
store.toast('error', e.message)
})
}
// Signing in by scanning a code with the Steam app: nothing is typed.
const beginQr = (): void => {
setBusy(true)
setDetail('')
setNeeds(null)
setQr(null)
current.current = ''
void window.moho
.rpc<{ loginId: string }>('addSteamAccountQr', { useTor })
.then((r) => setId(r.loginId))
.catch((e: Error) => {
setBusy(false)
store.toast('error', e.message)
})
}
const send = (): void => {
const typed = code.trim()
if (!typed || !loginId) return
void window.moho
.rpc('submitSteamGuardCode', { loginId, code: typed })
.then(() => setDetail('checking the code'))
.catch((e: Error) => store.toast('error', e.message))
}
const cancel = (): void => {
if (current.current) void window.moho.rpc('cancelSteamLogin', { loginId: current.current }).catch(() => {})
setId('')
setQr(null)
setNeeds(null)
setDetail('')
setBusy(false)
}
if (qr) {
return (
<div className="add-form">
<p>Scan this with the Steam app on your phone.</p>
<p className="small muted">
In the app, open Steam Guard and choose the scan option (the QR icon), point the phone
at the code, and approve the sign-in. moho keeps only the token Steam hands back.
</p>
<img className="qr-image" src={resolveMediaUrl(`file://${qr.path}#${qr.at}`)} alt="Steam sign-in QR code" />
{detail && <p className="small muted">{detail}…</p>}
<div className="field-row">
<button type="button" className="button" onClick={cancel}>
Cancel sign-in
</button>
</div>
</div>
)
}
const takesCode = !!needs?.options.some((o) => o.kind === 'deviceCode' || o.kind === 'emailCode')
const approvals = needs?.options.filter((o) => o.kind === 'approval' || o.kind === 'emailApproval') ?? []
if (needs) {
return (
<div className="add-form">
<p>Steam wants to check that this is you.</p>
{approvals.map((o) => (
<p key={o.kind} className="small muted">
{o.kind === 'approval'
? `Approve the sign-in in the Steam app on your phone${o.hint ? ` (${o.hint})` : ''}.`
: `Approve the sign-in from the email Steam sent${o.hint ? ` to ${o.hint}` : ''}.`}
</p>
))}
{takesCode && (
<label className="field">
<span className="small muted">
{needs.options.some((o) => o.kind === 'deviceCode')
? 'Code from your Steam Guard authenticator'
: 'Code Steam emailed you'}
</span>
<input
className="text-field"
autoFocus
autoComplete="off"
spellCheck={false}
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
onKeyDown={(e) => {
if (e.key === 'Enter') send()
}}
/>
</label>
)}
{needs.error && (
<div className="warning-note">
<Icon name="warning" size={16} />
<span>{needs.error}</span>
</div>
)}
{detail && <p className="small muted">{detail}…</p>}
<div className="field-row">
{takesCode && (
<button type="button" className="button primary" disabled={!code.trim()} onClick={send}>
Submit code
</button>
)}
<button type="button" className="button" onClick={cancel}>
Cancel sign-in
</button>
</div>
</div>
)
}
return (
<div className="add-form">
<p className="small muted">
Sign in with your Steam account name — the one you type at the Steam login, not the
display name your friends see. moho signs in as the Steam mobile app's chat, so it runs
beside the Steam launcher without signing it out. Your password goes to Steam and is not
kept; moho keeps only the token Steam hands back.
</p>
<TorSwitch service="steam" on={useTor} onChange={setUseTor} />
<div className="field-row">
<label className="field">
<span className="small muted">Account name</span>
<input
className="text-field"
autoComplete="off"
spellCheck={false}
value={username}
disabled={busy}
onChange={(e) => setUsername(e.target.value)}
/>
</label>
<label className="field">
<span className="small muted">Password</span>
<input
className="text-field"
type="password"
value={password}
disabled={busy}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && username.trim() && password && !busy) begin()
}}
/>
</label>
</div>
<div className="field-row">
<button
type="button"
className="button primary"
disabled={busy || !username.trim() || !password}
onClick={begin}
>
{busy ? 'Signing in…' : 'Sign in'}
</button>
<button
type="button"
className="button subtle"
disabled={busy}
title="Sign in by scanning a code with the Steam app on your phone, with no password typed here"
onClick={beginQr}
>
Sign in with a QR code
</button>
{busy && (
<button type="button" className="button" onClick={cancel}>
Cancel
</button>
)}
</div>
{busy && detail && <p className="small muted">{detail}…</p>}
</div>
)
}
/**
* Adding a Twitch account: sign in, or only read.
*
* Twitch's chat is public, so an account that never signs in still reads every
* channel it is pointed at - the same choice Kick's form offers. Signing in is
* what buys sending, the channels you follow and the emotes you can use.
*/
function TwitchForm({ onDone }: { onDone: () => void }): JSX.Element {
const store = useStore()
const [useTor, setUseTor] = useState(false)
const [busy, setBusy] = useState(false)
const readOnly = (): void => {
setBusy(true)
void window.moho
.rpc('addTwitchAccount', { useTor })
.then(() => {
void store.refreshAccounts()
onDone()
})
.catch((e: Error) => store.toast('error', e.message))
.finally(() => setBusy(false))
}
return (
<div className="add-form">
<TorSwitch service="twitch" on={useTor} onChange={setUseTor} />
<TwitchSignIn useTor={useTor} onDone={onDone}>
<button
type="button"
className="button subtle"
disabled={busy}
title="Read channels without an account. Twitch's chat is public; sending needs a sign-in."
onClick={readOnly}
>
Read without signing in
</button>
</TwitchSignIn>
</div>
)
}
/**
* What a Twitch account can be asked to do from its own card.
*
* Signing in (for one that only reads, or whose sign-in Twitch has stopped accepting; the new sign-in is a
* Twitch account of its own under the login that approved it, so an anonymous reader is left where it
* was), opening the channels the account follows.
*
* Video needs the website's own session, because Twitch's player takes only a token its own website made: the one
* moho gets for chat and the official API is refused there. It is not a second sign-in - one window signs in on
* twitch.tv and approves the code, and the session it saw on the way is kept with the account (see TwitchSignIn).
* It is the person's own website session, and says so - what it can do is what being signed in there can do - and
* it is what makes their Turbo or their subscriptions count, so ads they are not meant to see are not shown.
*/
function TwitchAccountTools({ account }: { account: Account }): JSX.Element {
const store = useStore()
const refused = account.state === 'auth_failed'
const [busy, setBusy] = useState('')
const run = (what: string, work: () => Promise<void>): void => {
setBusy(what)
void work()
.catch((e: Error) => store.toast('error', e.message))
.finally(() => setBusy(''))
}
const follows = (liveOnly: boolean): void =>
run('follows', async () => {
const r = await window.moho.rpc<{ count: number }>('syncTwitchFollows', { accountId: account.id, liveOnly })
store.toast('info', r.count === 0 ? 'You follow no channels that are on air right now' : `Opening ${r.count} channel${r.count === 1 ? '' : 's'}`)
})
return (
<div className="account-tools">
{(!account.hasPassword || refused) && (
<>
<p className="small muted">
{refused
? 'Twitch no longer accepts this sign-in. Signing in again keeps its channels and settings.'
: 'This account only reads. Sign in to send messages and to see the channels you follow.'}
</p>
<TwitchSignIn useTor={account.useTor} onDone={() => undefined} label={refused ? 'Sign in again' : 'Sign in'} />
</>
)}
{account.hasPassword && !refused && (
<div className="field-row">
<button type="button" className="button subtle" disabled={busy !== ''} onClick={() => follows(true)} title="Open the channels you follow that are streaming now">
Open followed channels that are live
</button>
<button type="button" className="button subtle" disabled={busy !== ''} onClick={() => follows(false)} title="Open everyone you follow, up to 200">
Open everyone I follow
</button>
</div>
)}
{account.hasPassword && !refused && (!account.moderation || !account.webSession) && (
<>
<p className="small muted">
This sign-in was made before {account.moderation ? 'video' : account.webSession ? 'moderation, whispers, shoutouts and blocking' : 'video, moderation, whispers, shoutouts and blocking'}{' '}
{account.moderation || account.webSession ? 'was' : 'were'} part of signing in. Signing in again adds {account.moderation || account.webSession ? 'it' : 'them'}, in the same one
window; nothing else changes.
</p>
<TwitchSignIn useTor={account.useTor} onDone={() => undefined} label="Sign in again" />
</>
)}
<p className="small muted">
{account.webSession
? 'Video plays as you: signed in on twitch.tv, so your Turbo or your subscriptions count and their ads are not shown.'
: 'Video plays without a twitch.tv sign-in, so Twitch puts ads in streams. Signing in again signs in on twitch.tv too, which lets your Turbo or your subscriptions count.'}
</p>
{account.webSession && (
<p className="small muted">
It is your twitch.tv login session, so what it can do is what being signed in there can do. Removing this account
forgets it here; to end it on Twitch, use Sign out everywhere in twitch.tv's security settings.
</p>
)}
</div>
)
}
/**
* Whether an account's connections go through Tor or a SOCKS5 proxy.
*

Some files were not shown because too many files have changed in this diff Show More