Compare commits
@@ -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
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 797 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 771 B |
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -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)
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
}
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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'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>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||