92 Commits
Author SHA1 Message Date
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
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
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
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
157 changed files with 13072 additions and 2370 deletions
+20
View File
@@ -270,6 +270,26 @@ 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
# 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
+108
View File
@@ -17,6 +17,114 @@ Each section has two parts:
that made it, including dependency updates
(`node scripts/changelog-deps.mjs <previous tag>` lists those).
## [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.0.0]
### Major features
+1 -1
Submodule nobilis updated: 637f296b21...f3f872349a
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "moho",
"version": "1.0.0-rc.2",
"version": "1.0.0-rc.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "moho",
"version": "1.0.0-rc.2",
"version": "1.0.0-rc.5",
"license": "GPL-3.0-or-later",
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
+7 -2
View File
@@ -1,6 +1,6 @@
{
"name": "moho",
"version": "1.0.0-rc.2",
"version": "1.0.0-rc.5",
"description": "Unified desktop chat client for IRC, Discord, Sneedchat and Matrix",
"author": "Salastil",
"license": "GPL-3.0-or-later",
@@ -21,12 +21,17 @@
"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",
"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"
},
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

+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)
+308
View File
@@ -0,0 +1,308 @@
/**
* 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 = [] } = {}) {
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'] : []), ...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 }
}
/**
* 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')
}
+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)
+18 -2
View File
@@ -18,8 +18,16 @@ 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"
# Through Cloudflare by default, which refuses a request body over 100 MB. The
# files that are bigger go to Gitea directly across the network, which is what
# MIRROR_GITEA is for - the same instance, reached by its own address:
#
# MIRROR_GITEA=http://192.168.1.212:8991/api/v1 MIRROR_MAX_BYTES=1000000000 \
# scripts/mirror-release.sh v1.0.0
GITEA="${MIRROR_GITEA:-https://git.salastil.com/api/v1}"
TOKEN="$(cat "$HOME/.config/gitea/token")"
# What Cloudflare in front of Gitea will take in one request.
MAX_UPLOAD="${MIRROR_MAX_BYTES:-100000000}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
@@ -33,7 +41,7 @@ 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
if id="$(gitea GET "/repos/$repo/releases/tags/$TAG" 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' 2>/dev/null)"; then
gitea PATCH "/repos/$repo/releases/$id" -H 'Content-Type: application/json' -d "$body" >/dev/null
for asset in $(gitea GET "/repos/$repo/releases/$id/assets" | python3 -c 'import json,sys; print(" ".join(str(a["id"]) for a in json.load(sys.stdin)))'); do
gitea DELETE "/repos/$repo/releases/$id/assets/$asset" >/dev/null
@@ -42,6 +50,14 @@ gitea_release() { # repo title notes-file prerelease file...
id="$(gitea POST "/repos/$repo/releases" -H 'Content-Type: application/json' -d "$body" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
fi
for f in "$@"; do
# Gitea is behind Cloudflare, which refuses a request body over 100 MB (a
# 413, or a 502 when the connection is cut mid-upload). A file over that
# stays on GitHub only, said plainly rather than failing the whole mirror
# and leaving everything after it - nobilis's releases - undone.
if [ "$(stat -c %s "$f")" -gt "$MAX_UPLOAD" ]; then
echo " SKIPPED $(basename "$f") ($(( $(stat -c %s "$f") / 1000000 )) MB) - over Cloudflare's ${MAX_UPLOAD} byte limit; it is on GitHub only" >&2
continue
fi
gitea POST "/repos/$repo/releases/$id/assets?name=$(basename "$f")" -F "attachment=@$f" >/dev/null
echo " uploaded $(basename "$f") -> Gitea $repo"
done
+101
View File
@@ -0,0 +1,101 @@
#!/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'], 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 }
})
})()`)
await until(page, `!!document.querySelector('.new-divider')`, 'the New line')
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}`)
} finally {
await stop()
irc.close()
}
console.log(failures ? `${failures} FAILED` : 'all passed')
process.exit(failures ? 1 : 0)
+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)
+13 -188
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. */
@@ -186,20 +67,13 @@ const rightClickInCorner = (selector, text) => `(() => {
return true
})()`
const click = (selector, text) => `(() => {
const el = [...document.querySelectorAll(${JSON.stringify(selector)})]
.find((e) => ${JSON.stringify(text ?? '')} === '' || (e.innerText + ' ' + (e.title || '')).includes(${JSON.stringify(text ?? '')}))
if (!el) return false
el.click()
return true
})()`
const SCENARIOS = [
{ name: 'emoji picker', open: click('button[title="Emoji"]') },
{ name: 'channel list menu, opened in the corner', open: rightClickInCorner('.buffer-row', '#ui') },
{ name: 'message menu, opened in the corner', open: rightClickInCorner('.message-row', 'line 12') },
{ name: 'member menu, opened in the corner', open: rightClickInCorner('.nick-row', 'alice') },
{ name: 'mentions inbox', open: click('button[title="Mentions"]') }
{ name: 'mentions inbox', open: click('button[title="Mentions"]') },
{ name: 'status popout', open: click('.user-identity-button') }
]
async function closePopups(page) {
@@ -217,58 +91,21 @@ async function closePopups(page) {
}
async function main() {
const irc = await fakeIrc()
const ircPort = irc.address().port
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'moho-ui-'))
const run = path.join(scratch, 'run')
fs.mkdirSync(run, { mode: 0o700 })
const port = await freePort()
// A CI runner's kernel refuses Chromium's sandbox (Ubuntu 24.04 restricts
// the unprivileged namespaces it needs); there it runs without, as an
// ordinary Chromium in a container does.
const flags = [`--remote-debugging-port=${port}`, ...(process.env.CI ? ['--no-sandbox'] : [])]
const app = spawn(electronPath, [root, ...flags], {
cwd: root,
detached: true,
stdio: process.env.MOHO_UI_VERBOSE ? 'inherit' : 'ignore',
env: {
...process.env,
HOME: scratch,
XDG_CONFIG_HOME: path.join(scratch, 'config'),
XDG_CACHE_HOME: path.join(scratch, 'cache'),
XDG_DATA_HOME: path.join(scratch, 'data'),
XDG_STATE_HOME: path.join(scratch, 'state'),
XDG_RUNTIME_DIR: run,
// The runtime directory is moved so the daemon's socket is this run's
// own - which also moves where a Wayland client looks for the
// compositor. An absolute path keeps it pointing at the real one.
...(process.env.WAYLAND_DISPLAY && process.env.XDG_RUNTIME_DIR && !path.isAbsolute(process.env.WAYLAND_DISPLAY)
? { WAYLAND_DISPLAY: path.join(process.env.XDG_RUNTIME_DIR, process.env.WAYLAND_DISPLAY) }
: {})
const irc = await fakeIrc({
people: PEOPLE,
onJoin: (channel, _nick, send) => {
for (let i = 0; i < 12; i++) {
const who = PEOPLE[i % PEOPLE.length]
send(`:${who}!u@h PRIVMSG ${channel} :line ${i + 1} from ${who}, long enough to wrap on a narrow window`)
}
}
})
const failures = []
let checked = 0
let page
const { page, stop } = await launchApp()
try {
page = cdp((await pageTarget(port)).webSocketDebuggerUrl)
await until(page, 'window.moho && document.querySelector("#root > *")', 'the window to load')
// An account on the fake server, with a channel to open popups over.
await until(page, 'window.moho.rpc("listAccounts", {}).then(() => true)', 'the daemon')
await page.evaluate(
`window.moho.rpc("addAccount", { nick: "checker", host: "localhost", port: ${ircPort}, ssl: false, autojoin: "#ui" })`
)
await until(page, 'window.moho.rpc("listBuffers", {}).then((b) => b.some((x) => x.id.endsWith("|#ui")))', 'the channel')
await page.call('Page.reload')
await sleep(1000)
await until(page, 'document.querySelector("[title*=\\"localhost\\"]")', 'the account in the rail')
await page.evaluate(click('[title*="localhost"]'))
await until(page, `[...document.querySelectorAll(".buffer-row")].some((r) => (r.title || r.innerText).includes("#ui"))`, 'the channel row')
await page.evaluate(click('.buffer-row', '#ui'))
await until(page, `[...document.querySelectorAll(".message-row")].some((r) => r.innerText.includes("line 12"))`, 'the messages')
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'line 12' })
for (const size of SIZES) {
await page.call('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: false })
@@ -302,20 +139,8 @@ async function main() {
}
}
} finally {
page?.close()
try {
process.kill(-app.pid, 'SIGTERM')
} catch {
/* already gone */
}
await sleep(1500)
try {
process.kill(-app.pid, 'SIGKILL')
} catch {
/* already gone */
}
await stop()
irc.close()
fs.rmSync(scratch, { recursive: true, force: true })
}
if (failures.length > 0) {
+1534
View File
File diff suppressed because it is too large Load Diff
+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)
}
}
+314 -68
View File
@@ -6,30 +6,36 @@ import {
app,
BrowserWindow,
clipboard,
ClipboardItem,
dialog,
globalShortcut,
ipcMain,
nativeImage,
nativeTheme,
net,
powerMonitor,
session,
Menu,
screen,
shell,
Tray
Tray,
type MenuItemConstructorOptions
} from 'electron'
import { electronApp, is, optimizer } from '@electron-toolkit/utils'
import { defaultSocketPath, NobilisClient } from './nobilis-client'
import { NobilisProcess } from './nobilis-process'
import { Prefs } from './prefs'
import { Notifier } from './notifications'
import { IdleWatcher } from './idle'
import { NOTHING_WAITING, trayIconFile, trayMenu, trayTooltip, type TrayState, type TrayStatus } from './tray'
import { browserLogin, LOGIN_FLOWS } from './browser-login'
import { EDIT_ACTIONS, IPC, POPOUT_FLAG, type EditAction, type EditMenuRequest, type PopoutState } from '../shared/ipc'
import { EDIT_ACTIONS, IPC, POPOUT_FLAG, UI_SHOTS_FLAG, type EditAction, type EditMenuRequest, type PopoutState } from '../shared/ipc'
import { clearnetLinks } from '../shared/clearnet'
import { readCapped, pictureNamedIn } from './imagepage'
import { DEEP_LINK_SCHEMES, isDeepLink } from '../shared/deeplink'
import { allowPickedFile, allowRoot, installMediaHandler, registerMediaScheme } from './media-protocol'
import { installScreenShare } from './screenshare'
import { defaultDownloadDir, saveMedia } from './downloads'
import { defaultDownloadDir, readMedia, saveMedia } from './downloads'
import type { Buffer as ChatBuffer } from '../shared/wire'
import { log } from './log'
import { applyPolicy, applyTunnel } from './tunnel'
@@ -130,6 +136,7 @@ let prefs: Prefs
let nobilis: NobilisProcess
let client: NobilisClient
let notifier: Notifier
let idleWatcher: IdleWatcher
let registeredHotkey: string | null = null
/**
@@ -189,6 +196,52 @@ function sniffImage(head: Buffer): string | null {
}
/** Every window with a renderer in it, main and popped-out conversations alike. */
/**
* Sets a preference and tells whoever holds a copy of it.
*
* `except` is the window that made the change, which already knows and has
* drawn it. The tray's own entries come through here too, so a setting flipped
* from the tray moves its switch in Settings.
*/
function applyPref(key: string, value: unknown, except?: Electron.WebContents): void {
prefs.set(key, value)
// The badge and the flash are decided from these as they stand.
if (key === 'notifications.badge' || key === 'notifications.flash') updateTray(lastTray)
// Every window keeps its own cache of these, so a setting changed in one
// is stale in the others until they are told. That is not cosmetic once
// there are several windows: muting a conversation, or switching the log
// to compact, would apply to whichever window happened to be asked.
for (const w of liveWindows()) {
if (w.webContents !== except) w.webContents.send(IPC.prefsChanged, key, value)
}
// Pins and mutes feed the tray/notification rules, which live here.
if (key === 'pinnedBuffers' || key === 'mutedBuffers') notifier.publish()
if (key === 'hotkey.toggle') applyHotkey(String(value))
if (key === 'presence.autoIdle' || key === 'presence.autoIdleMinutes') idleWatcher?.evaluate()
}
/**
* Brings up a conversation: a click on its notification, or on the tray's
* "open" entry.
*
* If it has a window of its own, that window is what the click asked for.
* Raising the main one and switching it would move somebody away from whatever
* they were reading in order to show them a conversation that was already open
* on their screen.
*/
function openConversation(bufferId: string): void {
const popout = popouts.get(bufferId)
if (popout && !popout.isDestroyed()) {
if (popout.isMinimized()) popout.restore()
popout.show()
popout.focus()
return
}
mainWindow?.show()
mainWindow?.focus()
mainWindow?.webContents.send(IPC.activateBuffer, bufferId)
}
function liveWindows(): BrowserWindow[] {
const all = mainWindow ? [mainWindow, ...popouts.values()] : [...popouts.values()]
return all.filter((w) => !w.isDestroyed())
@@ -225,6 +278,47 @@ function callerWindow(event: Electron.IpcMainInvokeEvent): BrowserWindow | null
* The gap between them is a fraction of a second of the background colour,
* which is a far better failure than no window at all.
*/
/**
* What to do when a window's page dies under it.
*
* Nothing used to be done, and nothing said: the renderer process would be
* gone - out of memory, killed, crashed - and the window stayed on screen as an
* empty shell with no page in it, which is what "the window just went
* invisible" was. Now the reason is written to the log, where it outlives the
* process, and the page is loaded again so the window comes back with the
* conversation list and everything else intact: the daemon is a separate
* process and kept running all along.
*
* Not when the app is quitting, and not more than a few times a minute: a page that dies as it loads would otherwise be reloaded for
* ever.
*/
function recoverFromCrash(win: BrowserWindow, name: string): void {
const recent: number[] = []
win.webContents.on('render-process-gone', (_e, details) => {
log.error(
`[window] ${name}: the page process is gone - ${details.reason} (exit code ${details.exitCode}) at ${win.isDestroyed() ? 'a destroyed window' : win.webContents.getURL()}`
)
// A page that exits cleanly while its window is still there is no window
// being closed - that would have taken the window with it - and left the
// window empty just the same. Only the app quitting is let go.
if (win.isDestroyed() || quitting) return
const now = Date.now()
while (recent.length > 0 && now - recent[0] > 60_000) recent.shift()
recent.push(now)
if (recent.length > 3) {
log.error(`[window] ${name}: it keeps dying; leaving it`)
return
}
setTimeout(() => {
// Loaded afresh rather than reloaded: a page that had navigated away
// would otherwise reload wherever it had got to.
if (!win.isDestroyed()) loadRenderer(win)
}, 500)
})
win.webContents.on('unresponsive', () => log.warn(`[window] ${name}: the page has stopped responding`))
win.webContents.on('responsive', () => log.warn(`[window] ${name}: the page is responding again`))
}
function revealOnce(win: BrowserWindow, focus = false): void {
let done = false
const reveal = (): void => {
@@ -265,6 +359,9 @@ function wireEditMenu(win: BrowserWindow): void {
})
}
/** The screenshot harness's flag, passed on to every window when it asked for it. */
const shotsArguments = process.env.MOHO_UI_SHOTS === '1' ? [UI_SHOTS_FLAG] : []
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1100,
@@ -290,11 +387,13 @@ function createWindow(): void {
// is allowed, and reads its popout flag from process.argv, which stays
// readable. See the note in whenReady for the hosts that cannot honour
// this.
sandbox: true
sandbox: true,
additionalArguments: shotsArguments
}
})
revealOnce(mainWindow)
recoverFromCrash(mainWindow, 'main')
wireEditMenu(mainWindow)
// A link that arrived before this window existed - from the click that
@@ -306,6 +405,9 @@ function createWindow(): void {
pendingDeepLink = null
mainWindow?.webContents.send(IPC.deepLink, url)
})
// The tray's first action is to show or hide this window, and says which.
mainWindow.on('show', refreshTrayMenu)
mainWindow.on('hide', refreshTrayMenu)
mainWindow.on('maximize', () => mainWindow?.webContents.send(IPC.maximizeChanged, true))
mainWindow.on('unmaximize', () => mainWindow?.webContents.send(IPC.maximizeChanged, false))
mainWindow.on('closed', () => {
@@ -459,7 +561,7 @@ function openPopout(bufferId: string, title?: string): void {
nodeIntegration: false,
// As the main window - a popout draws the same conversations.
sandbox: true,
additionalArguments: [`${POPOUT_FLAG}${bufferId}`]
additionalArguments: [`${POPOUT_FLAG}${bufferId}`, ...shotsArguments]
}
})
popouts.set(bufferId, win)
@@ -467,6 +569,7 @@ function openPopout(bufferId: string, title?: string): void {
// Focused as well as shown: unlike the main window at startup, this one was
// asked for just now.
revealOnce(win, true)
recoverFromCrash(win, `popout ${bufferId}`)
wireEditMenu(win)
// Sent to this window rather than broadcast: every window draws its own
@@ -514,46 +617,112 @@ function toggleWindow(): void {
}
}
function trayIcon(hasAlert: boolean): Electron.NativeImage {
const file = resourcePath('icons', hasAlert ? 'tray-alert.png' : 'tray.png')
const img = nativeImage.createFromPath(file)
function trayIcon(unread: number): Electron.NativeImage {
lastTrayIcon = trayIconFile(nativeTheme.shouldUseDarkColors, unread)
const img = nativeImage.createFromPath(resourcePath('icons', lastTrayIcon))
// A missing icon file would otherwise produce an invisible tray entry the
// user can never click; fall back to the app icon so the entry still exists.
return img.isEmpty() ? nativeImage.createFromPath(resourcePath('icons', 'moho.png')) : img
}
function createTray(): void {
tray = new Tray(trayIcon(false))
tray.setToolTip('moho')
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: 'Show/hide', click: toggleWindow },
{ label: 'Restart daemon', click: () => nobilis.restart() },
{
label: 'Stop daemon',
// Deliberately separate from Quit: stopping the daemon disconnects
// every account, which is worth asking for explicitly rather than
// making it a side effect of closing a window.
click: () => {
void stopDaemon()
}
},
{ type: 'separator' },
{
label: 'Quit',
click: () => {
app.quit()
}
}
])
)
tray.on('click', toggleWindow)
/** What the window last said each connected account's status is. */
let accountStatus: Record<string, string> = {}
/** The status the menu shows as checked, and the ones it offers: what the connected accounts can be set to. */
let trayStatus: TrayStatus | null = null
let trayOffered: TrayStatus[] = []
let trayInvisibleName = 'Invisible'
const isTrayStatus = (v: unknown): v is TrayStatus => v === 'online' || v === 'idle' || v === 'dnd' || v === 'invisible'
/**
* Asks the window to do something, showing it first: the tray's entries that
* are about moho itself - settings, and a status set across every account -
* are the window's to carry out, since it is what holds the accounts.
*/
function tellWindow(command: 'settings' | 'status', arg?: string): void {
if (!mainWindow) createWindow()
if (command === 'settings') {
mainWindow?.show()
mainWindow?.focus()
}
mainWindow?.webContents.send(IPC.trayCommand, command, arg)
}
function updateTray(unreadCount: number, hasAlert: boolean): void {
/**
* Starts moho again, daemon and all.
*
* Quitting already takes the daemon down cleanly - QUIT to every network, then
* gone - and the new process starts its own, so restarting is quit-and-relaunch.
* An AppImage has to be relaunched by the path it was started from: its
* executable is a temporary mount that disappears when it exits.
*/
function restartMoho(): void {
const appImage = process.env.APPIMAGE
app.relaunch(appImage ? { execPath: appImage, args: process.argv.slice(1) } : undefined)
app.quit()
}
/** What the tray last offered and showed, kept for the UI harness to read. */
let lastTrayMenu: MenuItemConstructorOptions[] = []
let lastTrayIcon = ''
let lastTrayTooltip = 'moho'
function refreshTrayMenu(): void {
if (!tray) return
tray.setImage(trayIcon(hasAlert))
tray.setToolTip(unreadCount > 0 ? `moho - ${unreadCount} unread` : 'moho')
lastTrayMenu = trayMenu(
{
state: lastTray,
windowVisible: !!mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible(),
status: trayStatus,
offered: trayOffered,
invisibleName: trayInvisibleName
},
{
toggleWindow,
openLatest: () => {
const id = lastTray.latest ?? notifier.latestUnread()
if (id) openConversation(id)
},
openSettings: () => tellWindow('settings'),
setStatus: (status) => {
trayStatus = status
tellWindow('status', status)
},
restart: restartMoho,
quit: () => app.quit()
}
)
tray.setContextMenu(Menu.buildFromTemplate(lastTrayMenu))
}
function createTray(): void {
tray = new Tray(trayIcon(0))
tray.setToolTip('moho')
refreshTrayMenu()
tray.on('click', toggleWindow)
// The icon is drawn for the panel it sits on, so it is drawn again when the
// system changes colour scheme.
nativeTheme.on('updated', () => updateTray(lastTray))
}
/** What the notifier last said, kept so a change of setting can redraw without waiting for the next message. */
let lastTray: TrayState = NOTHING_WAITING
function updateTray(state: TrayState): void {
lastTray = state
// The unread badge: the tray's count and the launcher's count where the
// desktop has one. Off, the tray is just the tray.
const badge = prefs.get<boolean>('notifications.badge', true)
app.setBadgeCount(badge ? state.unread : 0)
// The taskbar flash: asking for attention while the window is not in front.
const flash = prefs.get<boolean>('notifications.flash', true)
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.flashFrame(flash && state.unread > 0 && trayStatus !== 'dnd' && !mainWindow.isFocused())
}
refreshTrayMenu()
if (!tray) return
tray.setImage(trayIcon(badge ? state.unread : 0))
lastTrayTooltip = trayTooltip(state, badge)
tray.setToolTip(lastTrayTooltip)
send(IPC.link, client.linkUp)
}
@@ -585,6 +754,12 @@ function applyHotkey(accelerator: string): void {
}
}
app.on('child-process-gone', (_e, details) => {
// The graphics process or a utility: a window that stops painting with its
// page still alive is this.
log.error(`[process] ${details.type} process gone - ${details.reason} (exit code ${details.exitCode})`)
})
function wireIpc(): void {
ipcMain.handle(IPC.rpc, async (_e, method: string, params: Record<string, unknown>) => {
try {
@@ -598,19 +773,27 @@ function wireIpc(): void {
})
ipcMain.handle(IPC.prefsGetAll, () => prefs.all())
ipcMain.handle(IPC.prefsSet, (e, key: string, value: unknown) => {
prefs.set(key, value)
// Every window keeps its own cache of these, so a setting changed in one
// is stale in the others until they are told. That is not cosmetic once
// there are several windows: muting a conversation, or switching the log
// to compact, would apply to whichever window happened to be asked.
// Not echoed to the window that set it - it already knows, and has drawn.
for (const w of liveWindows()) {
if (w.webContents !== e.sender) w.webContents.send(IPC.prefsChanged, key, value)
}
// Pins and mutes feed the tray/notification rules, which live here.
if (key === 'pinnedBuffers' || key === 'mutedBuffers') notifier.publish()
if (key === 'hotkey.toggle') applyHotkey(String(value))
ipcMain.handle(IPC.prefsSet, (e, key: string, value: unknown) => applyPref(key, value, e.sender))
// The window says what status its accounts are at, for the tray's menu to check.
ipcMain.on(IPC.trayStatus, (_e, statuses: Record<string, string>, offered: string[], shown: string | null, invisibleName: string) => {
accountStatus = statuses && typeof statuses === 'object' ? statuses : {}
trayOffered = Array.isArray(offered) ? offered.filter(isTrayStatus) : []
trayStatus = isTrayStatus(shown) ? shown : null
trayInvisibleName = invisibleName === 'Offline' ? 'Offline' : 'Invisible'
refreshTrayMenu()
// Do not disturb is asking for quiet, including from the taskbar.
updateTray(lastTray)
})
ipcMain.on(IPC.activity, () => idleWatcher.activity())
// What is waiting, as the window counts it. The tray's number is the window's
// own: counted here from notifications it drifted from what the window showed.
ipcMain.on(IPC.trayUnread, (_e, count: { dms: number; mentions: number; latest: string | null }) => {
const dms = Math.max(0, Number(count?.dms) || 0)
const mentions = Math.max(0, Number(count?.mentions) || 0)
updateTray({ unread: dms + mentions, dms, mentions, latest: typeof count?.latest === 'string' ? count.latest : null })
})
ipcMain.handle(IPC.markBufferRead, (_e, bufferId: string) => notifier.clear(bufferId))
@@ -849,6 +1032,40 @@ function wireIpc(): void {
if (typeof text === 'string' && text.length > 0) clipboard.writeText(text)
})
// A picture on the clipboard, for pasting into another app. Taken from the
// page's own pixels when it could read them (that also covers formats the
// clipboard does not take as they are, such as WebP), and otherwise from
// wherever the picture lives.
ipcMain.handle(IPC.copyImage, async (_e, source: string, dataUrl?: string) => {
try {
const img =
typeof dataUrl === 'string' && dataUrl.startsWith('data:image/')
? nativeImage.createFromDataURL(dataUrl)
: nativeImage.createFromBuffer(
Buffer.from(
await readMedia(String(source), async (url) => {
const res = await net.fetch(url)
return { ok: res.ok, status: res.status, bytes: async () => new Uint8Array(await res.arrayBuffer()) }
})
)
)
if (img.isEmpty()) return { error: "this kind of picture can't be copied" }
await clipboard.write([new ClipboardItem({ 'image/png': new Blob([img.toPNG()], { type: 'image/png' }) })])
return {}
} catch (e) {
return { error: (e as Error).message }
}
})
ipcMain.handle(IPC.fileSize, async (_e, file: string) => {
try {
const st = await fsp.stat(String(file))
return st.isFile() ? st.size : null
} catch {
return null
}
})
ipcMain.handle(IPC.restartDaemon, () => nobilis.restart())
ipcMain.handle(IPC.daemonStatus, () => ({
binaryPath: nobilis.binaryPath,
@@ -1032,32 +1249,38 @@ app.whenReady().then(() => {
nobilis = new NobilisProcess()
client = new NobilisClient()
// Going idle when the person steps away. What it says, and to whom, is
// decided in the daemon - which accounts are online, which are in a call -
// so all this says is whether they are here.
idleWatcher = new IdleWatcher({
now: () => Date.now(),
systemIdleSeconds: () => powerMonitor.getSystemIdleTime(),
enabled: () => prefs.get<boolean>('presence.autoIdle', true),
thresholdMs: () => (Number(prefs.get<unknown>('presence.autoIdleMinutes', '10')) || 10) * 60_000,
onChange: (idle) => void client.request('setUserIdle', { idle }).catch(() => {})
})
setInterval(() => idleWatcher.evaluate(), 10_000).unref()
app.on('browser-window-focus', () => idleWatcher.activity())
powerMonitor.on('lock-screen', () => idleWatcher.lock())
powerMonitor.on('suspend', () => idleWatcher.lock())
powerMonitor.on('unlock-screen', () => idleWatcher.unlock())
powerMonitor.on('resume', () => idleWatcher.unlock())
notifier = new Notifier(
prefs,
updateTray,
(bufferId) => {
// If this conversation has a window of its own, that window is what the
// click asked for. Raising the main one and switching it would move
// somebody away from whatever they were reading in order to show them a
// conversation that was already open on their screen.
const popout = popouts.get(bufferId)
if (popout && !popout.isDestroyed()) {
if (popout.isMinimized()) popout.restore()
popout.show()
popout.focus()
return
}
mainWindow?.show()
mainWindow?.focus()
mainWindow?.webContents.send(IPC.activateBuffer, bufferId)
},
// The tray's number is the window's, not this tally's - see IPC.trayUnread.
() => {},
openConversation,
() => mainWindow?.webContents ?? null,
(bufferId) => popoutState().watched.includes(bufferId)
(bufferId) => popoutState().watched.includes(bufferId),
(accountId) => accountStatus[accountId] === 'dnd'
)
client.on('link', (up) => {
send(IPC.link, up)
if (!up) return
// A daemon that has just started knows nothing of this.
if (idleWatcher.isIdle()) void client.request('setUserIdle', { idle: true }).catch(() => {})
// Before anything is fetched for the window: whether its own traffic goes
// through Tor or the proxy is the daemon's setting, asked of it here.
void applyTunnel((method, params) => client.request(method, params))
@@ -1099,6 +1322,29 @@ app.whenReady().then(() => {
createWindow()
createTray()
applyHotkey(prefs.get<string>('hotkey.toggle'))
// For the UI harness, which has no way to look inside a tray: what it shows
// and offers, and the entries themselves to be clicked. Only when it asks.
if (process.env.MOHO_UI_SHOTS === '1') {
;(globalThis as Record<string, unknown>).__mohoTray = {
update: updateTray,
idle: (v: boolean | null) => idleWatcher.forceForTesting(v),
lastActivity: () => idleWatcher.lastActivityAt(),
state: () => lastTray,
pref: (key: string, value: unknown) => applyPref(key, value),
icon: () => lastTrayIcon,
tooltip: () => lastTrayTooltip,
menu: () => lastTrayMenu,
click: (path: string[]) => {
let items: MenuItemConstructorOptions[] = lastTrayMenu
let item: MenuItemConstructorOptions | undefined
for (const label of path) {
item = items.find((i) => i.label === label || (i.label ?? '').startsWith(label))
items = (item?.submenu as MenuItemConstructorOptions[]) ?? []
}
item?.click?.({ checked: !item.checked } as never, undefined, {} as never)
}
}
}
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
+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 }
]
}
+55 -2
View File
@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer, webUtils } from 'electron'
import { IPC, POPOUT_FLAG, type EditAction, type EditMenuRequest, type PopoutState, type ScreenSource } from '../shared/ipc'
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron'
import { IPC, POPOUT_FLAG, UI_SHOTS_FLAG, type EditAction, type EditMenuRequest, type PopoutState, type ScreenSource } from '../shared/ipc'
import type { NobilisEvent } from '../shared/wire'
/**
@@ -83,6 +83,27 @@ const api = {
return () => ipcRenderer.off(IPC.activateBuffer, handler)
},
/** The tray menu asking this window to open Settings, or to set a status on every account. */
onTrayCommand(cb: (command: 'settings' | 'status', arg?: string) => void): () => void {
const handler = (_e: unknown, command: 'settings' | 'status', arg?: string): void => cb(command, arg)
ipcRenderer.on(IPC.trayCommand, handler)
return () => ipcRenderer.off(IPC.trayCommand, handler)
},
/**
* Says what status each connected account is at, which statuses any of them
* can be set to, and the one to show as checked: for the tray menu, and for
* main to stay quiet for an account on Do not disturb.
*/
setAccountStatuses(statuses: Record<string, string>, offered: string[], shown: string | null, invisibleName: string): void {
ipcRenderer.send(IPC.trayStatus, statuses, offered, shown, invisibleName)
},
/** Says what is waiting, counted the way the rail counts it, for the tray's number. */
setTrayUnread(count: { dms: number; mentions: number; latest: string | null }): void {
ipcRenderer.send(IPC.trayUnread, count)
},
onMaximizeChange(cb: (maximized: boolean) => void): () => void {
const handler = (_e: unknown, v: boolean): void => cb(v)
ipcRenderer.on(IPC.maximizeChanged, handler)
@@ -121,6 +142,8 @@ const api = {
ipcRenderer.invoke(IPC.resolveImagePage, url),
/** Puts a line of text on the system clipboard. */
/** How large everything is drawn: 1 is as designed. */
setZoom: (factor: number): void => webFrame.setZoomFactor(factor),
copyText: (text: string): Promise<void> => ipcRenderer.invoke(IPC.writeClipboardText, text),
/**
* Where a dropped file actually lives.
@@ -156,6 +179,14 @@ const api = {
importGroupIcon: (groupId: string): Promise<{ path?: string; error?: string }> =>
ipcRenderer.invoke(IPC.importGroupIcon, groupId),
defaultDownloadDir: (): Promise<string> => ipcRenderer.invoke(IPC.defaultDownloadDir),
/**
* Puts a picture on the system clipboard: from its pixels where the page
* could read them, otherwise from where it lives.
*/
/** How big a file is, for the tray it is staged in; null for what cannot be read. */
fileSize: (path: string): Promise<number | null> => ipcRenderer.invoke(IPC.fileSize, path),
copyImage: (source: string, dataUrl?: string): Promise<{ error?: string }> =>
ipcRenderer.invoke(IPC.copyImage, source, dataUrl),
downloadMedia: (source: string, filename?: string): Promise<{ path?: string; error?: string }> =>
ipcRenderer.invoke(IPC.downloadMedia, source, filename),
readClipboardImage: (): Promise<string | null> => ipcRenderer.invoke(IPC.readClipboardImage),
@@ -182,6 +213,9 @@ const api = {
ipcRenderer.invoke(IPC.markBufferRead, bufferId),
/** Conversations in windows of their own. */
/** Whether the screenshot harness started this window - see UI_SHOTS_FLAG. */
uiShots: process.argv.includes(UI_SHOTS_FLAG),
popout: {
/** Set only in a popped-out window, naming the conversation it shows. */
bufferId: popoutBufferId,
@@ -199,6 +233,25 @@ const api = {
}
}
/**
* Tells main that somebody is using this window.
*
* Only that it happened - the event's kind and what it touched are not looked
* at, and nothing about them is sent. It is what lets moho know the person has
* stepped away, which is the one thing it wants to know, and it is sent at most
* every few seconds however much is going on.
*/
let lastActivityPing = 0
const noteActivity = (): void => {
const now = Date.now()
if (now - lastActivityPing < 5000) return
lastActivityPing = now
ipcRenderer.send(IPC.activity)
}
for (const kind of ['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart']) {
;(globalThis as unknown as EventTarget).addEventListener(kind, noteActivity, { capture: true, passive: true })
}
contextBridge.exposeInMainWorld('moho', api)
export type MohoApi = typeof api
+60 -16
View File
@@ -3,10 +3,12 @@ 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 { trayCount } from './lib/traycount'
import { Composer } from './components/Composer'
import { MembershipGate } from './components/MembershipGate'
import { NickList } from './components/NickList'
@@ -17,12 +19,11 @@ import { ProfileCard } from './components/ProfileCard'
import { ConversationTools } from './components/ConversationTools'
import { AccountsPanel } from './components/AccountsPanel'
import { SettingsPanel } from './components/settings/SettingsPanel'
import { DownloadsPanel } from './components/DownloadsPanel'
import { JoinPanel } from './components/JoinPanel'
import { PeekBar, PeekView } from './components/PeekView'
import { Toasts } from './components/Toasts'
import { IncomingCallPanel } from './components/IncomingCallPanel'
import { CallAudio, CallStage, IncomingMatrixCall, ScreenPicker } from './components/CallStage'
import { CallAudio, CallStage, ScreenPicker } from './components/CallStage'
import { CallWindowHost } from './components/stage/CallWindowHost'
import { StreamStage, StreamWindowHost } from './components/StreamStage'
import { EventCreatePanel, EventsPane } from './components/DiscordEvents'
@@ -33,12 +34,23 @@ 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 { 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()
const [booted, setBooted] = useState(false)
@@ -64,7 +76,36 @@ export default function App(): JSX.Element {
const streamElsewhere = !!watching && (watching.bufferId !== activeBufferId || activePanel !== '')
const [sidebarFolded, setSidebarFolded] = usePref<boolean>('ui.sidebarFolded', false)
// How the window looks, from the appearance settings: how large, how close
// together, whether things move, how the clock is written.
const [zoom] = usePref<number>('appearance.zoom', 1)
const [density] = usePref<string>('appearance.density', 'cozy')
const [reduceMotion] = usePref<boolean>('appearance.reduceMotion', false)
const [hourFormat] = usePref<string>('display.hourFormat', '24')
setHourFormat(hourFormat)
useEffect(() => window.moho.setZoom(zoom), [zoom])
useEffect(() => {
document.documentElement.dataset.density = density
if (reduceMotion) document.documentElement.dataset.reduceMotion = ''
else delete document.documentElement.dataset.reduceMotion
}, [density, reduceMotion])
const [userListFolded, setUserListFolded] = usePref<boolean>('ui.userListFolded', false)
// What the tray says is waiting, which is whatever the rail would add up to:
// the same rule, from the same lists, said to the main process whenever the
// answer changes. Told only when it has, since most changes to the buffers
// are not to what is waiting.
const [trayMuted] = usePref<string[]>('mutedBuffers', [])
const [trayHidden] = usePref<string[]>('hiddenBuffers', [])
const [trayMutedGroups] = usePref<string[]>('mutedGroups', [])
const lastTray = useRef('')
useEffect(() => {
const count = trayCount(allBuffers, trayMuted, trayHidden, trayMutedGroups)
const said = JSON.stringify(count)
if (said === lastTray.current) return
lastTray.current = said
window.moho.setTrayUnread(count)
}, [allBuffers, trayMuted, trayHidden, trayMutedGroups])
const [savedBufferId] = usePref<string>('ui.activeBufferId', '')
const [savedGroupId] = usePref<string>('ui.activeGroupId', '')
@@ -140,12 +181,8 @@ export default function App(): JSX.Element {
const headerTitle =
peeking
? `Looking · ${peek?.name || peek?.alias || ''}`
: activePanel === 'accounts'
? 'Accounts'
: activePanel === 'settings'
: activePanel === 'accounts' || activePanel === 'settings' || activePanel === 'downloads'
? 'Settings'
: activePanel === 'downloads'
? 'Downloads'
: activePanel === 'join'
? `Join · ${joinAccount?.displayName ?? ''}`
: onMentionsPage
@@ -210,8 +247,9 @@ export default function App(): JSX.Element {
{activePanel === '' && !onMentionsPage && !peeking && buffer?.kind === 'channel' && (
<IconButton
name={userListFolded ? 'group' : 'group_off'}
name="group"
title={userListFolded ? 'Show members' : 'Hide members'}
className={userListFolded ? undefined : 'active'}
onClick={() => setUserListFolded(!userListFolded)}
/>
)}
@@ -248,7 +286,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>
@@ -286,7 +325,6 @@ export default function App(): JSX.Element {
<IncomingCallPanel />
{/* A Matrix call rings here too, but is answered by this window rather
than by the daemon - the media is the window's. */}
<IncomingMatrixCall />
<ScreenPicker />
{/* A form a Discord bot asked for. Over everything, because it is the
answer to something just pressed and it expires. */}
@@ -314,6 +352,7 @@ export default function App(): JSX.Element {
<TransferPanel />
<FileDrop />
<Toasts />
<QuickSwitcher />
<EditMenu />
</div>
)
@@ -353,13 +392,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 />
@@ -369,6 +411,8 @@ function Body({
// An invitation stands where the conversation would, because it is the
// conversation being offered.
if (activeGroupId.startsWith(INVITE_PREFIX)) return <InvitePanel groupId={activeGroupId} />
// 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" />
}
+4 -2
View File
@@ -9,7 +9,8 @@ import { ConversationTools } from './components/ConversationTools'
import { BufferFace } from './components/BufferFace'
import { FileDrop } from './components/FileDrop'
import { Toasts } from './components/Toasts'
import { CallStage, IncomingMatrixCall, ScreenPicker } from './components/CallStage'
import { CallStage, ScreenPicker } from './components/CallStage'
import { IncomingCallPanel } from './components/IncomingCallPanel'
import { StreamStage } from './components/StreamStage'
import { Icon, IconButton } from './components/Icon'
import { useActiveBuffer, useChat, usePref, usePrefsReady, useStore } from './state/hooks'
@@ -137,7 +138,8 @@ export function Popout({ bufferId }: { bufferId: string }): JSX.Element {
{/* A call rings wherever the client is, including here: this window has
its own connection to the daemon and its own media, so a call
answered here is answered here. */}
<IncomingMatrixCall />
{/* Silent: the main window is the one that rings. */}
<IncomingCallPanel silent />
<ScreenPicker />
{/* A popped-out conversation is one conversation, so a call that is not
this one is in the corner and this one is above the log. */}
+2571 -608
View File
File diff suppressed because it is too large Load Diff
+50 -67
View File
@@ -1,4 +1,5 @@
import { useMediaUrl } from '../lib/route'
import { SwitchRow } from './Switch'
import { useEffect, useState } from 'react'
import { Icon, IconButton, ServiceMark } from './Icon'
import { MatrixAccountTools } from './MatrixAccountTools'
@@ -117,7 +118,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 +134,11 @@ export function AccountsPanel(): JSX.Element {
{adding === 'sneedchat' && <SneedChatForm />}
{adding === 'matrix' && <MatrixForm />}
{adding === 'kick' && <KickForm onDone={() => setAdding(null)} />}
{adding && (
<button type="button" className="button subtle small" onClick={() => setAdding(null)}>
Cancel
</button>
)}
</div>
<div className="panel-section">
@@ -153,7 +161,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
@@ -463,6 +474,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 +501,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 +511,7 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
<Icon name="delete" size={16} /> Remove account
</button>
)}
</div>
</div>
)}
</div>
@@ -548,20 +562,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 +640,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 +844,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 +852,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 +886,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()}>
@@ -6,6 +6,7 @@ import { useChat, useStore } from '../state/hooks'
import { classes } from '../lib/util'
import type { BufferEntry } from '../state/store'
import type { DiscordFriend } from '../../../shared/wire'
import { useEscapeLayer } from '../lib/layers'
/** Ten people including you, which is Discord's own ceiling. */
const GROUP_DM_MAX_OTHERS = 9
@@ -49,19 +50,16 @@ export function AddToConversation({ buffer }: { buffer: BufferEntry }): JSX.Elem
.catch((e: Error) => store.toast('error', e.message))
}, [open, accountId, store])
useEscapeLayer(() => setOpen(false), open)
useEffect(() => {
if (!open) return
const away = (e: MouseEvent): void => {
if (!box.current?.contains(e.target as Node)) setOpen(false)
}
const key = (e: KeyboardEvent): void => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', away)
window.addEventListener('keydown', key)
return () => {
document.removeEventListener('mousedown', away)
window.removeEventListener('keydown', key)
}
}, [open])
@@ -158,7 +156,7 @@ export function AddToConversation({ buffer }: { buffer: BufferEntry }): JSX.Elem
</span>
)}
<span className="ellipsis">{f.globalName || f.username}</span>
<span className={`presence-dot ${f.status || 'offline'}`} />
<span className={`status-dot ${f.status || 'offline'}`} />
</button>
))}
+2 -6
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { useMediaUrl } from '../lib/route'
import { nickColor } from '../lib/util'
import { presenceColor, presenceLabel } from '../lib/presence'
import { presenceClass, presenceLabel } from '../lib/presence'
/**
* Somebody's face, with a fallback for the many people who have no picture.
@@ -61,11 +61,7 @@ export function Avatar({
</span>
)}
{status && (
<span
className="presence-dot"
style={{ background: presenceColor(status) }}
title={presenceLabel(status)}
/>
<span className={`presence-dot ${presenceClass(status)}`} title={presenceLabel(status)} />
)}
</span>
)
+104 -45
View File
@@ -2,13 +2,15 @@ import { useMediaUrl } from '../lib/route'
import { useEffect, useMemo, useRef, useState } from 'react'
import { bufferLink } from '../lib/bufferlink'
import { Icon, IconButton, ServiceMark } from './Icon'
import { ContextMenu, useContextMenu } from './ContextMenu'
import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu'
import { bufferMenuEntries } from '../lib/buffermenu'
import { UserFooter } from './UserFooter'
import { VoiceChannels } from './VoiceChannels'
import { VoicePanel } from './VoicePanel'
import { presenceClass, presenceLabel } from '../lib/presence'
import { useChat, useIdSetPref, useMapPref, usePref, useStore } from '../state/hooks'
import {
compareInBand,
dmGroup,
DM_GROUP_ID,
isDirectMessage,
@@ -17,6 +19,7 @@ import {
MENTIONS_GROUP_ID,
PINNED_GROUP_ID,
pinnedGroup,
waitingCount,
visibleGroups,
type RailGroup
} from '../lib/groups'
@@ -180,7 +183,8 @@ export function BufferList(): JSX.Element {
/**
* What the pane lists, in reading order: pinned first, then direct messages,
* then channels, each band by most recent activity.
* then channels. Direct messages go by most recent activity; channels hold
* their place (see `compareInBand`).
*
* The pinned page is the same list unfiltered by group - a pin is a
* cross-service shortcut, so its page is the one place they all appear
@@ -210,9 +214,7 @@ export function BufferList(): JSX.Element {
if (b.kind === 'dm') return 2
return 3
}
return [...inScope].sort(
(a, b) => band(a) - band(b) || (b.lastActivityTs || 0) - (a.lastActivityTs || 0)
)
return [...inScope].sort((a, b) => band(a) - band(b) || compareInBand(a, b))
}, [visible, activeGroup, isPinnedPage, isDmPage, pinned])
/**
@@ -227,8 +229,7 @@ export function BufferList(): JSX.Element {
*
* Ordinary channels sort by the service's own position within a heading,
* since that is the order the server arranged them in and the reason it
* supplies one. The gathered pages have no such order to respect and keep
* the recency they were already sorted by.
* supplies one. The gathered pages keep the order they were sorted into.
*/
const grouped = useMemo(() => {
if (!activeGroup) return null
@@ -248,6 +249,11 @@ export function BufferList(): JSX.Element {
// The menu still toggles a buffer's *own* flag independently of a muted
// rail entry above it, the same way muting a channel inside an
// already-muted Slack workspace works.
/** The call that sets a mute on the account itself, for a service that keeps one. */
const accountMute = (accountId: string): string | null => {
const service = accounts.find((a) => a.id === accountId)?.service
return service === 'matrix' ? 'setMatrixRoomMuted' : service === 'discord' ? 'setDiscordChannelMuted' : null
}
const isEffectivelyMuted = (buffer: BufferEntry): boolean =>
isMutedBuffer(buffer, muted, mutedGroups)
@@ -279,9 +285,9 @@ export function BufferList(): JSX.Element {
}, [store, eventGuild?.accountId, eventGuild?.guildId])
const openCategoryMenu = (e: React.MouseEvent, section: CategorySection): void => {
e.preventDefault()
// Only a heading you made is yours to rename or remove; a server's
// category is theirs, and offering to rename it would be a lie.
if (!section.custom) return
// Every heading can be read through; only one you made is yours to rename
// or remove - a server's category is theirs, and offering to rename it
// would be a lie.
setCatMenu({ x: e.clientX, y: e.clientY, section })
}
@@ -293,6 +299,10 @@ export function BufferList(): JSX.Element {
muted={isEffectivelyMuted(b)}
pinned={isPinned(b.id)}
accounts={accounts}
// The pinned page gathers rooms from every service, so each says which
// one it is from - `#general` on Discord and on IRC look the same
// otherwise. A conversation with a person keeps their face instead.
showServiceIcon={isPinnedPage && b.kind !== 'dm'}
// Already on the page being shown; keep the rail where it is. A room
// still being joined has nothing behind it to select - it exists in
// this window only - so the row is there to be seen rather than opened.
@@ -300,15 +310,23 @@ export function BufferList(): JSX.Element {
onTogglePin={() => togglePin(b.id)}
onToggleMute={() => {
toggleMute(b.id)
// On Matrix the account itself can hold the answer, so muting here
// mutes on a phone too. Everywhere else this stays what it always
// was: a preference belonging to this window.
if (accounts.find((a) => a.id === b.accountId)?.service === 'matrix') {
// On Matrix and Discord the account itself can hold the answer, so
// muting here mutes on a phone too. Everywhere else this stays what
// it always was: a preference belonging to this window.
const writeBack = accountMute(b.accountId)
if (writeBack)
void window.moho
.rpc('setMatrixRoomMuted', { bufferId: b.id, muted: !isEffectivelyMuted(b) })
.rpc(writeBack, { bufferId: b.id, muted: !isEffectivelyMuted(b) })
.catch((e: Error) => store.toast('error', e.message))
}
}}
onUnmuteAccount={
accountMute(b.accountId)
? () =>
void window.moho
.rpc(accountMute(b.accountId)!, { bufferId: b.id, muted: false })
.catch((e: Error) => store.toast('error', e.message))
: undefined
}
onToggleAutojoin={
b.kind === 'channel' && accounts.find((a) => a.id === b.accountId)?.service === 'irc'
? () => store.toggleAutojoin(b.accountId, bufferDisplayName(b.name))
@@ -572,23 +590,44 @@ export function BufferList(): JSX.Element {
title={
section.custom
? 'Your heading — drop channels here, drag to reorder, right-click to rename or remove'
: `${section.name} — drag to reorder`
: `${section.name} — drag to reorder, right-click to mark as read`
}
>
<Icon name={folded ? 'chevron_right' : 'expand_more'} size={14} />
<Icon name="expand_more" size={14} className={classes('fold-chevron', folded && 'folded')} />
<span className="ellipsis">{section.name}</span>
{folded && section.buffers.length > 0 && (
<span className="muted category-count">{section.buffers.length}</span>
)}
{folded && section.buffers.length > 0 && (() => {
// What is waiting in what is folded away, which is
// the thing worth knowing about a closed heading;
// how many channels it has is the fallback.
const waiting = section.buffers.filter((b) => waitingCount(b, isEffectivelyMuted(b)) > 0)
const unread = waiting.reduce((n, b) => n + waitingCount(b, isEffectivelyMuted(b)), 0)
return unread > 0 ? (
<span
className={classes('unread-badge category-count', waiting.some((b) => b.highlight) && 'highlight')}
title={`${unread} ${unread === 1 ? 'mention' : 'mentions'} in ${waiting.length} ${waiting.length === 1 ? 'channel' : 'channels'}`}
>
{unread > 99 ? '99+' : unread}
</span>
) : (
<span className="muted category-count">{section.buffers.length}</span>
)
})()}
</button>
)
)}
{/* A collapsed heading still shows the channel you are
reading, or selecting it from elsewhere would appear
to do nothing. */}
to do nothing - and the ones with something waiting,
or folding a category would hide exactly what the
list is for. */}
{section.buffers
.filter((b) => !folded || b.id === activeBufferId)
.filter(
(b) =>
!folded ||
b.id === activeBufferId ||
(b.unread > 0 && (!isEffectivelyMuted(b) || b.highlight))
)
.map((b) => renderRow(b))}
{section.custom && section.buffers.length === 0 && !folded && (
<div className="category-empty small muted">Drag a channel onto this heading</div>
@@ -654,19 +693,30 @@ export function BufferList(): JSX.Element {
y={catMenu.y}
entries={[
{
label: 'Rename',
icon: 'edit',
onClick: () =>
setNaming({ id: catMenu.section.key, name: catMenu.section.name ?? '' })
label: 'Mark as read',
icon: 'mark_chat_read',
disabled: !catMenu.section.buffers.some((b) => b.unread > 0 || b.highlight),
onClick: () => void store.markBuffersRead(catMenu.section.buffers.map((b) => b.id))
},
{
label: 'Remove',
icon: 'delete',
danger: true,
// The channels stay; only the heading goes, and they fall back
// to wherever the service filed them.
onClick: () => writeCategories(myCategories.filter((c) => c.id !== catMenu.section.key))
}
...(catMenu.section.custom
? ([
{ separator: true },
{
label: 'Rename',
icon: 'edit',
onClick: () =>
setNaming({ id: catMenu.section.key, name: catMenu.section.name ?? '' })
},
{
label: 'Remove',
icon: 'delete',
danger: true,
// The channels stay; only the heading goes, and they fall back
// to wherever the service filed them.
onClick: () => writeCategories(myCategories.filter((c) => c.id !== catMenu.section.key))
}
] as MenuEntry[])
: [])
]}
onClose={() => setCatMenu(null)}
/>
@@ -776,13 +826,9 @@ export function dmStatus(
* is nothing to leave and this only hides it.
*/
function ConnectionDot({ state }: { state: string }): JSX.Element {
const color =
state === 'connected'
? 'var(--success)'
: state === 'connecting'
? 'var(--warning)'
: 'var(--outline)'
return <span className="connection-dot" style={{ background: color }} title={state} />
// The same dot as a person's, because it says the same kind of thing: here,
// being reached, or not.
return <span className={`connection-dot ${presenceClass(state)}`} title={presenceLabel(state)} />
}
interface BufferRowProps {
@@ -797,6 +843,8 @@ interface BufferRowProps {
onSelect: () => void
onTogglePin: () => void
onToggleMute: () => void
/** Matrix rooms muted on the account: take that mute off. */
onUnmuteAccount?: () => void
/** IRC channels only: rejoin this on every connect, or stop doing so. */
onToggleAutojoin?: () => void
/** Kick channels only: watch the stream where streams are watched. */
@@ -846,6 +894,7 @@ function BufferRow({
onSelect,
onTogglePin,
onToggleMute,
onUnmuteAccount,
onToggleAutojoin,
autojoins,
onOpenInBrowser,
@@ -872,6 +921,7 @@ function BufferRow({
onDragEnd
}: BufferRowProps): JSX.Element {
const { menu, open, close } = useContextMenu()
const store = useStore()
const account = accounts.find((a) => a.id === buffer.accountId)
// A DM's picture comes off the service's CDN; through the route for a
// strictly routed account (#257).
@@ -908,6 +958,9 @@ function BufferRow({
// that it is not one of the others - a notice about a quota is easy to
// scroll past when it arrives in what looks like a room a stranger made.
<Icon name={serviceRoomGlyph()} size={15} />
) : buffer.forum ? (
// A forum is posts, not a place to talk, and says so in the glyph.
<Icon name="forum" size={15} />
) : (
<Icon name={bufferKindGlyph(buffer.kind)} size={15} />
)
@@ -946,6 +999,7 @@ function BufferRow({
spaces,
onTogglePin,
onToggleMute,
onUnmuteAccount,
onToggleAutojoin,
onSpace,
onWatch,
@@ -958,6 +1012,7 @@ function BufferRow({
onFile,
onPopOut,
onDock,
onMarkRead: () => void store.markBuffersRead([buffer.id]),
onMarkUnread: account?.service === 'matrix' ? onMarkUnread : undefined,
onTag: account?.service === 'matrix' ? onTag : undefined,
markedUnread: buffer.markedUnread
@@ -971,6 +1026,10 @@ function BufferRow({
'buffer-row',
active && 'active',
buffer.highlight && 'highlight',
// Said by the name, not only by a number beside it: bold, and a bar
// at the edge. Not for a muted room, which asked not to be told.
((buffer.unread > 0 && (!muted || buffer.highlight)) || (buffer.markedUnread && !muted)) && 'unread',
muted && 'muted-row',
lifted && 'lifted',
link && 'unlinked'
)}
@@ -1009,9 +1068,9 @@ function BufferRow({
arrive is worse than either on its own. */}
{live ? (
<span className="live-badge">LIVE</span>
) : buffer.unread > 0 && !muted ? (
<span className={classes('unread-badge', buffer.highlight && 'highlight')}>
{buffer.unread > 99 ? '99+' : buffer.unread}
) : waitingCount(buffer, muted) > 0 ? (
<span className={classes('unread-badge', (buffer.highlight || muted) && 'highlight')}>
{waitingCount(buffer, muted) > 99 ? '99+' : waitingCount(buffer, muted)}
</span>
) : (
// Left unread on purpose, with nothing new in it. A dot rather than
+3 -47
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { Icon, IconButton } from './Icon'
import { Modal } from './Modal'
import { VideoStage } from './VideoStage'
import { Stage, fitTiles, type StageButton, type StageTile } from './stage/Stage'
import { Avatar } from './Avatar'
@@ -367,12 +368,7 @@ export function ScreenPicker(): JSX.Element | null {
if (!sources) return null
return (
<div className="lightbox-backdrop" onClick={() => store.chooseScreenSource(null)}>
<div className="screen-picker" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
<div className="screen-picker-head">
<span>Share a screen or window</span>
<IconButton name="close" size={16} title="Cancel" onClick={() => store.chooseScreenSource(null)} />
</div>
<Modal title="Share a screen or window" icon="screen_share" className="screen-picker" onClose={() => store.chooseScreenSource(null)}>
<div className="screen-picker-grid">
{sources.map((source) => (
<button
@@ -386,47 +382,7 @@ export function ScreenPicker(): JSX.Element | null {
</button>
))}
</div>
</div>
</div>
</Modal>
)
}
/**
* Somebody ringing, and the two answers.
*
* Its own panel rather than a line in the log, because a call is answered in
* the seconds it is offered and a message that scrolls is not an offer. Two
* ways in, because an offer with a camera and one without are different
* things to accept - and answering a video call with audio only is a thing
* people do on purpose.
*/
export function IncomingMatrixCall(): JSX.Element | null {
const store = useStore()
const ringing = useChat((s) => s.ringingCall)
const buffers = useChat((s) => s.buffers)
if (!ringing) return null
const where = buffers.find((b) => b.id === ringing.bufferId)?.name ?? ''
return (
<div className="incoming-calls">
<div className="incoming-call">
<Icon name={ringing.video ? 'videocam' : 'call'} size={18} />
<span className="ellipsis">
{ringing.from} is calling{where ? ` in ${where}` : ''}
</span>
<button type="button" className="button" onClick={() => void store.answerMatrixCall(false)}>
Answer
</button>
{ringing.video && (
<button type="button" className="button" onClick={() => void store.answerMatrixCall(true)}>
With video
</button>
)}
<button type="button" className="button danger" onClick={() => store.declineMatrixCall()}>
Decline
</button>
</div>
</div>
)
}
+6 -16
View File
@@ -8,6 +8,7 @@ import { cameraKey, streamKey } from '../lib/framefeed'
import type { VideoQuality } from '../lib/discordscreen'
import { usePref } from '../state/hooks'
import type { VoiceMember, VoiceSession } from '../../../shared/wire'
import { useEscapeLayer } from '../lib/layers'
/** How often to ask who is talking. */
const POLL_MS = 300
@@ -416,10 +417,9 @@ function GoLiveChooser({
.catch(() => setLimits({ maxHeight: 720, maxFramerate: 30, source: false }))
}, [accountId])
useEscapeLayer(onClose)
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onClose()
}
// A press anywhere else is a change of mind. Except on the button that
// opened this, which closes it by itself and would otherwise reopen it.
const onDown = (e: MouseEvent): void => {
@@ -427,12 +427,8 @@ function GoLiveChooser({
if (box.current?.contains(target) || target.closest('.stage-button')) return
onClose()
}
window.addEventListener('keydown', onKey)
window.addEventListener('mousedown', onDown)
return () => {
window.removeEventListener('keydown', onKey)
window.removeEventListener('mousedown', onDown)
}
return () => window.removeEventListener('mousedown', onDown)
}, [onClose])
const heights = HEIGHTS.filter((h) => (h === 'source' ? !!limits?.source : h <= (limits?.maxHeight ?? 720)))
@@ -496,21 +492,15 @@ function volumeBadge(volume: number | undefined): string | undefined {
*/
function StagePanel({ onClose, label, children }: { onClose: () => void; label: string; children: React.ReactNode }): JSX.Element {
const box = useRef<HTMLDivElement>(null)
useEscapeLayer(onClose)
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onClose()
}
const onDown = (e: MouseEvent): void => {
const target = e.target as HTMLElement
if (box.current?.contains(target) || target.closest('.stage-button')) return
onClose()
}
window.addEventListener('keydown', onKey)
window.addEventListener('mousedown', onDown)
return () => {
window.removeEventListener('keydown', onKey)
window.removeEventListener('mousedown', onDown)
}
return () => window.removeEventListener('mousedown', onDown)
}, [onClose])
return (
<div className="stage-panel" ref={box} role="dialog" aria-label={label}>
+476 -99
View File
@@ -4,15 +4,17 @@ import { applyFormat } from '../lib/composeFormatDom'
import { useMediaUrl } from '../lib/route'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Icon, IconButton, MaskIcon } from './Icon'
import { ContextMenu, type MenuEntry } from './ContextMenu'
import torMark from '../assets/tor.svg'
import { PollComposer } from './PollComposer'
import { Avatar } from './Avatar'
import { EmojiPicker, type StickerEntry } from './EmojiPicker'
import { COMMON_EMOJI, emojiToken, EmojiPicker, type StickerEntry } from './EmojiPicker'
import { GifPicker } from './GifPicker'
import { PlaceField } from './PlaceField'
import { VoiceRecorder } from './VoiceRecorder'
import { useActiveBuffer, useChat, useStore } from '../state/hooks'
import { emojiPreview } from '../lib/format'
import { bufferDisplayName, classes, fileNameOf, isImageFile, resolveMediaUrl } from '../lib/util'
import { bufferDisplayName, classes, fileNameOf, humanSize, isImageFile, resolveMediaUrl } from '../lib/util'
import { completeNick, cyclePrefix, type Completion } from '../lib/completion'
import {
mentionInsert,
@@ -21,12 +23,22 @@ import {
rankMentions,
type MentionTarget
} from '../lib/mentions'
import { STAGE_FILES, type StagedFile } from '../lib/staging'
import { withTone } from '../lib/skintone'
import { usePref } from '../state/hooks'
import { rankShortcodes, shortcodeQuery, unicodeTargets, type ShortcodeTarget } from '../lib/shortcodes'
interface StagedAttachment {
id: number
path: string
name: string
isImage: boolean
/** A picture of it where the path cannot be shown: made from the dropped file. */
preview?: string
/** In bytes, once it has been asked. */
size?: number | null
/** Sent hidden behind a spoiler: Discord only. */
spoiler: boolean
}
/**
@@ -96,11 +108,23 @@ interface TypedCommand {
command?: { options?: { name: string; type?: number; required?: boolean }[] }
}
export function Composer(): JSX.Element | null {
/**
* The message box. In a thread panel it is the same box answering the thread
* rather than the room: it takes the open thread's conversation, sends through
* the thread, and leaves alone what belongs to the room's own box - the
* keyboard listeners for typing anywhere, files dropped on the window, a reply
* in progress and the things that are a message of their own kind.
*/
export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Element | null {
const store = useStore()
const buffer = useActiveBuffer()
const replyingTo = useChat((s) => s.replyingTo)
const whisperingTo = useChat((s) => s.whisperingTo)
const activeBuffer = useActiveBuffer()
const threadBufferId = useChat((s) => s.openThread?.bufferId)
const allBuffers = useChat((s) => s.buffers)
const buffer = thread ? (allBuffers.find((b) => b.id === threadBufferId) ?? null) : activeBuffer
const storedReply = useChat((s) => s.replyingTo)
const storedWhisper = useChat((s) => s.whisperingTo)
const replyingTo = thread ? null : storedReply
const whisperingTo = thread ? null : storedWhisper
const accounts = useChat((s) => s.accounts)
const [text, setText] = useState('')
const [staged, setStaged] = useState<StagedAttachment[]>([])
@@ -110,6 +134,34 @@ export function Composer(): JSX.Element | null {
/** The account's sticker packs, fetched when the picker is first opened. */
const [stickers, setStickers] = useState<StickerEntry[]>([])
const [stickerPicker, setStickerPicker] = useState(false)
const [gifPicker, setGifPicker] = useState(false)
/**
* What to do with files dropped on the window: they land in the tray, to be
* looked over and sent with the next message rather than going out on the
* spot. Set further down, where the box's own staging is.
*/
const takeFiles = useRef<(files: StagedFile[]) => void>(() => {})
useEffect(() => {
// Files dropped on the window go to the room's box, not to this one.
if (thread) return
const onStage = (e: Event): void => takeFiles.current((e as CustomEvent<StagedFile[]>).detail)
window.addEventListener(STAGE_FILES, onStage)
return () => window.removeEventListener(STAGE_FILES, onStage)
}, [thread])
// The pictures made for the tray are held for as long as it is, and let go
// of when a card leaves it or the box is gone.
const previews = useRef(new Set<string>())
useEffect(() => {
const kept = new Set(staged.map((a) => a.preview).filter((p): p is string => !!p))
for (const url of staged.map((a) => a.preview)) if (url) previews.current.add(url)
for (const url of [...previews.current]) {
if (!kept.has(url)) {
URL.revokeObjectURL(url)
previews.current.delete(url)
}
}
}, [staged])
useEffect(() => () => previews.current.forEach((url) => URL.revokeObjectURL(url)), [])
/** The little form for sending a place, which is a field rather than a map. */
const [placeOpen, setPlaceOpen] = useState(false)
const seqRef = useRef(0)
@@ -144,6 +196,8 @@ export function Composer(): JSX.Element | null {
const cycle = useRef<{ last: Completion; attempt: number } | null>(null)
/** The "@..." being typed, and which suggestion is selected. */
const [mention, setMention] = useState<{ query: string; index: number } | null>(null)
/** The ":..." being typed, the same way. */
const [shortcode, setShortcode] = useState<{ query: string; index: number } | null>(null)
/**
* The slash commands this channel offers, while one is being typed.
*
@@ -166,11 +220,19 @@ export function Composer(): JSX.Element | null {
const [roles, setRoles] = useState<{ id: string; name: string; colour?: string }[]>([])
const emojiButtonRef = useRef<HTMLButtonElement>(null)
const stickerButtonRef = useRef<HTMLButtonElement>(null)
const placeButtonRef = useRef<HTMLButtonElement>(null)
const gifButtonRef = useRef<HTMLButtonElement>(null)
const plusButtonRef = useRef<HTMLButtonElement>(null)
/** The "+" menu, where it opened. */
const [plusMenu, setPlusMenu] = useState<{ x: number; y: number } | null>(null)
const smilies = useChat((s) => s.smilies)
const bufferEmojiByBuffer = useChat((s) => s.bufferEmoji)
const replyPing = useChat((s) => s.replyPing)
// The message being answered, if it is on screen: for its author's face.
const repliedTo = useChat((s) =>
s.replyingTo?.id && buffer ? s.messagesByBuffer[buffer.id]?.find((m) => m.id === s.replyingTo?.id) : undefined
)
const account = buffer && accounts.find((a) => a.id === buffer.accountId)
const service = account?.service
const tunnelAll = useChat((s) => s.tunnelAll)
@@ -198,11 +260,23 @@ export function Composer(): JSX.Element | null {
const supportsAttachments =
service === 'discord' || service === 'sneedchat' || service === 'matrix' || service === 'irc'
// Starting a reply puts the cursor in the box: the next thing anybody does
// is write the answer.
const replyingId = useChat((s) => s.replyingTo?.id ?? '')
useEffect(() => {
if (replyingId) inputRef.current?.focus()
}, [replyingId])
// Refocus on buffer switch so typing works immediately after clicking a
// channel, without a second click into the field.
useEffect(() => {
inputRef.current?.focus()
}, [buffer?.id])
if (!thread) inputRef.current?.focus()
}, [buffer?.id, thread])
// And a thread opened is a thread to answer.
const threadRoot = useChat((s) => s.openThread?.rootId)
useEffect(() => {
if (thread && threadRoot) inputRef.current?.focus()
}, [thread, threadRoot])
// Typing anywhere in an active window goes into the box, as it does in
// every other chat client: no click first. Only when nothing else is being
@@ -210,6 +284,8 @@ export function Composer(): JSX.Element | null {
// a picture being looked at - and never with a shortcut key held, which is
// the app's own to answer.
useEffect(() => {
// The room's box alone listens: two would both take the same key.
if (thread) return
const free = (): boolean => {
const active = document.activeElement
if (active instanceof HTMLElement) {
@@ -254,7 +330,7 @@ export function Composer(): JSX.Element | null {
document.removeEventListener('paste', onPaste)
window.removeEventListener('focus', onWindowFocus)
}
}, [])
}, [thread])
/**
* Everything that can be tagged here: the people in the conversation, the
@@ -263,9 +339,9 @@ export function Composer(): JSX.Element | null {
const mentionTargets = useMemo((): MentionTarget[] => {
const members: MentionTarget[] = (roster ?? []).map((m) => ({
name: m.nick,
detail: m.userId && m.userId !== m.nick ? undefined : undefined,
kind: 'member' as const,
userId: m.userId
userId: m.userId,
avatarUrl: m.avatarUrl
}))
const roleTargets: MentionTarget[] = roles.map((r) => ({
name: r.name,
@@ -286,6 +362,31 @@ export function Composer(): JSX.Element | null {
[mention, mentionTargets]
)
/**
* Everything a ":word" can become here: the common Unicode set, this
* channel's own emoji (a server's, or a Kick channel's) and, on Sneedchat,
* its smilies - the same places the picker draws from.
*/
const [tone] = usePref<number>('emoji.skinTone', 0)
const bufferEmoji = bufferEmojiByBuffer[buffer?.id ?? ''] || []
const shortcodeTargets = useMemo((): ShortcodeTarget[] => {
const own: ShortcodeTarget[] = bufferEmoji.map((e) => ({ name: e.name, aliases: [], token: emojiToken(e) }))
const sneed: ShortcodeTarget[] =
service === 'sneedchat'
? smilies.flatMap((s) => {
const [first, ...rest] = s.aliases ?? []
return first ? [{ name: first.replace(/^:|:$/g, ''), aliases: rest.map((a) => a.replace(/^:|:$/g, '')), token: first }] : []
})
: []
// The hands come in the tone chosen in the picker.
const common = unicodeTargets(COMMON_EMOJI.map((e) => ({ ...e, emoji: withTone(e.emoji, tone) })))
return [...own, ...sneed, ...common]
}, [bufferEmoji, smilies, service, tone])
const shortcodes = useMemo(
() => (shortcode ? rankShortcodes(shortcodeTargets, shortcode.query) : []),
[shortcode, shortcodeTargets]
)
// What has been typed as a command, if anything: a slash at the very start
// and the word after it. Not mid-message - "and/or" is not a command.
const typedCommand = /^\/\S*$/.test(text) ? text.slice(1) : null
@@ -333,10 +434,56 @@ export function Composer(): JSX.Element | null {
void window.moho.rpc('sendTyping', { bufferId: buffer.id }).catch(() => {})
}
const stage = (path: string): void => {
const stage = (path: string, file?: File): void => {
const name = fileNameOf(path)
setStaged((s) => [...s, { id: ++seqRef.current, path, name, isImage: isImageFile(name) }])
const id = ++seqRef.current
const isImage = isImageFile(name)
const preview = file && isImage ? URL.createObjectURL(file) : undefined
setStaged((s) => [...s, { id, path, name, isImage, spoiler: false, preview }])
// Its size is the main process's to read; the card says it when it comes.
void window.moho.fileSize(path).then((size) => setStaged((s) => s.map((a) => (a.id === id ? { ...a, size } : a))))
}
takeFiles.current = (files) => {
if (!supportsAttachments) {
store.toast('info', "Files can't be sent in this conversation")
return
}
files.forEach((f) => stage(f.path, f.file))
inputRef.current?.focus()
}
const pickAndStage = (): void => void window.moho.pickFile().then((p) => p && stage(p))
/**
* What the "+" holds. A file everywhere it can go; and on Matrix the things
* that are a message of their own kind rather than text - a place, a poll.
* With nothing but the file, the button is that and skips the menu.
*/
const plusEntries: MenuEntry[] = [
{
label: supportsAttachments ? 'Upload a file' : "Upload a file (not available here)",
icon: 'upload_file',
disabled: !supportsAttachments,
onClick: pickAndStage
},
...(service === 'matrix' && !thread
? ([
{
label: 'Send a location',
icon: 'location_on',
onClick: () => {
setPickerOpen(false)
setStickerPicker(false)
setPlaceOpen(true)
}
},
{ label: 'Start a poll', icon: 'ballot', onClick: () => setPolling(true) }
] as MenuEntry[])
: [])
]
/** Sends to where this box answers: the room, or the thread it is the panel's. */
const post = (body: string, attachment?: string | string[], spoilers?: string[]): Promise<void> =>
thread ? store.sendToThread(body, attachment, spoilers) : store.sendMessage(buffer.id, body, attachment, spoilers)
const submit = (): void => {
// The text as this service will read it: formatting in the box becomes
@@ -368,12 +515,26 @@ export function Composer(): JSX.Element | null {
if (staged.length > 0) {
// Each attachment is its own send; the typed text rides along as the
// caption on the first one, matching how Discord treats a caption.
staged.forEach((att, i) => {
void store.sendMessage(buffer.id, i === 0 ? body : '', att.path)
})
if (service === 'discord') {
// Discord carries up to ten files in one message, which is what its
// own client makes of several chosen at once: one post, one caption,
// one upload to watch. Past ten, the next ten make the next message.
for (let i = 0; i < staged.length; i += 10) {
const batch = staged.slice(i, i + 10)
void post(
i === 0 ? body : '',
batch.map((att) => att.path),
batch.filter((att) => att.spoiler).map((att) => att.path)
)
}
} else {
staged.forEach((att, i) => {
void post(i === 0 ? body : '', att.path)
})
}
setStaged([])
} else {
void store.sendMessage(buffer.id, body)
void post(body)
}
if (inputRef.current) inputRef.current.replaceChildren()
setText('')
@@ -422,9 +583,10 @@ export function Composer(): JSX.Element | null {
// Pasting into an editable div would otherwise bring the clipboard's own
// markup with it - fonts, colours, whole tables. Only the text is wanted.
// Newlines flattened for the same reason Enter does not make one, and
// because an input silently did this to a multi-line paste anyway.
const pasted = e.clipboardData.getData('text/plain').replace(/\s*\n\s*/g, ' ')
// Line breaks kept: they are the person's, and what becomes of them is the
// service's - a newline on Discord and Matrix, a batch or separate lines on
// IRC, a space on Kick. Only the carriage returns a paste brings are tidied.
const pasted = e.clipboardData.getData('text/plain').replace(/\r\n?/g, '\n')
if (pasted) {
// Deprecated, and still the only way to insert at the caret while
// keeping the box's own undo history intact. Chromium is the only
@@ -520,11 +682,14 @@ export function Composer(): JSX.Element | null {
const node = selection?.anchorNode
if (!selection || !node || node.nodeType !== Node.TEXT_NODE) {
setMention(null)
setShortcode(null)
return
}
const before = (node.textContent ?? '').slice(0, selection.anchorOffset)
const query = mentionQuery(before)
setMention(query === null ? null : { query, index: 0 })
const code = shortcodeQuery(before)
setShortcode(code === null ? null : { query: code, index: 0 })
}
/**
@@ -564,6 +729,45 @@ export function Composer(): JSX.Element | null {
if (inputRef.current) setText(composerText(inputRef.current))
}
/**
* Puts a chosen emoji in the box in place of the ":word" that was typed,
* as its picture where there is one - as the picker's own choice would be.
*/
const takeShortcode = (target: ShortcodeTarget): void => {
const selection = window.getSelection()
const node = selection?.anchorNode
if (!selection || !node || node.nodeType !== Node.TEXT_NODE || !shortcode) return
const offset = selection.anchorOffset
const start = offset - shortcode.query.length - 1
if (start < 0) return
const preview = emojiPreview(target.token, service === 'sneedchat' ? smilies : [])
let inserted: Node
if (preview) {
const img = document.createElement('img')
img.className = 'composer-emoji'
img.src = media(preview.src)
img.alt = preview.label
img.title = preview.label
img.dataset.token = target.token
inserted = img
} else {
inserted = document.createTextNode(target.token)
}
const range = document.createRange()
range.setStart(node, start)
range.setEnd(node, offset)
range.deleteContents()
range.insertNode(inserted)
range.setStartAfter(inserted)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
setShortcode(null)
if (inputRef.current) setText(composerText(inputRef.current))
}
/**
* Replaces the word before the caret with a roster name.
*
@@ -606,6 +810,27 @@ export function Composer(): JSX.Element | null {
if (inputRef.current) setText(composerText(inputRef.current))
}
// Somewhere that can be read and not written in: a channel this account has
// no permission to send to, or Discord's own notices. A box that every send
// is refused from is a worse answer than saying so where it would be.
if (buffer.readOnly) {
const official = buffer.readOnly.includes('official Discord')
return (
<div className="composer">
<div className="divider-h" />
<div className="composer-readonly" role="status">
<Icon name={official ? 'verified_user' : 'lock'} size={22} />
<div className="composer-readonly-text">
<span className="composer-readonly-title">{buffer.readOnly}</span>
{official && (
<span className="small">Discord will never ask you for your password or account token.</span>
)}
</div>
</div>
</div>
)
}
// A Kick account with no sign-in watches; it cannot talk. Offering the
// box anyway meant typing a message the service was always going to refuse.
if (account && account.service === 'kick' && !account.hasPassword) {
@@ -640,7 +865,7 @@ export function Composer(): JSX.Element | null {
as a bug until the line above says what was searched for. */}
{commands.length > 0 && (
<div className="command-picker" role="listbox" aria-label="Run a command">
<div className="command-picker-head small muted">
<div className="picker-head small muted">
Commands matching /{typedCommand}
</div>
{commands.map((c, i) => (
@@ -674,6 +899,7 @@ export function Composer(): JSX.Element | null {
{mention && suggestions.length > 0 && (
<div className="mention-picker" role="listbox" aria-label="Tag somebody">
<div className="picker-head small muted">Members matching @{mention.query}</div>
{suggestions.map((target, i) => (
<button
key={`${target.kind}:${target.name}`}
@@ -690,7 +916,7 @@ export function Composer(): JSX.Element | null {
onMouseEnter={() => setMention({ ...mention, index: i })}
>
{target.kind === 'member' ? (
<Avatar name={target.name} size={20} />
<Avatar name={target.name} url={target.avatarUrl} size={24} accountId={account?.id} />
) : (
<span className="mention-glyph" style={target.colour ? { color: target.colour } : undefined}>
<Icon name={target.kind === 'role' ? 'group' : 'campaign'} size={16} />
@@ -705,6 +931,34 @@ export function Composer(): JSX.Element | null {
</div>
)}
{shortcode && shortcodes.length > 0 && (
<div className="shortcode-picker" role="listbox" aria-label="Pick an emoji">
<div className="picker-head small muted">Emoji matching :{shortcode.query}</div>
{shortcodes.map((target, i) => {
const preview = emojiPreview(target.token, service === 'sneedchat' ? smilies : [])
return (
<button
key={`${target.token}:${target.name}`}
type="button"
role="option"
aria-selected={i === shortcode.index}
className={classes('shortcode-option', i === shortcode.index && 'active')}
onMouseDown={(e) => {
e.preventDefault()
takeShortcode(target)
}}
onMouseEnter={() => setShortcode({ ...shortcode, index: i })}
>
<span className="shortcode-glyph">
{preview ? <img src={media(preview.src)} alt="" /> : target.glyph}
</span>
<span className="ellipsis">:{target.name}:</span>
</button>
)
})}
</div>
)}
<TypingLine bufferId={buffer.id} />
{whisperingTo && (
@@ -720,40 +974,98 @@ export function Composer(): JSX.Element | null {
{replyingTo && (
<div className="composer-reply small">
<Icon name="reply" size={14} />
<span className="ellipsis muted">
Replying to {replyingTo.from}
{replyingTo.body ? `: ${replyingTo.body}` : ''}
{repliedTo?.avatarUrl && <img className="reply-avatar" src={media(repliedTo.avatarUrl)} alt="" />}
<span className="ellipsis">
Replying to <strong>{replyingTo.from}</strong>
{replyingTo.body ? <span className="muted">{`: ${replyingTo.body}`}</span> : ''}
</span>
<IconButton name="close" size={14} title="Cancel reply" onClick={() => store.cancelReply()} />
{/* Whether the one answered is told, as Discord's own bar has it.
Only there: nothing else here can say it. */}
{service === 'discord' && replyingTo.id && (
<button
type="button"
className={classes('reply-ping', replyPing && 'on')}
title={replyPing ? 'The author will be pinged. Click to answer without pinging them.' : 'The author will not be pinged. Click to ping them.'}
aria-pressed={replyPing}
onClick={() => store.toggleReplyPing()}
>
@ {replyPing ? 'ON' : 'OFF'}
</button>
)}
<IconButton name="close" size={14} title="Cancel reply (Esc)" onClick={() => store.cancelReply()} />
</div>
)}
{staged.length > 0 && (
<div className="composer-attachments">
{staged.map((att) => (
<div key={att.id} className="staged-thumb" title={att.name}>
{att.isImage ? (
<img src={resolveMediaUrl(att.path)} alt={att.name} />
) : (
<div className="staged-file">
<Icon name="description" size={22} />
<span className="small ellipsis">{att.name}</span>
</div>
)}
<button
type="button"
className="staged-remove"
title="Remove"
onClick={() => setStaged((s) => s.filter((x) => x.id !== att.id))}
>
<Icon name="close" size={13} color="var(--error)" />
</button>
<div key={att.id} className={classes('staged-card', att.spoiler && 'is-spoiler')}>
<div className="staged-preview">
{att.isImage ? (
<img src={att.preview ?? resolveMediaUrl(att.path)} alt="" />
) : (
<Icon name="description" size={32} />
)}
{att.spoiler && <span className="staged-spoiler-pill">SPOILER</span>}
</div>
<div className="staged-meta">
<span className="staged-name ellipsis" title={att.name}>
{att.name}
</span>
{typeof att.size === 'number' && <span className="small muted">{humanSize(att.size)}</span>}
</div>
{/* On the card's corner, and there only while it is pointed at
or focused - they would be a dozen buttons otherwise. */}
<div className="staged-actions">
{service === 'discord' && (
<button
type="button"
className={classes('staged-action', att.spoiler && 'on')}
title={att.spoiler ? 'Not a spoiler' : 'Mark as spoiler'}
aria-pressed={att.spoiler}
onClick={() => setStaged((s) => s.map((x) => (x.id === att.id ? { ...x, spoiler: !x.spoiler } : x)))}
>
<Icon name={att.spoiler ? 'visibility_off' : 'visibility'} size={16} />
</button>
)}
<button
type="button"
className="staged-action danger"
title="Remove"
onClick={() => setStaged((s) => s.filter((x) => x.id !== att.id))}
>
<Icon name="delete" size={16} />
</button>
</div>
</div>
))}
</div>
)}
<div className="composer-row">
<div className="composer-box">
{/* Everything that is not typed goes through here, inside the box on
the left as in Discord. Where there is only the one thing - a
file - it is that thing, without a menu in between. */}
<button
ref={plusButtonRef}
type="button"
className="icon-button composer-plus"
title={plusEntries.length > 1 ? 'Add to the message' : supportsAttachments ? 'Attach a file' : "Attachments aren't supported for this service"}
aria-haspopup={plusEntries.length > 1 ? 'menu' : undefined}
disabled={plusEntries.length <= 1 && !supportsAttachments}
onClick={(e) => {
if (plusEntries.length > 1) {
const r = e.currentTarget.getBoundingClientRect()
setPlusMenu({ x: r.left, y: r.top - 6 })
} else {
pickAndStage()
}
}}
>
<Icon name="add_circle" size={22} fill />
</button>
{/* Where this conversation's traffic goes: through Tor, the account
being routed or everything being. Beside the lock rather than
instead of it - a Matrix room can be both encrypted and routed,
@@ -772,12 +1084,13 @@ export function Composer(): JSX.Element | null {
</span>
))}
{service === 'matrix' && (
<Icon
name={buffer.encrypted ? 'lock' : 'lock_open'}
size={16}
color={buffer.encrypted ? 'var(--primary)' : 'var(--surface-variant-text)'}
style={{ margin: '0 2px' }}
/>
<span className="composer-lock" title={buffer.encrypted ? 'Encrypted' : 'Not encrypted'}>
<Icon
name={buffer.encrypted ? 'lock' : 'lock_open'}
size={16}
color={buffer.encrypted ? 'var(--primary)' : 'var(--surface-variant-text)'}
/>
</span>
)}
<div className="composer-input-wrap">
@@ -791,18 +1104,18 @@ export function Composer(): JSX.Element | null {
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
aria-label={`Message ${bufferDisplayName(buffer.name)}`}
aria-label={thread ? 'Reply in thread' : `Message ${bufferDisplayName(buffer.name)}`}
onInput={(e) => {
setText(composerText(e.currentTarget))
noteTyping()
noteMention()
}}
onKeyDown={(e) => {
// Enter sends and Shift+Enter does nothing, which is what the
// input this replaced did. An editable div would happily take a
// second line, but a newline reaching IRC is a malformed
// PRIVMSG - nothing between here and the socket splits one - so
// multi-line is a separate change with its own thinking to do.
// Enter sends and Shift+Enter makes a new line. What a line break
// becomes on the way out is the daemon's to decide per service -
// IRC cannot carry one in a PRIVMSG, so it goes as a multiline
// batch where the network has them and as separate lines where
// it does not.
// While the mention list is up it owns the keys somebody is
// already using to drive it - a list you steer with the arrows
// and take with Enter is one nobody has to be taught.
@@ -827,6 +1140,24 @@ export function Composer(): JSX.Element | null {
return
}
}
if (shortcode && shortcodes.length > 0) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
const step = e.key === 'ArrowDown' ? 1 : shortcodes.length - 1
setShortcode({ ...shortcode, index: (shortcode.index + step) % shortcodes.length })
return
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
takeShortcode(shortcodes[shortcode.index])
return
}
if (e.key === 'Escape') {
e.preventDefault()
setShortcode(null)
return
}
}
if (mention && suggestions.length > 0) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
@@ -848,8 +1179,37 @@ export function Composer(): JSX.Element | null {
if (e.key === 'Enter') {
e.preventDefault()
if (!e.shiftKey) submit()
// Shift+Enter is a new line, on every service: what becomes of
// it on the way out is the daemon's business per protocol.
else document.execCommand('insertLineBreak')
return
}
// Escape puts a reply down - after the lists above, which took
// theirs first.
if (e.key === 'Escape' && replyingTo) {
e.preventDefault()
store.cancelReply()
return
}
// Up in an empty box opens your last message for editing, as in
// Discord and Element: the quickest way to fix a typo.
if (
!thread &&
e.key === 'ArrowUp' &&
!e.shiftKey &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
!text &&
(service === 'discord' || service === 'sneedchat' || service === 'matrix')
) {
const last = store.lastEditableOwn(buffer.id)
if (last) {
e.preventDefault()
store.startEdit(last)
return
}
}
// The browser would make its own bold and italic here, in markup
// this service may not have. Taken over: the same keys, applied
// as the formats this service carries, and nothing where it has
@@ -890,7 +1250,7 @@ export function Composer(): JSX.Element | null {
/>
{!text.trim() && (
<span className="composer-placeholder muted">
Message {bufferDisplayName(buffer.name)}
{thread ? 'Reply in thread' : `Message ${bufferDisplayName(buffer.name)}`}
</span>
)}
</div>
@@ -902,6 +1262,7 @@ export function Composer(): JSX.Element | null {
title="Emoji"
onClick={() => {
setStickerPicker(false)
setGifPicker(false)
if (!pickerOpen) loadStickers()
setPickerOpen(!pickerOpen)
}}
@@ -909,11 +1270,30 @@ export function Composer(): JSX.Element | null {
<Icon name="mood" size={18} />
</button>
{/* GIFs, wherever there is a Discord account to ask for them: the
picker borrows that account's way of looking, and what it finds can
be sent in any conversation. */}
{!thread && accounts.some((a) => a.service === 'discord') && (
<button
ref={gifButtonRef}
type="button"
className="icon-button"
title="GIFs"
onClick={() => {
setPickerOpen(false)
setStickerPicker(false)
setGifPicker(!gifPicker)
}}
>
<Icon name="gif_box" size={18} />
</button>
)}
{/* Its own button beside the emoji one, because it is its own
gesture: an emoji goes into the line being written and a sticker
is the message. Only where the service has them: Matrix, where a
pack supplies them, and Discord, where each guild has its own. */}
{(service === 'matrix' || service === 'discord') && (
{!thread && (service === 'matrix' || service === 'discord') && (
<button
ref={stickerButtonRef}
type="button"
@@ -922,6 +1302,7 @@ export function Composer(): JSX.Element | null {
onClick={() => {
const opening = !stickerPicker
setPickerOpen(false)
setGifPicker(false)
setStickerPicker(opening)
if (opening) loadStickers()
}}
@@ -930,53 +1311,29 @@ export function Composer(): JSX.Element | null {
</button>
)}
{/* Beside the sticker button for the same reason it is beside the
emoji one: a location is the message rather than something typed
into it. Matrix only, which is the one service here that carries
a place as its own kind of message. */}
{service === 'matrix' && (
<button
ref={placeButtonRef}
type="button"
className="icon-button"
title="Send a location"
onClick={() => {
setPickerOpen(false)
setStickerPicker(false)
setPlaceOpen(!placeOpen)
}}
>
<Icon name="location_on" size={18} />
</button>
)}
{/* Saying it rather than typing it. The two services that have voice
messages at all: Discord's flag and base64 waveform, and Matrix's
MSC3245 marker with integers. The daemon speaks both from one
recording, so this is the same button either way. */}
{(service === 'discord' || service === 'matrix') && <VoiceRecorder bufferId={buffer.id} />}
{!thread && (service === 'discord' || service === 'matrix') && <VoiceRecorder bufferId={buffer.id} />}
{/* Only Matrix, and only because it is the only service whose polls
this client can start: Kick's are the streamer's to make and
Discord's are read here. */}
{service === 'matrix' && (
<IconButton name="ballot" title="Start a poll" onClick={() => setPolling(true)} />
{/* Sending is the one thing the box does that its own Enter does not
say, so it is only drawn when there is something to send. */}
{(text.trim() || staged.length > 0) && (
<button type="button" className="icon-button composer-send" title="Send (Enter)" onClick={submit}>
<Icon name="send" size={20} fill />
</button>
)}
<IconButton
name="add"
title={
supportsAttachments ? 'Attach a file' : "Attachments aren't supported for this service"
}
disabled={!supportsAttachments}
onClick={() => void window.moho.pickFile().then((p) => p && stage(p))}
/>
<IconButton name="send" title="Send" onClick={submit} />
</div>
</div>
{plusMenu && (
<ContextMenu x={plusMenu.x} y={plusMenu.y} above entries={plusEntries} onClose={() => setPlusMenu(null)} />
)}
{placeOpen && (
<PlaceField
anchor={placeButtonRef.current}
anchor={plusButtonRef.current}
onClose={() => setPlaceOpen(false)}
onSend={(place, label) => {
setPlaceOpen(false)
@@ -990,6 +1347,16 @@ export function Composer(): JSX.Element | null {
{polling && <PollComposer bufferId={buffer.id} onClose={() => setPolling(false)} />}
{gifPicker && (
<GifPicker
anchor={gifButtonRef.current}
bufferId={buffer.id}
accountId={buffer.accountId}
discord={service === 'discord'}
onClose={() => setGifPicker(false)}
/>
)}
{(pickerOpen || stickerPicker) && (
<EmojiPicker
// Remounted when the other button is pressed, so it opens on that
@@ -1047,7 +1414,7 @@ export function Composer(): JSX.Element | null {
* stopped, and a Matrix client that closes mid-sentence never sends its
* cancel. Without this the line would sit there indefinitely.
*/
function TypingLine({ bufferId }: { bufferId: string }): JSX.Element | null {
function TypingLine({ bufferId }: { bufferId: string }): JSX.Element {
const entry = useChat((s) => s.typingByBuffer[bufferId])
const [, tick] = useState(0)
@@ -1059,7 +1426,12 @@ function TypingLine({ bufferId }: { bufferId: string }): JSX.Element | null {
return () => clearTimeout(t)
}, [entry])
if (!entry || entry.until <= Date.now() || entry.nicks.length === 0) return null
// Always drawn, empty or not: the strip keeps its height whether anybody is
// writing or not, so the log above it is not shoved up and down as people
// start and stop.
if (!entry || entry.until <= Date.now() || entry.nicks.length === 0) {
return <div className="composer-typing small muted" aria-live="polite" />
}
const names = entry.nicks
const who =
@@ -1070,8 +1442,13 @@ function TypingLine({ bufferId }: { bufferId: string }): JSX.Element | null {
: `${names.length} people are typing`
return (
<div className="composer-typing small muted ellipsis" aria-live="polite">
{who}…
<div className="composer-typing small muted" aria-live="polite">
<span className="typing-dots" aria-hidden>
<i />
<i />
<i />
</span>
<span className="ellipsis">{who}</span>
</div>
)
}
@@ -0,0 +1,58 @@
import { useEffect, useState } from 'react'
/**
* A button for something that interrupts - restarting Tor, restarting the
* daemon - that asks once more before doing it, in place.
*
* Not a dialog: the question is about the thing in front of you and answers
* itself in a click, and it takes itself back after a few seconds so a
* forgotten question is not left lying about as a loaded button.
*/
export function ConfirmButton({
label,
question,
confirmLabel,
onConfirm,
danger
}: {
label: string
/** What is being asked, in a few words: "Restart Tor and lose its circuits?" */
question: string
confirmLabel: string
onConfirm: () => void
danger?: boolean
}): JSX.Element {
const [asking, setAsking] = useState(false)
useEffect(() => {
if (!asking) return
const t = setTimeout(() => setAsking(false), 6000)
return () => clearTimeout(t)
}, [asking])
if (!asking) {
return (
<button type="button" className="button subtle" onClick={() => setAsking(true)}>
{label}
</button>
)
}
return (
<div className="confirm-button" role="alertdialog" aria-label={question}>
<span className="small">{question}</span>
<button
type="button"
className={danger ? 'button danger' : 'button'}
onClick={() => {
setAsking(false)
onConfirm()
}}
>
{confirmLabel}
</button>
<button type="button" className="button subtle" autoFocus onClick={() => setAsking(false)}>
Cancel
</button>
</div>
)
}
+86 -12
View File
@@ -1,6 +1,7 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Icon } from './Icon'
import { useEscapeLayer } from '../lib/layers'
export interface MenuItem {
label: string
@@ -8,6 +9,10 @@ export interface MenuItem {
danger?: boolean
disabled?: boolean
separator?: false
/** On or chosen: drawn with a tick, and read as such by a screen reader. */
checked?: boolean
/** The key that does this without the menu, said beside it: "Ctrl+K". */
shortcut?: string
onClick: () => void
}
@@ -38,6 +43,8 @@ interface Props {
x: number
y: number
entries: MenuEntry[]
/** `y` is the menu's bottom edge rather than its top: for a button at the foot of the window. */
above?: boolean
onClose: () => void
}
@@ -46,19 +53,60 @@ interface Props {
* or clipping ancestor, then nudged back inside the viewport once its real
* size is known.
*/
export function ContextMenu({ x, y, entries, onClose }: Props): JSX.Element {
export function ContextMenu({ x, y, entries, above, onClose }: Props): JSX.Element {
const ref = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState({ x, y })
useLayoutEffect(() => {
const el = ref.current
if (!el) return
const rect = el.getBoundingClientRect()
// The layout size, not getBoundingClientRect: that is the size as drawn,
// and while the entry animation is scaling the menu it is a few pixels
// smaller than it will be - which parked the menu that far off the edge.
const width = el.offsetWidth
const height = el.offsetHeight
const top = above ? y - height : y
setPos({
x: Math.max(4, Math.min(x, window.innerWidth - rect.width - 4)),
y: Math.max(4, Math.min(y, window.innerHeight - rect.height - 4))
x: Math.max(4, Math.min(x, window.innerWidth - width - 4)),
y: Math.max(4, Math.min(top, window.innerHeight - height - 4))
})
}, [x, y, entries.length])
}, [x, y, above, entries.length])
useEscapeLayer(onClose)
// Opened from the keyboard or the mouse, it is the thing with focus, so the
// arrows work at once and closing it gives the focus back to where it was.
useEffect(() => {
const before = document.activeElement
ref.current?.focus({ preventScroll: true })
return () => {
if (before instanceof HTMLElement && document.contains(before)) before.focus({ preventScroll: true })
}
}, [])
/** The entries that can be moved onto, in order. */
const items = (): HTMLButtonElement[] =>
[...(ref.current?.querySelectorAll<HTMLButtonElement>('button.context-menu-item:not([disabled])') ?? [])]
const move = (to: 'next' | 'previous' | 'first' | 'last'): void => {
const list = items()
if (list.length === 0) return
const here = list.indexOf(document.activeElement as HTMLButtonElement)
const index =
to === 'first' ? 0 : to === 'last' ? list.length - 1 : to === 'next' ? (here + 1) % list.length : (here <= 0 ? list.length : here) - 1
list[index].focus()
}
useEffect(() => {
// A menu belongs to the place it was opened over: once that scrolls away,
// or the window is left, it has nothing to point at.
const away = (): void => onClose()
window.addEventListener('scroll', away, true)
window.addEventListener('blur', away)
return () => {
window.removeEventListener('scroll', away, true)
window.removeEventListener('blur', away)
}
}, [onClose])
useEffect(() => {
// Presses inside the menu must not dismiss it. This listener runs in the
@@ -73,24 +121,37 @@ export function ContextMenu({ x, y, entries, onClose }: Props): JSX.Element {
onClose()
}
const onResize = (): void => onClose()
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('mousedown', onMouseDown, true)
window.addEventListener('resize', onResize)
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('mousedown', onMouseDown, true)
window.removeEventListener('resize', onResize)
window.removeEventListener('keydown', onKey)
}
}, [onClose])
return createPortal(
<div
ref={ref}
className="context-menu"
className={above ? 'context-menu above' : 'context-menu'}
role="menu"
tabIndex={-1}
style={{ left: pos.x, top: pos.y }}
onKeyDown={(e) => {
const keys: Record<string, 'next' | 'previous' | 'first' | 'last'> = {
ArrowDown: 'next',
ArrowUp: 'previous',
Home: 'first',
End: 'last'
}
if (e.key in keys) {
e.preventDefault()
move(keys[e.key])
} else if (e.key === 'Tab') {
// Leaving the menu by Tab is closing it.
e.preventDefault()
onClose()
}
}}
>
{entries.map((entry, i) =>
entry.separator ? (
@@ -115,6 +176,8 @@ export function ContextMenu({ x, y, entries, onClose }: Props): JSX.Element {
<button
key={i}
type="button"
role={entry.checked !== undefined ? 'menuitemcheckbox' : 'menuitem'}
aria-checked={entry.checked}
className={`context-menu-item${entry.danger ? ' danger' : ''}`}
disabled={entry.disabled}
onClick={() => {
@@ -123,7 +186,9 @@ export function ContextMenu({ x, y, entries, onClose }: Props): JSX.Element {
}}
>
{entry.icon && <Icon name={entry.icon} size={16} />}
<span>{entry.label}</span>
<span className="context-menu-label">{entry.label}</span>
{entry.shortcut && <kbd className="context-menu-shortcut">{entry.shortcut}</kbd>}
{entry.checked && <Icon name="check" size={16} className="context-menu-check" />}
</button>
)
)}
@@ -136,6 +201,8 @@ export function ContextMenu({ x, y, entries, onClose }: Props): JSX.Element {
export function useContextMenu(): {
menu: { x: number; y: number } | null
open: (e: React.MouseEvent) => void
/** Opens it under an element rather than at the pointer: for a button that is a menu. */
openFrom: (el: HTMLElement) => void
close: () => void
} {
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
@@ -146,6 +213,13 @@ export function useContextMenu(): {
e.stopPropagation()
setMenu({ x: e.clientX, y: e.clientY })
},
openFrom: (el: HTMLElement) => {
const r = el.getBoundingClientRect()
// To the side of a button in a column, below one in a row; the menu
// nudges itself back inside the window either way.
const side = r.width < 80 && r.left < 120
setMenu(side ? { x: r.right + 6, y: r.top } : { x: r.left, y: r.bottom + 4 })
},
close: () => setMenu(null)
}
}
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { HeaderTopic } from './HeaderTopic'
import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu'
import { ExportDialog } from './ExportDialog'
import { ReasonPrompt } from './ReasonPrompt'
@@ -74,6 +75,13 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
.catch((e: Error) => store.toast('error', e.message))
}
},
onUnmuteAccount:
account?.service === 'matrix'
? () =>
void window.moho
.rpc('setMatrixRoomMuted', { bufferId: buffer.id, muted: false })
.catch((e: Error) => store.toast('error', e.message))
: undefined,
onToggleAutojoin:
buffer.kind === 'channel' && account?.service === 'irc'
? () => store.toggleAutojoin(buffer.accountId, name)
@@ -94,6 +102,7 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
onFile: (categoryId) => setAssignment(buffer.id, categoryId),
onPopOut: () => store.popOut(buffer.id),
onDock: () => store.dock(buffer.id),
onMarkRead: () => void store.markBuffersRead([buffer.id]),
onMarkUnread: account?.service === 'matrix' ? () => void store.markUnread(buffer.id) : undefined,
onTag:
account?.service === 'matrix'
@@ -142,7 +151,7 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
<>
<button
type="button"
className={classes('header-nameplate', menu && 'open')}
className={classes('header-nameplate', menu && 'open', buffer.topic && 'with-topic')}
title={`${name} — options`}
aria-haspopup="menu"
onClick={(e) => open(e)}
@@ -159,6 +168,8 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
<span className="main-header-title ellipsis">{name}</span>
</button>
{buffer.topic && <HeaderTopic topic={buffer.topic} />}
{menu && <ContextMenu x={menu.x} y={menu.y} entries={entries} onClose={close} />}
{reporting && (
@@ -467,9 +467,9 @@ function PinnedMessages({ buffer }: { buffer: BufferEntry }): JSX.Element | null
}
setOpen(true)
setRows(null)
void window.moho
.rpc<{ pinned: Message[] }>('listPinned', { bufferId: buffer.id })
.then((answer) => setRows(answer.pinned))
void store
.loadPins(buffer.id)
.then((pinned) => setRows(pinned))
.catch((e: Error) => {
store.toast('error', e.message)
setRows([])
@@ -494,6 +494,7 @@ function PinnedMessages({ buffer }: { buffer: BufferEntry }): JSX.Element | null
className={open ? 'active' : undefined}
onClick={show}
/>
{count > 0 && <span className="header-count">{count > 99 ? '99+' : count}</span>}
</span>
{open && (
<HeaderPopover anchor={button.current} width={400} onClose={() => setOpen(false)}>
+12 -42
View File
@@ -1,6 +1,6 @@
import { useMediaUrl } from '../lib/route'
import { Modal } from './Modal'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Icon, IconButton } from './Icon'
import { Avatar } from './Avatar'
import { ContextMenu, type MenuEntry } from './ContextMenu'
@@ -89,15 +89,6 @@ export function EventsPane(): JSX.Element | null {
if (count !== undefined) load()
}, [count, load])
useEffect(() => {
if (!pane) return
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape' && !menu && !sharing) store.closeEvents()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [pane, menu, sharing, store])
if (!pane) return null
const act = (event: DiscordEvent, method: string, params: Record<string, unknown>, failed: string): void => {
@@ -133,9 +124,8 @@ export function EventsPane(): JSX.Element | null {
]
const n = events?.length ?? 0
return createPortal(
<div className="lightbox-backdrop" onClick={() => store.closeEvents()}>
<div className="events-pane" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Events">
return (
<Modal title="Events" className="events-pane" onClose={() => store.closeEvents()} header={
<div className="events-pane-head">
<Icon name="calendar_month" size={22} />
<span className="events-pane-count">
@@ -148,6 +138,7 @@ export function EventsPane(): JSX.Element | null {
<span className="events-pane-spacer" />
<IconButton name="close" title="Close" onClick={() => store.closeEvents()} />
</div>
}>
<div className="events-pane-list">
{events === null && <p className="small muted">Loading…</p>}
@@ -210,11 +201,9 @@ export function EventsPane(): JSX.Element | null {
</div>
))}
</div>
</div>
{menu && <ContextMenu x={menu.x} y={menu.y} entries={menuFor(menu.event)} onClose={() => setMenu(null)} />}
{sharing && <ShareEvent accountId={pane.accountId} event={sharing} onClose={() => setSharing(null)} />}
</div>,
document.body
</Modal>
)
}
@@ -276,15 +265,6 @@ export function EventCreatePanel(): JSX.Element | null {
.catch(() => setChannels([]))
}, [target])
useEffect(() => {
if (!target) return
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') store.closeEventCreate()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [target, store])
if (!target) return null
const offered = channels.filter((c) => (kind === 'stage' ? c.stage : !c.stage))
@@ -349,15 +329,15 @@ export function EventCreatePanel(): JSX.Element | null {
</label>
)
return createPortal(
<div className="lightbox-backdrop" onClick={() => store.closeEventCreate()}>
<div className="events-pane event-create" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Create an event">
return (
<Modal title="Create an event" className="events-pane event-create" onClose={() => store.closeEventCreate()} header={
<div className="events-pane-head">
<Icon name="calendar_add_on" size={22} />
<span className="events-pane-count">Create an event</span>
<span className="events-pane-spacer" />
<IconButton name="close" title="Close" onClick={() => store.closeEventCreate()} />
</div>
}>
<div className="event-create-body">
<div className="event-create-section">
@@ -442,9 +422,7 @@ export function EventCreatePanel(): JSX.Element | null {
{sending ? 'Creating…' : 'Create Event'}
</button>
</div>
</div>
</div>,
document.body
</Modal>
)
}
@@ -489,13 +467,6 @@ function ShareEvent({
.catch(() => setTargets([]))
}, [accountId, event.guildId, event.id])
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
const q = query.trim().toLowerCase()
const shown = (targets ?? []).filter(
@@ -551,8 +522,7 @@ function ShareEvent({
}
return (
<div className="lightbox-backdrop share-event-backdrop" onClick={onClose}>
<div className="events-pane share-event" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Invite friends to event">
<Modal title="Invite friends to event" className="events-pane share-event" onClose={onClose} header={
<div className="share-event-head">
<div>
<div className="events-pane-count">Invite friends to event</div>
@@ -565,6 +535,7 @@ function ShareEvent({
<span className="events-pane-spacer" />
<IconButton name="close" title="Close" onClick={onClose} />
</div>
}>
<div className="emoji-search share-event-search">
<Icon name="search" size={16} />
<input autoFocus placeholder="Search for friends" value={query} onChange={(e) => setQuery(e.target.value)} />
@@ -595,7 +566,6 @@ function ShareEvent({
: ''}
</div>
</div>
</div>
</div>
</Modal>
)
}
+4 -20
View File
@@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Icon } from './Icon'
import { Modal } from './Modal'
import { useChat, useStore } from '../state/hooks'
/**
@@ -34,14 +33,6 @@ export function DiscordModal(): JSX.Element | null {
first.current?.focus()
}, [modal])
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') store.closeDiscordModal()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [store])
if (!modal) return null
// What the bot said it needs. Checked here so the button says so before it
@@ -60,13 +51,8 @@ export function DiscordModal(): JSX.Element | null {
void store.submitDiscordModal(values)
}
return createPortal(
<div className="lightbox-backdrop" onClick={() => store.closeDiscordModal()}>
<div className="discord-modal" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
<div className="discord-modal-head">
<Icon name="edit_note" size={18} />
<span>{modal.title}</span>
</div>
return (
<Modal title={modal.title} icon="edit_note" className="discord-modal" onClose={() => store.closeDiscordModal()}>
{modal.fields.map((field, i) => {
const value = values[field.customId] ?? ''
@@ -114,8 +100,6 @@ export function DiscordModal(): JSX.Element | null {
{sending ? 'Sending…' : 'Send'}
</button>
</div>
</div>
</div>,
document.body
</Modal>
)
}
+152 -32
View File
@@ -6,6 +6,9 @@ import { discordEmojiUrl, emojiPreview, type SmilieEntry } from '../lib/format'
import { drawableEmoteUrl, onLocalEmotes } from '../lib/emotecache'
import { LottieSticker } from './LottieSticker'
import type { CustomEmoji } from '../../../shared/wire'
import { useEscapeLayer } from '../lib/layers'
import { TONE_NAMES, TONES, withTone } from '../lib/skintone'
import { usePref } from '../state/hooks'
/**
* Emoji insertion popup: a search box, then a small curated set of common
@@ -17,7 +20,7 @@ import type { CustomEmoji } from '../../../shared/wire'
* the categories that come up constantly in chat. Each entry carries search
* keywords, since "fire" only finds 🔥 if something says so.
*/
const COMMON_EMOJI: { emoji: string; name: string }[] = [
export const COMMON_EMOJI: { emoji: string; name: string }[] = [
{ emoji: '😀', name: 'grinning happy' },
{ emoji: '😂', name: 'joy laughing tears' },
{ emoji: '😅', name: 'sweat smile nervous' },
@@ -35,6 +38,10 @@ const COMMON_EMOJI: { emoji: string; name: string }[] = [
{ emoji: '👍', name: 'thumbs up yes approve' },
{ emoji: '👎', name: 'thumbs down no' },
{ emoji: '👏', name: 'clap applause' },
{ emoji: '👋', name: 'wave hello bye' },
{ emoji: '🙌', name: 'raised hands praise' },
{ emoji: '👌', name: 'ok perfect' },
{ emoji: '✌️', name: 'peace victory' },
{ emoji: '🙏', name: 'pray thanks please' },
{ emoji: '🤝', name: 'handshake deal' },
{ emoji: '💪', name: 'muscle strong' },
@@ -64,7 +71,7 @@ const COMMON_EMOJI: { emoji: string; name: string }[] = [
* draw the picture still shows something readable; Discord's is the other way
* round and flags animation. Neither is negotiable - the server parses it.
*/
function emojiToken(e: CustomEmoji): string {
export function emojiToken(e: CustomEmoji): string {
if (e.url) return `[emote:${e.id}:${e.name}]`
return `<${e.animated ? 'a' : ''}:${e.name}:${e.id}>`
}
@@ -111,7 +118,7 @@ function stickerKey(s: StickerEntry): string {
return s.id ?? s.mxc ?? s.name
}
function readRecent(accountId?: string): string[] {
export function readRecent(accountId?: string): string[] {
try {
const stored = JSON.parse(localStorage.getItem(recentKey(accountId)) || '[]')
return Array.isArray(stored) ? stored : []
@@ -221,9 +228,25 @@ export function EmojiPicker({
const [recentStickers, setRecentStickers] = useState<string[]>(() => readRecentStickers(accountId))
/** What is under the pointer, for the line along the bottom. */
const [hovered, setHovered] = useState<{ name: string; from?: string; src?: string; text?: string } | null>(null)
/** The line along the bottom, said for one cell: under the pointer or under the arrow keys. */
const describe = (cell: HTMLElement): void => {
const img = cell.querySelector('img')
const heading = cell.closest('.emoji-grid-window')?.previousElementSibling?.textContent ?? undefined
setHovered({
name: cell.dataset.name || cell.title,
from: heading,
src: img?.getAttribute('src') ?? undefined,
text: img || cell.querySelector('.lottie-sticker') ? undefined : cell.textContent ?? undefined
})
}
/** The section the list is scrolled to, lit in the rail. */
const [current, setCurrent] = useState<string>('')
const [query, setQuery] = useState('')
// Which skin tone the hands are drawn in, kept across windows and sessions.
const [tone, setTone] = usePref<number>('emoji.skinTone', 0)
const [toneMenu, setToneMenu] = useState(false)
/** The cell the arrow keys are on: the search box keeps the focus. */
const keyed = useRef<HTMLElement | null>(null)
const [pos, setPos] = useState({ left: 0, top: 0 })
const [recent, setRecent] = useState<string[]>(() => readRecent(accountId))
// Everything this account can send, wherever it came from. Asked of the
@@ -306,10 +329,9 @@ export function EmojiPicker({
}
}, [anchor])
useEscapeLayer(onClose)
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onClose()
}
// Same capture-phase hazard as ContextMenu: dismissing on any mousedown
// would unmount the cell before its click could land, so a press inside
// the picker never selected anything. The anchor is excluded too, so the
@@ -321,12 +343,8 @@ export function EmojiPicker({
if (anchor?.contains(target)) return
onClose()
}
window.addEventListener('keydown', onKey)
window.addEventListener('mousedown', onMouseDown, true)
return () => {
window.removeEventListener('keydown', onKey)
window.removeEventListener('mousedown', onMouseDown, true)
}
return () => window.removeEventListener('mousedown', onMouseDown, true)
}, [onClose, anchor])
const q = query.trim().toLowerCase()
@@ -535,6 +553,82 @@ export function EmojiPicker({
}
useEffect(followScroll, [tab, q, rail.length])
const clearKeyed = (): void => {
keyed.current?.classList.remove('kbd')
keyed.current = null
}
// A new search is a new list: the cell that was keyed is not in it.
useEffect(clearKeyed, [q, tab])
/** Every cell that can be chosen, in reading order. */
const choosable = (): HTMLElement[] =>
[...(scroll.current?.querySelectorAll<HTMLElement>('.emoji-cell:not(:disabled)') ?? [])]
const key = (cell: HTMLElement): void => {
clearKeyed()
cell.classList.add('kbd')
keyed.current = cell
cell.scrollIntoView({ block: 'nearest' })
describe(cell)
}
/**
* Moves the arrow keys' place through the grid. Left and right go cell to
* cell; up and down go to the nearest cell in the row above or below, which
* is how a grid of unequal sections has to be walked - there is no common
* column count. Only the rows built are walked, so at the end of those it
* scrolls a little and tries once more, which builds the next.
*/
const move = (dir: 'ArrowDown' | 'ArrowUp' | 'ArrowLeft' | 'ArrowRight', retry = true): void => {
const list = choosable()
if (list.length === 0) return
const here = keyed.current?.isConnected ? keyed.current : null
if (!here) {
if (dir === 'ArrowDown' || dir === 'ArrowRight') key(list[0])
return
}
const i = list.indexOf(here)
if (dir === 'ArrowRight' || dir === 'ArrowLeft') {
const next = list[i + (dir === 'ArrowRight' ? 1 : -1)]
if (next) key(next)
else if (dir === 'ArrowRight' && retry) {
scroll.current?.scrollBy({ top: 120 })
requestAnimationFrame(() => move(dir, false))
}
return
}
const r = here.getBoundingClientRect()
const cx = r.left + r.width / 2
const rects = list.map((el) => ({ el, r: el.getBoundingClientRect() }))
const beyond = rects.filter((x) => (dir === 'ArrowDown' ? x.r.top > r.top + 4 : x.r.top < r.top - 4))
if (beyond.length === 0) {
if (dir === 'ArrowUp') clearKeyed()
else if (retry) {
scroll.current?.scrollBy({ top: 120 })
requestAnimationFrame(() => move(dir, false))
}
return
}
// The nearest row in that direction, then the cell in it nearest across.
const rowTop = dir === 'ArrowDown' ? Math.min(...beyond.map((x) => x.r.top)) : Math.max(...beyond.map((x) => x.r.top))
const row = beyond.filter((x) => Math.abs(x.r.top - rowTop) < 4)
row.sort((a, b) => Math.abs(a.r.left + a.r.width / 2 - cx) - Math.abs(b.r.left + b.r.width / 2 - cx))
key(row[0].el)
}
const onSearchKey = (e: React.KeyboardEvent): void => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
move(e.key)
} else if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && keyed.current?.isConnected) {
// Only once a cell is keyed: before that they are the caret's.
e.preventDefault()
move(e.key)
} else if (e.key === 'Enter') {
// The one keyed, or the top result when none is.
e.preventDefault()
const cell = keyed.current?.isConnected ? keyed.current : choosable()[0]
cell?.click()
}
}
return createPortal(
<div
ref={ref}
@@ -570,7 +664,42 @@ export function EmojiPicker({
placeholder={stickersOnly ? 'Find the perfect sticker' : 'Find the perfect emoji'}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onSearchKey}
/>
{/* Which skin tone the hands come in. Only where there are hands. */}
{!stickersOnly && (
<span className="emoji-tone">
<button
type="button"
className="emoji-tone-button"
title={`Skin tone: ${TONE_NAMES[tone] ?? TONE_NAMES[0]}`}
aria-expanded={toneMenu}
onClick={() => setToneMenu((open) => !open)}
>
{TONES[tone] ?? TONES[0]}
</button>
{toneMenu && (
<span className="emoji-tone-menu" role="radiogroup" aria-label="Skin tone">
{TONES.map((glyph, i) => (
<button
key={i}
type="button"
role="radio"
aria-checked={tone === i}
className={`emoji-tone-choice${tone === i ? ' active' : ''}`}
title={TONE_NAMES[i]}
onClick={() => {
setTone(i)
setToneMenu(false)
}}
>
{glyph}
</button>
))}
</span>
)}
</span>
)}
</div>
<div className="emoji-body">
@@ -600,15 +729,7 @@ export function EmojiPicker({
// heading of the section it is in.
onMouseOver={(e) => {
const cell = (e.target as HTMLElement).closest('.emoji-cell') as HTMLElement | null
if (!cell) return
const img = cell.querySelector('img')
const heading = cell.closest('.emoji-grid-window')?.previousElementSibling?.textContent ?? undefined
setHovered({
name: cell.dataset.name || cell.title,
from: heading,
src: img?.getAttribute('src') ?? undefined,
text: img || cell.querySelector('.lottie-sticker') ? undefined : cell.textContent ?? undefined
})
if (cell) describe(cell)
}}
>
<PickerScroll.Provider value={scroll}>
@@ -712,17 +833,14 @@ export function EmojiPicker({
{!stickersOnly && unicode.length > 0 && (
<Section title="Emoji" anchorRef={(el) => (sectionRefs.current.unicode = el)}>
{unicode.map((e) => (
<button
key={e.emoji}
type="button"
className="emoji-cell"
title={e.name}
onClick={() => pick(e.emoji)}
>
{e.emoji}
</button>
))}
{unicode.map((e) => {
const shown = withTone(e.emoji, tone)
return (
<button key={e.emoji} type="button" className="emoji-cell" title={e.name} onClick={() => pick(shown)}>
{shown}
</button>
)
})}
</Section>
)}
@@ -803,7 +921,9 @@ export function EmojiPicker({
</span>
</>
) : (
<span className="small muted">{stickersOnly ? 'Pick a sticker to send it' : 'Pick an emoji'}</span>
<span className="small muted">
{stickersOnly ? 'Pick a sticker to send it' : 'Pick an emoji'} · arrow keys to move, Enter to choose
</span>
)}
</div>
</div>,
+7 -22
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Icon, IconButton } from './Icon'
import { IconButton } from './Icon'
import { Modal } from './Modal'
import { useStore } from '../state/hooks'
/** What the daemon read back off the server. */
@@ -46,28 +46,15 @@ export function EventSource({
.catch((e: Error) => setFailed(e.message))
}, [bufferId, messageId])
// Escape closes it, the same as every other dialog here. A panel of JSON is
// exactly the thing somebody opens by accident and wants gone.
useEffect(() => {
const key = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', key)
return () => window.removeEventListener('keydown', key)
}, [onClose])
const copy = (value: unknown): void => {
void window.moho.copyText(JSON.stringify(value, null, 2))
store.toast('info', 'Copied')
}
return createPortal(
<div className="lightbox-backdrop" onClick={onClose}>
<div className="event-source" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
<div className="reason-prompt-head">
<Icon name="data_object" size={18} />
<span>Message source</span>
</div>
// Escape closes it, the same as every other dialog here: a panel of JSON is
// exactly the thing somebody opens by accident and wants gone.
return (
<Modal title="Message source" icon="data_object" className="event-source" onClose={onClose}>
<div className="small muted ellipsis">{messageId}</div>
{!source && !failed && <div className="small muted">Reading…</div>}
@@ -94,9 +81,7 @@ export function EventSource({
Close
</button>
</div>
</div>
</div>,
document.body
</Modal>
)
}
+6 -21
View File
@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Icon } from './Icon'
import { useState } from 'react'
import { Modal } from './Modal'
import { canReachBack, type ExportRange } from '../lib/exporter'
/**
@@ -57,13 +56,6 @@ export function ExportDialog({
const [to, setTo] = useState(() => localNow())
const [media, setMedia] = useState(true)
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onCancel()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onCancel])
// A datetime-local value is already local wall-clock time, so `new Date` on
// it means what was typed. Seconds are not offered, so the start is taken at
@@ -78,13 +70,8 @@ export function ExportDialog({
const wouldReachBack = !everything && !!oldestHeld && since > 0 && since < oldestHeld
const shortfall = wouldReachBack && !canReachBack(service)
return createPortal(
<div className="lightbox-backdrop" onClick={onCancel}>
<div className="reason-prompt" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
<div className="reason-prompt-head">
<Icon name="download" size={18} />
<span>Export {title}</span>
</div>
return (
<Modal title={`Export ${title}`} icon="download" onClose={onCancel} className="reason-prompt">
<p className="small muted">
Written as a folder of HTML, the way it looks here, into your downloads.
</p>
@@ -162,7 +149,7 @@ export function ExportDialog({
)}
<div className="reason-prompt-actions">
<button type="button" className="button" onClick={onCancel}>
<button type="button" className="button subtle" onClick={onCancel}>
Cancel
</button>
<button
@@ -174,8 +161,6 @@ export function ExportDialog({
Export
</button>
</div>
</div>
</div>,
document.body
</Modal>
)
}
+10 -122
View File
@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Icon } from './Icon'
import { useActiveBuffer, useStore } from '../state/hooks'
import { bufferDisplayName, fileNameOf } from '../lib/util'
import { useActiveBuffer } from '../state/hooks'
import { bufferDisplayName } from '../lib/util'
import { stageFiles } from '../lib/staging'
/**
* Dropping a file anywhere on the window.
@@ -12,23 +13,15 @@ import { bufferDisplayName, fileNameOf } from '../lib/util'
* a miss meant the browser navigated away from the app to display whatever
* had been dropped.
*
* It asks before sending. A file dragged in is one gesture away from being
* posted to a room full of people, and the wrong window happens to be
* focused often enough that "which conversation is this going to" is worth a
* sentence and a button rather than a surprise afterwards.
* It does not send. A dropped file goes into the message box's tray, beside
* anything already there, where it can be looked at, given a caption or taken
* out again - and goes with the next message, as one chosen from the file
* picker does. Nothing is posted to a room by a gesture that may have landed
* on the wrong window.
*/
interface Dropped {
/** Where the file is, for the daemon to read. */
path: string
/** The file itself, so a picture can be previewed without a path. */
file: File
}
export function FileDrop(): JSX.Element | null {
const store = useStore()
const buffer = useActiveBuffer()
const [dragging, setDragging] = useState(false)
const [dropped, setDropped] = useState<Dropped[] | null>(null)
useEffect(() => {
/**
@@ -92,7 +85,7 @@ export function FileDrop(): JSX.Element | null {
const items = files
.map((file) => ({ file, path: window.moho.pathForFile(file) }))
.filter((item) => !!item.path)
if (items.length > 0) setDropped(items)
if (items.length > 0) stageFiles(items)
}
// Captured, so the flag is set before anything else in the app sees the
@@ -115,120 +108,15 @@ export function FileDrop(): JSX.Element | null {
}
}, [])
useEffect(() => {
if (!dropped) return
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') setDropped(null)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [dropped])
const send = (): void => {
if (!buffer || !dropped) return
// One message each, the same as several files staged in the composer:
// every service here takes one file per message.
for (const item of dropped) void store.sendMessage(buffer.id, '', item.path)
setDropped(null)
}
const where = buffer ? bufferDisplayName(buffer.name) : null
if (dropped) {
return createPortal(
<div className="lightbox-backdrop" onClick={() => setDropped(null)}>
<div
className="drop-confirm"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<div className="drop-confirm-head">
<Icon name="upload_file" size={20} />
<span>
{where ? (
<>
Upload to <b>{where}</b>?
</>
) : (
'Nowhere to upload to'
)}
</span>
</div>
<div className="drop-preview">
{dropped.map((item) => (
<DroppedItem key={item.path} item={item} />
))}
</div>
{where ? (
<p className="small muted">
{dropped.length === 1
? 'It will be sent as its own message.'
: `${dropped.length} files, each sent as its own message.`}
</p>
) : (
<p className="small muted">Open a conversation first, then drop the file again.</p>
)}
<div className="drop-confirm-actions">
<button type="button" className="button" onClick={() => setDropped(null)} autoFocus>
Cancel
</button>
{where && (
<button type="button" className="button primary" onClick={send}>
Upload
</button>
)}
</div>
</div>
</div>,
document.body
)
}
if (!dragging) return null
return createPortal(
<div className="drop-hint">
<Icon name="upload_file" size={34} />
<span>{where ? `Drop to upload to ${where}` : 'Drop a file'}</span>
<span>{where ? `Drop to add to your message in ${where}` : 'Open a conversation to send a file'}</span>
</div>,
document.body
)
}
/**
* One dropped file in the dialog.
*
* A picture is shown from the File itself rather than from its path. The
* guarded media scheme only serves what the daemon cached or what somebody
* picked in a dialog, and widening it to take a path the renderer supplies
* would be a real hole for one preview - where the dropped File is already
* here and needs no filesystem access at all.
*/
function DroppedItem({ item }: { item: Dropped }): JSX.Element {
const [url, setUrl] = useState<string | null>(null)
useEffect(() => {
if (!item.file.type.startsWith('image/')) return
const made = URL.createObjectURL(item.file)
setUrl(made)
// Revoked on unmount, or the blob is held for as long as the window is.
return () => URL.revokeObjectURL(made)
}, [item.file])
return (
<div className="drop-item">
{url ? (
<img src={url} alt="" />
) : (
<span className="drop-item-file">
<Icon name="description" size={26} />
</span>
)}
<span className="small ellipsis">{fileNameOf(item.path)}</span>
</div>
)
}
+293
View File
@@ -0,0 +1,293 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Icon } from './Icon'
import { Modal } from './Modal'
import { useMediaUrl } from '../lib/route'
import { useChat, useStore } from '../state/hooks'
import { classes } from '../lib/util'
import { filterPosts, postAge, type ForumPost } from '../lib/forum'
import { formatMessage } from '../lib/format'
import { RichText } from '../lib/richtext'
import type { BufferEntry } from '../state/store'
interface ForumTag {
id: string
name: string
emoji?: string | null
moderated?: boolean
}
interface Page {
posts: ForumPost[]
hasMore: boolean
total?: number | null
tags: ForumTag[]
}
/**
* A Discord forum: a list of posts, each of which is a thread.
*
* Not a conversation, so it is not drawn as one - there is nothing in it to
* scroll back through and nowhere to type. What it holds is the posts, and
* what is worth seeing of each is the title, the words that began it, how many
* have answered, how long ago, and a picture if there was one. Opening a post
* opens it as the conversation it is, in the list under this forum.
*/
export function ForumPane({ buffer }: { buffer: BufferEntry }): JSX.Element {
const store = useStore()
const media = useMediaUrl(buffer.accountId)
const cached = useChat((s) => s.forumPages)[buffer.id]
const [posts, setPosts] = useState<ForumPost[]>(cached?.posts ?? [])
const [tags, setTags] = useState<ForumTag[]>(cached?.tags ?? [])
const [hasMore, setHasMore] = useState(cached?.hasMore ?? false)
const [loading, setLoading] = useState(!cached)
const [error, setError] = useState('')
const [sort, setSort] = useState<'active' | 'created'>(cached?.sort ?? 'active')
const [query, setQuery] = useState('')
const [opening, setOpening] = useState('')
const [writing, setWriting] = useState(false)
const load = useCallback(
(offset: number) => {
setLoading(true)
setError('')
void window.moho
.rpc<Page>('listForumPosts', { bufferId: buffer.id, sort, offset })
.then((page) => {
setHasMore(page.hasMore)
setTags(page.tags ?? [])
setPosts((had) => {
const next = offset === 0 ? page.posts : [...had, ...page.posts.filter((p) => !had.some((h) => h.id === p.id))]
store.setForumPage(buffer.id, { at: Date.now(), sort, posts: next, hasMore: page.hasMore, tags: page.tags ?? [] })
return next
})
})
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false))
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[buffer.id, sort]
)
useEffect(() => {
setQuery('')
// As it was left, if that was a moment ago and the same sort.
const fresh = cached && cached.sort === sort && Date.now() - cached.at < 60_000
if (fresh) {
setPosts(cached.posts)
setHasMore(cached.hasMore)
setTags(cached.tags)
setLoading(false)
return
}
setPosts([])
load(0)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [load])
const open = (post: ForumPost): void => {
if (opening) return
setOpening(post.id)
void window.moho
.rpc<{ bufferId: string }>('openDiscordThread', { bufferId: buffer.id, threadId: post.id })
.then((answer) => store.selectBuffer(answer.bufferId, true))
.catch((e: Error) => store.toast('error', e.message))
.finally(() => setOpening(''))
}
const shown = useMemo(() => filterPosts(posts, query), [posts, query])
const live = shown.filter((p) => !p.archived || p.pinned)
const older = shown.filter((p) => p.archived && !p.pinned)
const card = (post: ForumPost): JSX.Element => (
<button
key={post.id}
type="button"
className={classes('forum-post', post.archived && 'older', opening === post.id && 'opening')}
onClick={() => open(post)}
>
<span className="forum-post-main">
<span className="forum-post-title">
{post.pinned && <Icon name="push_pin" size={14} className="forum-pin" />}
{post.name}
</span>
{!!post.tags.length && (
<span className="forum-post-tags">
{post.tags.map((t) => (
<span key={t.name} className="forum-tag small">
{t.emoji ? `${t.emoji} ` : ''}
{t.name}
</span>
))}
</span>
)}
{(post.author || post.content) && (
<span className="forum-post-text small">
{post.author && <strong>{post.author}: </strong>}
<RichText html={formatMessage(post.content)} ownMarkup onOpenLink={() => {}} />
</span>
)}
<span className="forum-post-meta small muted">
<span className="forum-post-count">
<Icon name="chat_bubble" size={13} /> {post.messageCount.toLocaleString()}
</span>
{post.reaction && (
<span>
{post.reaction.emoji.startsWith('<') ? '' : post.reaction.emoji} {post.reaction.count}
</span>
)}
<span>{postAge(post.lastTs || post.createdTs)}</span>
</span>
</span>
{post.thumbnail && <img className="forum-post-thumb" src={media(post.thumbnail)} alt="" loading="lazy" />}
</button>
)
return (
<div className="forum-pane">
<div className="forum-bar">
<label className="forum-search">
<Icon name="search" size={18} />
<input
placeholder="Search posts"
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label="Search posts"
/>
</label>
<button type="button" className="button primary" onClick={() => setWriting(true)}>
<Icon name="add_comment" size={16} /> New Post
</button>
</div>
<div className="forum-sorts" role="radiogroup" aria-label="Sort posts">
{(
[
['active', 'Recently active'],
['created', 'Date posted']
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
role="radio"
aria-checked={sort === id}
className={classes('service-chip', sort === id && 'active')}
onClick={() => setSort(id)}
>
{label}
</button>
))}
</div>
<div className="forum-list">
{error && <p className="small error-text">{error}</p>}
{live.map(card)}
{older.length > 0 && <div className="forum-older small muted">Older posts</div>}
{older.map(card)}
{!loading && !error && shown.length === 0 && (
<div className="forum-empty muted">
<Icon name="forum" size={32} />
<p>{query ? 'No posts match that.' : 'No posts yet. Be the first.'}</p>
</div>
)}
{loading && <p className="small muted forum-loading"><span className="spinner" /> Reading the forum…</p>}
{!loading && hasMore && !query && (
<button type="button" className="button subtle forum-more" onClick={() => load(posts.length)}>
Show more posts
</button>
)}
</div>
{writing && (
<NewPost
buffer={buffer}
tags={tags}
onClose={() => setWriting(false)}
onMade={(bufferId) => {
setWriting(false)
void store.selectBuffer(bufferId, true)
}}
/>
)}
</div>
)
}
/** The form for a new post: its title, what it says, and any tags the forum offers. */
function NewPost({
buffer,
tags,
onClose,
onMade
}: {
buffer: BufferEntry
tags: ForumTag[]
onClose: () => void
onMade: (bufferId: string) => void
}): JSX.Element {
const store = useStore()
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [chosen, setChosen] = useState<string[]>([])
const [sending, setSending] = useState(false)
const ready = title.trim() !== '' && body.trim() !== '' && !sending
const send = (): void => {
if (!ready) return
setSending(true)
void window.moho
.rpc<{ bufferId: string }>('createForumPost', { bufferId: buffer.id, title: title.trim(), body, tags: chosen })
.then((answer) => onMade(answer.bufferId))
.catch((e: Error) => {
setSending(false)
store.toast('error', e.message)
})
}
return (
<Modal title="New post" icon="add_comment" className="forum-new" onClose={onClose}>
<label className="field">
<span className="small muted">Title</span>
<input className="text-field" value={title} maxLength={100} placeholder="What is this about?" onChange={(e) => setTitle(e.target.value)} />
</label>
<label className="field">
<span className="small muted">Message</span>
<textarea
className="reason-prompt-box"
rows={6}
value={body}
placeholder="Say more…"
onChange={(e) => setBody(e.target.value)}
/>
</label>
{tags.length > 0 && (
<div className="field">
<span className="small muted">Tags</span>
<div className="forum-new-tags">
{tags
.filter((t) => !t.moderated)
.map((t) => (
<button
key={t.id}
type="button"
aria-pressed={chosen.includes(t.id)}
className={classes('service-chip', chosen.includes(t.id) && 'active')}
onClick={() => setChosen(chosen.includes(t.id) ? chosen.filter((c) => c !== t.id) : [...chosen, t.id])}
>
{t.emoji ? `${t.emoji} ` : ''}
{t.name}
</button>
))}
</div>
</div>
)}
<div className="field-row">
<button type="button" className="button subtle" onClick={onClose}>
Cancel
</button>
<button type="button" className="button primary" disabled={!ready} onClick={send}>
{sending ? 'Posting…' : 'Post'}
</button>
</div>
</Modal>
)
}
+5 -18
View File
@@ -1,6 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Icon } from './Icon'
import { Modal } from './Modal'
import { Avatar } from './Avatar'
import { useChat, useStore } from '../state/hooks'
import { bufferDisplayName } from '../lib/util'
@@ -37,12 +36,7 @@ export function ForwardPicker({
useEffect(() => {
box.current?.focus()
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
}, [])
const source = buffers.find((b) => b.id === bufferId)
const choices = useMemo(() => {
@@ -68,13 +62,8 @@ export function ForwardPicker({
})
}
return createPortal(
<div className="lightbox-backdrop" onClick={onClose}>
<div className="forward-picker" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
<div className="discord-modal-head">
<Icon name="forward" size={18} />
<span>Send this on</span>
</div>
return (
<Modal title="Send this on" icon="forward" className="forward-picker" onClose={onClose}>
<input
ref={box}
className="text-field"
@@ -106,9 +95,7 @@ export function ForwardPicker({
<p className="small muted emoji-empty">No conversation here by that name.</p>
)}
</div>
</div>
</div>,
document.body
</Modal>
)
}
+238
View File
@@ -0,0 +1,238 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Icon } from './Icon'
import { useEscapeLayer } from '../lib/layers'
import { useMediaUrl } from '../lib/route'
import { useStore } from '../state/hooks'
/** One of the GIFs the daemon lists: what to play, and what to send. */
export interface GifEntry {
id: string
title: string
/** The page on Tenor or Klipy, which is what Discord is sent. */
url: string
/** The video, to play in the grid. */
src: string
/** The file, which is what everything that is not Discord is sent. */
gifSrc: string
preview: string
width: number
height: number
}
interface GifCategory {
name: string
src: string
}
/** How long after the last keystroke before a search is made. */
const SEARCH_DELAY = 450
/**
* GIFs to browse and send, as Discord's own picker has them: what is trending,
* a search, and a row of categories to start from.
*
* They come from Discord - any account will do, whichever conversation this is
* open in - and go anywhere: to a Discord conversation as the link Discord's
* own client sends, so that Discord unfurls it; to every other service as the
* file itself, which a window of this kind draws as the GIF and any other
* client shows as a link.
*/
export function GifPicker({
anchor,
bufferId,
accountId,
discord,
onClose
}: {
anchor: HTMLElement | null
bufferId: string
accountId: string
/** Whether the conversation is on Discord, which decides what is sent. */
discord: boolean
onClose: () => void
}): JSX.Element {
const store = useStore()
const media = useMediaUrl(accountId)
const ref = useRef<HTMLDivElement>(null)
const scroll = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState({ left: 0, top: 0 })
const [query, setQuery] = useState('')
const [gifs, setGifs] = useState<GifEntry[]>([])
const [categories, setCategories] = useState<GifCategory[]>([])
const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading')
const [error, setError] = useState('')
useEscapeLayer(onClose)
// Above and right-aligned to its button, and kept in view, as the emoji
// picker is.
useLayoutEffect(() => {
const el = ref.current
if (!el || !anchor) return
const place = (): void => {
const a = anchor.getBoundingClientRect()
const r = el.getBoundingClientRect()
const left = Math.max(4, Math.min(a.right - r.width, window.innerWidth - r.width - 4))
const top = Math.max(4, Math.min(a.top - r.height - 6, window.innerHeight - r.height - 4))
setPos((was) => (was.left === left && was.top === top ? was : { left, top }))
}
place()
const ro = new ResizeObserver(place)
ro.observe(el)
window.addEventListener('resize', place)
return () => {
ro.disconnect()
window.removeEventListener('resize', place)
}
}, [anchor])
// Pressing anywhere else closes it; pressing inside, or on its button, does not.
useEffect(() => {
const onDown = (e: MouseEvent): void => {
const target = e.target as Node
if (ref.current?.contains(target) || anchor?.contains(target)) return
onClose()
}
window.addEventListener('mousedown', onDown, true)
return () => window.removeEventListener('mousedown', onDown, true)
}, [anchor, onClose])
// What is trending when it opens, and a search once typing has stopped. An
// answer that arrives after a newer question was asked is not shown.
useEffect(() => {
// The harness has no Discord to ask, so it can leave the answer here. Only
// a window it started has the hook it is read from.
const fixture = (window as unknown as { __mohoShots?: unknown; __gifFixture?: { gifs: GifEntry[]; categories: GifCategory[] } })
if (fixture.__mohoShots && fixture.__gifFixture) {
const wanted = query.trim().toLowerCase()
setGifs(fixture.__gifFixture.gifs.filter((g) => !wanted || g.title.toLowerCase().includes(wanted)))
setCategories(fixture.__gifFixture.categories)
setState('ready')
return
}
let current = true
setState('loading')
const wait = setTimeout(
() => {
window.moho
.rpc<{ gifs: GifEntry[]; categories: GifCategory[] }>('listGifs', { query: query.trim() })
.then((answer) => {
if (!current) return
setGifs(answer.gifs)
setCategories(answer.categories)
setState('ready')
})
.catch((e: Error) => {
if (!current) return
setError(e.message)
setState('failed')
})
},
query.trim() ? SEARCH_DELAY : 0
)
return () => {
current = false
clearTimeout(wait)
}
}, [query])
const pick = (gif: GifEntry): void => {
onClose()
// Discord's own client sends the link, which Discord turns into the GIF.
// Everything else is sent the file, since a page address would be only
// that.
void store.sendMessage(bufferId, discord ? gif.url : gif.gifSrc)
}
// Two columns, each GIF into whichever is shorter, so differently shaped ones
// fit together the way Discord's do.
const columns: GifEntry[][] = [[], []]
const heights = [0, 0]
for (const gif of gifs) {
const into = heights[0] <= heights[1] ? 0 : 1
columns[into].push(gif)
heights[into] += gif.width && gif.height ? gif.height / gif.width : 0.75
}
return createPortal(
<div ref={ref} className="gif-picker" style={{ left: pos.left, top: pos.top }} role="dialog" aria-label="GIFs">
<div className="gif-search">
<Icon name="search" size={18} />
<input
autoFocus
className="text-field"
value={query}
placeholder="Search for a GIF"
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<div className="gif-scroll" ref={scroll}>
{!query.trim() && categories.length > 0 && (
<div className="gif-categories">
{categories.map((c) => (
<button key={c.name} type="button" className="gif-category" onClick={() => setQuery(c.name)}>
{c.name}
</button>
))}
</div>
)}
{state === 'failed' && <div className="gif-empty muted">{error || 'GIFs could not be loaded'}</div>}
{state === 'loading' && gifs.length === 0 && <div className="gif-empty muted">Loading…</div>}
{state === 'ready' && gifs.length === 0 && <div className="gif-empty muted">No GIFs found</div>}
{gifs.length > 0 && (
<div className="gif-grid">
{columns.map((column, i) => (
<div key={i} className="gif-column">
{column.map((gif) => (
<GifCell key={gif.id} gif={gif} media={media} root={scroll} onPick={pick} />
))}
</div>
))}
</div>
)}
</div>
</div>,
document.body
)
}
/**
* One GIF. Played only while it is on screen: a grid of forty videos all
* running is a great deal of work for a list nobody is looking at the bottom
* of, so out of view it is a still picture and nothing more.
*/
function GifCell({
gif,
media,
root,
onPick
}: {
gif: GifEntry
media: (url: string) => string
root: React.RefObject<HTMLDivElement | null>
onPick: (gif: GifEntry) => void
}): JSX.Element {
const cell = useRef<HTMLButtonElement>(null)
const [visible, setVisible] = useState(false)
useEffect(() => {
const el = cell.current
if (!el) return
const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting), { root: root.current, rootMargin: '80px' })
observer.observe(el)
return () => observer.disconnect()
}, [root])
const ratio = gif.width && gif.height ? `${gif.width} / ${gif.height}` : '4 / 3'
return (
<button ref={cell} type="button" className="gif-cell" title={gif.title} style={{ aspectRatio: ratio }} onClick={() => onPick(gif)}>
{visible ? (
<video src={media(gif.src)} poster={gif.preview ? media(gif.preview) : undefined} autoPlay loop muted playsInline />
) : gif.preview ? (
<img src={media(gif.preview)} alt="" draggable={false} />
) : null}
</button>
)
}
@@ -1,4 +1,5 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useEscapeLayer } from '../lib/layers'
/**
* A panel hanging from a header button, over the conversation.
@@ -67,20 +68,16 @@ export function HeaderPopover({
return () => window.removeEventListener('resize', place)
}, [anchor, width, children])
useEscapeLayer(onClose)
useEffect(() => {
const away = (e: MouseEvent): void => {
if (box.current?.contains(e.target as Node)) return
if (anchor?.contains(e.target as Node)) return
onClose()
}
const key = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('mousedown', away)
document.addEventListener('keydown', key)
return () => {
document.removeEventListener('mousedown', away)
document.removeEventListener('keydown', key)
}
}, [anchor, onClose])
@@ -0,0 +1,46 @@
import { useMemo, useRef, useState } from 'react'
import { HeaderPopover } from './HeaderPopover'
import { RichText } from '../lib/richtext'
import { formatMessage } from '../lib/format'
/**
* What a conversation says it is for, on one line beside its name.
*
* Every client of this kind has it and it was the one thing here that did
* not: an IRC channel's topic arrived as a line in the log and then scrolled
* away. A click opens the whole of it, with its links live - a topic is very
* often a rule, a schedule and a few links, and one line holds the start of
* that.
*/
export function HeaderTopic({ topic }: { topic: string }): JSX.Element {
const [open, setOpen] = useState(false)
const button = useRef<HTMLButtonElement>(null)
// Through the same pipeline every other body goes through, so a link is a
// link. Built only when opened.
const html = useMemo(() => (open ? formatMessage(topic) : ''), [open, topic])
const oneLine = topic.replace(/\s+/g, ' ').trim()
return (
<>
<button
ref={button}
type="button"
className="header-topic"
title="Show the whole topic"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(!open)}
>
<span className="ellipsis">{oneLine}</span>
</button>
{open && (
<HeaderPopover anchor={button.current} width={460} onClose={() => setOpen(false)}>
<div className="small muted">Topic</div>
<div className="header-topic-full">
<RichText html={html} />
</div>
</HeaderPopover>
)}
</>
)
}
+8 -3
View File
@@ -20,7 +20,10 @@ export function Icon({ name, size = 20, color, fill, className, style }: Props):
width: size,
height: size,
color,
...(fill ? { fontVariationSettings: "'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24" } : {}),
// The glyph is drawn for its size: the font's optical-size axis runs
// 20-48, and small ones are given a little more weight, or they read
// as hairlines next to text.
fontVariationSettings: `'FILL' ${fill ? 1 : 0}, 'wght' ${size <= 14 ? 500 : 400}, 'GRAD' 0, 'opsz' ${Math.min(48, Math.max(20, size))}`,
...style
}}
>
@@ -43,7 +46,8 @@ export function IconButton({
disabled,
onClick,
className,
style
style,
fill
}: ButtonProps): JSX.Element {
return (
<button
@@ -55,7 +59,8 @@ export function IconButton({
onClick={onClick}
style={style}
>
<Icon name={name} size={size} color={color} />
{/* A toggle that is on is drawn filled as well as tinted, so it reads as on without the colour. */}
<Icon name={name} size={size} color={color} fill={fill ?? /(^| )active( |$)/.test(className ?? '')} />
</button>
)
}
@@ -15,65 +15,111 @@ import { bufferDisplayName } from '../lib/util'
*
* Several at once are stacked rather than collapsed into a count: each is a
* different person waiting on a different answer, and there is no sensible
* single button for two of them.
* single button for two of them. Discord's and Matrix's are in the one list,
* in the one container - two containers at the same corner drew a Discord call
* and a Matrix call on top of each other.
*/
export function IncomingCallPanel(): JSX.Element | null {
/** One ring, whichever service it came from. */
interface Ring {
key: string
name: string
avatarUrl?: string
accountId?: string
/** Who is ringing and on which account, said under the name. */
detail: string
answer: () => void
/** Only where the offer carries a camera: answering with it is its own choice. */
answerWithVideo?: () => void
decline: () => void
}
export function IncomingCallPanel({ silent = false }: { silent?: boolean } = {}): JSX.Element | null {
const store = useStore()
const calls = useChat((s) => s.incomingCalls)
const matrix = useChat((s) => s.ringingCall)
const buffers = useChat((s) => s.buffers)
const accounts = useChat((s) => s.accounts)
const ringtone = useRef<Ringtone>()
const rings: Ring[] = [
...calls.map((call): Ring => {
const buffer = buffers.find((b) => b.id === call.bufferId)
const account = accounts.find((a) => a.id === call.accountId)
return {
key: `d:${call.bufferId}`,
// A call from a conversation this window has not loaded yet still has
// to say something. The channel is not a name, but it is not nothing.
name: buffer ? bufferDisplayName(buffer.name) : 'Someone',
avatarUrl: buffer?.avatarUrl,
accountId: buffer?.accountId,
detail: `Incoming call${account ? ` · ${account.displayName || account.id}` : ''}`,
answer: () => void store.acceptCall(call.bufferId),
decline: () => void store.declineCall(call.bufferId)
}
}),
...(matrix
? [
((): Ring => {
const buffer = buffers.find((b) => b.id === matrix.bufferId)
const account = accounts.find((a) => a.id === matrix.accountId)
return {
key: `m:${matrix.callId}`,
name: buffer ? bufferDisplayName(buffer.name) : matrix.from,
avatarUrl: buffer?.avatarUrl,
accountId: buffer?.accountId ?? matrix.accountId,
detail: `${matrix.from} is calling${matrix.video ? ' · video' : ''}${account ? ` · ${account.displayName || account.id}` : ''}`,
answer: () => void store.answerMatrixCall(false),
answerWithVideo: matrix.video ? () => void store.answerMatrixCall(true) : undefined,
decline: () => store.declineMatrixCall()
}
})()
]
: [])
]
// One ringtone for any number of calls: two people calling at once should
// not ring twice as loudly and out of phase with each other.
useEffect(() => {
if (silent) return
if (!ringtone.current) ringtone.current = new Ringtone()
if (calls.length > 0) ringtone.current.start()
if (rings.length > 0) ringtone.current.start()
else ringtone.current.stop()
}, [calls.length])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rings.length, silent])
// Silence on the way out. Without this a window closed mid-ring leaves an
// oscillator running with nothing on screen to explain it.
useEffect(() => () => ringtone.current?.stop(), [])
if (calls.length === 0) return null
if (rings.length === 0) return null
return (
<div className="incoming-calls">
{calls.map((call) => {
const buffer = buffers.find((b) => b.id === call.bufferId)
const account = accounts.find((a) => a.id === call.accountId)
// A call from a conversation this window has not loaded yet still has
// to say something. The channel is not a name, but it is not nothing.
const name = buffer ? bufferDisplayName(buffer.name) : 'Someone'
return (
<div key={call.bufferId} className="incoming-call">
<Avatar name={name} url={buffer?.avatarUrl} size={44} accountId={buffer?.accountId} />
<div className="incoming-call-who">
<span className="ellipsis incoming-call-name">{name}</span>
<span className="small muted ellipsis">
Incoming call{account ? ` · ${account.displayName || account.id}` : ''}
</span>
</div>
{rings.map((ring) => (
<div key={ring.key} className="incoming-call">
<Avatar name={ring.name} url={ring.avatarUrl} size={44} accountId={ring.accountId} />
<div className="incoming-call-who">
<span className="ellipsis incoming-call-name">{ring.name}</span>
<span className="small muted ellipsis">{ring.detail}</span>
</div>
<button type="button" className="call-answer" title={`Answer ${ring.name}`} onClick={ring.answer}>
<Icon name="call" size={20} />
</button>
{ring.answerWithVideo && (
<button
type="button"
className="call-answer"
title={`Answer ${name}`}
onClick={() => void store.acceptCall(call.bufferId)}
title={`Answer ${ring.name} with video`}
onClick={ring.answerWithVideo}
>
<Icon name="call" size={20} />
<Icon name="videocam" size={20} />
</button>
<button
type="button"
className="call-decline"
title={`Decline ${name}`}
onClick={() => void store.declineCall(call.bufferId)}
>
<Icon name="call_end" size={20} />
</button>
</div>
)
})}
)}
<button type="button" className="call-decline" title={`Decline ${ring.name}`} onClick={ring.decline}>
<Icon name="call_end" size={20} />
</button>
</div>
))}
</div>
)
}
+1 -1
View File
@@ -44,7 +44,7 @@ export function InvitePanel({ groupId }: { groupId: string }): JSX.Element | nul
<button type="button" className="button primary" onClick={() => answer(true)}>
Accept
</button>
<button type="button" className="button danger" onClick={() => answer(false)}>
<button type="button" className="button subtle" onClick={() => answer(false)}>
Decline
</button>
</div>
+142 -71
View File
@@ -1,10 +1,11 @@
import { useMediaUrl } from '../lib/route'
import { useEffect, useState } from 'react'
import { SwitchRow } from './Switch'
import { useEffect, useRef, useState } from 'react'
import { Icon } from './Icon'
import { ContextMenu } from './ContextMenu'
import { RoomSearch } from './RoomSearch'
import { useChat, useStore } from '../state/hooks'
import { classes } from '../lib/util'
import { classes, serviceLabel } from '../lib/util'
import type { Account, DiscordFriend } from '../../../shared/wire'
/**
@@ -67,22 +68,53 @@ export function JoinPanel(): JSX.Element {
const accountId = useChat((s) => s.joinPanelAccountId)
const account = useChat((s) => s.accounts).find((a) => a.id === accountId)
const store = useStore()
const all = useChat((s) => s.accounts)
if (!account) return <div className="panel muted">No account selected.</div>
switch (account.service) {
case 'irc':
return <IrcJoin account={account} />
case 'matrix':
return <MatrixJoin account={account} />
case 'discord':
return <DiscordJoin account={account} />
case 'kick':
return <KickJoin account={account} />
case 'sneedchat':
return <SneedchatRooms account={account} />
default:
return <div className="panel muted">Joining isn&apos;t supported for this service yet.</div>
}
const body = ((): JSX.Element => {
switch (account.service) {
case 'irc':
return <IrcJoin account={account} />
case 'matrix':
return <MatrixJoin account={account} />
case 'discord':
return <DiscordJoin account={account} />
case 'kick':
return <KickJoin account={account} />
case 'sneedchat':
return <SneedchatRooms account={account} />
default:
return <div className="panel muted">Joining isn&apos;t supported for this service yet.</div>
}
})()
const joinable = all.filter((a) => ['irc', 'matrix', 'discord', 'kick', 'sneedchat'].includes(a.service))
return (
<div className="join-frame">
{/* Which account this is for, first: it decides everything below it,
and was only ever the one selected in the footer. */}
{joinable.length > 1 && (
<div className="join-picker" role="radiogroup" aria-label="Join on">
<span className="small muted">Join on</span>
{joinable.map((a) => (
<button
key={a.id}
type="button"
role="radio"
aria-checked={a.id === account.id}
className={`service-chip${a.id === account.id ? ' active' : ''}`}
onClick={() => store.setActivePanel('join', a.id)}
>
{a.displayName || a.id}
<span className="small muted"> · {serviceLabel(a.service)}</span>
</button>
))}
</div>
)}
{body}
</div>
)
}
/** A labelled field that fires on Enter and clears itself. */
@@ -113,7 +145,7 @@ function SubmitField({
onKeyDown={(e) => e.key === 'Enter' && submit()}
/>
<button type="button" className="button" onClick={submit} disabled={!value.trim()}>
Go
Join
</button>
</div>
</div>
@@ -162,40 +194,83 @@ function IrcChannelBrowser({ account }: { account: Account }): JSX.Element {
const serverBuffer = useChat((s) => s.buffers).find(
(b) => b.accountId === account.id && b.kind === 'server'
)
const [channels, setChannels] = useState<IrcChannelListing[] | null>(null)
/** One page of the network's directory, from the daemon: the window never holds the whole of it. */
const [directory, setDirectory] = useState<{ total: number; matching: number; channels: IrcChannelListing[] } | null>(null)
const [asking, setAsking] = useState(false)
const [filter, setFilter] = useState('')
const [problem, setProblem] = useState('')
const waiting = useRef<ReturnType<typeof setTimeout> | null>(null)
/** The busiest few that match, asked of the daemon, which holds the list. */
const fetchPage = (query: string): void => {
void window.moho
.rpc<{ total: number; matching: number; channels: IrcChannelListing[] }>('getIrcChannels', {
accountId: account.id,
query,
limit: 200
})
.then(setDirectory)
.catch((e: Error) => setProblem(e.message))
}
useEffect(() => {
return window.moho.onEvent((frame) => {
if (frame.event !== 'ircChannelList') return
const data = frame.data as { accountId: string; channels: IrcChannelListing[] }
const data = frame.data as { accountId: string; count?: number; error?: string }
if (data.accountId !== account.id) return
if (waiting.current) clearTimeout(waiting.current)
setAsking(false)
setChannels(data.channels)
// The network said no, or to come back later: said, rather than left
// spinning for a list that is not coming.
if (data.error) {
setProblem(`The network did not give its list: ${data.error}`)
return
}
setProblem('')
fetchPage('')
setFilter('')
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [account.id])
useEffect(() => () => {
if (waiting.current) clearTimeout(waiting.current)
}, [])
// Filtering is the daemon's to do, a moment after the last key.
useEffect(() => {
if (!directory) return
const t = setTimeout(() => fetchPage(filter), 200)
return () => clearTimeout(t)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filter])
const ask = (): void => {
if (!serverBuffer) {
store.toast('error', 'Not connected to this network yet')
return
}
setAsking(true)
setChannels(null)
setProblem('')
if (waiting.current) clearTimeout(waiting.current)
// A network that never answers - some drop the request, and a few take
// minutes - must not leave this waiting for good. A list that does arrive
// later is still taken.
waiting.current = setTimeout(() => {
setAsking(false)
setProblem('No answer after a minute. The network may be busy or may not allow the list; try again, or join a channel by name above.')
}, 60_000)
void window.moho.rpc('sendMessage', { bufferId: serverBuffer.id, body: '/list' }).catch((e: Error) => {
if (waiting.current) clearTimeout(waiting.current)
setAsking(false)
store.toast('error', e.message)
})
}
const shown = channels
? channels.filter((c) => {
const q = filter.trim().toLowerCase()
if (!q) return true
return c.name.toLowerCase().includes(q) || c.topic.toLowerCase().includes(q)
})
: []
const stopWaiting = (): void => {
if (waiting.current) clearTimeout(waiting.current)
setAsking(false)
}
return (
<div className="field">
@@ -203,33 +278,39 @@ function IrcChannelBrowser({ account }: { account: Account }): JSX.Element {
<div className="field-row">
<input
className="text-field"
placeholder={channels ? 'filter by name or topic' : 'ask the server for its list first'}
placeholder={directory ? 'filter by name or topic' : 'ask the server for its list first'}
value={filter}
disabled={!channels}
disabled={!directory}
onChange={(e) => setFilter(e.target.value)}
/>
<button type="button" className="button" disabled={asking || !serverBuffer} onClick={ask}>
{asking ? 'Asking…' : channels ? 'Refresh' : 'List channels'}
</button>
{asking ? (
<button type="button" className="button subtle" onClick={stopWaiting}>
Stop waiting
</button>
) : (
<button type="button" className="button" disabled={!serverBuffer} onClick={ask}>
{directory ? 'Refresh' : 'List channels'}
</button>
)}
</div>
{asking && (
<p className="small muted">
A busy network can take a minute to answer, and some refuse the request entirely.
<span className="spinner" /> Asking the network. A busy one can take a minute to answer, and some refuse the
request entirely.
</p>
)}
{problem && <p className="small error-text">{problem}</p>}
{channels && (
{directory && (
<>
<p className="small muted">
{channels.length.toLocaleString()} channels, busiest first
{shown.length !== channels.length ? ` — ${shown.length.toLocaleString()} matching` : ''}
{directory.total.toLocaleString()} channels, busiest first
{directory.matching !== directory.total ? ` — ${directory.matching.toLocaleString()} matching` : ''}
{directory.matching > directory.channels.length ? `; the busiest ${directory.channels.length} shown` : ''}
</p>
<div className="channel-browser">
{/* Capped, because a filter that matches nothing in particular
still matches forty thousand rows, and drawing them would
freeze the window to no purpose. */}
{shown.slice(0, 200).map((c) => (
{directory.channels.map((c) => (
<button
key={c.name}
type="button"
@@ -353,35 +434,25 @@ function CreateMatrixRoom({ account }: { account: Account }): JSX.Element {
onChange={(e) => setTopic(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && create()}
/>
<label className="checkbox-row">
<input type="checkbox" checked={isSpace} onChange={(e) => setIsSpace(e.target.checked)} />
<span>
Make it a space
<span className="small muted"> — a container for other rooms rather than a place to talk</span>
</span>
</label>
<label className="checkbox-row">
<input type="checkbox" checked={isPublic} onChange={(e) => setIsPublic(e.target.checked)} />
<span>
Anyone can find and join it
<span className="small muted"> — otherwise it is invite only</span>
</span>
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={encrypted}
disabled={isPublic}
onChange={(e) => setEncrypted(e.target.checked)}
/>
<span>
Encrypt it
<span className="small muted">
{' '}
— cannot be turned off later, so a room that might need it should have it from the start
</span>
</span>
</label>
<SwitchRow
title="Make it a space"
description="A container for other rooms rather than a place to talk"
checked={isSpace}
onChange={setIsSpace}
/>
<SwitchRow
title="Anyone can find and join it"
description="Otherwise it is invite only"
checked={isPublic}
onChange={setIsPublic}
/>
<SwitchRow
title="Encrypt it"
description="Cannot be turned off later, so a room that might need it should have it from the start"
checked={encrypted}
disabled={isPublic}
onChange={setEncrypted}
/>
<div className="field-row">
<button type="button" className="button primary" disabled={busy || !name.trim()} onClick={create}>
{busy ? 'Creating…' : 'Create'}
@@ -886,7 +957,7 @@ function DiscordJoin({ account }: { account: Account }): JSX.Element {
)}
<span className="ellipsis">{f.globalName || f.username}</span>
<span className="small muted ellipsis">{f.username}</span>
<span className={`presence-dot ${f.status || 'offline'}`} />
<span className={`status-dot ${f.status || 'offline'}`} />
</button>
))}
+3 -5
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { Icon } from './Icon'
import { useStore } from '../state/hooks'
import { useEscapeLayer } from '../lib/layers'
/**
* Channel points, and what the streamer will trade them for.
@@ -100,19 +101,16 @@ export function KickPoints({ bufferId }: { bufferId: string }): JSX.Element | nu
// over a conversation it is not about.
useEffect(() => setOpen(false), [bufferId])
useEscapeLayer(() => setOpen(false), open)
useEffect(() => {
if (!open) return
const away = (e: MouseEvent): void => {
if (!box.current?.contains(e.target as Node)) setOpen(false)
}
const key = (e: KeyboardEvent): void => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', away)
window.addEventListener('keydown', key)
return () => {
document.removeEventListener('mousedown', away)
window.removeEventListener('keydown', key)
}
}, [open])
+12 -32
View File
@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Icon } from './Icon'
import { Modal } from './Modal'
/**
* Confirming something that cannot be undone from here.
@@ -33,37 +32,18 @@ export function LeaveConfirm(props: {
return () => clearTimeout(t)
}, [left])
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onCancel()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onCancel])
return createPortal(
<div className="lightbox-backdrop" onClick={onCancel}>
<div className="leave-confirm" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
<div className="leave-confirm-head">
<Icon name="warning" size={20} color="var(--warning)" />
<span>Leave {name}?</span>
</div>
<p className="small">{detail}</p>
<div className="leave-confirm-actions">
<button type="button" className="button" onClick={onCancel} autoFocus>
Cancel
</button>
<button
type="button"
className="button danger"
disabled={left > 0}
onClick={onLeave}
>
{left > 0 ? `Leave in ${left}` : 'Leave'}
</button>
</div>
return (
<Modal title={`Leave ${name}?`} icon="warning" iconColor="var(--warning)" onClose={onCancel} className="leave-confirm">
<p className="small">{detail}</p>
<div className="leave-confirm-actions">
<button type="button" className="button subtle" onClick={onCancel} autoFocus>
Cancel
</button>
<button type="button" className="button danger" disabled={left > 0} onClick={onLeave}>
{left > 0 ? `Leave in ${left}` : 'Leave'}
</button>
</div>
</div>,
document.body
</Modal>
)
}
+183 -40
View File
@@ -1,6 +1,9 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEscapeLayer } from '../lib/layers'
import { createPortal } from 'react-dom'
import { IconButton } from './Icon'
import { Icon, IconButton } from './Icon'
import { galleryAround, stepIndex } from '../lib/gallery'
import { actualSize, clampView, FIT, wheelFactor, zoomAt, type View } from '../lib/zoompan'
export interface LightboxSource {
kind: 'image' | 'video'
@@ -20,6 +23,11 @@ export interface LightboxSource {
interface Props {
source: LightboxSource
/**
* The element in the log this was opened from. With it the viewer can step
* to the other pictures and videos beside it in the conversation.
*/
anchor?: HTMLElement | null
onClose: () => void
}
@@ -32,45 +40,105 @@ interface Props {
* cache, a Sneedchat attachment pulled over Tor and a Discord CDN link all
* open the same way.
*/
export function Lightbox({ source, onClose }: Props): JSX.Element {
const [zoomed, setZoomed] = useState(false)
const [pan, setPan] = useState({ x: 0, y: 0 })
export function Lightbox({ source: opened, anchor = null, onClose }: Props): JSX.Element {
// Who is beside it, worked out once on opening: scrolling the log behind the
// viewer should not change what the arrows reach.
const gallery = useMemo(() => galleryAround<LightboxSource>(anchor), [anchor])
const [index, setIndex] = useState(gallery?.index ?? 0)
const first = gallery?.index ?? 0
// The one opened is the one the log hands over as it changes (its full-size
// picture may arrive after the viewer is up); the others are asked for.
const source = gallery && index !== first ? gallery.items[index]() : opened
const count = gallery?.items.length ?? 1
const [view, setView] = useState<View>(FIT)
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState('')
const dragging = useRef<{ x: number; y: number } | null>(null)
const [note, setNote] = useState('')
const dragging = useRef<{ x: number; y: number; moved: boolean } | null>(null)
const stageRef = useRef<HTMLDivElement>(null)
const imgRef = useRef<HTMLImageElement>(null)
useEscapeLayer(onClose)
const step = (by: 1 | -1): void => {
if (!gallery) return
const next = stepIndex(index, count, by)
if (next === null) return
setIndex(next)
setView(FIT)
setNatural(null)
setNote('')
}
// How big the picture is drawn at rest, and the window it is drawn in: what
// zooming and panning are measured against.
const measure = (): { fitted: { w: number; h: number }; stage: { w: number; h: number } } | null => {
const img = imgRef.current
const stage = stageRef.current
if (!img || !stage) return null
return { fitted: { w: img.offsetWidth, h: img.offsetHeight }, stage: { w: stage.clientWidth, h: stage.clientHeight } }
}
/** A point in the window, given from its middle. */
const fromCentre = (clientX: number, clientY: number): { x: number; y: number } => {
const r = stageRef.current!.getBoundingClientRect()
return { x: clientX - (r.left + r.width / 2), y: clientY - (r.top + r.height / 2) }
}
const zoomBy = (factor: number, at = { x: 0, y: 0 }): void => {
const m = measure()
if (m) setView((v) => zoomAt(v, factor, at, m.fitted, m.stage))
}
// The wheel zooms where the pointer is. Native rather than React's own, as
// it has to cancel the scroll, which a passive listener cannot.
useEffect(() => {
const stage = stageRef.current
if (!stage || source.kind !== 'image') return
const onWheel = (e: WheelEvent): void => {
e.preventDefault()
zoomBy(wheelFactor(e.deltaY, e.ctrlKey), fromCentre(e.clientX, e.clientY))
}
stage.addEventListener('wheel', onWheel, { passive: false })
return () => stage.removeEventListener('wheel', onWheel)
})
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onClose()
// A video being scrubbed uses the arrows itself.
if (e.target instanceof HTMLVideoElement || e.ctrlKey || e.metaKey || e.altKey) return
if (e.key === 'ArrowLeft') step(-1)
else if (e.key === 'ArrowRight') step(1)
else if (source.kind === 'image' && (e.key === '+' || e.key === '=')) zoomBy(1.5)
else if (source.kind === 'image' && (e.key === '-' || e.key === '_')) zoomBy(1 / 1.5)
else if (e.key === '0') setView(FIT)
else return
e.preventDefault()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
})
// Zooming a picture that is already smaller than the window would only
// blur it, so the control is offered only when there is detail to reveal.
const size = natural ?? (source.width && source.height ? { w: source.width, h: source.height } : null)
const canZoom =
source.kind === 'image' &&
!!size &&
(size.w > window.innerWidth * 0.92 || size.h > window.innerHeight * 0.86)
const toggleZoom = (): void => {
if (!canZoom) return
setPan({ x: 0, y: 0 })
setZoomed((z) => !z)
/** One click goes in to actual size at the pointer, and the next comes back out. */
const toggleZoom = (e: React.MouseEvent): void => {
const m = measure()
if (!m) return
if (view.scale > 1) setView(FIT)
else zoomBy(natural ? actualSize(natural, m.fitted) : 2, fromCentre(e.clientX, e.clientY))
}
const onPointerDown = (e: React.PointerEvent): void => {
if (!zoomed) return
dragging.current = { x: e.clientX - pan.x, y: e.clientY - pan.y }
if (view.scale <= 1) return
dragging.current = { x: e.clientX - view.x, y: e.clientY - view.y, moved: false }
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
}
const onPointerMove = (e: React.PointerEvent): void => {
const d = dragging.current
if (!d) return
setPan({ x: e.clientX - d.x, y: e.clientY - d.y })
const m = measure()
if (!d || !m) return
const x = e.clientX - d.x
const y = e.clientY - d.y
if (Math.abs(x - view.x) + Math.abs(y - view.y) > 2) d.moved = true
setView(clampView({ scale: view.scale, x, y }, m.fitted, m.stage))
}
const onPointerUp = (): void => {
dragging.current = null
@@ -88,14 +156,41 @@ export function Lightbox({ source, onClose }: Props): JSX.Element {
const download = (): void => {
if (saving) return
setSaving(true)
setSaved('')
setNote('')
void window.moho
.downloadMedia(source.externalUrl || source.src, source.filename)
.then((res) => setSaved(res.error ? `Couldn't save: ${res.error}` : `Saved to ${res.path}`))
.catch((e: Error) => setSaved(`Couldn't save: ${e.message}`))
.then((res) => setNote(res.error ? `Couldn't save: ${res.error}` : `Saved to ${res.path}`))
.catch((e: Error) => setNote(`Couldn't save: ${e.message}`))
.finally(() => setSaving(false))
}
/**
* Puts the picture on the clipboard. From its pixels where the page may read
* them - which is every picture it drew from this app's own cache - and from
* where it lives where it may not (a remote image taints the canvas).
*/
const copyImage = (): void => {
const img = imgRef.current
let pixels: string | undefined
try {
if (img && img.naturalWidth) {
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
canvas.getContext('2d')?.drawImage(img, 0, 0)
pixels = canvas.toDataURL('image/png')
}
} catch {
pixels = undefined
}
void window.moho
.copyImage(source.externalUrl || source.src, pixels)
.then((res) => setNote(res.error ? `Couldn't copy: ${res.error}` : 'Copied the picture'))
.catch((e: Error) => setNote(`Couldn't copy: ${e.message}`))
}
const zoomed = view.scale > 1
return createPortal(
// Clicking the backdrop closes; the media and toolbar stop that
// themselves, so a click that lands on either is never a dismissal.
@@ -104,21 +199,34 @@ export function Lightbox({ source, onClose }: Props): JSX.Element {
<span className="lightbox-title ellipsis">
{source.from && <strong>{source.from}</strong>}
{source.filename && <span className="small muted">{source.filename}</span>}
{saved && <span className="small lightbox-saved ellipsis">{saved}</span>}
{count > 1 && (
<span className="small muted lightbox-count">
{index + 1} / {count}
</span>
)}
{note && <span className={`small lightbox-saved ellipsis${/^Couldn't/.test(note) ? ' failed' : ''}`}>{note}</span>}
</span>
<span className="lightbox-actions">
{canZoom && (
<IconButton
name={zoomed ? 'zoom_out' : 'zoom_in'}
size={20}
title={zoomed ? 'Fit to window' : 'Zoom to full size'}
onClick={toggleZoom}
/>
{source.kind === 'image' && (
<>
<IconButton name="zoom_out" size={20} title="Zoom out (-)" disabled={!zoomed} onClick={() => zoomBy(1 / 1.5)} />
<button
type="button"
className="lightbox-level small"
title={zoomed ? 'Back to fit (0)' : 'Fitted to the window'}
disabled={!zoomed}
onClick={() => setView(FIT)}
>
{Math.round(view.scale * 100)}%
</button>
<IconButton name="zoom_in" size={20} title="Zoom in (+, or the wheel)" onClick={() => zoomBy(1.5)} />
<IconButton name="content_copy" size={20} title="Copy the picture" onClick={copyImage} />
</>
)}
<IconButton
name={saving ? 'hourglass_empty' : 'download'}
size={20}
title={saved || 'Save to your downloads folder'}
title={note || 'Save to your downloads folder'}
disabled={saving}
onClick={download}
/>
@@ -130,9 +238,10 @@ export function Lightbox({ source, onClose }: Props): JSX.Element {
{/* The stage fills the backdrop, so it must not stop the click itself -
the empty space around the media is part of "outside", and clicking
there closes. Only the media and the toolbar hold their clicks. */}
<div className={`lightbox-stage${zoomed ? ' zoomed' : ''}`}>
<div ref={stageRef} className={`lightbox-stage${zoomed ? ' zoomed' : ''}`}>
{source.kind === 'video' ? (
<video
key={index}
className="lightbox-media"
src={source.src}
controls
@@ -143,17 +252,24 @@ export function Lightbox({ source, onClose }: Props): JSX.Element {
/>
) : (
<img
key={index}
ref={imgRef}
className="lightbox-media"
src={source.src}
alt={source.filename || ''}
style={zoomed ? { transform: `translate(${pan.x}px, ${pan.y}px)` } : undefined}
draggable={false}
style={zoomed ? { transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})` } : undefined}
onLoad={(e) =>
setNatural({
w: e.currentTarget.naturalWidth,
h: e.currentTarget.naturalHeight
})
}
onClick={toggleZoom}
onClick={(e) => {
// The end of a drag is not a click on the picture.
if (dragging.current?.moved) return
toggleZoom(e)
}}
// The picture itself is not "outside": clicking it zooms.
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={onPointerDown}
@@ -163,6 +279,33 @@ export function Lightbox({ source, onClose }: Props): JSX.Element {
/>
)}
</div>
{count > 1 && (
<>
<button
type="button"
className="lightbox-nav prev"
title="Previous (←)"
aria-label="Previous"
disabled={index === 0}
onMouseDown={(e) => e.stopPropagation()}
onClick={() => step(-1)}
>
<Icon name="chevron_left" size={32} />
</button>
<button
type="button"
className="lightbox-nav next"
title="Next (→)"
aria-label="Next"
disabled={index === count - 1}
onMouseDown={(e) => e.stopPropagation()}
onClick={() => step(1)}
>
<Icon name="chevron_right" size={32} />
</button>
</>
)}
</div>,
document.body
)
+91 -30
View File
@@ -1,10 +1,12 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Icon } from './Icon'
import { Lightbox } from './Lightbox'
import { Lightbox, type LightboxSource } from './Lightbox'
import { registerMedia } from '../lib/gallery'
import { VoiceMessage } from './VoiceMessage'
import { LottieSticker, lottieStickerId } from './LottieSticker'
import { fullImageFor, knownFullImage } from '../lib/fullimage'
import { useMediaUrl, useStrictRoute } from '../lib/route'
import { classes, humanSize } from '../lib/util'
import type { MediaItem } from '../lib/format'
import type { Attachment } from '../../../shared/wire'
@@ -38,8 +40,16 @@ interface Props {
* the caller with every other picture on screen that says the same.
*/
onStale?: (bufferId: string, messageId: string) => void
/**
* One cell of a grid, where several pictures arrived in one message: it
* fills the cell and crops to it rather than keeping its own shape.
*/
tile?: boolean
}
/** What Discord puts at the front of a file name to hide it behind a spoiler. */
const SPOILER_PREFIX = 'SPOILER_'
/**
* Every cdn.discordapp.com / media.discordapp.net link Discord hands out is
* signed with an ex=/is=/hm= query string that lapses roughly 24h after issue.
@@ -62,11 +72,6 @@ function linkExpired(url: string): boolean {
return Date.now() / 1000 >= parseInt(ex, 16)
}
function humanSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
/**
* The shape an unmeasured picture is given to sit in.
@@ -88,7 +93,8 @@ export function MediaEmbed({
from,
onOpenInDiscord,
onRefresh,
onStale
onStale,
tile
}: Props): JSX.Element | null {
// Through the account's route when it is strictly routed (#257).
const media = useMediaUrl()
@@ -106,6 +112,12 @@ export function MediaEmbed({
/** What the picture turned out to be, once it had loaded enough to say. */
const [seen, setSeen] = useState<{ width: number; height: number } | null>(null)
const [playing, setPlaying] = useState(false)
// Hidden until it is clicked, as Discord does with a file sent as a spoiler.
const spoiler = !!attachment?.filename?.startsWith(SPOILER_PREFIX)
const [revealed, setRevealed] = useState(false)
const hidden = spoiler && !revealed
// What it is called, less the marker: that is not part of the name.
const shownName = spoiler ? attachment?.filename?.slice(SPOILER_PREFIX.length) : attachment?.filename
const [expanded, setExpanded] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [refreshError, setRefreshError] = useState('')
@@ -146,6 +158,30 @@ export function MediaEmbed({
const open = (): void => void window.moho.openExternal(openTarget)
/** What the viewer shows for this one: also what it shows when stepped to from another. */
const viewerSource = (): LightboxSource =>
kind === 'video'
? { kind: 'video', src: media(fullSrc), externalUrl: openTarget, filename: shownName, loop, from }
: {
kind: 'image',
src: media(expandedSrc),
externalUrl: openTarget,
filename: shownName,
width: attachment?.width,
height: attachment?.height,
from
}
// Part of the conversation's gallery for as long as it is on screen, so the
// arrows in the viewer reach it from its neighbours.
const wrapRef = useRef<HTMLSpanElement>(null)
const viewerRef = useRef(viewerSource)
viewerRef.current = viewerSource
useEffect(() => {
const el = wrapRef.current
if (!el || (kind !== 'image' && kind !== 'video')) return
return registerMedia(el, () => viewerRef.current())
}, [kind])
/**
* The size the picture is going to be, decided before it arrives.
*
@@ -163,7 +199,22 @@ export function MediaEmbed({
const measured = seen ?? (attachment?.width && attachment?.height
? { width: attachment.width, height: attachment.height }
: null)
const ratio = { aspectRatio: measured ? `${measured.width} / ${measured.height}` : UNKNOWN_RATIO }
const ratio = tile ? {} : { aspectRatio: measured ? `${measured.width} / ${measured.height}` : UNKNOWN_RATIO }
/** The cover over a hidden picture: the click that takes it away is not a click on the picture. */
const cover = hidden ? (
<span className="spoiler-cover" title="Click to reveal" role="button" aria-label="Reveal the spoiler">
<span className="spoiler-pill">SPOILER</span>
</span>
) : null
const reveal = hidden
? {
onClickCapture: (e: React.MouseEvent): void => {
e.stopPropagation()
e.preventDefault()
setRevealed(true)
}
}
: {}
const canRefresh = !!(isDiscordAttachment(openTarget) && bufferId && messageId && onRefresh)
const lapsed = canRefresh && linkExpired(openTarget)
@@ -316,7 +367,12 @@ export function MediaEmbed({
}
return (
<span className="media-embed-wrap" style={ratio}>
<span
ref={wrapRef}
className={classes('media-embed-wrap', tile && 'tile', hidden && 'spoilered')}
style={ratio}
{...reveal}
>
<img
className="media-embed"
style={ratio}
@@ -341,6 +397,7 @@ export function MediaEmbed({
onError={() => (showingPreview ? setPreviewGone(true) : setFailed(true))}
onClick={load}
/>
{cover}
{showingPreview && stale && (
<span className="expired-overlay small">
<Icon name={refreshing ? 'hourglass_empty' : 'refresh'} size={16} />
@@ -349,15 +406,8 @@ export function MediaEmbed({
)}
{expanded && (
<Lightbox
source={{
kind: 'image',
src: media(expandedSrc),
externalUrl: openTarget,
filename: attachment?.filename,
width: attachment?.width,
height: attachment?.height,
from
}}
source={viewerSource()}
anchor={wrapRef.current}
onClose={() => setExpanded(false)}
/>
)}
@@ -373,16 +423,32 @@ export function MediaEmbed({
// and handling the failure is the honest test.
return (
<>
<span className="media-embed-wrap video" style={ratio}>
<span
ref={wrapRef}
className={classes('media-embed-wrap video', tile && 'tile', hidden && 'spoilered')}
style={ratio}
{...reveal}
>
<video
className="media-embed"
style={ratio}
src={media(fullSrc)}
controls
// A GIF is silent, runs on its own and goes round, and has no
// player to operate: it plays when it is shown if autoplay is on,
// and otherwise while the pointer is over it. Any other video is a
// video, with its controls.
{...(attachment?.gif
? {
muted: true,
autoPlay: autoplay,
loop: true,
onMouseEnter: (e: React.MouseEvent<HTMLVideoElement>) => !autoplay && void e.currentTarget.play().catch(() => {}),
onMouseLeave: (e: React.MouseEvent<HTMLVideoElement>) => !autoplay && e.currentTarget.pause()
}
: { controls: true, loop })}
// Enough to draw the first frame and know how long it is, without
// pulling megabytes for every video in a scrolled-past backlog.
preload="metadata"
loop={loop}
playsInline
onError={() => setFailed(true)}
/>
@@ -394,17 +460,12 @@ export function MediaEmbed({
>
<Icon name="fullscreen" size={16} />
</button>
{cover}
</span>
{expanded && (
<Lightbox
source={{
kind: 'video',
src: media(fullSrc),
externalUrl: openTarget,
filename: attachment?.filename,
loop,
from
}}
source={viewerSource()}
anchor={wrapRef.current}
onClose={() => setExpanded(false)}
/>
)}
+3 -10
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { Icon } from './Icon'
import { Modal } from './Modal'
import { useChat, useStore } from '../state/hooks'
import type { BufferEntry } from '../state/store'
@@ -72,14 +73,7 @@ export function MembershipGate({ buffer }: { buffer: BufferEntry }): JSX.Element
</div>
{rules && (
<div className="lightbox-backdrop" onClick={() => setRules(null)}>
<div
className="membership-rules"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<div className="membership-rules-head">{group.name}</div>
<Modal title={group.name} icon="gavel" className="membership-rules" onClose={() => setRules(null)}>
{rules.description && <p className="small">{rules.description}</p>}
{terms?.label && <p className="small">{terms.label}</p>}
{terms?.description && <p className="small muted">{terms.description}</p>}
@@ -96,8 +90,7 @@ export function MembershipGate({ buffer }: { buffer: BufferEntry }): JSX.Element
Agree and continue
</button>
</div>
</div>
</div>
</Modal>
)}
</>
)
@@ -6,6 +6,7 @@ import { Avatar } from './Avatar'
import { useChat, usePref, useStore } from '../state/hooks'
import { isMutedBuffer, MENTIONS_GROUP_ID } from '../lib/groups'
import { bufferDisplayName, formatFullTime, formatRelativeTime, serviceLabel } from '../lib/util'
import { useEscapeLayer } from '../lib/layers'
/**
* The inbox: everything that mentioned you and that you have not seen yet.
@@ -69,6 +70,8 @@ export function MentionsInbox(): JSX.Element {
[mentions, dismissedTs, lastReadTs, buffers, muted, mutedGroups]
)
useEscapeLayer(() => setOpen(false), open)
// Clicking anywhere else puts it away, the way any other transient panel
// behaves.
useEffect(() => {
@@ -76,14 +79,9 @@ export function MentionsInbox(): JSX.Element {
const close = (e: MouseEvent): void => {
if (!box.current?.contains(e.target as Node)) setOpen(false)
}
const key = (e: KeyboardEvent): void => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', close)
document.addEventListener('keydown', key)
return () => {
document.removeEventListener('mousedown', close)
document.removeEventListener('keydown', key)
}
}, [open])
+111 -36
View File
@@ -1,9 +1,11 @@
import { useState } from 'react'
import { accountOfBuffer } from '../lib/route'
import { Icon } from './Icon'
import { Avatar } from './Avatar'
import { useChat, usePref, useStore } from '../state/hooks'
import { isMutedBuffer } from '../lib/groups'
import { bufferDisplayName, formatFullTime, serviceLabel } from '../lib/util'
import { bufferDisplayName, classes, dayLabel, dayOf, formatFullTime, serviceLabel } from '../lib/util'
import { IconButton } from './Icon'
/**
* Everything that mentioned you, from every service, newest first - read and
@@ -27,61 +29,134 @@ export function MentionsPage(): JSX.Element {
const rows = useChat((s) => s.mentions)
const [muted] = usePref<string[]>('mutedBuffers', [])
const [mutedGroups] = usePref<string[]>('mutedGroups', [])
const lastRead = useChat((s) => s.lastReadTs)
// Put away one at a time, and remembered: the list is everything that ever
// mentioned you, and is only worth reading while it holds what still needs
// an answer.
const [dismissed, setDismissed] = usePref<string[]>('mentions.dismissed', [])
const [service, setService] = useState<string>('all')
const [unreadOnly, setUnreadOnly] = useState(false)
// A muted conversation was told not to ask for attention. Collecting its
// mentions into a page whose entire purpose is asking for attention would
// be the one place that instruction is ignored.
const mentions = rows.filter((m) => {
const live = rows.filter((m) => {
const buffer = buffers.find((b) => b.id === m.bufferId)
return !buffer || !isMutedBuffer(buffer, muted, mutedGroups)
return (!buffer || !isMutedBuffer(buffer, muted, mutedGroups)) && !dismissed.includes(m.id)
})
const serviceOf = (bufferId: string): string => {
const buffer = buffers.find((b) => b.id === bufferId)
return accounts.find((a) => a.id === buffer?.accountId)?.service ?? ''
}
const isUnread = (m: (typeof rows)[number]): boolean => m.ts > (lastRead[m.bufferId] ?? 0)
const services = [...new Set(live.map((m) => serviceOf(m.bufferId)).filter(Boolean))]
const mentions = live.filter(
(m) => (service === 'all' || serviceOf(m.bufferId) === service) && (!unreadOnly || isUnread(m))
)
if (mentions.length === 0) {
if (live.length === 0) {
return (
<div className="mentions-page empty muted">
<Icon name="alternate_email" size={32} />
<p>Nothing has mentioned you yet.</p>
<p>{rows.length === 0 ? 'Nothing has mentioned you yet.' : 'Nothing left to answer.'}</p>
{dismissed.length > 0 && (
<button type="button" className="button subtle small" onClick={() => setDismissed([])}>
Bring back the ones put away
</button>
)}
</div>
)
}
// Grouped by the day they came, newest first, as the list already is.
const groups: { day: number; label: string; items: typeof mentions }[] = []
for (const m of mentions) {
const day = dayOf(m.ts)
const last = groups[groups.length - 1]
if (last && last.day === day) last.items.push(m)
else groups.push({ day, label: dayLabel(m.ts), items: [m] })
}
return (
<div className="mentions-page">
{mentions.map((m) => {
const buffer = buffers.find((b) => b.id === m.bufferId)
const account = accounts.find((a) => a.id === buffer?.accountId)
// A mention whose conversation this client has since forgotten still
// shows: the message is the point, and refusing to draw it because the
// buffer list has moved on would hide the very thing being collected.
const where = buffer ? bufferDisplayName(buffer.name) : 'a closed conversation'
return (
<div className="mentions-filters">
{['all', ...services].map((value) => (
<button
key={m.id}
key={value}
type="button"
className="mention-row"
// Opens the conversation and scrolls to the message itself, which
// is the only reason to click one of these.
onClick={() => {
if (!buffer) return
void store.selectBuffer(buffer.id, true).then(() => store.jumpToMessage(buffer.id, m.id).then(() => store.setJumpTarget(m.id)))
}}
disabled={!buffer}
className={classes('service-chip', service === value && 'active')}
onClick={() => setService(value)}
>
<Avatar name={m.from} url={m.avatarUrl} size={32} accountId={accountOfBuffer(m.bufferId)} />
<span className="mention-body">
<span className="mention-head small">
<span className="mention-from">{m.from}</span>
<span className="muted">
in {where}
{account ? ` · ${serviceLabel(account.service)}` : ''}
</span>
<span className="muted mention-when">{formatFullTime(m.ts)}</span>
</span>
<span className="mention-text ellipsis">{m.body}</span>
</span>
{value === 'all' ? 'All' : serviceLabel(value as never)}
</button>
)
})}
))}
<span className="mentions-filters-gap" />
<button
type="button"
className={classes('service-chip', unreadOnly && 'active')}
aria-pressed={unreadOnly}
onClick={() => setUnreadOnly(!unreadOnly)}
>
Unread
</button>
</div>
{mentions.length === 0 && <p className="muted small mentions-none">Nothing matches those filters.</p>}
{groups.map((group) => (
<div key={group.day}>
<div className="date-divider" role="separator">
<span>{group.label}</span>
</div>
{group.items.map((m) => {
const buffer = buffers.find((b) => b.id === m.bufferId)
const account = accounts.find((a) => a.id === buffer?.accountId)
// A mention whose conversation this client has since forgotten still
// shows: the message is the point, and refusing to draw it because the
// buffer list has moved on would hide the very thing being collected.
const where = buffer ? bufferDisplayName(buffer.name) : 'a closed conversation'
return (
<div key={m.id} className={classes('mention-entry', isUnread(m) && 'unread')}>
<button
type="button"
className="mention-open"
// Opens the conversation and scrolls to the message itself, which
// is the only reason to click one of these.
onClick={() => {
if (!buffer) return
void store
.selectBuffer(buffer.id, true)
.then(() => store.jumpToMessage(buffer.id, m.id).then(() => store.setJumpTarget(m.id)))
}}
disabled={!buffer}
>
<Avatar name={m.from} url={m.avatarUrl} size={32} accountId={accountOfBuffer(m.bufferId)} />
<span className="mention-body">
<span className="mention-head small">
<span className="mention-from">{m.from}</span>
<span className="muted">
in {where}
{account ? ` · ${serviceLabel(account.service)}` : ''}
</span>
<span className="muted mention-when" title={formatFullTime(m.ts)}>
{new Date(m.ts * 1000).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
</span>
</span>
<span className="mention-text-two">{m.body}</span>
</span>
</button>
<IconButton
name="close"
size={14}
title="Put this away"
className="mention-dismiss"
onClick={() => setDismissed([...dismissed, m.id].slice(-500))}
/>
</div>
)
})}
</div>
))}
</div>
)
}
+122 -8
View File
@@ -9,7 +9,7 @@ import { StreamStage } from './StreamStage'
import { ServiceBanner } from './ServiceBanner'
import { RouteAccount, useStrictRoute } from '../lib/route'
import { useChat, usePref, useStore } from '../state/hooks'
import { bufferDisplayName, isChatKind } from '../lib/util'
import { bufferDisplayName, dayLabel, isChatKind, startsNewDay } from '../lib/util'
import type { ChannelIndex } from '../lib/format'
import type { ChatMessage } from '../state/store'
@@ -52,6 +52,9 @@ const REANCHOR_THRESHOLD = 100
*/
const SCROLL_JITTER = 2
/** How far down from the top edge to look for the row being read, nearest first. */
const PLACE_PROBES = [1, 24, 48, 80, 120, 180]
/**
* How long after the reader last touched the list a scroll still counts as
* theirs.
@@ -187,6 +190,12 @@ export function MessageList(): JSX.Element {
)?.service
const dividerTs = dividerTsByBuffer[bufferId] || 0
/** Whether the "New" line is above what is on screen, so there is more to read than is shown. */
const [dividerAbove, setDividerAbove] = useState(false)
/** The "N new messages" bar, put away for this much of this conversation. */
const [barDismissed, setBarDismissed] = useState('')
/** Set when a buffer opens with a lot unread: the layout effect that follows takes it to the line. */
const wantDividerRef = useRef(false)
const isLoadingMore = !!loadingMore[bufferId]
const isLoadingNewer = !!loadingNewer[bufferId]
// Where this conversation is missing its middle, from having been entered
@@ -357,17 +366,29 @@ export function MessageList(): JSX.Element {
*
* By hit test rather than by walking the rows: this runs on every scroll,
* and a list of two thousand is not a thing to scan that often.
*
* Every element under the point, not the topmost one, and a few points down
* the page rather than one. The "N new messages since" bar is laid over the
* top of the log, so asking what was at the edge was answered with the bar
* for as long as it was up - and the place stopped being kept, which the
* next thing to change size then put the view back to. A date line or the
* gap between two groups at the edge gave the same non-answer for a moment.
*/
const notePlace = useCallback(() => {
const el = scrollRef.current
if (!el) return
const box = el.getBoundingClientRect()
const hit = document.elementFromPoint(box.left + 8, box.top + 1)
const row = hit?.closest?.('[data-msg-id]') as HTMLElement | null
if (!row) return
placeRef.current = {
id: row.dataset.msgId ?? '',
offset: row.getBoundingClientRect().top - box.top
for (const down of PLACE_PROBES) {
for (const hit of document.elementsFromPoint(box.left + 8, box.top + down)) {
if (!el.contains(hit)) continue
const row = hit.closest?.('[data-msg-id]') as HTMLElement | null
if (!row) continue
placeRef.current = {
id: row.dataset.msgId ?? '',
offset: row.getBoundingClientRect().top - box.top
}
return
}
}
}, [])
@@ -498,8 +519,63 @@ export function MessageList(): JSX.Element {
anchor(true)
setMissedCount(0)
scrollToBottom()
setDividerAbove(false)
}, [bufferId, messages, anchor, scrollToBottom])
// Opened with a good deal unread: the place to start reading is where that
// begins, not the end of it. Decided once per opening, and not at the moment
// of switching, because what has been read from the daemon may not have
// arrived yet - the line has to be in the drawn part of the log, which is a
// tail, before it can be scrolled to.
const openedAtRef = useRef('')
useLayoutEffect(() => {
const key = `${bufferId}|${dividerTs}`
if (openedAtRef.current === key || messages.length === 0) return
openedAtRef.current = key
const first = dividerTs > 0 ? messages.findIndex((m) => m.ts > dividerTs) : -1
if (first >= 0 && messages.length - first > 8) {
wantDividerRef.current = true
setShown((n) => Math.max(n, messages.length - first + JUMP_MARGIN))
}
}, [bufferId, dividerTs, messages])
/** Where the "New" line is against what is showing; read off the page, since that is what the person sees. */
const measureDivider = useCallback(() => {
const el = scrollRef.current
const line = el?.querySelector<HTMLElement>('.new-divider')
if (!el || !line) {
setDividerAbove(false)
return
}
// Clearly above, not merely just off the top: with the line a few pixels
// out of sight, everything after it is on screen and there is nothing to
// go back for.
setDividerAbove(line.getBoundingClientRect().bottom < el.getBoundingClientRect().top - 80)
}, [])
const toDivider = useCallback((smooth: boolean) => {
const el = scrollRef.current
const line = el?.querySelector<HTMLElement>('.new-divider')
if (!el || !line) return
anchor(false)
// Going back to the line is not missing anything: what is below is what
// was already there, not news.
setMissedCount(0)
lastIdRef.current = messages[messages.length - 1]?.id
const target = el.scrollTop + line.getBoundingClientRect().top - el.getBoundingClientRect().top - 48
el.scrollTo({ top: Math.max(0, target), behavior: smooth ? 'smooth' : 'auto' })
}, [anchor, messages])
// Once the line is drawn, start there.
useLayoutEffect(() => {
if (!wantDividerRef.current) return
const line = scrollRef.current?.querySelector('.new-divider')
if (!line) return
wantDividerRef.current = false
toDivider(false)
measureDivider()
}, [messages, shown, bufferId, toDivider, measureDivider])
useLayoutEffect(() => {
// Older messages prepended by a load-more: hold the user's reading
// position rather than letting the new content shove it down the page.
@@ -562,6 +638,7 @@ export function MessageList(): JSX.Element {
lastHeightRef.current = height
lastViewportRef.current = viewport
notePlace()
measureDivider()
// Reading away un-pins the view, and reading back pins it again; nothing
// else moves it. Both halves of that have been got wrong here before, and
@@ -641,6 +718,16 @@ export function MessageList(): JSX.Element {
scrollToBottom('smooth')
}
// Escape, from anywhere in the window (lib/shortcuts.ts), is "I am done with
// this": read it and go to the end of it.
const jumpRef = useRef(jumpToPresent)
jumpRef.current = jumpToPresent
useEffect(() => {
const go = (): void => jumpRef.current()
window.addEventListener('moho:jump-to-present', go)
return () => window.removeEventListener('moho:jump-to-present', go)
}, [])
// The divider marks the first message newer than the snapshot taken when the
// buffer was opened; it deliberately doesn't move as more arrive.
const dividerIndex = dividerTs > 0 ? messages.findIndex((m) => m.ts > dividerTs) : -1
@@ -714,6 +801,14 @@ export function MessageList(): JSX.Element {
const i = start + offset
return (
<div key={msg.id}>
{/* A new day, said once, where it begins - and on the first
line drawn, so the top of the screen is never a time of day
with no day. */}
{(startsNewDay(messages, i) || (offset === 0 && msg.ts > 0)) && (
<div className="date-divider" role="separator">
<span>{dayLabel(msg.ts)}</span>
</div>
)}
{i === dividerIndex && (
<div className="new-divider">
<span>New</span>
@@ -780,6 +875,25 @@ export function MessageList(): JSX.Element {
</div>
)}
{dividerAbove && barDismissed !== `${bufferId}|${dividerTs}` && dividerIndex >= 0 && (
<div className="unread-bar" role="status">
<button type="button" className="unread-bar-text" onClick={() => toDivider(true)}>
{messages.length - dividerIndex} new message{messages.length - dividerIndex === 1 ? '' : 's'} since{' '}
{new Date(dividerTs * 1000).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
</button>
<button
type="button"
className="unread-bar-read"
onClick={() => {
void store.markBuffersRead([bufferId])
setBarDismissed(`${bufferId}|${dividerTs}`)
}}
>
Mark as read <Icon name="mark_chat_read" size={14} />
</button>
</div>
)}
{!anchored && (
<button type="button" className="jump-to-present" onClick={jumpToPresent}>
<Icon name="arrow_downward" size={16} />
@@ -835,7 +949,7 @@ function HistoryGap({ loading, onFill }: { loading: boolean; onFill: () => void
)
}
function isGrouped(messages: ChatMessage[], index: number): boolean {
export function isGrouped(messages: ChatMessage[], index: number): boolean {
if (index === 0) return false
const prev = messages[index - 1]
const cur = messages[index]
+483 -69
View File
@@ -7,14 +7,17 @@ import { EventSource } from './EventSource'
import { ForwardPicker } from './ForwardPicker'
import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu'
import { MediaEmbed } from './MediaEmbed'
import { EmojiPicker } from './EmojiPicker'
import { COMMON_EMOJI, EmojiPicker, readRecent } from './EmojiPicker'
import { reactionLabel, reactorsSentence } from '../lib/reactions'
import { UploadMeter } from './UploadMeter'
import { RichText } from '../lib/richtext'
import { useChat, usePref, useStore } from '../state/hooks'
import { useTick } from '../lib/clock'
import { useSniffedTypes, sniffUrl } from '../lib/sniff'
import { useMediaUrl, useStrictRoute } from '../lib/route'
import type { ChatMessage } from '../state/store'
import type {
Attachment,
CustomEmoji,
MessageBadge,
MessageComponent,
@@ -42,6 +45,7 @@ import {
classes,
formatFullTime,
formatRelativeTime,
formatRelativeShort,
formatTime,
hasDirectMessages,
isChatKind,
@@ -50,6 +54,7 @@ import {
isWhisper,
nickColor
} from '../lib/util'
import { useEscapeLayer } from '../lib/layers'
/**
* A badge, short enough to sit beside a name.
@@ -79,7 +84,28 @@ function badgeLabel(badge: MessageBadge): string {
}
/** A handful of one-click reactions on the hover toolbar. */
const QUICK_REACTIONS = ['👍', '❤️', '😂', '🔥']
/** What a line that is not speech is marked with, by the daemon's word for it. */
const SYSTEM_MARKS: Record<string, string> = {
join: 'login',
part: 'logout',
quit: 'logout',
kick: 'person_remove',
nick: 'badge',
topic: 'sell',
mode: 'shield'
}
const DEFAULT_REACTIONS = ['👍', '❤️', '😂', '🔥']
/**
* The four faces on the hover toolbar: what this account has reacted with
* lately, topped up with the usual ones. Plain emoji only - a custom one
* belongs to a server and may not be sendable from wherever the toolbar is.
*/
function quickReactions(accountId?: string): string[] {
const recent = readRecent(accountId).filter((e) => !e.startsWith('<') && !e.startsWith(':'))
return [...new Set([...recent, ...DEFAULT_REACTIONS])].slice(0, 4)
}
export type MessageMode = 'classic' | 'comfy' | 'bubbles'
@@ -258,21 +284,16 @@ function ReaderList({
})
}, [at])
useEscapeLayer(onClose)
useEffect(() => {
const away = (e: MouseEvent): void => {
if (!box.current?.contains(e.target as Node)) onClose()
}
const key = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onClose()
}
// Capturing, so a click anywhere closes this before that click does
// anything else - including on another message's faces.
window.addEventListener('mousedown', away, true)
window.addEventListener('keydown', key)
return () => {
window.removeEventListener('mousedown', away, true)
window.removeEventListener('keydown', key)
}
return () => window.removeEventListener('mousedown', away, true)
}, [onClose])
const shown = readers.slice(0, MAX_READER_NAMES)
@@ -318,8 +339,25 @@ function MessageRowBody({
const strictRoute = useStrictRoute()
const store = useStore()
const { menu, open, close } = useContextMenu()
const [editing, setEditing] = useState(false)
// The message this one answers, if it is on screen to be asked: its name
// colour and picture are the author's, and are not in the reply's own
// preview. The object itself, so the row only re-renders when that one
// message changes.
const replyId = message.replyTo && !message.replyTo.thread && !message.replyTo.forwarded ? message.replyTo.id : ''
const original = useChat((s) => (replyId ? s.messagesByBuffer[bufferId]?.find((m) => m.id === replyId) : undefined))
// Which message is open for editing is the window's, not each row's, so
// Up arrow in the message box can open the last one.
const editing = useChat((s) => s.editingId === message.id)
const setEditing = (on: boolean): void => (on ? store.startEdit(message.id) : store.stopEdit())
/** Saving an empty edit is asking to delete it, which is asked about first. */
const [askDelete, setAskDelete] = useState(false)
const [draft, setDraft] = useState(message.body)
// Opened from elsewhere - Up arrow in the message box - the field starts
// with what the message says now.
useEffect(() => {
if (editing) setDraft(message.body)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editing])
const [revealed, setRevealed] = useState<Record<number, boolean>>({})
const [pickerOpen, setPickerOpen] = useState(false)
/** Writing the reason for a report, before it is sent. */
@@ -346,6 +384,12 @@ function MessageRowBody({
const canEditDelete =
!!message.isOwn && (service === 'discord' || service === 'sneedchat' || service === 'matrix')
const canReact = service === 'discord' || service === 'matrix'
// Read again after the picker has been used, which is when it changes.
const quick = useMemo(
() => quickReactions(store.accountFor(bufferId)?.id),
// eslint-disable-next-line react-hooks/exhaustive-deps
[bufferId, pickerOpen]
)
const pinned = shared.pinned.includes(message.id)
const isSystem = !isChatKind(message.kind)
// Said to you rather than to the room. Drawn differently on purpose: the
@@ -375,7 +419,30 @@ function MessageRowBody({
*/
const own = bubbles && !isSystem && !!message.isOwn
const attachments = message.attachments ?? []
// A GIF that was linked is a picture to play, and goes where pictures go:
// the same sizing, the same autoplay and loop settings, the same viewer.
const gifs = useMemo(
() =>
(message.embeds ?? [])
.filter((e) => e.kind === 'gif' && e.mediaUrl)
.map((e): Attachment => ({
// As the daemon said; by the ending only for one that did not.
kind: e.mediaKind ?? (/\.(mp4|webm|mov|mkv|ogv)(\?|$)/i.test(e.mediaUrl!) ? 'video' : 'image'),
gif: true,
url: e.mediaUrl,
width: e.width,
height: e.height,
filename: 'gif'
})),
[message.embeds]
)
const attachments = useMemo(() => [...(message.attachments ?? []), ...gifs], [message.attachments, gifs])
// The ones that go in a grid, and the ones that stay in the row, each with
// its place in the message so its key stays the same whichever it is.
const indexed = attachments.map((att, i) => ({ att, i }))
const gridable = indexed.filter(({ att }) => att.kind === 'image' || att.kind === 'video')
const tiled = gridable.length >= 2 ? gridable : []
const rowed = gridable.length >= 2 ? indexed.filter((x) => !gridable.includes(x)) : indexed
// The extraction passes each walk the same normalised body independently,
// matching how the original structured this - one do-everything function
@@ -399,7 +466,12 @@ function MessageRowBody({
const codeBlocks = extractCodeBlocks(normalized)
const quoteBlocks = extractQuoteBlocks(stripCodeBlocks(normalized))
let body = stripEmbeddedUrls(normalized, media)
// The page a GIF was linked from is not shown as well as the GIF: it is the
// same thing said twice, and is how a posted GIF read as an address.
const gifPages = (message.embeds ?? [])
.filter((e) => e.kind === 'gif' && e.url)
.map((e) => ({ url: e.url!, kind: 'image' as const }))
let body = stripEmbeddedUrls(normalized, [...media, ...gifPages])
body = stripCodeBlocks(body)
body = stripQuoteBlocks(body)
@@ -430,6 +502,43 @@ function MessageRowBody({
}
}, [message.body, message.html, contentSniffing, sniffed, revealed, isSneedchat, smilieIndex, channels, service, ircColours, strictRoute])
// Somebody else's name or picture opens who they are, as it does in the
// clients this is measured against; your own and the system's have nothing
// to open.
// Read when the menu opens: by the time an entry is clicked the window may
// have let go of the selection.
const selectedRef = useRef('')
const selected = selectedRef.current
const canOpenProfile = !!message.from && !isSystem && !message.isOwn
const openProfile = (): void => store.showProfile(bufferId, message.from, message.senderId)
// Back to what this answers, bringing it into view first if it is a long
// way up - and saying so if it is gone, rather than doing nothing.
const jumpToReply = (): void => {
if (!replyId) return
void store.jumpToMessage(bufferId, replyId).then((found) => {
if (found) store.setJumpTarget(replyId)
else store.toast('info', "Couldn't find the message this answers")
})
}
// Said a moment ago, as this row is first drawn: it arrives rather than
// appears. History being scrolled into view is old and stays still.
const fresh = useRef(Math.floor(Date.now() / 1000) - message.ts < 3).current
// Reactions that are new, or have gone up, since this row last drew: they
// land with a pop. Nothing pops on the first drawing - a row scrolled into
// view full of reactions has not just been reacted to.
const seenReactions = useRef<Record<string, number> | null>(null)
const popped = new Set<string>()
if (seenReactions.current) {
for (const r of message.reactions ?? []) {
if ((seenReactions.current[r.emoji] ?? 0) < r.count) popped.add(r.emoji)
}
}
useEffect(() => {
seenReactions.current = Object.fromEntries((message.reactions ?? []).map((r) => [r.emoji, r.count]))
}, [message.reactions])
const entries: MenuEntry[] = [
// First, above what to do about the message: the question a right click
// on somebody's line usually asks is who they are, and finding them in a
@@ -444,6 +553,15 @@ function MessageRowBody({
] as MenuEntry[])
: []),
{ label: 'Reply', icon: 'reply', onClick: () => reply() },
// What was selected when the menu opened, first: right-clicking on
// highlighted text and finding no Copy is the one thing this menu must not
// do, having replaced the one the window would have given.
...(selected
? ([{ label: 'Copy', icon: 'content_copy', onClick: () => void window.moho.copyText(selected) }] as MenuEntry[])
: []),
...(message.body && !isSystem
? ([{ label: 'Copy text', icon: 'content_copy', onClick: () => void window.moho.copyText(message.body) }] as MenuEntry[])
: []),
// Sending somebody else's message on. Only where the service has a real
// forward: Discord's carries the original itself, and Matrix's sends its
// content again, the same upload and formatting. Anywhere else it would
@@ -568,6 +686,15 @@ function MessageRowBody({
}
] as MenuEntry[])
: []),
...(service === 'discord' && message.id && !isSystem
? ([
{
label: 'Copy message link',
icon: 'link',
onClick: () => void store.copyDiscordMessageLink(bufferId, message.id)
}
] as MenuEntry[])
: []),
...(service === 'discord'
? ([
{
@@ -613,7 +740,10 @@ function MessageRowBody({
store.startReply(message.id, message.from, message.body)
}
const timeLabel = relativeTimestamps ? formatRelativeTime(message.ts) : formatTime(message.ts)
// Aged on a timer shared by every row, so "now" becomes "3m" without the
// list being redrawn.
const tick = useTick(relativeTimestamps)
const timeLabel = relativeTimestamps ? formatRelativeShort(message.ts, tick || Date.now()) : formatTime(message.ts)
/**
* Which unfurled link belongs to which rich embed.
@@ -657,6 +787,7 @@ function MessageRowBody({
isSystem && 'system',
reward && 'reward',
grouped && 'grouped',
fresh && 'fresh',
comfy && 'comfy',
bubbles && 'bubbles',
own && 'own',
@@ -672,12 +803,15 @@ function MessageRowBody({
// Where a picture in it came from, for fetching it again once its
// cached copy has gone - see lib/mediarestore.
data-buffer-id={bufferId}
onContextMenu={open}
onContextMenu={(e) => {
selectedRef.current = window.getSelection()?.toString().trim() ?? ''
open(e)
}}
>
{/* Bubbles carry their own time inside, which is the whole point of
the shape - so the gutter that reserves 42px for it on every other
row would be 42px of nothing. */}
{!bubbles && (
{mode === 'classic' && (
<span className="message-time small muted" title={formatFullTime(message.ts)}>
{grouped ? '' : timeLabel}
</span>
@@ -691,8 +825,28 @@ function MessageRowBody({
client, where a group's text all shares one left edge. Wrapped
lines never showed it because they wrap inside message-content,
which was already in the right place. */}
{/* A join, a part, a topic change: not somebody speaking, so no face -
but a small mark in the picture's own column, so the line's text
starts where every other line's does instead of at the edge. */}
{mode === 'comfy' && isSystem && (
<span className="message-avatar system-mark" aria-hidden>
<Icon name={SYSTEM_MARKS[message.kind] ?? 'info'} size={16} />
</span>
)}
{comfy && !isSystem && (
<span className="message-avatar">
<span
className={classes('message-avatar', canOpenProfile && !grouped && 'clickable')}
onClick={canOpenProfile && !grouped ? openProfile : undefined}
title={canOpenProfile && !grouped ? `Profile of ${message.from}` : undefined}
>
{/* A follow-up line has no name line to carry its time, so the
time waits in the gutter where the picture would be, and shows
when the pointer is on the line. */}
{mode === 'comfy' && grouped && (
<span className="gutter-time" title={formatFullTime(message.ts)}>
{formatTime(message.ts)}
</span>
)}
{!grouped &&
(message.avatarUrl ? (
<img
@@ -726,9 +880,22 @@ function MessageRowBody({
byline, so they live in it. */}
{!grouped && !isSystem && !own && (
<span className="message-byline">
<span className="message-from" style={{ color: message.senderColor || nickColor(message.from) }}>
<span
className={classes('message-from', canOpenProfile && 'clickable')}
style={{ color: message.senderColor || nickColor(message.from) }}
onClick={canOpenProfile ? openProfile : undefined}
role={canOpenProfile ? 'button' : undefined}
title={canOpenProfile ? `Profile of ${message.from}` : undefined}
>
{message.isAction ? `* ${message.from}` : message.from}
</span>
{/* The time on the name's own line, as Discord does it, in the
full form the tooltip also carries. */}
{mode === 'comfy' && (
<span className="message-stamp" title={formatFullTime(message.ts)}>
{timeLabel}
</span>
)}
{message.badges?.map((badge) => (
<span
key={badge.type}
@@ -774,7 +941,10 @@ function MessageRowBody({
the thread it is in. The second is a button, because a thread is
somewhere you can go - unless this is already the thread, where
it would only reopen what is on screen. */}
{/* Inside the thread itself, "in thread" above every reply says what
the panel's title already says. */}
{message.replyTo &&
!(inThread && message.replyTo.thread) &&
(message.replyTo.thread && !inThread ? (
<button
type="button"
@@ -786,7 +956,14 @@ function MessageRowBody({
{message.replyTo.body && <span className="ellipsis">{message.replyTo.body}</span>}
</button>
) : (
<div className="reply-preview small muted">
<div
className={classes('reply-preview small muted', replyId && 'reply-jump')}
onClick={replyId ? jumpToReply : undefined}
role={replyId ? 'button' : undefined}
tabIndex={replyId ? 0 : undefined}
onKeyDown={replyId ? (e) => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), jumpToReply()) : undefined}
title={replyId ? 'Go to the message' : undefined}
>
{/* A forward is not a reply and does not get the reply's
arrow: it is somebody else's message arriving, which is a
different thing from an answer to one. Its own word for
@@ -799,10 +976,17 @@ function MessageRowBody({
}
size={13}
/>
{/* The author's own picture where the original is on screen, the
way Discord draws a reply. */}
{original?.avatarUrl && (
<img className="reply-avatar" src={media(original.avatarUrl)} alt="" loading="lazy" />
)}
<span
className="reply-from"
style={
message.replyTo.forwarded ? undefined : { color: nickColor(message.replyTo.from) }
message.replyTo.forwarded
? undefined
: { color: original?.senderColor || nickColor(message.replyTo.from) }
}
>
{message.replyTo.from}
@@ -812,20 +996,68 @@ function MessageRowBody({
))}
{editing ? (
<input
className="text-field"
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') setEditing(false)
if (e.key === 'Enter') {
void store.editMessage(bufferId, message.id, draft)
setEditing(false)
}
}}
onBlur={() => setEditing(false)}
/>
<div className="message-editor">
<textarea
className="text-field message-edit-field"
autoFocus
rows={Math.min(8, Math.max(1, draft.split('\n').length))}
value={draft}
onFocus={(e) => e.currentTarget.setSelectionRange(draft.length, draft.length)}
onChange={(e) => {
setDraft(e.target.value)
setAskDelete(false)
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault()
setAskDelete(false)
setEditing(false)
} else if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
if (!draft.trim()) {
setAskDelete(true)
return
}
if (draft !== message.body) void store.editMessage(bufferId, message.id, draft)
setEditing(false)
}
}}
/>
{askDelete ? (
<div className="message-edit-hint small">
<span>Nothing left. Delete the message instead?</span>
<button
type="button"
className="link-button danger-text"
onClick={() => {
void store.deleteMessage(bufferId, message.id)
setAskDelete(false)
setEditing(false)
}}
>
Delete
</button>
<button type="button" className="link-button" onClick={() => setAskDelete(false)}>
Keep editing
</button>
</div>
) : (
<div className="message-edit-hint small muted">
escape to <button type="button" className="link-button" onClick={() => setEditing(false)}>cancel</button> · enter to{' '}
<button
type="button"
className="link-button"
onClick={() => {
if (!draft.trim()) return setAskDelete(true)
if (draft !== message.body) void store.editMessage(bufferId, message.id, draft)
setEditing(false)
}}
>
save
</button>
</div>
)}
</div>
) : (
parts.html && (
<span className="message-body selectable">
@@ -843,7 +1075,15 @@ function MessageRowBody({
onOpenChannel={(id) => void store.selectBuffer(id, true)}
onOpenLink={(url) => store.followDeepLink(url)}
/>
{message.edited && <span className="edited-tag small muted"> (edited)</span>}
{message.edited && (
<span
className="edited-tag small muted"
title={message.editedTs ? `Edited ${formatFullTime(message.editedTs)}` : 'Edited'}
>
{' '}
(edited)
</span>
)}
</span>
)
)}
@@ -860,15 +1100,27 @@ function MessageRowBody({
</pre>
))}
{(message.embeds || []).map((embed, i) => (
{(message.embeds || []).map((embed, i) =>
embed.kind === 'gif' ? null : (
<div
key={`e${i}`}
className="rich-embed selectable"
className={classes('rich-embed selectable', embed.kind === 'notice' && 'notice')}
style={{ borderLeftColor: embedColor(embed.color) || 'var(--outline-strong)' }}
>
{embed.provider && <div className="rich-embed-provider small muted">{embed.provider}</div>}
{embed.author && <div className="rich-embed-author small">{embed.author}</div>}
{embed.title &&
{embed.title && embed.kind === 'notice' && (
<div className="rich-embed-title notice-title">
<Icon name={embed.icon === 'warning' ? 'warning' : 'campaign'} size={16} color={embedColor(embed.color) || undefined} />
{embed.title}
</div>
)}
{embed.kind === 'notice' && embed.timestamp && (
<div className="small muted" title={formatFullTime(Math.floor(Date.parse(embed.timestamp) / 1000))}>
{formatRelativeTime(Math.floor(Date.parse(embed.timestamp) / 1000))}
</div>
)}
{embed.title && embed.kind !== 'notice' &&
(embed.url ? (
<a
className="rich-embed-title"
@@ -893,6 +1145,27 @@ function MessageRowBody({
/>
</div>
)}
{!!embed.fields?.length && (
<div className="rich-embed-fields small">
{embed.fields.map((f, k) => (
<div key={k} className="rich-embed-field">
<div className="rich-embed-field-name">{f.name}</div>
<div>{f.value}</div>
</div>
))}
</div>
)}
{embed.footer && <div className="rich-embed-footer small muted">{embed.footer}</div>}
{embed.cta && (
<button
type="button"
className={classes('rich-embed-cta', embed.color === 0xda373c && 'danger')}
disabled={!embed.cta.url}
onClick={() => embed.cta?.url && void window.moho.openExternal(embed.cta.url)}
>
{embed.cta.label}
</button>
)}
{/* The picture the card is about, where the card brought one
of its own. A Matrix link preview is unfurled by the
homeserver and arrives as a file the daemon has already
@@ -921,13 +1194,38 @@ function MessageRowBody({
</div>
)}
</div>
))}
)
)}
{(attachments.length > 0 || embedMedia.loose.length > 0) && (
{/* Pictures and videos that came together are laid out together:
two side by side, three with the first tall, four in a square,
as Discord and Element have them. One alone, or files that are
not pictures, keep the row they always had. */}
{tiled.length >= 2 && (
<div className={`media-grid n${Math.min(tiled.length, 6)}`}>
{tiled.map(({ att, i }) => (
<MediaEmbed
key={`att-${i}-${att.path || att.url || att.filename}`}
attachment={att}
tile
autoplay={mediaAutoplay}
loop={mediaLoop}
bufferId={bufferId}
messageId={message.id}
from={message.from}
onOpenInDiscord={(b, m) => void store.openInDiscord(b, m)}
onRefresh={(b, m) => store.refreshAttachments(b, m)}
onStale={(b, m) => store.resignWhenIdle(b, m)}
/>
))}
</div>
)}
{(rowed.length > 0 || embedMedia.loose.length > 0) && (
<div className="media-row">
{/* Files nobilis described: mimetype and dimensions known up
front, so these lay out without waiting on bytes. */}
{attachments.map((att, i) => (
{rowed.map(({ att, i }) => (
<MediaEmbed
key={`att-${i}-${att.path || att.url || att.filename}`}
attachment={att}
@@ -975,16 +1273,13 @@ function MessageRowBody({
{(message.reactions?.length ?? 0) > 0 && (
<div className="reaction-row">
{message.reactions!.map((r) => (
<button
<ReactionPill
key={r.emoji}
type="button"
className={classes('reaction-pill', r.me && 'mine')}
onClick={() => void store.toggleReaction(bufferId, message.id, r.emoji, !r.me)}
title={r.emoji}
>
<ReactionEmoji emoji={r.emoji} animated={r.animated} />
<span className="small">{r.count}</span>
</button>
reaction={r}
bufferId={bufferId}
messageId={message.id}
popped={popped.has(r.emoji)}
/>
))}
{canReact && (
<button
@@ -1008,18 +1303,26 @@ function MessageRowBody({
bytes={message.upload.bytes}
host={message.upload.host}
since={message.upload.since}
sent={message.upload.sent}
total={message.upload.total}
files={message.upload.files}
/>
)}
{message.failed && (
<button
type="button"
className="send-failed small"
onClick={() => store.retrySend(message.id)}
title={message.errorText}
>
<Icon name="error" size={13} /> Failed to send — retry
</button>
<div className="send-failed-actions small">
<button
type="button"
className="send-failed small"
onClick={() => store.retrySend(message.id)}
title={message.errorText}
>
<Icon name="error" size={13} /> Failed to send — retry
</button>
<button type="button" className="send-discard small" onClick={() => store.discardFailed(message.id)}>
<Icon name="delete" size={13} /> Delete
</button>
</div>
)}
{/* Inside the bubble, at its foot. On a phone the time is part of
@@ -1052,10 +1355,14 @@ function MessageRowBody({
one" but "where has everybody got to". */}
{readers && readers.length > 0 && <ReadMarkers readers={readers} />}
{/* Hover toolbar: quick reactions, add-reaction, reply, more. */}
{/* Hover toolbar: quick reactions, add-reaction, reply, edit, copy,
more. Not on a line that is not a message - a join, a notice - or
one that has not been sent: there is nothing yet to react to or
answer. */}
{!isSystem && !message.pending && !message.failed && (
<div className="hover-toolbar">
{canReact &&
QUICK_REACTIONS.map((emoji) => (
quick.map((emoji) => (
<button
key={emoji}
type="button"
@@ -1080,10 +1387,34 @@ function MessageRowBody({
<button type="button" className="toolbar-button" title="Reply" onClick={reply}>
<Icon name="reply" size={15} />
</button>
{canEditDelete && (
<button
type="button"
className="toolbar-button"
title="Edit"
onClick={() => {
setDraft(message.body)
setEditing(true)
}}
>
<Icon name="edit" size={15} />
</button>
)}
{message.body && (
<button
type="button"
className="toolbar-button"
title="Copy text"
onClick={() => void window.moho.copyText(message.body)}
>
<Icon name="content_copy" size={15} />
</button>
)}
<button type="button" className="toolbar-button" title="More" onClick={open}>
<Icon name="more_horiz" size={15} />
</button>
</div>
)}
</div>
{pickerOpen && (
@@ -1131,17 +1462,100 @@ function MessageRowBody({
)
}
/** What the first word of an emoji's name is, for the ones the picker knows: what a reaction is called when it is not a custom one. */
const UNICODE_NAMES: ReadonlyMap<string, string> = new Map(
COMMON_EMOJI.flatMap((e) => {
const name = e.name.split(/\s+/)[0]
// The picture form carries a selector some clients leave off.
return [[e.emoji, name] as const, [e.emoji.replace(/\uFE0F$/, ''), name] as const]
})
)
/** Who reacted, once asked, kept briefly so pointing at the same reaction twice is one question. */
const reactorCache = new Map<string, { at: number; count: number; names: string[] }>()
const REACTORS_KEPT_MS = 30_000
/**
* A Discord custom reaction; a plain Unicode reaction is just the character
* itself.
*
* nobilis stores these wrapped as `<:name:id>` - the same shape a message body
* carries - and only unwraps them at the point it calls Discord's reaction
* endpoint. Matching only the bare `name:id` therefore matched nothing, and
* every custom reaction on every message rendered as its literal token beside
* the count.
* One reaction under a message, with the sentence Discord shows over it: who
* reacted, and with what. The names are asked for when the pointer has stayed
* a moment, not carried on every message - the services that can say do so only
* on request, and most reactions are never pointed at.
*/
function ReactionEmoji({ emoji }: { emoji: string; animated?: boolean }): JSX.Element {
function ReactionPill({
reaction: r,
bufferId,
messageId,
popped
}: {
reaction: { emoji: string; count: number; me: boolean; animated?: boolean }
bufferId: string
messageId: string
popped: boolean
}): JSX.Element {
const store = useStore()
const [tip, setTip] = useState(false)
const [names, setNames] = useState<string[]>([])
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const label = reactionLabel(r.emoji, UNICODE_NAMES)
const sentence = reactorsSentence(label, names, r.count)
const ask = (): void => {
const service = store.accountFor(bufferId)?.service
if (service !== 'discord' && service !== 'matrix') return
const key = `${bufferId}|${messageId}|${r.emoji}`
const kept = reactorCache.get(key)
if (kept && kept.count === r.count && Date.now() - kept.at < REACTORS_KEPT_MS) {
setNames(kept.names)
return
}
void window.moho
.rpc<{ users: { name: string }[] }>('listReactors', { bufferId, messageId, emoji: r.emoji })
.then((answer) => {
const list = (answer.users ?? []).map((u) => u.name)
reactorCache.set(key, { at: Date.now(), count: r.count, names: list })
setNames(list)
})
// An older daemon has no such question, and a refusal is not worth a
// toast over a tooltip: the count is still said.
.catch(() => {})
}
return (
<button
type="button"
className={classes('reaction-pill', r.me && 'mine', popped && 'pop')}
onClick={() => void store.toggleReaction(bufferId, messageId, r.emoji, !r.me)}
onMouseEnter={() => {
setTip(true)
timer.current = setTimeout(ask, 250)
}}
onMouseLeave={() => {
setTip(false)
if (timer.current) clearTimeout(timer.current)
}}
aria-label={sentence}
>
<ReactionEmoji emoji={r.emoji} />
<span className="small">{r.count}</span>
{tip && (
<span className="reaction-tip small" role="tooltip">
{sentence}
</span>
)}
</button>
)
}
/**
* A reaction's picture. Discord's custom emoji are fetched by id from its CDN
* as WebP, which is animated for the ones that move (`animated=true` in the
* address, so nothing has to be known about each one), and a Unicode emoji is
* just text.
*
* Without this branch every custom reaction was drawn as its literal token
* beside the count.
*/
function ReactionEmoji({ emoji }: { emoji: string }): JSX.Element {
const media = useMediaUrl()
const custom = emoji.match(/^<?a?:?([A-Za-z0-9_~]{2,32}):(\d+)>?$/)
if (!custom) return <span>{emoji}</span>
+59
View File
@@ -0,0 +1,59 @@
import { createPortal } from 'react-dom'
import { Icon, IconButton } from './Icon'
import { useEscapeLayer } from '../lib/layers'
/**
* The frame every dialog shares: a scrim that stops below the title bar (so
* the window can still be dragged and closed), a panel that grows in, a
* heading with its title and a way out, and Escape wired to the layer stack.
* Keeping focus inside it, and giving it back afterwards, is lib/dialogs.ts,
* which watches for anything marked `role="dialog" aria-modal="true"`.
*
* `className` is the panel's own look - its width and padding - since a
* reason box and a forwarding list are not the same shape.
*/
export function Modal({
title,
icon,
iconColor,
onClose,
className,
header,
children
}: {
title: string
icon?: string
iconColor?: string
onClose: () => void
className?: string
/**
* A heading of the dialog's own, in place of the title row: for a dialog whose
* head is a person's face, or a search box. It brings its own way out; the
* title is still what a screen reader calls the dialog.
*/
header?: React.ReactNode
children: React.ReactNode
}): JSX.Element {
useEscapeLayer(onClose)
return createPortal(
<div className="modal-scrim" onClick={onClose}>
<div
className={`dialog${className ? ` ${className}` : ''}`}
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(e) => e.stopPropagation()}
>
{header ?? (
<div className="dialog-head">
{icon && <Icon name={icon} size={18} color={iconColor} />}
<span className="dialog-title ellipsis">{title}</span>
<IconButton name="close" size={16} title="Close" onClick={onClose} />
</div>
)}
{children}
</div>
</div>,
document.body
)
}
+15 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { Avatar } from './Avatar'
import { Icon, IconButton } from './Icon'
import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu'
import { useActiveBuffer, useChat, useIdSetPref, useStore } from '../state/hooks'
@@ -639,9 +640,22 @@ function MemberRow({
type="button"
className={classes('nick-row', member.away && 'away', blocked && 'blocked')}
onContextMenu={open}
onClick={onMention}
// Who they are is the click, as in Discord, Slack and Element;
// mentioning is Shift+click and the menu's own entry.
onClick={(e) => (onProfile && !e.shiftKey ? onProfile() : onMention())}
title={memberTitle(member)}
>
{/* A face and a dot, where the service has presence to report. The
others - IRC, Sneedchat, Kick - are lists of names, and a column
of initials on those would be decoration. */}
{member.status !== undefined && (
<Avatar
name={member.nick}
url={member.avatarUrl}
size={24}
status={member.status}
/>
)}
{/* IRC's rank is one character and sits in a fixed slot before the
name. Kick's is a word - "moderator", "subscriber" - which
overflowed that eight-pixel slot and painted straight over the
+79 -4
View File
@@ -1,6 +1,6 @@
import { useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Icon, IconButton } from './Icon'
import { useChat, usePref } from '../state/hooks'
import { useChat, usePref, useStore } from '../state/hooks'
import { formatMessage, normalizeBBCode } from '../lib/format'
import { RichText } from '../lib/richtext'
@@ -35,13 +35,20 @@ const REMEMBERED_DISMISSALS = 100
*/
export function PinnedBar({ bufferId }: { bufferId: string }): JSX.Element | null {
const pin = useChat((s) => s.pinnedByBuffer)[bufferId]
// Kick pins one line and sends it unasked; Discord and Matrix pin many and
// are asked. The same place for both, since it is the same thing.
if (!pin) return <PinnedMessagesBanner bufferId={bufferId} />
return <PinnedLine bufferId={bufferId} pin={pin} />
}
function PinnedLine({ pin }: { bufferId: string; pin: { id: string; from: string; body: string } }): JSX.Element | null {
const [dismissed, setDismissed] = usePref<string[]>('kick.pinDismissed', [])
// Through the same pipeline every other body goes through, so a link in a
// pin is a link and an emote is an emote - a pin is very often exactly a
// link, which is most of why a channel pins one.
const html = useMemo(() => formatMessage(normalizeBBCode(pin?.body || '')), [pin?.body])
const html = useMemo(() => formatMessage(normalizeBBCode(pin.body || '')), [pin.body])
if (!pin || dismissed.includes(pin.id)) return null
if (dismissed.includes(pin.id)) return null
const dismiss = (): void =>
setDismissed([...dismissed.filter((id) => id !== pin.id), pin.id].slice(-REMEMBERED_DISMISSALS))
@@ -60,3 +67,71 @@ export function PinnedBar({ bufferId }: { bufferId: string }): JSX.Element | nul
</div>
)
}
/** Conversations whose pins have been asked for this session, so a failed ask is not repeated on every switch. */
const asked = new Set<string>()
/** Banners put away this session, by conversation: the pins are still in the header. */
const putAway = new Set<string>()
/**
* What a Discord channel or a Matrix room has pinned, over the log: the
* newest, with how many there are, and a click that goes to it.
*
* Element has this banner and Discord has only the header's list; the list is
* kept (it is where a pin is taken off) and this is what makes a pin something
* that is seen rather than something somebody knows to look for. Clicking goes
* to the message and moves on to the next pin, so a run of pins is read by
* clicking through it.
*/
function PinnedMessagesBanner({ bufferId }: { bufferId: string }): JSX.Element | null {
const store = useStore()
const account = useChat((s) => s.buffers).find((b) => b.id === bufferId)?.accountId ?? ''
const rows = useChat((s) => s.pinnedRows)[bufferId]
const [at, setAt] = useState(0)
const [, redraw] = useState(0)
const hasPins = account.startsWith('discord:') || account.startsWith('matrix:')
useEffect(() => {
setAt(0)
if (!hasPins || rows || asked.has(bufferId)) return
asked.add(bufferId)
void store.loadPins(bufferId).catch(() => {})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bufferId, hasPins])
if (!hasPins || !rows || rows.length === 0 || putAway.has(bufferId)) return null
const shown = rows[Math.min(at, rows.length - 1)]
const first = (shown.body || '').split('\n')[0].trim() || 'A message with no text'
return (
<div className="pinned-bar pinned-banner">
<Icon name="push_pin" size={14} className="pinned-mark" />
<button
type="button"
className="pinned-banner-body"
title="Go to the pinned message"
onClick={() => {
void store.jumpToMessage(bufferId, shown.id).then((there) => {
if (there) store.setJumpTarget(shown.id)
else store.toast('info', 'That message could not be reached')
})
if (rows.length > 1) setAt((at + 1) % rows.length)
}}
>
<span className="small muted">
{rows.length > 1 ? `Pinned · ${Math.min(at, rows.length - 1) + 1} of ${rows.length}` : 'Pinned'} · {shown.from}
</span>
<span className="ellipsis">{first}</span>
</button>
<IconButton
name="close"
size={14}
title="Hide this banner (the pins stay in the header)"
onClick={() => {
putAway.add(bufferId)
redraw((n) => n + 1)
}}
/>
</div>
)
}
+3 -8
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { IconButton } from './Icon'
import { Modal } from './Modal'
import { useStore } from '../state/hooks'
/** The spec's own ceiling, so twenty-one answers is refused here not there. */
@@ -35,12 +36,7 @@ export function PollComposer({ bufferId, onClose }: { bufferId: string; onClose:
}
return (
<div className="modal-scrim" onClick={onClose}>
<div className="modal poll-composer" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<h2 className="modal-title">New poll</h2>
<IconButton name="close" size={18} title="Close" onClick={onClose} />
</div>
<Modal title="New poll" icon="ballot" className="modal poll-composer" onClose={onClose}>
<label className="field">
<span className="small muted">Question</span>
@@ -99,7 +95,6 @@ export function PollComposer({ bufferId, onClose }: { bufferId: string; onClose:
{sending ? 'Starting…' : 'Start the poll'}
</button>
</div>
</div>
</div>
</Modal>
)
}
+5 -16
View File
@@ -1,5 +1,4 @@
import { useEffect } from 'react'
import { createPortal } from 'react-dom'
import { Modal } from './Modal'
import { Avatar } from './Avatar'
import { Icon, IconButton } from './Icon'
import { useChat, useStore } from '../state/hooks'
@@ -23,14 +22,6 @@ export function ProfileCard(): JSX.Element | null {
const profile = useChat((s) => s.profile)
const store = useStore()
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') store.closeProfile()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [store])
if (!profile) return null
const rows: [string, string][] = []
@@ -42,9 +33,8 @@ export function ProfileCard(): JSX.Element | null {
if (profile.idleSeconds !== undefined) rows.push(['Idle', describeIdle(profile.idleSeconds)])
for (const extra of profile.extra ?? []) rows.push([extra.label, extra.value])
return createPortal(
<div className="modal-scrim" onClick={() => store.closeProfile()}>
<div className="profile-card" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
return (
<Modal title={profile.name} className="profile-card" onClose={() => store.closeProfile()} header={
<div className="profile-head">
<Avatar name={profile.name} url={profile.avatarUrl} size={40} status={profile.status} accountId={profile.accountId} />
<div className="profile-title">
@@ -56,6 +46,7 @@ export function ProfileCard(): JSX.Element | null {
</div>
<IconButton name="close" title="Close" onClick={() => store.closeProfile()} />
</div>
}>
{/* Their standing, said plainly. A role list is the service's own
words for it - "Operator in #channel", "Admin", a subscriber
@@ -135,9 +126,7 @@ export function ProfileCard(): JSX.Element | null {
<Icon name="info" size={14} /> {profile.service} keeps nothing else about them.
</p>
)}
</div>
</div>,
document.body
</Modal>
)
}
@@ -0,0 +1,147 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Avatar } from './Avatar'
import { Icon } from './Icon'
import { useChat, useIdSetPref, usePref, useStore } from '../state/hooks'
import { isMutedBuffer, waitingCount } from '../lib/groups'
import { rankBuffers, whereText } from '../lib/switcher'
import { bufferKindGlyph, classes } from '../lib/util'
/** The window event that opens it - from Ctrl+K and from the title bar's search button. */
export const OPEN_SWITCHER = 'moho:open-switcher'
/**
* Jump to any conversation on any account by typing a few letters of its name.
* What is waiting comes first, so pressing the key and Enter is "take me to
* the next thing that needs me".
*/
export function QuickSwitcher(): JSX.Element | null {
const store = useStore()
const buffers = useChat((s) => s.buffers)
const accounts = useChat((s) => s.accounts)
const [muted] = useIdSetPref('mutedBuffers')
const [mutedGroups] = usePref<string[]>('mutedGroups', [])
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [index, setIndex] = useState(0)
const input = useRef<HTMLInputElement>(null)
const list = useRef<HTMLDivElement>(null)
useEffect(() => {
const toggle = (): void => {
setOpen((was) => !was)
setQuery('')
setIndex(0)
}
window.addEventListener(OPEN_SWITCHER, toggle)
return () => window.removeEventListener(OPEN_SWITCHER, toggle)
}, [])
const accountName = (id: string): string => accounts.find((a) => a.id === id)?.displayName ?? ''
const hits = useMemo(
() => (open ? rankBuffers(buffers, query, (b) => whereText(b, accountName)) : []),
// accountName closes over accounts, which is what changes it.
// eslint-disable-next-line react-hooks/exhaustive-deps
[open, buffers, query, accounts]
)
useEffect(() => {
if (open) input.current?.focus()
}, [open])
// Keeps the chosen row in view as the arrows move it.
useEffect(() => {
list.current?.querySelector('.switcher-row.selected')?.scrollIntoView({ block: 'nearest' })
}, [index, hits.length])
if (!open) return null
const close = (): void => setOpen(false)
const go = (i: number): void => {
const hit = hits[i]
if (!hit) return
close()
void store.selectBuffer(hit.buffer.id, true)
}
return createPortal(
<div className="modal-scrim switcher-scrim" onMouseDown={close}>
<div
className="switcher"
role="dialog"
aria-modal="true"
aria-label="Jump to a conversation"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="switcher-field">
<Icon name="search" size={18} />
<input
ref={input}
className="switcher-input"
placeholder="Where would you like to go?"
value={query}
spellCheck={false}
onChange={(e) => {
setQuery(e.target.value)
setIndex(0)
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault()
close()
} else if (e.key === 'ArrowDown' || (e.key === 'Tab' && !e.shiftKey)) {
e.preventDefault()
setIndex((i) => Math.min(hits.length - 1, i + 1))
} else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
e.preventDefault()
setIndex((i) => Math.max(0, i - 1))
} else if (e.key === 'Enter') {
e.preventDefault()
go(index)
}
}}
/>
</div>
<div className="switcher-list" ref={list} role="listbox">
{hits.length === 0 && <div className="switcher-empty muted">Nothing by that name.</div>}
{hits.map((hit, i) => {
const b = hit.buffer
const waiting = waitingCount(b, isMutedBuffer(b, muted, mutedGroups))
return (
<button
key={b.id}
type="button"
role="option"
aria-selected={i === index}
className={classes('switcher-row', i === index && 'selected', b.unread > 0 && 'unread')}
onMouseMove={() => i !== index && setIndex(i)}
onClick={() => go(i)}
>
<span className="switcher-lead">
{b.kind === 'dm' ? (
<Avatar name={hit.title} url={b.avatarUrl} size={20} accountId={b.accountId} />
) : (
<Icon name={bufferKindGlyph(b.kind)} size={16} />
)}
</span>
<span className="switcher-title ellipsis">{hit.title}</span>
<span className="switcher-where muted small ellipsis">{hit.where}</span>
{waiting > 0 && (
<span className={classes('unread-badge', b.highlight && 'highlight')}>
{waiting > 99 ? '99+' : waiting}
</span>
)}
</button>
)
})}
</div>
<div className="switcher-hint muted small">
<span>↑↓ to move</span>
<span>Enter to open</span>
<span>Esc to close</span>
</div>
</div>
</div>,
document.body
)
}
+34 -47
View File
@@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Icon } from './Icon'
import { Modal } from './Modal'
/**
* A sentence somebody has to write before something happens.
@@ -40,54 +39,42 @@ export function ReasonPrompt({
useEffect(() => {
box.current?.focus()
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onCancel()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onCancel])
}, [])
const ready = optional || reason.trim().length > 0
return createPortal(
<div className="lightbox-backdrop" onClick={onCancel}>
<div className="reason-prompt" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
<div className="reason-prompt-head">
<Icon name={danger ? 'flag' : 'door_front'} size={18} />
<span>{title}</span>
</div>
<p className="small muted">{detail}</p>
<textarea
ref={box}
className="reason-prompt-box"
rows={3}
value={reason}
placeholder={placeholder}
onChange={(e) => setReason(e.target.value)}
onKeyDown={(e) => {
// Enter sends, because this is one sentence rather than a
// document; a newline is still there for anybody who wants two.
if (e.key === 'Enter' && !e.shiftKey && ready) {
e.preventDefault()
onConfirm(reason.trim())
}
}}
/>
<div className="reason-prompt-actions">
<button type="button" className="button" onClick={onCancel}>
Cancel
</button>
<button
type="button"
className={danger ? 'button danger' : 'button'}
disabled={!ready}
onClick={() => onConfirm(reason.trim())}
>
{confirmLabel}
</button>
</div>
return (
<Modal title={title} icon={danger ? 'flag' : 'door_front'} onClose={onCancel} className="reason-prompt">
<p className="small muted">{detail}</p>
<textarea
ref={box}
className="reason-prompt-box"
rows={3}
value={reason}
placeholder={placeholder}
onChange={(e) => setReason(e.target.value)}
onKeyDown={(e) => {
// Enter sends, because this is one sentence rather than a
// document; a newline is still there for anybody who wants two.
if (e.key === 'Enter' && !e.shiftKey && ready) {
e.preventDefault()
onConfirm(reason.trim())
}
}}
/>
<div className="reason-prompt-actions">
<button type="button" className="button subtle" onClick={onCancel}>
Cancel
</button>
<button
type="button"
className={danger ? 'button danger' : 'button'}
disabled={!ready}
onClick={() => onConfirm(reason.trim())}
>
{confirmLabel}
</button>
</div>
</div>,
document.body
</Modal>
)
}
+5 -20
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ReasonPrompt } from './ReasonPrompt'
import { createPortal } from 'react-dom'
import { Modal } from './Modal'
import { Avatar } from './Avatar'
import { Icon, IconButton } from './Icon'
import { usePref, useStore } from '../state/hooks'
@@ -123,13 +123,6 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: ()
// for what they are actually looking at now.
const generation = useRef(0)
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
const servers = extraServers
@@ -292,15 +285,8 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: ()
const rooms = (answer?.rooms ?? []).filter((room) => !disabled.has(room.via))
const morePages = Object.keys(answer?.next ?? {}).length > 0
return createPortal(
<div className="modal-scrim" onClick={onClose}>
<div
className="room-search"
role="dialog"
aria-modal="true"
aria-label="Find a room"
onClick={(e) => e.stopPropagation()}
>
return (
<Modal title="Find a room" className="room-search" onClose={onClose} header={
<div className="room-search-head">
<Icon name="search" size={18} />
<input
@@ -328,6 +314,7 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: ()
/>
<IconButton name="close" title="Close" onClick={onClose} />
</div>
}>
{/* Which servers, by name, on their own row so a dozen of them fit -
and each one a switch, because "search everywhere" and "search
@@ -550,8 +537,6 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: ()
</p>
)}
</div>
</div>
</div>,
document.body
</Modal>
)
}
+61 -23
View File
@@ -1,6 +1,6 @@
import { useMemo, useRef, useState } from 'react'
import { Icon, IconButton } from './Icon'
import { HeaderPopover } from './HeaderPopover'
import { Modal } from './Modal'
import { ChoiceSetting } from './settings/controls'
import { useChat, useStore } from '../state/hooks'
import type { BufferEntry } from '../state/store'
@@ -185,6 +185,18 @@ export function RoomSettings({ buffer }: { buffer: BufferEntry }): JSX.Element {
}
}
const [tab, setTab] = useState<'security' | 'moderation' | 'advanced'>('security')
const loadPolicy = (): void => {
setPolicyOpen(true)
void window.moho
.rpc<Policy>('matrixRoomPolicy', { bufferId: buffer.id })
.then(setPolicy)
.catch((e: Error) => {
store.toast('error', e.message)
setPolicyOpen(false)
})
}
return (
<>
<span ref={button} className="header-anchor">
@@ -196,9 +208,39 @@ export function RoomSettings({ buffer }: { buffer: BufferEntry }): JSX.Element {
/>
</span>
{open && (
<HeaderPopover anchor={button.current} width={400} onClose={() => setOpen(false)}>
<div className="small muted">This room&apos;s settings</div>
{history === null && join === null && <div className="small muted">Looking…</div>}
<Modal title="Room settings" icon="tune" className="room-settings" onClose={() => setOpen(false)}>
{/* Three pages: who can come in and read, what the room refuses, and
the one thing that is not a setting. A popover this tall was a
list somebody had to read to the end to know what was in it. */}
<div className="room-settings-tabs" role="tablist">
{(
[
['security', 'Security'],
['moderation', 'Moderation'],
['advanced', 'Advanced']
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
role="tab"
aria-selected={tab === id}
className={`room-settings-tab${tab === id ? ' active' : ''}`}
onClick={() => {
setTab(id)
// Reading the policy needs the room's whole state, so it is
// asked for when its page is opened and not before.
if (id === 'moderation' && !policy && !policyOpen) loadPolicy()
}}
>
{label}
</button>
))}
</div>
<div className="room-settings-body">
{history === null && join === null && tab === 'security' && <div className="small muted">Looking…</div>}
{tab === 'security' && (
<>
{/* First, because it is the more fundamental of the two: who is in
the room at all comes before how much of it they can read. */}
{join && (
@@ -268,6 +310,10 @@ export function RoomSettings({ buffer }: { buffer: BufferEntry }): JSX.Element {
</>
)}
</>
)}
{tab === 'moderation' && (
<>
{/* What the room refuses, and whose judgement it follows. Behind
its own press because reading it needs the room's whole state -
a big answer on a large room, to a question almost nobody is
@@ -280,24 +326,6 @@ export function RoomSettings({ buffer }: { buffer: BufferEntry }): JSX.Element {
Which servers this room takes events from, and any ban lists it publishes.
</div>
</div>
{!policyOpen && (
<button
type="button"
className="button subtle"
onClick={() => {
setPolicyOpen(true)
void window.moho
.rpc<Policy>('matrixRoomPolicy', { bufferId: buffer.id })
.then(setPolicy)
.catch((e: Error) => {
store.toast('error', e.message)
setPolicyOpen(false)
})
}}
>
Show
</button>
)}
</div>
{policyOpen && !policy && <div className="small muted">Reading the room…</div>}
@@ -386,6 +414,10 @@ export function RoomSettings({ buffer }: { buffer: BufferEntry }): JSX.Element {
</>
)}
</>
)}
{tab === 'advanced' && (
<>
{/* Not a setting: an upgrade makes a new room and leaves a
tombstone pointing at it. Offered only where there is somewhere
to go and somebody who can make the tombstone - and with what
@@ -427,7 +459,13 @@ export function RoomSettings({ buffer }: { buffer: BufferEntry }): JSX.Element {
)}
</div>
)}
</HeaderPopover>
{!(version?.behind && version.canUpgrade) && (
<div className="small muted">Nothing here for this room: it is on the version this server makes now, or you cannot change it.</div>
)}
</>
)}
</div>
</Modal>
)}
</>
)
+128 -51
View File
@@ -1,12 +1,15 @@
import { useMediaUrl } from '../lib/route'
import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { ContextMenu, useContextMenu } from './ContextMenu'
import { railConnecting } from '../lib/bufferlink'
import { Icon, IconButton, MaskIcon } from './Icon'
import { Icon, MaskIcon } from './Icon'
import { Modal } from './Modal'
import { Avatar } from './Avatar'
import { LeaveConfirm } from './LeaveConfirm'
import { ircNetworkFor } from '../lib/networks'
import { useChat, usePref, useStore } from '../state/hooks'
import { RAIL_TIP, hideRailTip, showRailTip, type RailTipDetail } from '../lib/railtip'
import { bufferDisplayName, classes, nickColor, resolveMediaUrl, serviceIcon } from '../lib/util'
import { SpaceRooms } from './SpaceRooms'
import {
@@ -25,7 +28,8 @@ import {
reorderFolder,
FOLDER_DRAG_PREFIX,
visibleGroups,
countsTowardRail,
countedFor,
isMutedBuffer,
dmGroup,
DM_GROUP_ID,
isDirectMessage,
@@ -57,6 +61,8 @@ interface TileProps {
active: boolean
unread: number
highlight: boolean
/** How many unread messages mention this account: the number the tile shows. */
mentions: number
draggable: boolean
dropTarget: boolean
/** Being dragged right now: it leaves a gap where it was. */
@@ -176,8 +182,11 @@ function isMerge(e: React.DragEvent): boolean {
}
function RailTile(props: TileProps): JSX.Element {
const { group, active, unread, highlight, draggable, dropTarget, customIcon, muted, lifted, mergeTarget } = props
const { group, active, unread, highlight, mentions, draggable, dropTarget, customIcon, muted, lifted, mergeTarget } = props
const { menu, open, close } = useContextMenu()
const store = useStore()
// What is waiting inside this server, for "Mark as read".
const waiting = useChat((s) => s.buffers).filter((b) => b.groupId === group.id && (b.unread > 0 || b.highlight))
const service = serviceIcon(group.service)
// Only a guild or space needs telling apart by service: its face is a
// picture or initials that say nothing about where it came from. An account
@@ -203,7 +212,10 @@ function RailTile(props: TileProps): JSX.Element {
muted && 'muted',
connecting && 'connecting'
)}
title={connecting ? `${group.name} — connecting…` : group.name}
onMouseEnter={(e) => showRailTip(e.currentTarget, connecting ? `${group.name} — connecting…` : group.name)}
onMouseLeave={hideRailTip}
onFocus={(e) => showRailTip(e.currentTarget, group.name)}
onBlur={hideRailTip}
aria-label={group.name}
aria-current={active}
draggable={draggable}
@@ -250,6 +262,12 @@ function RailTile(props: TileProps): JSX.Element {
x={menu.x}
y={menu.y}
entries={[
{
label: 'Mark as read',
icon: 'mark_chat_read',
disabled: waiting.length === 0,
onClick: () => void store.markBuffersRead(waiting.map((b) => b.id))
},
{
label: muted ? `Unmute ${group.name}` : `Mute ${group.name}`,
icon: muted ? 'notifications_active' : 'notifications_off',
@@ -284,7 +302,17 @@ function RailTile(props: TileProps): JSX.Element {
onClose={close}
/>
)}
{badge && (
{/* Mentions say how many are waiting, in the corner the service mark sits
in - the mark gives way, since a count is what somebody scanning this
column came to see. Only mentions: how many messages there are in a
busy channel is not what this column is for, and the pill beside the
tile already says there is something to read. */}
{mentions > 0 && (
<span className="rail-count highlight" title={`${mentions} mention${mentions === 1 ? '' : 's'}`}>
{mentions > 99 ? '99+' : mentions}
</span>
)}
{badge && mentions === 0 && (
<span className={`rail-service${highlight ? ' highlight' : ''}`}>
{badge.colour && badge.mark ? (
<img src={badge.mark} alt="" draggable={false} />
@@ -316,7 +344,8 @@ function DmTile(props: { buffer: BufferEntry; active: boolean; onSelect: () => v
<button
type="button"
className={classes('rail-tile', 'rail-dm', active && 'active')}
title={`${name} - ${count} unread message${count === 1 ? '' : 's'}`}
onMouseEnter={(e) => showRailTip(e.currentTarget, `${name} - ${count} unread message${count === 1 ? '' : 's'}`)}
onMouseLeave={hideRailTip}
aria-label={`${name}, ${count} unread`}
onClick={onSelect}
>
@@ -351,10 +380,10 @@ export function ServerRail(): JSX.Element | null {
const [customIcons] = usePref<Record<string, string>>('groupIcons', {})
const [mutedGroups, setMutedGroups] = usePref<string[]>('mutedGroups', [])
const [folders, setFolders] = usePref<RailFolder[]>('railFolders', [])
// Which folder is showing its contents, if any. Deliberately not
// remembered: a folder is a place to put things, and it looks the same
// either way - there is no state here worth carrying across a restart.
const [expanded, setExpanded] = useState('')
// Which folder is showing its contents, if any. Remembered: a column that
// forgets which folders were open and shuts them all on every start is one
// you re-arrange each morning.
const [expanded, setExpanded] = usePref<string>('ui.railFolderOpen', '')
const isFolderOpen = (id: string): boolean => expanded === id
const toggleFolder = (id: string): void => setExpanded(expanded === id ? '' : id)
const [folderMenu, setFolderMenu] = useState<{ x: number; y: number; folder: RailFolder } | null>(null)
@@ -395,18 +424,24 @@ export function ServerRail(): JSX.Element | null {
// Unread rolls up from the buffers under each entry, so a guild whose
// channels are all collapsed away still shows it has something waiting.
const totals = new Map<string, { unread: number; highlight: boolean }>()
const totals = new Map<string, { unread: number; highlight: boolean; mentions: number }>()
// A direct message counts for every unread message in it - all of them are
// for you - and anywhere else for its mentions (see countedFor).
const bump = (key: string, b: BufferEntry): void => {
const t = totals.get(key) || { unread: 0, highlight: false }
t.unread += b.unread
t.highlight = t.highlight || b.highlight
const waiting = countedFor(b, muted, hidden, mutedGroups)
// What a muted conversation says is only that your name was used in it:
// its unread messages are not asking for anything.
const quiet = isMutedBuffer(b, muted, mutedGroups)
const t = totals.get(key) || { unread: 0, highlight: false, mentions: 0 }
t.unread += quiet ? 0 : b.unread
t.highlight = t.highlight || (quiet ? waiting > 0 : b.highlight)
t.mentions += waiting
totals.set(key, t)
}
const all = buffers as BufferEntry[]
for (const b of all) {
// Only what the pane below would show: a hidden buffer has no row to
// reach, and a muted one is not asking for attention.
if (!countsTowardRail(b, muted, hidden, mutedGroups)) continue
// A hidden buffer has no row to reach.
if (hidden.includes(b.id)) continue
// A buffer can count on more than one tile: where it lives, and on any
// aggregate page that also lists it. Both are places the user would look.
if (b.groupId) bump(b.groupId, b)
@@ -496,7 +531,7 @@ export function ServerRail(): JSX.Element | null {
*/
const unreadDms = all
.filter(
(b) => isDirectMessage(b) && b.unread > 0 && countsTowardRail(b, muted, hidden, mutedGroups)
(b) => isDirectMessage(b) && countedFor(b, muted, hidden, mutedGroups) > 0
)
.sort((a, b) => (b.lastActivityTs || 0) - (a.lastActivityTs || 0))
@@ -528,6 +563,7 @@ export function ServerRail(): JSX.Element | null {
onBrowse={g.kind === 'space' ? () => setBrowsing(g) : undefined}
unread={t?.unread ?? 0}
highlight={t?.highlight ?? false}
mentions={t?.mentions ?? 0}
// Direct messages and pinned lead the rail by definition, so
// there is nowhere for them to be dragged to.
draggable={!isFixedEntry(g)}
@@ -572,6 +608,10 @@ export function ServerRail(): JSX.Element | null {
members={entry.members}
customIcons={customIcons}
dropTarget={over === entry.id && dragging !== ''}
open={open}
unread={entry.members.reduce((n, m) => n + (totals.get(m.id)?.unread ?? 0), 0)}
highlight={entry.members.some((m) => totals.get(m.id)?.highlight)}
mentions={entry.members.reduce((n, m) => n + (totals.get(m.id)?.mentions ?? 0), 0)}
onToggle={() => toggleFolder(entry.id)}
lifted={dragging === `${FOLDER_DRAG_PREFIX}${entry.id}`}
onDragStart={() => beginDrag(`${FOLDER_DRAG_PREFIX}${entry.id}`)}
@@ -592,6 +632,7 @@ export function ServerRail(): JSX.Element | null {
return (
<nav className="server-rail" aria-label="Servers">
<RailTip />
{/* Only the entries scroll; the cog stays pinned to the foot. The empty
space below them is where something is dropped to put it last, and to
take it back out of a folder on the way - folders themselves are made
@@ -652,6 +693,7 @@ export function ServerRail(): JSX.Element | null {
)}
{rest.map(renderEntry)}
</div>
{folderMenu && (
@@ -717,6 +759,26 @@ export function ServerRail(): JSX.Element | null {
)}
<div className="rail-divider" />
{/* Where a server is added or joined: pinned below the list with a rule
above it, so it never moves however many servers there are or
wherever one is dropped, and the list scrolls above it. For whichever
account is being looked at. */}
<button
type="button"
className="rail-tile rail-add"
aria-label="Add or join a server"
onMouseEnter={(e) => showRailTip(e.currentTarget, 'Add or join a server')}
onMouseLeave={hideRailTip}
onClick={() => {
const account =
groups.find((g) => g.id === activeGroupId)?.accountId || (store.getSnapshot().accounts[0]?.id ?? '')
store.setActivePanel('join', account)
}}
>
<span className="rail-face">
<Icon name="add" size={22} />
</span>
</button>
<RailMenu />
</nav>
)
@@ -735,6 +797,11 @@ function FolderTile(props: {
customIcons: Record<string, string>
dropTarget: boolean
lifted: boolean
open: boolean
/** What is waiting in the servers inside, so a closed folder can say so. */
unread: number
highlight: boolean
mentions: number
onToggle: () => void
onContextMenu: (e: React.MouseEvent) => void
onDragStart: () => void
@@ -742,7 +809,10 @@ function FolderTile(props: {
onDrop: () => void
onDragEnd: () => void
}): JSX.Element {
const { folder, members, customIcons, dropTarget, lifted } = props
const { folder, members, customIcons, dropTarget, lifted, open, unread, mentions } = props
// Open, its servers show their own; closed, what is inside would be
// invisible without this.
const waiting = !open && unread > 0
return (
<button
type="button"
@@ -757,7 +827,10 @@ function FolderTile(props: {
e.dataTransfer.effectAllowed = 'move'
props.onDragStart()
}}
title={`${folder.name} — ${members.length} ${members.length === 1 ? 'server' : 'servers'}`}
onMouseEnter={(e) =>
showRailTip(e.currentTarget, `${folder.name} — ${members.length} ${members.length === 1 ? 'server' : 'servers'}`)
}
onMouseLeave={hideRailTip}
aria-label={folder.name}
onClick={props.onToggle}
onContextMenu={props.onContextMenu}
@@ -771,6 +844,7 @@ function FolderTile(props: {
}}
onDragEnd={props.onDragEnd}
>
<span className={`rail-pill${open ? ' active' : waiting ? ' unread' : ''}`} />
{/* The same face whether or not it is showing its contents: a folder
is a folder, and an icon that changes underfoot is one more thing to
read. Empty it is just a folder; once there is something in it, the
@@ -802,11 +876,16 @@ function FolderTile(props: {
{/* Once the face is a grid of other people's icons, nothing says it is
a folder any more - so it takes the same corner badge a guild uses
to name its service. An empty folder is already unmistakably one. */}
{members.length > 0 && (
{members.length > 0 && !(!open && mentions > 0) && (
<span className="rail-service">
<Icon name="folder" size={11} />
</span>
)}
{!open && mentions > 0 && (
<span className="rail-count highlight" title={`${mentions} mention${mentions === 1 ? '' : 's'}`}>
{mentions > 99 ? '99+' : mentions}
</span>
)}
</button>
)
}
@@ -830,12 +909,7 @@ function FolderSettings({
const [colour, setColour] = useState(folder.colour)
return (
<div className="modal-scrim" onClick={onClose}>
<div className="modal folder-settings" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<h2>Folder Settings</h2>
<IconButton name="close" title="Close" onClick={onClose} />
</div>
<Modal title="Folder Settings" icon="folder" className="modal folder-settings" onClose={onClose}>
<label className="folder-field">
<span className="small muted">Folder Name</span>
@@ -881,8 +955,7 @@ function FolderSettings({
<button type="button" className="button primary" onClick={() => onSave(name.trim() || folder.name, colour)}>
Done
</button>
</div>
</div>
</Modal>
)
}
@@ -893,7 +966,6 @@ function FolderSettings({
*/
function RailMenu(): JSX.Element {
const store = useStore()
const { menu, open, close } = useContextMenu()
const transfers = useChat((s) => s.transfers)
// What is going on behind whatever is on screen. Exports count alongside
// files because they are the same thing to somebody who started one and
@@ -919,11 +991,10 @@ function RailMenu(): JSX.Element {
<button
type="button"
className="rail-tile rail-cog"
title={busy > 0 ? `Accounts and settings - ${busyLabel}` : 'Accounts and settings'}
aria-label="Accounts and settings"
// Opened by left click, unlike the tiles above it - it is a menu
// button, not a thing being acted upon.
onClick={open}
title={busy > 0 ? `Settings - ${busyLabel}` : 'Settings'}
aria-label="Settings"
// Straight to Settings, where Accounts and Downloads are pages of it.
onClick={() => store.setActivePanel('settings')}
>
<span className="rail-face">
<Icon name="settings" size={20} />
@@ -935,21 +1006,27 @@ function RailMenu(): JSX.Element {
<span className={exporting > 0 ? 'rail-downloads exporting' : 'rail-downloads'}>{busy}</span>
)}
</button>
{menu && (
<ContextMenu
x={menu.x}
y={menu.y}
entries={[
{ label: 'Accounts', icon: 'manage_accounts', onClick: () => store.setActivePanel('accounts') },
// Between the two: it is about the app rather than about a server,
// which is what this menu is for, and it is the one entry here
// that can be busy while you are looking at something else.
{ label: 'Downloads', icon: 'download', onClick: () => store.setActivePanel('downloads') },
{ label: 'Settings', icon: 'settings', onClick: () => store.setActivePanel('settings') }
]}
onClose={close}
/>
)}
</>
)
}
/** The flyout every tile in the column points at; one, wherever the pointer is. */
function RailTip(): JSX.Element | null {
const [tip, setTip] = useState<RailTipDetail | null>(null)
useEffect(() => {
const on = (e: Event): void => setTip((e as CustomEvent<RailTipDetail>).detail)
window.addEventListener(RAIL_TIP, on)
return () => window.removeEventListener(RAIL_TIP, on)
}, [])
if (!tip?.rect) return null
return createPortal(
<div
className="rail-tip"
role="tooltip"
style={{ left: tip.rect.right + 8, top: tip.rect.top + tip.rect.height / 2 }}
>
{tip.text}
</div>,
document.body
)
}
+4 -14
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { Icon, IconButton } from './Icon'
import { Icon } from './Icon'
import { Modal } from './Modal'
import { useStore } from '../state/hooks'
interface SpaceRoom {
@@ -68,11 +69,6 @@ export function SpaceRooms({
useEffect(() => {
load('')
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [groupId])
const join = (room: SpaceRoom): void => {
@@ -87,12 +83,7 @@ export function SpaceRooms({
}
return (
<div className="modal-scrim" onClick={onClose}>
<div className="modal space-rooms" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<h2 className="modal-title ellipsis">{title}</h2>
<IconButton name="close" size={18} title="Close" onClick={onClose} />
</div>
<Modal title={title} icon="workspaces" className="modal space-rooms" onClose={onClose}>
{error && <p className="small error-text">{error}</p>}
{!error && !busy && rooms.length === 0 && (
@@ -142,7 +133,6 @@ export function SpaceRooms({
</button>
)}
{busy && rooms.length === 0 && <p className="small muted">Reading the space…</p>}
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,184 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Icon } from './Icon'
import { useStore, usePref } from '../state/hooks'
import { useEscapeLayer } from '../lib/layers'
import { presenceClass } from '../lib/presence'
import { classes } from '../lib/util'
import type { Account } from '../../../shared/wire'
import { statusName, supportsStatus } from '../lib/status'
import type { Status } from './UserFooter'
/** The statuses that can be chosen, with the dot each is drawn with. Signing out is separate, below them. */
const CHOICES: { id: Exclude<Status, 'offline'>; label: string; hint?: string; dot: string }[] = [
{ id: 'online', label: 'Online', dot: 'online' },
{ id: 'idle', label: 'Idle', dot: 'idle' },
{ id: 'dnd', label: 'Do not disturb', dot: 'dnd' },
// Connected and reading, and counted as away by everybody looking.
{ id: 'invisible', label: 'Invisible', hint: 'You will appear offline', dot: 'offline' }
]
/** Longest custom status the services will take; Discord's is 128. */
const MAX_TEXT = 128
/**
* How you are presenting, over the footer it belongs to.
*
* A popout rather than a context menu because it holds more than a list of
* choices: the status with the one in use ticked and drawn in its own colour,
* what to say beside it where the service has a place for that, and whether
* the choice is for this account or for all of them.
*/
export function StatusPopout({
account,
status,
anchor,
onClose
}: {
account: Account
status: Status | 'connecting'
anchor: HTMLElement | null
onClose: () => void
}): JSX.Element {
const store = useStore()
const ref = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState<{ left: number; bottom: number } | null>(null)
const [applyAll, setApplyAll] = usePref<boolean>('status.applyToAll', false)
const canSay = account.service === 'discord' || account.service === 'matrix'
const [text, setText] = useState(account.statusText ?? '')
useEscapeLayer(onClose)
// Above the footer it opened from, with its left edge on it.
useLayoutEffect(() => {
if (!anchor) return
const r = anchor.getBoundingClientRect()
setPos({ left: Math.max(8, Math.min(r.left, window.innerWidth - 280 - 8)), bottom: window.innerHeight - r.top + 6 })
}, [anchor])
useEffect(() => {
// Pressing inside it must not close it; anywhere else does.
const onDown = (e: MouseEvent): void => {
const target = e.target as Node
if (ref.current?.contains(target) || anchor?.contains(target)) return
onClose()
}
window.addEventListener('mousedown', onDown, true)
return () => window.removeEventListener('mousedown', onDown, true)
}, [anchor, onClose])
const choose = (next: Exclude<Status, 'offline'>): void => {
// The words ride with a choice of status where there are any to say, so a
// change of colour does not quietly take them down.
const said = canSay ? text.trim() : undefined
if (applyAll) void store.setStatusEverywhere(next, said)
else void store.setPresence(account.id, next, said)
onClose()
}
const saveText = (): void => {
const current = status === 'connecting' || status === 'offline' ? 'online' : status
void store.setAccountStatus(account.id, current as Exclude<Status, 'offline'>, text.trim())
onClose()
}
return createPortal(
<div
ref={ref}
className="status-popout"
role="menu"
aria-label="Set your status"
style={pos ? { left: pos.left, bottom: pos.bottom } : { visibility: 'hidden' }}
>
{CHOICES.filter((c) => supportsStatus(account.service, c.id)).map((c) => (
<button
key={c.id}
type="button"
role="menuitemradio"
aria-checked={c.id === status}
className={classes('status-choice', c.id === status && 'current')}
onClick={() => choose(c.id)}
>
<span className={classes('status-dot', presenceClass(c.dot))} />
<span className="status-choice-text">
<span>{statusName(account.service, c.id)}</span>
{c.hint && <span className="small muted">{account.service === 'matrix' ? 'Your presence is set to offline' : c.hint}</span>}
</span>
{c.id === status && <Icon name="check" size={16} className="status-check" />}
</button>
))}
{canSay && (
<>
<div className="status-rule" />
<label className="status-say">
<span className="small muted">Custom status</span>
<span className="status-say-row">
<input
className="text-field"
value={text}
maxLength={MAX_TEXT}
placeholder="What's up?"
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
saveText()
}
}}
/>
{text && (
<button
type="button"
className="icon-button"
title="Clear"
onClick={() => {
setText('')
void store.setAccountStatus(account.id, (status === 'connecting' || status === 'offline' ? 'online' : status) as Exclude<Status, 'offline'>, '')
}}
>
<Icon name="close" size={14} />
</button>
)}
</span>
</label>
</>
)}
<div className="status-rule" />
<div className="switch-row status-all">
<span className="switch-text">
<span>Apply to all accounts</span>
<span className="small muted">Your choice goes to every account that is signed in</span>
</span>
<button
type="button"
role="switch"
aria-checked={applyAll}
className={classes('switch', applyAll && 'on')}
onClick={() => setApplyAll(!applyAll)}
>
<span className="switch-knob" />
</button>
</div>
<div className="status-rule" />
<button
type="button"
role="menuitem"
className={classes('status-choice', 'danger')}
disabled={status === 'offline'}
onClick={() => {
void store.setPresence(account.id, 'offline')
onClose()
}}
>
<Icon name="logout" size={16} />
<span className="status-choice-text">
<span>Sign out</span>
</span>
</button>
</div>,
document.body
)
}
+62
View File
@@ -0,0 +1,62 @@
import { classes } from '../lib/util'
/**
* The one on/off control. Every boolean in the app is this, at the right-hand
* end of its row: there used to be a native checkbox, a checkbox with its box
* on the left and this, sometimes on the same page.
*/
export function Switch({
checked,
onChange,
label,
disabled
}: {
checked: boolean
onChange: (next: boolean) => void
/** What a screen reader says it is, where the row's own text is not next to it in the tree. */
label?: string
disabled?: boolean
}): JSX.Element {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
className={classes('switch', checked && 'on')}
onClick={() => onChange(!checked)}
>
<span className="switch-knob" />
</button>
)
}
/**
* A switch with its words: what it does on the left, the switch on the right,
* centred on the pair. Every boolean in settings and accounts is a row of
* this shape, so the right-hand edge is always where to look.
*/
export function SwitchRow({
title,
description,
checked,
onChange,
disabled
}: {
title: string
description?: string
checked: boolean
onChange: (next: boolean) => void
disabled?: boolean
}): JSX.Element {
return (
<div className="switch-row">
<span className="switch-text">
<span>{title}</span>
{description && <span className="small muted">{description}</span>}
</span>
<Switch checked={checked} onChange={onChange} label={title} disabled={disabled} />
</div>
)
}
+28 -36
View File
@@ -1,6 +1,8 @@
import { useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef } from 'react'
import { MessageRow, useRowContext } from './MessageRow'
import { isGrouped } from './MessageList'
import { IconButton } from './Icon'
import { Composer } from './Composer'
import { useChat, usePref, useStore } from '../state/hooks'
import type { ChatMessage } from '../state/store'
@@ -20,16 +22,16 @@ import type { ChatMessage } from '../state/store'
export function ThreadPanel(): JSX.Element | null {
const thread = useChat((s) => s.openThread)
const messagesByBuffer = useChat((s) => s.messagesByBuffer)
const buffers = useChat((s) => s.buffers)
const [comfy] = usePref<'classic' | 'comfy' | 'bubbles'>('ui.messageMode', 'comfy')
const [relativeTimestamps] = usePref<boolean>('ui.relativeTimestamps', false)
// The same two settings the room itself reads (display.*): this panel used
// to ask for `ui.*`, which nothing writes, so it ignored both.
const [comfy] = usePref<'classic' | 'comfy' | 'bubbles'>('display.messageMode', 'comfy')
const [relativeTimestamps] = usePref<boolean>('display.relativeTimestamps', false)
const store = useStore()
// Before the early return below, as every hook here has to be.
const shared = useRowContext(thread?.bufferId ?? '')
const [draft, setDraft] = useState('')
const inputRef = useRef<HTMLTextAreaElement>(null)
const service = buffers.find((b) => b.id === thread?.bufferId)?.id.split(':')[0]
// From the account, as the room does, not by splitting its id.
const service = thread ? store.accountFor(thread.bufferId)?.service : undefined
const body = useRef<HTMLDivElement>(null)
const messages = useMemo((): ChatMessage[] => {
if (!thread) return []
@@ -44,31 +46,35 @@ export function ThreadPanel(): JSX.Element | null {
return [...byId.values()].sort((a, b) => a.ts - b.ts)
}, [thread, messagesByBuffer])
if (!thread) return null
// Opened on, and following, the newest reply: a thread is read from its end.
const count = messages.length
useEffect(() => {
const el = body.current
if (el) el.scrollTop = el.scrollHeight
}, [thread?.rootId, count])
const send = (): void => {
const body = draft.trim()
if (!body) return
setDraft('')
void store.sendToThread(body)
}
if (!thread) return null
return (
<div className="thread-pane">
<div className="thread-header">
<span className="thread-title ellipsis">Thread</span>
<span className="small muted">{Math.max(0, messages.length - 1)} replies</span>
<span className="small muted">
{Math.max(0, messages.length - 1) === 1 ? '1 reply' : `${Math.max(0, messages.length - 1)} replies`}
</span>
<IconButton name="close" title="Close thread" onClick={() => store.closeThreadPanel()} />
</div>
<div className="thread-body">
<div className="thread-body" ref={body}>
{messages.map((msg, i) => (
<MessageRow
key={msg.id}
message={msg}
bufferId={thread.bufferId}
service={service}
grouped={false}
// One author's run reads as one, as it does in the room - but the
// thread's first message stands on its own.
grouped={comfy !== 'classic' && i > 1 && isGrouped(messages, i)}
mode={comfy === 'bubbles' ? 'comfy' : comfy}
lastInRun={i === messages.length - 1}
relativeTimestamps={relativeTimestamps}
@@ -89,25 +95,11 @@ export function ThreadPanel(): JSX.Element | null {
)}
</div>
{/* Its own box, not the room's. Typing here answers the thread, and the
room's composer still answers the room - which is the whole reason
{/* The room's own box, answering the thread: emoji, names, files and
formatting all work here. Typing in it answers the thread, and the
room's box still answers the room - which is the whole reason
somebody opens one of these. */}
<div className="thread-composer">
<textarea
ref={inputRef}
rows={1}
value={draft}
placeholder="Reply in thread"
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}}
/>
<IconButton name="send" title="Send" onClick={send} />
</div>
<Composer thread />
</div>
)
}
+18 -2
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { Icon, IconButton } from './Icon'
import { useChat } from '../state/hooks'
import { OPEN_SWITCHER } from './QuickSwitcher'
/**
* The window's own chrome, drawn in the renderer because the BrowserWindow is
@@ -42,14 +43,29 @@ export function TitleBar({ title }: { title?: string } = {}): JSX.Element {
)}
</div>
{/* Only in the main window: a popout is one conversation, and nowhere
else to go. */}
{title === undefined && (
<button
type="button"
className="titlebar-search"
title="Jump to a conversation (Ctrl+K)"
onClick={() => window.dispatchEvent(new CustomEvent(OPEN_SWITCHER))}
>
<Icon name="search" size={14} />
<span>Where to?</span>
<kbd>Ctrl K</kbd>
</button>
)}
<div className="titlebar-controls">
<IconButton name="remove" title="Minimize" onClick={() => void window.moho.window.minimize()} />
<IconButton
name={maximized ? 'fullscreen_exit' : 'fullscreen'}
name={maximized ? 'filter_none' : 'crop_square'}
title={maximized ? 'Restore' : 'Maximize'}
onClick={() => void window.moho.window.toggleMaximize().then(setMaximized)}
/>
<IconButton name="close" title="Close" onClick={() => void window.moho.window.close()} />
<IconButton name="close" title="Close" className="titlebar-close" onClick={() => void window.moho.window.close()} />
</div>
</div>
)

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