Compare commits
@@ -219,6 +219,45 @@ called `subscribe` for that buffer.
|
||||
rather than only watched. The stream itself
|
||||
plays in the window over HLS, with its VODs and clips.
|
||||
|
||||
- **Twitch** - a streamer's chat over Twitch's IRC service (read with no account, sent through the
|
||||
official API), with emotes (Twitch's own, animated ones, and the channel's whole set in the picker with the ones the
|
||||
account cannot use greyed out; BetterTTV, FrankerFaceZ and 7TV are browser extensions Twitch does not carry, so
|
||||
they are not drawn), badges as pictures, cheers, replies and threads, subscriptions, gifts and
|
||||
raids, raids out, polls and predictions as they happen, moderation, whispers, shoutouts and blocking.
|
||||
One sign-in on twitch.tv gives the account its API token and the website session its player takes, so
|
||||
the stream plays in the window as the person (their Turbo or subscriptions count) with Twitch's ad
|
||||
breaks said and never skipped.
|
||||
|
||||
### Notifications are cards
|
||||
|
||||
Anything a service *says* rather than somebody *saying* - a join or part, a topic or mode change, a kick,
|
||||
a ban, a timeout, a room's slow mode, a removed message, a raid, a gift, a pinned message, a warning, a
|
||||
notice from the server itself - is drawn in the window as a **card**: the same plate Kick's and Twitch's
|
||||
events use (`KickEventCard.tsx`), with a mark for what it was, its subject in bold, and what happened.
|
||||
This holds for every protocol, and **for every protocol added later**; it is a rule of the design and not
|
||||
a styling choice of any one backend.
|
||||
|
||||
How a backend takes part, which is the whole of its job:
|
||||
|
||||
- It sends the line with a kind that is not speech (`system`, `join`, `part`, `topic`, `mode`, `kick`,
|
||||
`moderation`, and so on - `isChatKind` in `lib/util.ts` is the test). The window then draws a
|
||||
`NotificationCard` for it on its own: a mark chosen by kind (`SYSTEM_MARKS` in `MessageRow.tsx`), the
|
||||
line's own words, a warning in red. Nothing else is needed, which is why a new protocol's notifications
|
||||
are cards from its first connection.
|
||||
- It may say more by attaching an embed to the line (`kind: "moderation"`, `"raid"`, `"subscribe"`,
|
||||
`"gift-sub"`, `"stream"`, ...) with the subject in `author`, what happened in `title`, who did it in
|
||||
`description` where that is known, and a Material Symbols glyph in `icon`. A penalty (`gavel`,
|
||||
`timer_off`, `delete`, `block`) draws in the danger colour, anything else in the window's accent.
|
||||
- It says each thing **once**. What the service already tells the whole room (a timeout, a mode change) is
|
||||
not also written by the client that caused it; only what happens to this account alone is written
|
||||
locally, as a card.
|
||||
- A line that is not speech is **never** left as bare grey text, and a card is never used for a person
|
||||
speaking: the divide is whether somebody said it.
|
||||
|
||||
A new protocol is not finished until its joins, parts, topic changes, moderation, mode changes and
|
||||
service notices come out of it as cards. `scripts/shot-notification-cards.mjs` draws every kind of line
|
||||
beside ordinary chat and a moderation card, to check that they are the same sort of thing.
|
||||
|
||||
## On-disk state
|
||||
|
||||
| Path | Contents |
|
||||
|
||||
@@ -17,6 +17,56 @@ Each section has two parts:
|
||||
that made it, including dependency updates
|
||||
(`node scripts/changelog-deps.mjs <previous tag>` lists those).
|
||||
|
||||
## [1.2.0]
|
||||
|
||||
### Major features
|
||||
|
||||
- **Twitch, fully ported:** a streamer's chat beside the stream, in the same window as everything else. Read with no account at all, or sign in once and have chat, video, channel points, moderation and whispers from that single sign-in.
|
||||
- **Twitch chat:** messages with name colours, badges, cheers with their pictures, replies and threads, Twitch's own emotes (animated ones move), first-time and returning chatters, and subscriptions, gifts, raids and announcements drawn as cards. The channels you follow are opened the first time you sign in, and the member list counts who is talking.
|
||||
- **Twitch video:** the stream plays in the window the way Kick's does, in the conversation, in the corner or in a window of its own, with a resolution menu, captions where the streamer sends them, and Twitch's ad breaks said instead of left to look like a fault. A subscriber's or Turbo account plays without them.
|
||||
- **Twitch emotes:** the picker shows the selected channel's whole set with the ones you cannot use greyed out and the usable ones first, then Twitch's own, then a section for each other channel that gives you emotes, each with the channel's picture. What you send leaves as its word and shows as its picture in your own line; an emote that lapses leaves the picker, and an emote that cannot be sent in a conversation is not offered as a recent one on any service.
|
||||
- **Twitch channel points:** the balance on a plaque, one panel over the chat that lists a channel's rewards cheapest first and redeems them (Highlight My Message, Choose an Emote to Unlock, Unlock a Random Sub Emote, Modify a Single Emote, and the streamer's own, with the text a reward asks for), and watching a stream in moho now earns points because the player reports that it is being watched, once a minute and only while the picture is playing, with a switch in Settings.
|
||||
- **Twitch polls, predictions and raids** as they happen, as cards: vote in a poll and bet on a prediction from the card, and go along with a raid.
|
||||
- **Twitch moderation and whispers** from one sign-in: removing messages, timeouts, bans, the room's modes, announcements, shoutouts, blocking, and whispers as conversations of their own, from menus and from slash commands, for the channels where the account has the rights.
|
||||
- **Twitch's own sections in the list:** Channels and Direct Messages are fixed headings, and a whisper is a conversation named for the person, with a badge to tell it from a channel.
|
||||
- **Kick and Twitch share one rewards panel** for channel points, opened from the plaque over the chat like the quick switcher.
|
||||
|
||||
### Fixes, patches and changes
|
||||
|
||||
- Every notification a service sends is drawn as a card in the same style, and that is the rule for protocols to come ([moho `9ef8a67`](https://github.com/Moho-Chat/moho/commit/9ef8a67))
|
||||
- IRC explains a channel's modes in words when you join and when one changes, so `+Cnrt` is not left for a new user to decode ([moho `34fcddf`](https://github.com/Moho-Chat/moho/commit/34fcddf), [nobilis `28481f3`](https://github.com/Moho-Chat/nobilis/commit/28481f3))
|
||||
- Sneedchat greentext is drawn in 4chan's green, and a sent greentext line is matched with the site's coloured echo ([moho `bb2edf5`](https://github.com/Moho-Chat/moho/commit/bb2edf5))
|
||||
- Recent emoji are offered only where they can be sent, in every conversation of every service ([moho `aacbde7`](https://github.com/Moho-Chat/moho/commit/aacbde7))
|
||||
- The channel list, the member list, the stream above the conversation and the corner panel can be resized by dragging their edges ([moho `d1e39f1`](https://github.com/Moho-Chat/moho/commit/d1e39f1))
|
||||
- Kick video plays about 3.5 seconds behind instead of 20, without rebuilding the picture on every new address, and has a resolution menu and live captions ([moho `3aff255`](https://github.com/Moho-Chat/moho/commit/3aff255), [nobilis `9f0ca86`](https://github.com/Moho-Chat/nobilis/commit/9f0ca86))
|
||||
- Kick video no longer guesses prefetched segments for streams whose segments differ in length ([moho `a7ef798`](https://github.com/Moho-Chat/moho/commit/a7ef798))
|
||||
- Kick shows threads from replies and removed messages, struck through, left out or replaced as chosen ([moho `8e3d110`](https://github.com/Moho-Chat/moho/commit/8e3d110), [nobilis `bb318cf`](https://github.com/Moho-Chat/nobilis/commit/bb318cf))
|
||||
- Kick's recently-talking count keeps rising past the length of what is listed ([moho `987644e`](https://github.com/Moho-Chat/moho/commit/987644e), [nobilis `41e93b8`](https://github.com/Moho-Chat/nobilis/commit/41e93b8))
|
||||
- Kick's points plaque follows its push, and badge pictures are drawn beside names ([moho `dd44e76`](https://github.com/Moho-Chat/moho/commit/dd44e76), [nobilis `4c17be0`](https://github.com/Moho-Chat/nobilis/commit/4c17be0))
|
||||
|
||||
## [1.1.0]
|
||||
|
||||
### Major features
|
||||
|
||||
- **Steam, fully ported:** friends and direct messages, group chats with their rooms, members, roles and permissions, voice rooms and one-to-one voice calls, all in the same window as everything else.
|
||||
- **Steam friends:** the friends list with presence and what each person is playing, friend requests and invitations, blocking and unblocking, nicknames, and profile cards; Steam's own friend folders are the headings on the Steam account's own page, made, renamed, deleted and filled on Steam, and what people were last called is kept between runs so a start shows names and not numbers.
|
||||
- **Steam messages:** emoticons, stickers and reactions in both directions, pictures and files (spoilers too) into friends and group rooms, mentions with `@name`, `@all` and `@online`, link cards, message history that stops asking once it reaches the start, and Steam's own wording for every refusal.
|
||||
- **Steam group chats:** joining by link, a friend's invitation answered from the Invitations tile, leaving, bans, kicks, role giving, notification levels, a group's picture, and the group's voice rooms with who is in them.
|
||||
- **Steam voice:** group voice rooms and one-to-one calls over the same relay the Steam client uses, with the speaking indicator, and a call that retries the way the client does when Steam fails it in passing.
|
||||
- **Sign in to Steam two ways:** a password with Steam Guard, or a QR code scanned by the Steam phone app; the token Steam hands back is kept, not the password.
|
||||
- **One Invitations tile** for everything that waits on a yes or a no, from every service: Matrix rooms, Steam friend requests and group invitations, Discord requests, and verification.
|
||||
- **Direct messages sorted under a heading per account** ("Steam - Goons", "Discord - name"), made once and yours to rename, move and delete after that.
|
||||
|
||||
### Fixes, patches and changes
|
||||
|
||||
- Kick chat moved from Pusher to Kick's realtime service ([moho `042f7b6`](https://github.com/Moho-Chat/moho/commit/042f7b6), [nobilis `c26f968`](https://github.com/Moho-Chat/nobilis/commit/c26f968))
|
||||
- Fixed a link at the end of a clause taking the clause's punctuation with it ([moho `0770478`](https://github.com/Moho-Chat/moho/commit/0770478))
|
||||
- Fixed Sneedchat members having no pictures ([nobilis `d1d0770`](https://github.com/Moho-Chat/nobilis/commit/d1d0770))
|
||||
- The profile card has a Status section ([moho `68684a4`](https://github.com/Moho-Chat/moho/commit/68684a4))
|
||||
- Comfy message lists follow Discord's layout on every service, and Classic stays names ([moho `82665ee`](https://github.com/Moho-Chat/moho/commit/82665ee))
|
||||
- Dropping a conversation on a category lights the whole category, not a line above it ([moho `0633a2c`](https://github.com/Moho-Chat/moho/commit/0633a2c))
|
||||
- Fixed Steam invite links not copying: the page's own clipboard is refused, so they go through the main process like every other copy
|
||||
|
||||
## [1.0.0]
|
||||
|
||||
### Major features
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
pkgname=moho-git
|
||||
pkgver=r1.268751e
|
||||
pkgrel=1
|
||||
pkgdesc="Unified desktop chat client for IRC, Discord, Sneedchat and Matrix"
|
||||
pkgdesc="Unified desktop chat client for IRC, Discord, Matrix, Kick, Sneedchat and Steam"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/Moho-Chat/moho"
|
||||
license=('GPL3')
|
||||
|
||||
@@ -1,48 +1,44 @@
|
||||
# moho
|
||||
|
||||
**Every conversation you're in, in one window.**
|
||||
<img width="1545" height="912" alt="showcase-screenshot" src="https://github.com/user-attachments/assets/b9dabddb-bb3c-4a71-b872-9ad167ecd400" />
|
||||
|
||||
moho is a desktop chat client that speaks IRC, Discord, Matrix, Kick and Sneedchat at the
|
||||
same time. One buffer list down the side, one log in the middle, one set of habits — whether
|
||||
the room is a twenty-year-old IRC channel, a Discord server, an encrypted Matrix room, or a
|
||||
stream's chat going past at forty lines a minute.
|
||||
|
||||
It isn't five web apps in a trenchcoat. Underneath sits a small Rust daemon that holds the
|
||||
connections; the window is just what draws them. Close it and you stay connected. Open it
|
||||
again and everything is where you left it.
|
||||
moho is a desktop chat client that speaks IRC, Discord, Matrix, Kick, Twitch, Sneedchat, Steam and many more protocols at the
|
||||
same time. Whether the room is a twenty-year-old IRC channel, a Discord server, an encrypted Matrix room, or a
|
||||
stream's chat going past at forty lines a minute you're able to be part of the conversation.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Five networks, one place.** IRC, Discord, Matrix, Kick and Sneedchat, side by side, with
|
||||
- **Many networks, one place.** IRC, Discord, Matrix, Kick, Twitch, Sneedchat, Steam and many more protocols soon, side by side, with
|
||||
the same reply, edit, react, search and tag-someone gestures on each — as far as each
|
||||
service actually supports them.
|
||||
- **A log, not a feed.** Compact IRC-style scrollback with nick colours, markdown and BBCode,
|
||||
spoilers, code blocks, inline images and video, GIFs, YouTube cards, and custom emoji. There
|
||||
are two roomier modes if you prefer one: Comfy, with avatars, and Bubbles.
|
||||
- **Made for being open all day.** A quick switcher on Ctrl+K, a tray with the number waiting
|
||||
and your status, Do not disturb that really silences everything, idle detection, and
|
||||
conversations that open where you stopped reading rather than a thousand messages back.
|
||||
- **It stays connected.** The daemon keeps IRC registered, Matrix syncing and Tor circuits
|
||||
open while the window is shut. Quitting says a proper goodbye rather than dropping the
|
||||
socket, so you don't come back to a ghosted nick.
|
||||
- **Calls.** Voice, camera and screen sharing on Discord and on Matrix, including the group
|
||||
calls Element holds on a media server. On Discord, Go Live streams both ways with sound,
|
||||
- **Calls.** Voice, camera and screen sharing on Discord and on Matrix, voice rooms and calls on Steam, including the group
|
||||
calls Element holds on a media server. On Discord, Go Live, streams both ways with sound,
|
||||
echo cancellation, volume per person, the soundboard and stage channels. A call can sit in
|
||||
the corner or pop out into a window of its own.
|
||||
- **Matrix, properly encrypted.** End-to-end encryption with device verification by emoji or
|
||||
QR, cross-signing, key backup, and encrypted attachments.
|
||||
- **Sneedchat, and Tor where you want it.** The site's proof-of-work gate and its login
|
||||
captcha both handled, and every room you've joined connected at once. Any account on any of
|
||||
the five can go through an embedded Tor client or a SOCKS5 proxy instead of the open internet.
|
||||
- **Kick streams in the window.** Watch the stream beside its chat, in the corner or in a
|
||||
window of its own, with VODs, clips, 7TV and BTTV emotes, polls and predictions.
|
||||
- **No captchas on Discord, on purpose.** Discord puts joining a server, adding a friend and
|
||||
making a server behind a captcha when they come from anything but its own client, and
|
||||
answering one gets an account flagged for spam. moho opens discord.com for those three
|
||||
instead of putting your account at risk.
|
||||
- **Kick and Twitch streams in the window.** Watch the stream beside its chat, in the corner or in a
|
||||
window of its own, polls, predictions and other interactive methods are functional and at your fingertips.
|
||||
- **Yours.** GPL-3.0, no telemetry, no account with us — your history is a SQLite file on
|
||||
your own disk.
|
||||
|
||||
## Supported protocols
|
||||
|
||||
| Protocol | State | What you get |
|
||||
| --- | --- | --- |
|
||||
| **IRC** | Supported | Channels and queries on any number of networks, with SASL, TLS, DCC file transfers and the IRCv3 capabilities servers offer |
|
||||
| **Matrix** | Supported | Rooms, spaces and direct messages, end-to-end encryption with device verification and key backup, Element Call, and signing in by QR code |
|
||||
| **Discord** | Supported | Servers, direct messages, forums and threads, voice, video, screen sharing and Go Live with end-to-end encryption, stages and the soundboard |
|
||||
| **Kick** | Supported | A streamer's chat beside the stream, with polls, predictions and its events drawn as cards |
|
||||
| **Twitch** | Supported, fully ported | A streamer's chat beside the stream, which plays in the window with a resolution menu and its ad breaks said; emotes (animated, and the channel's whole set with the ones you cannot use greyed out), badges, cheers, replies and threads, its events drawn as cards, channel points with a rewards panel and watch time that earns them, polls, predictions and raids you can answer, and moderation and whispers, all from one sign-in |
|
||||
| **Sneedchat** | Supported | The forum's chat rooms, over clearnet or Tor |
|
||||
| **Steam** | Supported, fully ported | Friends and direct messages, group chats with rooms, roles and invitations, voice rooms and calls, emoticons, stickers, reactions, pictures and files, friend folders, and signing in by password or by QR code |
|
||||
|
||||
Every protocol gets the same window, mentions inbox, search and per-account Tor or SOCKS5 routing.
|
||||
How much of each service's own feature set a protocol has is as far as its service lets a
|
||||
third-party client go; more protocols are planned.
|
||||
|
||||
## Get it
|
||||
|
||||
Prebuilt Linux and Windows downloads are on the
|
||||
@@ -50,10 +46,6 @@ Prebuilt Linux and Windows downloads are on the
|
||||
changed is in [CHANGELOG.md](CHANGELOG.md). moho tells you in its mentions inbox when a newer
|
||||
version is out.
|
||||
|
||||
Versions are MAJOR.FEATURE.FIX: a major release, then protocols added and major features,
|
||||
then bug and security fixes - 1.1.0 would add a protocol, 1.0.5 is the fifth fix release
|
||||
of 1.0.
|
||||
|
||||
The AppImage needs `libfuse2` on recent distributions; if it refuses to start, either install
|
||||
that or run it with `APPIMAGE_EXTRACT_AND_RUN=1`.
|
||||
|
||||
|
||||
+1
-1
Submodule nobilis updated: 649ebb0313...2355fcb6ac
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "moho",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "moho",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moho",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"description": "Unified desktop chat client for IRC, Discord, Sneedchat and Matrix",
|
||||
"author": "Salastil <[email protected]>",
|
||||
"license": "GPL-3.0-or-later",
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The edges that can be dragged, in a scratch moho with injected state (nothing is contacted): the channel list
|
||||
* and the member list change width and keep it, double-click puts them back, a stream above the conversation
|
||||
* changes height by its bottom edge, and the corner panel changes size from every edge and corner.
|
||||
*
|
||||
* node scripts/check-resize.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const ev = (code) => page.evaluate(code)
|
||||
const shot = async (name) => {
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
}
|
||||
const results = []
|
||||
const check = (what, ok, detail = '') => {
|
||||
results.push(ok)
|
||||
console.log(ok ? ' ok ' : ' FAIL', what, detail)
|
||||
}
|
||||
const mouse = (type, x, y) => page.call('Input.dispatchMouseEvent', { type, x, y, button: 'left', buttons: type === 'mouseReleased' ? 0 : 1, clickCount: 1 })
|
||||
const drag = async (from, to) => {
|
||||
await mouse('mouseMoved', from.x, from.y)
|
||||
await mouse('mousePressed', from.x, from.y)
|
||||
for (let i = 1; i <= 6; i++) await mouse('mouseMoved', from.x + ((to.x - from.x) * i) / 6, from.y + ((to.y - from.y) * i) / 6)
|
||||
await mouse('mouseReleased', to.x, to.y)
|
||||
await sleep(300)
|
||||
}
|
||||
const rect = (sel) => ev(`(() => { const r = document.querySelector(${JSON.stringify(sel)})?.getBoundingClientRect(); return r ? { x: r.left, y: r.top, w: r.width, h: r.height, r: r.right, b: r.bottom } : null })()`)
|
||||
const centre = (r) => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 })
|
||||
try {
|
||||
await sleep(2500)
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
await ev(`(() => {
|
||||
const s = window.__mohoShots.store
|
||||
const acct = { id: 'twitch:me', service: 'twitch', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, nick: 'me' }
|
||||
const mk = (name) => ({ id: 'twitch:me|' + name, accountId: 'twitch:me', kind: 'channel', name, lastActivityTs: 5, category: 'Channels', groupId: 'account:twitch:me' })
|
||||
const t = Math.floor(Date.now() / 1000) - 600
|
||||
const messages = Array.from({ length: 8 }, (_, i) => ({ id: 'm' + i, bufferId: 'twitch:me|xqc', from: 'user' + i, body: 'hello ' + i, ts: t + i * 20, kind: 'chat', isAction: false, isHighlight: false, isOwn: false, edited: false }))
|
||||
s.set({ accounts: [acct], groups: [{ id: 'account:twitch:me', accountId: 'twitch:me', service: 'twitch', kind: 'account', name: 'me', position: 0 }], buffers: [mk('xqc'), mk('other')], messagesByBuffer: { 'twitch:me|xqc': messages }, activeBufferId: 'twitch:me|xqc' })
|
||||
s.selectGroup('account:twitch:me')
|
||||
})()`)
|
||||
await sleep(1200)
|
||||
|
||||
console.log('Channel list')
|
||||
let side = await rect('.sidebar')
|
||||
const divider = await rect('.divider-v.resizer')
|
||||
check('the divider beside the channel list can be taken', !!divider, JSON.stringify(divider))
|
||||
const before = side.w
|
||||
await drag(centre(divider), { x: centre(divider).x + 90, y: centre(divider).y })
|
||||
side = await rect('.sidebar')
|
||||
check('dragging it wider widens the channel list', Math.abs(side.w - (before + 90)) <= 2, `${before} -> ${side.w}`)
|
||||
await drag(centre(await rect('.divider-v.resizer')), { x: 5, y: 300 })
|
||||
side = await rect('.sidebar')
|
||||
check('it stops at its narrowest', side.w >= 158 && side.w <= 162, String(side.w))
|
||||
await ev(`document.querySelector('.divider-v.resizer').dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))`)
|
||||
await sleep(300)
|
||||
side = await rect('.sidebar')
|
||||
check('double-click puts it back to its usual width', Math.abs(side.w - 220) <= 2, String(side.w))
|
||||
console.log('Member list')
|
||||
await ev(`window.__mohoShots.store.set({ roster: {} })`)
|
||||
const nick = await rect('.nicklist-pane')
|
||||
if (nick) {
|
||||
const handles = await ev(`[...document.querySelectorAll('.divider-v.resizer')].map((d) => { const r = d.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 } })`)
|
||||
const right = handles[handles.length - 1]
|
||||
const w0 = nick.w
|
||||
await drag(right, { x: right.x - 70, y: right.y })
|
||||
const w1 = (await rect('.nicklist-pane')).w
|
||||
check('dragging the member list divider left widens the member list', Math.abs(w1 - (w0 + 70)) <= 2, `${w0} -> ${w1}`)
|
||||
} else check('the member list is shown for a channel', false)
|
||||
await shot('columns.png')
|
||||
|
||||
console.log('Stream above the conversation')
|
||||
await ev(`(() => {
|
||||
const s = window.__mohoShots.store
|
||||
s.set({ watching: { bufferId: 'twitch:me|xqc', accountId: 'twitch:me', title: 'xqc' }, kickStreams: { 'twitch:me|xqc': { bufferId: 'twitch:me|xqc', live: true, title: 'a stream', viewers: 5, playbackUrl: '' } } })
|
||||
})()`)
|
||||
await sleep(800)
|
||||
const edge = await rect('.call-stage:not(.pip) .stage-resize')
|
||||
check('the stream has a bottom edge to take', !!edge, JSON.stringify(edge))
|
||||
if (edge) {
|
||||
const h0 = (await rect('.video-stage')).h
|
||||
await drag(centre(edge), { x: centre(edge).x, y: centre(edge).y - 60 })
|
||||
const h1 = (await rect('.video-stage')).h
|
||||
check('dragging it up makes the picture shorter', h1 < h0 - 40, `${h0} -> ${h1}`)
|
||||
await shot('stream-inline.png')
|
||||
}
|
||||
|
||||
console.log('Corner panel')
|
||||
await ev(`window.__mohoShots.store.selectBuffer('twitch:me|other')`)
|
||||
await sleep(800)
|
||||
let pip = await rect('.call-stage.pip')
|
||||
check('the stream goes to a corner when another channel is open', !!pip, JSON.stringify(pip))
|
||||
if (pip) {
|
||||
for (const [dir, dx, dy, bigger] of [['l', -80, 0, true], ['t', 0, -60, true], ['tl', -60, -40, true], ['r', 60, 0, true], ['b', 0, 50, true]]) {
|
||||
const was = (await rect('.call-stage.pip')).w
|
||||
const handle = await rect(`.call-stage.pip .edge-${dir}`)
|
||||
if (!handle) { check(`the ${dir} edge can be taken`, false); continue }
|
||||
const start = centre(handle)
|
||||
await drag(start, { x: start.x + dx, y: start.y + dy })
|
||||
const now = await rect('.call-stage.pip')
|
||||
check(`dragging the ${dir} edge changes the corner panel's size`, bigger ? now.w > was + 20 : now.w < was - 20, `${Math.round(was)} -> ${Math.round(now.w)}`)
|
||||
check(`...and it stays inside the window (${dir})`, now.x >= 0 && now.r <= 1280 && now.y >= 0 && now.b <= 800, JSON.stringify(now))
|
||||
}
|
||||
await shot('pip.png')
|
||||
}
|
||||
} finally {
|
||||
await stop()
|
||||
console.log(results.every(Boolean) ? 'ALL OK' : 'SOME FAILED')
|
||||
process.exitCode = results.every(Boolean) ? 0 : 1
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The channel points panel over the chat, for Twitch and for Kick, in a scratch moho with the daemon's answers
|
||||
* stood in for (nothing is contacted, nothing spent): it opens from the plaque, lists the rewards with their
|
||||
* costs, says why one cannot be pressed, needs a second press to spend, asks for the text a reward wants, and
|
||||
* closes on Escape.
|
||||
*
|
||||
* node scripts/check-rewards-panel.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const ev = (code) => page.evaluate(code)
|
||||
const shot = async (name) => {
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
}
|
||||
const results = []
|
||||
const check = (what, ok, detail = '') => {
|
||||
results.push(ok)
|
||||
console.log(ok ? ' ok ' : ' FAIL', what, detail)
|
||||
}
|
||||
const key = (k) => page.call('Input.dispatchKeyEvent', { type: 'keyDown', key: k, code: k }).then(() => page.call('Input.dispatchKeyEvent', { type: 'keyUp', key: k, code: k }))
|
||||
try {
|
||||
await sleep(2500)
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1200, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
await ev(`(() => {
|
||||
window.__redeemed = []
|
||||
window.__rpcFixture = {
|
||||
listTwitchRewards: () => ({ points: 600, name: 'Sloths', rewards: [
|
||||
{ id: 'r1', title: 'Drink water', cost: 500, description: null, enabled: true, paused: false, needsInput: false },
|
||||
{ id: 'r2', title: 'Say a name', cost: 100, description: 'Who?', enabled: true, paused: false, needsInput: true },
|
||||
{ id: 'r3', title: 'Dance', cost: 50, description: null, enabled: true, paused: true, needsInput: false },
|
||||
{ id: 'r4', title: 'Costly', cost: 9000, description: null, enabled: true, paused: false, needsInput: false },
|
||||
{ id: 'a1', title: 'Choose an Emote to Unlock', cost: 480, description: 'Pick a subscriber emote to unlock for 24 hours', enabled: true, paused: false, needsInput: false, choices: [{ id: 'e1', label: 'binxbWut' }, { id: 'e2', label: 'binxbYAP' }] },
|
||||
{ id: 'a3', title: 'Modify a Single Emote', cost: 550, description: null, enabled: true, paused: false, needsInput: false, choices: [{ id: 'e1_BW', label: 'binxbWut, black and white', group: 'binxbWut', groupImage: null }, { id: 'e1_SG', label: 'binxbWut, with sunglasses', group: 'binxbWut', groupImage: null }, { id: 'e2_SG', label: 'binxbYAP, with sunglasses', group: 'binxbYAP', groupImage: null }] },
|
||||
{ id: 'a2', title: 'Sub-only', cost: 350, description: null, enabled: true, paused: false, needsInput: false, unavailable: 'Send this one from twitch.tv chat box' } ] }),
|
||||
redeemTwitchReward: (p) => { window.__redeemed.push(p); return { points: 600 } },
|
||||
listKickRewards: () => ({ points: 1200, rewards: [ { id: 'k1', title: 'Hydrate', cost: 300, enabled: true, paused: false, needsInput: false }, { id: 'k2', title: 'Type something', cost: 100, enabled: true, paused: false, needsInput: true } ] }),
|
||||
redeemKickReward: (p) => { window.__redeemed.push(p); return { points: 900 } },
|
||||
twitchPoints: () => ({ points: 600, name: 'Sloths' }),
|
||||
kickPoints: () => ({ points: 1200 })
|
||||
}
|
||||
const s = window.__mohoShots.store
|
||||
const pic = 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#7b5cd6"/></svg>')
|
||||
const accts = [
|
||||
{ id: 'twitch:me', service: 'twitch', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, webSession: true, nick: 'me' },
|
||||
{ id: 'kick:me', service: 'kick', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, nick: 'me' }
|
||||
]
|
||||
const bufs = [
|
||||
{ id: 'twitch:me|xqc', accountId: 'twitch:me', kind: 'channel', name: 'xqc', lastActivityTs: 5, avatarUrl: pic, groupId: 'account:twitch:me' },
|
||||
{ id: 'kick:me|spreen', accountId: 'kick:me', kind: 'channel', name: 'spreen', lastActivityTs: 5, avatarUrl: pic, groupId: 'account:kick:me' }
|
||||
]
|
||||
s.set({ accounts: accts, groups: accts.map((a, i) => ({ id: 'account:' + a.id, accountId: a.id, service: a.service, kind: 'account', name: 'me', position: i })), buffers: bufs, activeBufferId: 'twitch:me|xqc', twitchPoints: { 'twitch:me|xqc': { points: 600, name: 'Sloths' } }, kickPointsByBuffer: { 'kick:me|spreen': 1200 } })
|
||||
s.selectGroup('account:twitch:me')
|
||||
})()`)
|
||||
await sleep(1500)
|
||||
|
||||
await ev(`window.__errs = []; window.addEventListener('error', (e) => window.__errs.push(String(e.message))); window.addEventListener('unhandledrejection', (e) => window.__errs.push('rej ' + String(e.reason)))`)
|
||||
console.log('Twitch')
|
||||
const plaque = `document.querySelector('.kick-points-plaque')`
|
||||
check('the plaque shows the balance with the channel\'s name for its points', await ev(`${plaque}?.innerText.includes('600 Sloths')`))
|
||||
await ev(`${plaque}.click()`)
|
||||
await sleep(600)
|
||||
check('a panel opens over the chat', await ev(`!!document.querySelector('.switcher.rewards')`))
|
||||
check('it lists the four rewards, cheapest first within the ones that can be had, then the ones that cannot', await ev(`JSON.stringify([...document.querySelectorAll('.reward-row')].map((r) => r.querySelector('.reward-cost').innerText)) === JSON.stringify(['100', '480', '500', '550', '50', '350', '9,000'])`))
|
||||
check('under two headings', await ev(`JSON.stringify([...document.querySelectorAll('.reward-group')].map((h) => h.innerText.toLowerCase())) === JSON.stringify(['available', 'unavailable'])`))
|
||||
check('a paused reward and an unaffordable one are disabled with the reason', await ev(`(() => { const r = [...document.querySelectorAll('.reward-row')]; return r.slice(0, 4).every((x) => !x.disabled) && r[4].disabled && r[4].innerText.includes('Paused') && r[5].disabled && r[5].innerText.includes('twitch.tv chat box') && r[6].disabled && r[6].innerText.includes('You have 600 of 9,000') })()`))
|
||||
await shot('rewards-twitch.png')
|
||||
await ev(`document.querySelectorAll('.reward-row')[2].click()`)
|
||||
await sleep(300)
|
||||
check('the first press arms it and spends nothing', await ev(`!!document.querySelector('.reward-confirm') && window.__redeemed.length === 0`))
|
||||
await ev(`document.querySelector('.reward-confirm .button.primary').click()`)
|
||||
await sleep(500)
|
||||
check('Redeem spends it', await ev(`window.__redeemed.length === 1 && window.__redeemed[0].rewardId === 'r1'`))
|
||||
await ev(`document.querySelectorAll('.reward-row')[0].click()`)
|
||||
await sleep(300)
|
||||
check('a reward that asks for text shows its field, and Redeem waits for it', await ev(`!!document.querySelector('.reward-confirm input') && document.querySelector('.reward-confirm .button.primary').disabled`))
|
||||
await page.call('Input.insertText', { text: 'Greg' })
|
||||
await sleep(200)
|
||||
await ev(`document.querySelector('.reward-confirm .button.primary').click()`)
|
||||
await sleep(500)
|
||||
check('what was typed goes with it', await ev(`window.__redeemed[1]?.input === 'Greg'`))
|
||||
await ev(`document.querySelectorAll('.reward-row')[1].click()`)
|
||||
await sleep(300)
|
||||
check('an emote unlock shows the emotes to press, with a search, and Redeem waits for one', await ev(`!document.querySelector('.reward-confirm select') && document.querySelectorAll('.reward-confirm .reward-emote').length === 2 && !!document.querySelector('.reward-confirm input[placeholder="Search emotes"]') && document.querySelector('.reward-confirm .button.primary').disabled`))
|
||||
await ev(`document.querySelector('.reward-confirm .reward-emote[title="binxbYAP"]').click()`)
|
||||
await sleep(200)
|
||||
check('pressing one picks it', await ev(`!document.querySelector('.reward-confirm .button.primary').disabled && document.querySelector('.reward-emote.selected')?.title === 'binxbYAP'`))
|
||||
await ev(`document.querySelector('.reward-confirm .button.primary').click()`)
|
||||
await sleep(500)
|
||||
check('the emote picked goes with it', await ev(`window.__redeemed[2]?.rewardId === 'a1' && window.__redeemed[2]?.choice === 'e2'`))
|
||||
await ev(`[...document.querySelectorAll('.reward-row')].find((r) => r.innerText.includes('Modify a Single Emote')).click()`)
|
||||
await sleep(300)
|
||||
check('a change to an emote shows the emotes first', await ev(`[...document.querySelectorAll('.reward-confirm .reward-emote')].map((b) => b.title).join() === 'binxbWut,binxbYAP'`))
|
||||
await ev(`document.querySelector('.reward-confirm .reward-emote[title="binxbWut"]').click()`)
|
||||
await sleep(200)
|
||||
check('then what can be done to the one picked', await ev(`[...document.querySelectorAll('.reward-confirm .reward-emote')].map((b) => b.title).join() === 'binxbWut, black and white,binxbWut, with sunglasses'`))
|
||||
await ev(`document.querySelector('.reward-confirm .reward-emote[title="binxbWut, with sunglasses"]').click()`)
|
||||
await sleep(200)
|
||||
await ev(`document.querySelector('.reward-confirm .button.primary').click()`)
|
||||
await sleep(500)
|
||||
check('the change picked goes with it', await ev(`window.__redeemed[3]?.rewardId === 'a3' && window.__redeemed[3]?.choice === 'e1_SG'`))
|
||||
await key('Escape')
|
||||
await sleep(300)
|
||||
check('Escape closes the panel', await ev(`!document.querySelector('.switcher.rewards')`))
|
||||
|
||||
console.log('Kick')
|
||||
await ev(`window.__mohoShots.store.selectGroup('account:kick:me'); window.__mohoShots.store.selectBuffer('kick:me|spreen', true)`)
|
||||
await sleep(1200)
|
||||
await ev(`document.querySelector('.kick-points-plaque')?.click()`)
|
||||
await sleep(600)
|
||||
check('Kick opens the same panel', await ev(`!!document.querySelector('.switcher.rewards') && document.querySelectorAll('.reward-row').length === 2`))
|
||||
check('a Kick reward that asks for text is disabled with a reason', await ev(`(() => { const r = document.querySelectorAll('.reward-row')[1]; return r.disabled && r.innerText.includes('cannot do') })()`))
|
||||
await shot('rewards-kick.png')
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
console.log(results.every(Boolean) ? 'ALL OK' : `${results.filter((r) => !r).length} FAILED`)
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Makes a Twitch integrity token the way the app does when Twitch asks for one - in a hidden page on
|
||||
* twitch.tv, from main - and then uses it for a stream's playback token from outside the page, to show the
|
||||
* token is the kind Twitch's GraphQL takes.
|
||||
*
|
||||
* node scripts/check-twitch-integrity.mjs
|
||||
*/
|
||||
import { cdp, freePort, launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const inspectPort = await freePort()
|
||||
const { stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' }, args: [`--inspect=${inspectPort}`] })
|
||||
try {
|
||||
let main = null
|
||||
for (let i = 0; i < 60 && !main; i++) {
|
||||
try {
|
||||
const list = await (await fetch(`http://127.0.0.1:${inspectPort}/json/list`)).json()
|
||||
const target = list.find((t) => t.webSocketDebuggerUrl)
|
||||
if (target) main = cdp(target.webSocketDebuggerUrl)
|
||||
} catch {
|
||||
/* not up yet */
|
||||
}
|
||||
await sleep(500)
|
||||
}
|
||||
if (!main) throw new Error('main never offered its inspector')
|
||||
for (let i = 0; i < 40 && !(await main.evaluate('!!globalThis.__mohoTwitchIntegrity').catch(() => false)); i++) await sleep(500)
|
||||
const device = `check${Math.random().toString(16).slice(2, 14)}`
|
||||
const t0 = Date.now()
|
||||
const made = await main.evaluate(`globalThis.__mohoTwitchIntegrity.mint(${JSON.stringify(device)})`)
|
||||
console.log(`made in ${((Date.now() - t0) / 1000).toFixed(1)}s:`, { prefix: made.token.slice(0, 9), length: made.token.length, goodForHours: +((made.expiration - Date.now()) / 3600000).toFixed(1) })
|
||||
// From outside any page: the player's own token query, with the integrity token and the device it was made for.
|
||||
const res = await fetch('https://gql.twitch.tv/gql', {
|
||||
method: 'POST',
|
||||
headers: { 'Client-ID': 'kimne78kx3ncx6brgo4mv6wki5h1ko', 'Content-Type': 'text/plain;charset=UTF-8', 'X-Device-Id': device, 'Client-Integrity': made.token },
|
||||
body: JSON.stringify({ operationName: 'PlaybackAccessToken', variables: { isLive: true, login: 'xqc', isVod: false, vodID: '', playerType: 'site', platform: 'web' }, extensions: { persistedQuery: { version: 1, sha256Hash: 'ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9' } } })
|
||||
})
|
||||
const body = await res.json()
|
||||
const token = body?.data?.streamPlaybackAccessToken
|
||||
console.log('playback token call', res.status, token ? `token for device ${JSON.parse(token.value).device_id}` : JSON.stringify(body).slice(0, 200))
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Drives the sign-in window of a scratch moho without anybody signing in: after the window opens, a made-up
|
||||
* `auth-token` cookie stands in for the one a real sign-in leaves, and the window is watched to see that it is
|
||||
* taken to Twitch's approval page and kept there when something sends it back to the front page. Nothing real
|
||||
* is typed or approved; the sign-in is cancelled at the end.
|
||||
*
|
||||
* node scripts/check-twitch-signin-window.mjs
|
||||
*/
|
||||
import { cdp, launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const { page, stop, port } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
|
||||
const ev = (code) => page.evaluate(code)
|
||||
const targets = async () => (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()).filter((t) => t.type === 'page' && /twitch\.tv/.test(t.url))
|
||||
const clickText = (selector, text) => ev(`(() => { const b = [...document.querySelectorAll(${JSON.stringify(selector)})].find((x) => x.textContent.trim().startsWith(${JSON.stringify(text)})); if (!b) return false; b.click(); return true })()`)
|
||||
try {
|
||||
await sleep(2500)
|
||||
await ev(`window.__mohoShots.store.setActivePanel('accounts')`)
|
||||
await sleep(600)
|
||||
await clickText('.service-chip', 'Twitch')
|
||||
await sleep(500)
|
||||
say('sign in', await clickText('button', 'Sign in with Twitch'))
|
||||
let t
|
||||
for (let i = 0; i < 40 && !t; i++) {
|
||||
await sleep(500)
|
||||
t = (await targets())[0]
|
||||
}
|
||||
say('window at', t?.url)
|
||||
const win = cdp(t.webSocketDebuggerUrl)
|
||||
await sleep(3000)
|
||||
await win.call('Network.enable')
|
||||
await win.call('Network.setCookie', { name: 'auth-token', value: 'madeupmadeupmadeup', domain: '.twitch.tv', path: '/', secure: true })
|
||||
say('cookie set')
|
||||
let at = ''
|
||||
for (let i = 0; i < 40 && !/activate/.test(at); i++) {
|
||||
await sleep(500)
|
||||
at = (await targets())[0]?.url ?? ''
|
||||
}
|
||||
say('window now at', at)
|
||||
// Something sends it back to the front page, as a late redirect would.
|
||||
await win.evaluate(`location.href = 'https://www.twitch.tv/'`).catch(() => {})
|
||||
const seen = []
|
||||
for (let i = 0; i < 24; i++) {
|
||||
await sleep(250)
|
||||
const u = (await targets())[0]?.url ?? ''
|
||||
const where = new URL(u).pathname
|
||||
if (seen[seen.length - 1] !== where) seen.push(where)
|
||||
}
|
||||
say('the window went', seen.join(' -> '))
|
||||
await sleep(6000)
|
||||
say('page text', JSON.stringify(await win.evaluate(`document.body.innerText.slice(0, 300)`)))
|
||||
say('inputs', JSON.stringify(await win.evaluate(`[...document.querySelectorAll('input')].map((i) => ({ type: i.type, name: i.name, id: i.id, ph: i.placeholder, max: i.maxLength, value: i.value, shown: i.getClientRects().length > 0 }))`)))
|
||||
win.close()
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The Twitch parts of the window, in a scratch moho with injected state (nothing is contacted): the list's fixed
|
||||
* headings, whispers' badge, Settings -> Twitch, Ctrl+K finding a Twitch channel, readable name colours, emotes
|
||||
* and cheers drawn as pictures, links, and a signed-in account's composer with no GIF button.
|
||||
*
|
||||
* node scripts/check-twitch-ui.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const ev = (code) => page.evaluate(code)
|
||||
const shot = async (name) => {
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
}
|
||||
const results = []
|
||||
const check = (what, ok, detail = '') => {
|
||||
results.push(ok)
|
||||
console.log(ok ? ' ok ' : ' FAIL', what, detail)
|
||||
}
|
||||
const key = (k, mods = 0) => page.call('Input.dispatchKeyEvent', { type: 'keyDown', key: k, code: k.length === 1 ? 'Key' + k.toUpperCase() : k, modifiers: mods, windowsVirtualKeyCode: k.length === 1 ? k.toUpperCase().charCodeAt(0) : 0 }).then(() => page.call('Input.dispatchKeyEvent', { type: 'keyUp', key: k, modifiers: mods }))
|
||||
try {
|
||||
await sleep(2500)
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1200, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
await ev(`(() => {
|
||||
const s = window.__mohoShots.store
|
||||
const acct = { id: 'twitch:me', service: 'twitch', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, webSession: true, moderation: true, nick: 'me' }
|
||||
const pic = 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#7b5cd6"/></svg>')
|
||||
const mk = (name, kind, t, cat) => ({ id: 'twitch:me|' + name, accountId: 'twitch:me', kind, name, lastActivityTs: t, avatarUrl: pic, category: cat, groupId: 'account:twitch:me' })
|
||||
const buffers = [mk('xqc', 'channel', 5, 'Channels'), mk('skitzy_va', 'channel', 4, 'Channels'), mk('@friend', 'dm', 6, 'Direct Messages')]
|
||||
const t = Math.floor(Date.now() / 1000) - 600
|
||||
const m = (i, from, body, extra = {}) => ({ id: 'm' + i, bufferId: 'twitch:me|xqc', from, body, ts: t + i * 20, kind: 'chat', isAction: false, isHighlight: false, isOwn: false, edited: false, ...extra })
|
||||
const messages = [
|
||||
m(1, 'darkblue', 'a very dark colour name', { senderColor: '#0000FF' }),
|
||||
m(2, 'pinkie', 'with an emote [twitch:emote:25:Kappa] and a moving [twitch:aemote:emotesv2_abc:Dance] one'),
|
||||
m(3, 'whale', 'cheered [twitch:cheer:cheer:100] for the stream', {}),
|
||||
m(4, 'linker', 'see https://example.com/page for details'),
|
||||
m(5, 'newbie', 'first message here', { firstMessage: true })
|
||||
]
|
||||
s.set({ accounts: [acct], groups: [{ id: 'account:twitch:me', accountId: 'twitch:me', service: 'twitch', kind: 'account', name: 'me', position: 0 }], buffers, messagesByBuffer: { 'twitch:me|xqc': messages }, activeBufferId: 'twitch:me|xqc' })
|
||||
s.selectGroup('account:twitch:me')
|
||||
})()`)
|
||||
await sleep(1500)
|
||||
|
||||
console.log('Channel list')
|
||||
const rows = await ev(`[...document.querySelectorAll('.category, .buffer-row')].map((r) => r.innerText.replace(/\\n/g, ' ').trim())`)
|
||||
check('fixed headings with the whisper under Direct Messages', JSON.stringify(rows).includes('DIRECT MESSAGES') && JSON.stringify(rows).includes('CHANNELS'), JSON.stringify(rows))
|
||||
check('the whisper row has the mail badge', await ev(`!!document.querySelector('.avatar-badge')`), await ev(`[...document.querySelectorAll('.buffer-row')].map((r) => r.innerHTML.slice(0, 200)).join(' || ')`))
|
||||
|
||||
console.log('Messages')
|
||||
check('a very dark name colour is made readable', await ev(`(() => { const e = [...document.querySelectorAll('.message-sender, .message-from, .sender')].find((x) => x.textContent.includes('darkblue')); if (!e) return false; const m = getComputedStyle(e).color.match(/\\d+/g).map(Number); return (0.2126 * m[0] + 0.7152 * m[1] + 0.0722 * m[2]) > 90 })()`))
|
||||
check('emotes are pictures', (await ev(`document.querySelectorAll('.message-row img[src*="emoticons"], .message-row img.custom-emoji').length`)) >= 2, String(await ev(`[...document.querySelectorAll('.message-row img')].map((i) => i.src.slice(0, 60)).join(' | ')`)))
|
||||
check('a cheer is drawn with its amount', await ev(`document.body.innerText.includes('100')`))
|
||||
check('a link is a link', await ev(`!!document.querySelector('.message-row a[href^="https://example.com"], .message-row .link')`))
|
||||
await shot('twitch-messages.png')
|
||||
|
||||
console.log('Composer')
|
||||
check('the signed-in composer has an emoji button and no GIF button', await ev(`!!document.querySelector('button[title="Emoji"]') && !document.querySelector('button[title="GIFs"]')`))
|
||||
|
||||
console.log('Ctrl+K')
|
||||
await key('k', 2)
|
||||
await sleep(600)
|
||||
const opened = await ev(`!!document.querySelector('input[placeholder*="Where"], .switcher, [role="dialog"] input')`)
|
||||
check('the quick switcher opens', opened)
|
||||
if (opened) {
|
||||
await ev(`(() => { const i = document.querySelector('.switcher input, [role="dialog"] input, input[placeholder*="Where"]'); i.focus() })()`)
|
||||
await page.call('Input.insertText', { text: 'skitz' })
|
||||
await sleep(500)
|
||||
check('it finds a Twitch channel by name', await ev(`document.body.innerText.includes('skitzy_va')`))
|
||||
await key('Escape')
|
||||
await sleep(300)
|
||||
}
|
||||
|
||||
console.log('Settings')
|
||||
await ev(`window.__mohoShots.store.setActivePanel('settings')`)
|
||||
await sleep(800)
|
||||
const clicked = await ev(`(() => { const b = [...document.querySelectorAll('button, [role=tab], a')].find((x) => x.textContent.trim() === 'Twitch'); if (!b) return false; b.click(); return true })()`)
|
||||
check('Settings has a Twitch page', clicked)
|
||||
await sleep(600)
|
||||
const text = await ev(`document.body.innerText`)
|
||||
for (const want of ['What to show besides chat', 'Badges', 'Watching', 'Polls, predictions and raids', 'Recent messages']) check(`Settings -> Twitch has "${want}"`, text.includes(want))
|
||||
await shot('twitch-settings.png')
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
console.log(results.every(Boolean) ? 'ALL OK' : `${results.filter((r) => !r).length} FAILED`)
|
||||
@@ -207,10 +207,12 @@ function moveAside(pid) {
|
||||
* 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 = [] } = {}) {
|
||||
export async function launchApp({ env = {}, args = [], seed } = {}) {
|
||||
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'moho-ui-'))
|
||||
const run = path.join(scratch, 'run')
|
||||
fs.mkdirSync(run, { mode: 0o700 })
|
||||
// Files the app should find on its first start, written into its own home.
|
||||
seed?.(scratch)
|
||||
const port = await freePort()
|
||||
|
||||
// A CI runner's kernel refuses Chromium's sandbox (Ubuntu 24.04 restricts
|
||||
@@ -281,7 +283,7 @@ export async function launchApp({ env = {}, args = [] } = {}) {
|
||||
await stop()
|
||||
throw e
|
||||
}
|
||||
return { page, stop, port }
|
||||
return { page, stop, port, scratch }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// The page the Kick video probe plays in: one hls.js player per configuration, measured while it plays.
|
||||
import Hls from 'hls.js'
|
||||
import { ivsAbr, ivsLoader } from '../../src/renderer/src/lib/ivsplaylist'
|
||||
|
||||
interface Config {
|
||||
name: string
|
||||
url: string
|
||||
seconds: number
|
||||
prefetch: number
|
||||
hls: Record<string, unknown>
|
||||
cap?: number
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
runProbe: (config: Config) => Promise<Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
|
||||
window.runProbe = async (cfg) => {
|
||||
// How far this clock is from the server's, from the time the playlist says it was made at.
|
||||
const sent = Date.now()
|
||||
const text = await (await fetch(cfg.url)).text()
|
||||
const received = Date.now()
|
||||
const server = Number(/SERVER-TIME",VALUE="([0-9.]+)"/.exec(text)?.[1] ?? NaN)
|
||||
const offset = server * 1000 - (sent + received) / 2
|
||||
return new Promise((resolve) => {
|
||||
const video = document.getElementById('v') as HTMLVideoElement
|
||||
const hls = new Hls({ ...cfg.hls, ...(cfg.prefetch > 0 ? { loader: ivsLoader(cfg.prefetch), abrController: ivsAbr() } : {}) } as never)
|
||||
const latency: number[] = []
|
||||
const levels = new Set<string>()
|
||||
const fatal: string[] = []
|
||||
let stalls = 0
|
||||
let waits = 0
|
||||
let switches = 0
|
||||
let waitStart = 0
|
||||
let waited = 0
|
||||
let firstFrame = 0
|
||||
const began = Date.now()
|
||||
video.muted = true
|
||||
video.onwaiting = (): void => {
|
||||
waits++
|
||||
if (!waitStart && firstFrame) waitStart = Date.now()
|
||||
}
|
||||
video.onplaying = (): void => {
|
||||
if (!firstFrame) firstFrame = Date.now() - began
|
||||
if (waitStart) {
|
||||
waited += Date.now() - waitStart
|
||||
waitStart = 0
|
||||
}
|
||||
}
|
||||
hls.on(Hls.Events.ERROR, (_e, d) => {
|
||||
if (d.details === 'bufferStalledError') stalls++
|
||||
if (d.fatal) fatal.push(d.details)
|
||||
})
|
||||
hls.on(Hls.Events.LEVEL_SWITCHED, (_e, d) => {
|
||||
switches++
|
||||
levels.add(`${hls.levels[d.level]?.height}p`)
|
||||
})
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
if (cfg.cap) {
|
||||
const best = hls.levels.reduce((b, l, i) => (l.height <= (cfg.cap ?? 720) && l.height >= (hls.levels[b]?.height ?? 0) ? i : b), -1)
|
||||
if (best >= 0) hls.autoLevelCapping = best
|
||||
}
|
||||
void video.play().catch(() => {})
|
||||
})
|
||||
hls.attachMedia(video)
|
||||
hls.loadSource(cfg.url)
|
||||
const timer = setInterval(() => {
|
||||
const at = hls.playingDate
|
||||
if (at && !video.paused && firstFrame) latency.push((Date.now() + offset - at.getTime()) / 1000)
|
||||
if (Date.now() - began < cfg.seconds * 1000) return
|
||||
clearInterval(timer)
|
||||
const q = video.getVideoPlaybackQuality()
|
||||
const sorted = [...latency].sort((a, b) => a - b)
|
||||
const pick = (p: number): number => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] ?? NaN
|
||||
const settled = sorted.slice(Math.floor(sorted.length * 0.2))
|
||||
const mean = settled.length ? settled.reduce((a, b) => a + b, 0) / settled.length : NaN
|
||||
hls.destroy()
|
||||
resolve({
|
||||
name: cfg.name,
|
||||
startedInMs: firstFrame,
|
||||
samples: latency.length,
|
||||
latencyMin: +Math.min(...latency).toFixed(2),
|
||||
latencyMax: +Math.max(...latency).toFixed(2),
|
||||
latencyMedian: +pick(0.5).toFixed(2),
|
||||
latencyP95: +pick(0.95).toFixed(2),
|
||||
latencyMeanAfterSettling: +mean.toFixed(2),
|
||||
rebufferSeconds: +(waited / 1000).toFixed(2),
|
||||
waits,
|
||||
stallErrors: stalls,
|
||||
dropped: `${q.droppedVideoFrames}/${q.totalVideoFrames}`,
|
||||
levelSwitches: switches,
|
||||
levels: [...levels].join(','),
|
||||
fatal: fatal.join(',')
|
||||
})
|
||||
}, 1000)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Plays a live Kick stream with several hls.js configurations, one after the other, in the app's own
|
||||
* Chromium, and says for each how far behind the broadcaster it ran, how often it stopped to wait,
|
||||
* and how many frames it dropped. Nothing is signed in: the playlist is public.
|
||||
*
|
||||
* node scripts/probe-kick-video.mjs [channel] [seconds] (defaults: spreen, 75)
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const channel = process.argv[2] ?? 'spreen'
|
||||
const seconds = Number(process.argv[3] ?? 75)
|
||||
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..')
|
||||
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'kick-video-'))
|
||||
execFileSync(path.join(root, 'node_modules/.bin/esbuild'), [path.join(root, 'scripts/lib/hlsprobe.ts'), '--bundle', '--format=iife', `--outfile=${path.join(work, 'probe.js')}`, '--log-level=error'])
|
||||
fs.writeFileSync(path.join(work, 'probe.html'), '<!doctype html><body style="margin:0"><video id="v" width="1280" height="720" autoplay muted playsinline></video><script src="probe.js"></script></body>')
|
||||
|
||||
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
|
||||
const playlist = async () => {
|
||||
const r = await fetch(`https://kick.com/api/v2/channels/${channel}/playback-url`, { headers: { 'User-Agent': UA, Accept: 'application/json' } })
|
||||
return (await r.json()).data
|
||||
}
|
||||
|
||||
const base = { capLevelToPlayerSize: true }
|
||||
const configs = [
|
||||
{ name: 'as it is now (3 target durations, no prefetch)', prefetch: 0, hls: { ...base, liveSyncDurationCount: 3 } },
|
||||
{ name: 'seconds: sync 4s, no prefetch', prefetch: 0, hls: { ...base, liveSyncDuration: 4, liveMaxLatencyDuration: 10 } },
|
||||
{ name: 'prefetch 2, sync 4s', prefetch: 2, hls: { ...base, liveSyncDuration: 4, liveMaxLatencyDuration: 10 } },
|
||||
{ name: 'prefetch 2, sync 3s, catch-up 1.1', prefetch: 2, hls: { ...base, liveSyncDuration: 3, liveMaxLatencyDuration: 8, maxLiveSyncPlaybackRate: 1.1 } },
|
||||
{ name: 'prefetch 1, sync 3s, catch-up 1.1', prefetch: 1, hls: { ...base, liveSyncDuration: 3, liveMaxLatencyDuration: 8, maxLiveSyncPlaybackRate: 1.1 } },
|
||||
{ name: 'prefetch 2, sync 5s, catch-up 1.1', prefetch: 2, hls: { ...base, liveSyncDuration: 5, liveMaxLatencyDuration: 10, maxLiveSyncPlaybackRate: 1.1 } }
|
||||
]
|
||||
if (process.env.LIST === 'steady') {
|
||||
configs.length = 0
|
||||
configs.push(
|
||||
{ name: 'prefetch 2, sync 3s', prefetch: 2, hls: { ...base, liveSyncDuration: 3, liveMaxLatencyDuration: 10 } },
|
||||
{ name: 'prefetch 2, sync 4s', prefetch: 2, hls: { ...base, liveSyncDuration: 4, liveMaxLatencyDuration: 10 } },
|
||||
{ name: 'prefetch 2, sync 5s', prefetch: 2, hls: { ...base, liveSyncDuration: 5, liveMaxLatencyDuration: 10 } }
|
||||
)
|
||||
}
|
||||
const only = process.env.ONLY ? process.env.ONLY.split(',').map(Number) : null
|
||||
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
try {
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
await sleep(2500)
|
||||
await page.call('Page.navigate', { url: `file://${path.join(work, 'probe.html')}` })
|
||||
await sleep(1500)
|
||||
for (const [i, cfg] of configs.entries()) {
|
||||
if (only && !only.includes(i)) continue
|
||||
const url = await playlist()
|
||||
const out = await page.evaluate(`window.runProbe(${JSON.stringify({ ...cfg, url, seconds, cap: 720 })})`)
|
||||
console.log(JSON.stringify(out))
|
||||
}
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* A one-to-one Steam call with somebody on the official client: a scratch app signed in as
|
||||
* the throwaway account (never the real one) with a fake microphone joins the group's voice room
|
||||
* and reports what it hears every ten seconds, until ~/.local/share/moho-dev/stop exists.
|
||||
*
|
||||
* MODE=call node scripts/probe-steam-call.mjs (or MODE=wait, which answers the first ring)
|
||||
*
|
||||
* npx electron-vite build && node scripts/probe-steam-voice.mjs
|
||||
*
|
||||
* Needs the throwaway account's block in ~/.config/nobilis/accounts.toml. The app is the
|
||||
* one built in out/, with its own home and runtime directory, and signs the account in
|
||||
* for as long as this runs - so nothing else may be signed in as it.
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const live = fs.readFileSync(path.join(os.homedir(), '.config/nobilis/accounts.toml'), 'utf8')
|
||||
const block = live.split(/\n(?=\[)/).find((b) => b.startsWith('[[steam_account]]') && /username\s*=\s*"mohotester"/.test(b))
|
||||
if (!block) throw new Error('the throwaway account is not in accounts.toml')
|
||||
|
||||
const { page, stop, scratch } = await launchApp({
|
||||
env: { MOHO_UI_SHOTS: '1' },
|
||||
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', `--use-file-for-fake-audio-capture=${os.homedir()}/.local/share/moho-dev/speech.wav`],
|
||||
seed: (home) => {
|
||||
fs.mkdirSync(path.join(home, 'config/nobilis'), { recursive: true })
|
||||
fs.writeFileSync(path.join(home, 'config/nobilis/accounts.toml'), block + '\n')
|
||||
}
|
||||
})
|
||||
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
|
||||
const ev = (code) => page.evaluate(code)
|
||||
try {
|
||||
// The window onto workspace 2, where the user does not look.
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
let account = ''
|
||||
for (let i = 0; i < 90 && !account; i++) {
|
||||
account = await ev(`(() => { const a = window.__mohoShots.state().accounts.find((a) => a.id.startsWith('steam:') && a.state === 'connected'); return a ? a.id : '' })()`)
|
||||
if (!account) await sleep(1000)
|
||||
}
|
||||
say('account', account || 'NOT CONNECTED')
|
||||
if (!account) throw new Error('not connected')
|
||||
const rpc = (m, p) => page.evaluate(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
|
||||
page.on('console', (m) => say('console', m.type(), m.text().slice(0, 300)))
|
||||
const mode = process.env.MODE || 'call'
|
||||
const stopFile = path.join(os.homedir(), '.local/share/moho-dev/stop')
|
||||
const state = (expr) => ev(`(() => { const s = window.__mohoShots.state(); return ${expr} })()`)
|
||||
if (mode === 'call') {
|
||||
let buffer = ''
|
||||
for (let i = 0; i < 60 && !buffer; i++) {
|
||||
buffer = await state(`(s.buffers.find((b) => b.accountId === ${JSON.stringify(account)} && b.kind === 'dm' && b.name === 'Salastil') || {}).id || ''`)
|
||||
if (!buffer) await sleep(1000)
|
||||
}
|
||||
say('calling', buffer)
|
||||
await ev(`window.__mohoShots.store.callBuffer(${JSON.stringify(buffer)})`)
|
||||
} else {
|
||||
say('waiting for a ring')
|
||||
}
|
||||
let answered = false
|
||||
let phase = ''
|
||||
for (let t = 0; t < 1200 && !fs.existsSync(stopFile); t += 3) {
|
||||
if (mode === 'wait' && !answered) {
|
||||
const ring = await state(`s.steamRings && s.steamRings.length ? JSON.stringify(s.steamRings[0]) : ''`)
|
||||
if (ring) {
|
||||
say('ringing', ring)
|
||||
await ev(`window.__mohoShots.store.answerSteamCall(window.__mohoShots.state().steamRings[0])`)
|
||||
answered = true
|
||||
}
|
||||
}
|
||||
const now = await state(`JSON.stringify({ phase: s.steamVoiceNow ? s.steamVoiceNow.phase : 'idle', toasts: s.toasts.map((t) => t.message || t.text || JSON.stringify(t)) })`)
|
||||
if (now !== phase) { phase = now; say('state', now) }
|
||||
const report = await ev(`(async () => {
|
||||
const v = window.__mohoShots.store.steamVoiceLive
|
||||
if (!v || !v.pc) return { none: true }
|
||||
const stats = []
|
||||
const rep = await v.pc.getStats()
|
||||
rep.forEach((s) => { if (['outbound-rtp', 'inbound-rtp', 'media-source'].includes(s.type)) stats.push({ type: s.type, packetsSent: s.packetsSent, packetsReceived: s.packetsReceived, audioLevel: s.audioLevel }) })
|
||||
return { phase: v.phase, rtt: v.rttMs, ice: v.pc.iceConnectionState, stats }
|
||||
})().catch((e) => ({ error: String(e) }))`)
|
||||
say('report', JSON.stringify(report))
|
||||
await sleep(3000)
|
||||
}
|
||||
await ev(`window.__mohoShots.store.leaveVoice(${JSON.stringify(account)})`)
|
||||
await sleep(2000)
|
||||
say('left the call')
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The groups Steam says an account is in, after a fresh sign-in: a scratch app signed in as
|
||||
* the throwaway account (never the real one) with a fake microphone joins the group's voice room
|
||||
* and reports what it hears every ten seconds, until ~/.local/share/moho-dev/stop exists.
|
||||
*
|
||||
* NAME='Biggest Newest Moho Test' WAY=rail node scripts/probe-steam-leave.mjs (WAY=rail or settings)
|
||||
*
|
||||
* npx electron-vite build && node scripts/probe-steam-voice.mjs
|
||||
*
|
||||
* Needs the throwaway account's block in ~/.config/nobilis/accounts.toml. The app is the
|
||||
* one built in out/, with its own home and runtime directory, and signs the account in
|
||||
* for as long as this runs - so nothing else may be signed in as it.
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const live = fs.readFileSync(path.join(os.homedir(), '.config/nobilis/accounts.toml'), 'utf8')
|
||||
const block = live.split(/\n(?=\[)/).find((b) => b.startsWith('[[steam_account]]') && /username\s*=\s*"mohotester"/.test(b))
|
||||
if (!block) throw new Error('the throwaway account is not in accounts.toml')
|
||||
|
||||
const { page, stop, scratch } = await launchApp({
|
||||
env: { MOHO_UI_SHOTS: '1' },
|
||||
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', `--use-file-for-fake-audio-capture=${os.homedir()}/.local/share/moho-dev/speech.wav`],
|
||||
seed: (home) => {
|
||||
fs.mkdirSync(path.join(home, 'config/nobilis'), { recursive: true })
|
||||
fs.writeFileSync(path.join(home, 'config/nobilis/accounts.toml'), block + '\n')
|
||||
}
|
||||
})
|
||||
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
|
||||
const ev = (code) => page.evaluate(code)
|
||||
try {
|
||||
// The window onto workspace 2, where the user does not look.
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
let account = ''
|
||||
for (let i = 0; i < 90 && !account; i++) {
|
||||
account = await ev(`(() => { const a = window.__mohoShots.state().accounts.find((a) => a.id.startsWith('steam:') && a.state === 'connected'); return a ? a.id : '' })()`)
|
||||
if (!account) await sleep(1000)
|
||||
}
|
||||
say('account', account || 'NOT CONNECTED')
|
||||
if (!account) throw new Error('not connected')
|
||||
const rpc = (m, p) => page.evaluate(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
|
||||
page.on('console', (m) => say('console', m.type(), m.text().slice(0, 300)))
|
||||
await sleep(5000)
|
||||
say('window groups', await ev(`JSON.stringify(window.__mohoShots.state().groups.filter((g) => g.accountId === ${JSON.stringify(account)}).map((g) => g.name))`))
|
||||
say('daemon groups', JSON.stringify(((await rpc('steamChatGroups', { accountId: account })) ?? []).map((g) => g.name)))
|
||||
say('steam channels', await ev(`JSON.stringify(window.__mohoShots.state().buffers.filter((b) => b.accountId === ${JSON.stringify(account)} && b.kind === 'channel').map((b) => b.name))`))
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Leaving a Steam group through the window, the way a person does: a scratch app signed in as
|
||||
* the throwaway account (never the real one) with a fake microphone joins the group's voice room
|
||||
* and reports what it hears every ten seconds, until ~/.local/share/moho-dev/stop exists.
|
||||
*
|
||||
* NAME='Biggest Newest Moho Test' WAY=rail node scripts/probe-steam-leave.mjs (WAY=rail or settings)
|
||||
*
|
||||
* npx electron-vite build && node scripts/probe-steam-voice.mjs
|
||||
*
|
||||
* Needs the throwaway account's block in ~/.config/nobilis/accounts.toml. The app is the
|
||||
* one built in out/, with its own home and runtime directory, and signs the account in
|
||||
* for as long as this runs - so nothing else may be signed in as it.
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const live = fs.readFileSync(path.join(os.homedir(), '.config/nobilis/accounts.toml'), 'utf8')
|
||||
const block = live.split(/\n(?=\[)/).find((b) => b.startsWith('[[steam_account]]') && /username\s*=\s*"mohotester"/.test(b))
|
||||
if (!block) throw new Error('the throwaway account is not in accounts.toml')
|
||||
|
||||
const { page, stop, scratch } = await launchApp({
|
||||
env: { MOHO_UI_SHOTS: '1' },
|
||||
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', `--use-file-for-fake-audio-capture=${os.homedir()}/.local/share/moho-dev/speech.wav`],
|
||||
seed: (home) => {
|
||||
fs.mkdirSync(path.join(home, 'config/nobilis'), { recursive: true })
|
||||
fs.writeFileSync(path.join(home, 'config/nobilis/accounts.toml'), block + '\n')
|
||||
}
|
||||
})
|
||||
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
|
||||
const ev = (code) => page.evaluate(code)
|
||||
try {
|
||||
// The window onto workspace 2, where the user does not look.
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
let account = ''
|
||||
for (let i = 0; i < 90 && !account; i++) {
|
||||
account = await ev(`(() => { const a = window.__mohoShots.state().accounts.find((a) => a.id.startsWith('steam:') && a.state === 'connected'); return a ? a.id : '' })()`)
|
||||
if (!account) await sleep(1000)
|
||||
}
|
||||
say('account', account || 'NOT CONNECTED')
|
||||
if (!account) throw new Error('not connected')
|
||||
const rpc = (m, p) => page.evaluate(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
|
||||
page.on('console', (m) => say('console', m.type(), m.text().slice(0, 300)))
|
||||
const name = process.env.NAME ?? ''
|
||||
const way = process.env.WAY ?? 'rail'
|
||||
const state = (expr) => ev(`(() => { const s = window.__mohoShots.state(); return ${expr} })()`)
|
||||
if (process.env.CREATE) say('created', JSON.stringify(await rpc('steamChatGroupAction', { accountId: account, action: 'create', name })))
|
||||
let group = null
|
||||
for (let i = 0; i < 60 && !group; i++) {
|
||||
group = await state(`(s.groups.find((g) => g.accountId === ${JSON.stringify(account)} && g.name === ${JSON.stringify(name)}) || null)`)
|
||||
if (!group) await sleep(1000)
|
||||
}
|
||||
say('group', JSON.stringify(group))
|
||||
if (process.env.VOICE && group) {
|
||||
// In the group's voice room when it is left.
|
||||
const number = group.id.split('steamgroup:')[1]
|
||||
say('voice room', JSON.stringify(await rpc('steamChatGroupAction', { accountId: account, action: 'createRoom', groupId: number, name: 'talk', voice: true })))
|
||||
let rooms = []
|
||||
for (let i = 0; i < 20 && !rooms.length; i++) {
|
||||
rooms = await rpc('steamVoiceRooms', { accountId: account, groupId: number })
|
||||
if (!rooms.length) await sleep(500)
|
||||
}
|
||||
await ev(`window.__mohoShots.store.joinSteamVoice(${JSON.stringify(account)}, ${JSON.stringify(number)}, ${JSON.stringify(rooms[0].roomId)})`)
|
||||
for (let i = 0; i < 30; i++) {
|
||||
if ((await state(`s.steamVoiceNow ? s.steamVoiceNow.phase : 'idle'`)) === 'connected') break
|
||||
await sleep(500)
|
||||
}
|
||||
say('in voice', await state(`s.steamVoiceNow ? s.steamVoiceNow.phase : 'idle'`))
|
||||
await sleep(3000)
|
||||
}
|
||||
if (!group) throw new Error('the group is not in the window')
|
||||
await sleep(4000)
|
||||
const click = (text) => ev(`(() => { const e = [...document.querySelectorAll('button, [role=menuitem]')].find((b) => b.textContent.trim().endsWith(${JSON.stringify(text)})); if (!e) return false; e.click(); return true })()`)
|
||||
if (way === 'room') {
|
||||
// A room's own menu: what it offers is leaving the group.
|
||||
await ev(`window.__mohoShots.store.selectGroup(${JSON.stringify(group.id)})`)
|
||||
let room = null
|
||||
for (let i = 0; i < 30 && !room; i++) {
|
||||
room = await state(`(s.buffers.find((b) => b.groupId === ${JSON.stringify(group.id)} && b.kind === 'channel') || null)`)
|
||||
if (!room) await sleep(500)
|
||||
}
|
||||
say('room', JSON.stringify(room && room.name))
|
||||
await sleep(800)
|
||||
say('menu opened', await ev(`(() => { const row = [...document.querySelectorAll('.buffer-row')][0]; if (!row) return false; const r = row.getBoundingClientRect(); row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: r.x + 20, clientY: r.y + 10 })); return true })()`))
|
||||
await sleep(400)
|
||||
say('menu', JSON.stringify(await ev(`[...document.querySelectorAll('.context-menu button, .context-menu [role=menuitem]')].map((e) => e.textContent.trim())`)))
|
||||
say('clicked', await click('Leave group…'))
|
||||
} else if (way === 'rail') {
|
||||
say('menu opened', await ev(`(() => { const tile = [...document.querySelectorAll('.rail-tile')].find((t) => t.getAttribute('aria-label') === ${JSON.stringify(name)}); if (!tile) return false; const r = tile.getBoundingClientRect(); tile.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: r.x + 10, clientY: r.y + 10 })); return true })()`))
|
||||
await sleep(400)
|
||||
say('menu', JSON.stringify(await ev(`[...document.querySelectorAll('.context-menu button, .context-menu [role=menuitem]')].map((e) => e.textContent.trim())`)))
|
||||
say('clicked Leave server', await click('Leave server'))
|
||||
} else {
|
||||
say('menu opened', await ev(`(() => { const tile = [...document.querySelectorAll('.rail-tile')].find((t) => t.getAttribute('aria-label') === ${JSON.stringify(name)}); if (!tile) return false; const r = tile.getBoundingClientRect(); tile.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: r.x + 10, clientY: r.y + 10 })); return true })()`))
|
||||
await sleep(400)
|
||||
say('clicked Group settings', await click('Group settings'))
|
||||
await sleep(800)
|
||||
say('clicked Leave group', await click('Leave group'))
|
||||
}
|
||||
await sleep(600)
|
||||
say('buttons now', JSON.stringify(await ev(`[...document.querySelectorAll('button')].map((b) => b.textContent.trim()).filter((t) => /leave/i.test(t))`)))
|
||||
// The confirmation counts down before it will leave ("Leave in 10"): waited out, as a person has to.
|
||||
for (let i = 0; i < 40; i++) {
|
||||
if (await ev(`[...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Leave' && !b.disabled)`)) break
|
||||
await sleep(500)
|
||||
}
|
||||
// The dialog's own Leave (or the inline confirm's).
|
||||
say('confirmed', await ev(`(() => { const all = [...document.querySelectorAll('button')].filter((b) => b.textContent.trim() === 'Leave'); const e = all[all.length - 1]; if (!e) return false; e.click(); return true })()`))
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const still = await state(`s.groups.some((g) => g.id === ${JSON.stringify(group.id)})`)
|
||||
if (!still) break
|
||||
await sleep(500)
|
||||
}
|
||||
say('still in the window', await state(`s.groups.some((g) => g.id === ${JSON.stringify(group.id)})`))
|
||||
say('toasts', await state(`JSON.stringify(s.toasts.map((t) => t.message || t.text || JSON.stringify(t)))`))
|
||||
say('voice after', await state(`s.steamVoiceNow ? s.steamVoiceNow.phase : 'idle'`))
|
||||
const held = await rpc('steamChatGroups', { accountId: account })
|
||||
say('daemon still holds', JSON.stringify((held ?? []).map((g) => g.name)))
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Signing in to Steam by scanning a QR code with the phone app, through the window as a person
|
||||
* does it: a scratch app with no accounts, Accounts -> Steam -> "Sign in with a QR code". The code is
|
||||
* photographed to <output directory>/qr-N.png each time Steam replaces it, for somebody to scan,
|
||||
* and the run ends when the account is signed in or when STOP exists. The scratch home is thrown
|
||||
* away, so the token Steam hands back is not kept.
|
||||
*
|
||||
* node scripts/probe-steam-qr.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const stopFile = path.join(os.homedir(), '.local/share/moho-dev/stop')
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
|
||||
const ev = (code) => page.evaluate(code)
|
||||
const shot = async (name) => {
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
}
|
||||
try {
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
await sleep(2500)
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 900, height: 900, deviceScaleFactor: 1, mobile: false })
|
||||
// Everything the daemon says about the sign-in, kept as it comes.
|
||||
await ev(`window.__events = []; window.moho.onEvent((e) => { if (String(e.event).startsWith('steamLogin') || e.event === 'accountsChanged') window.__events.push({ at: Date.now(), event: e.event, data: JSON.stringify(e.data).slice(0, 300) }) })`)
|
||||
await ev(`window.__mohoShots.store.setActivePanel('accounts')`)
|
||||
await sleep(800)
|
||||
const click = (text) => ev(`(() => { const e = [...document.querySelectorAll('button')].find((b) => b.textContent.trim().includes(${JSON.stringify(text)})); if (!e) return false; e.click(); return true })()`)
|
||||
say('add form', JSON.stringify(await ev(`[...document.querySelectorAll('button')].map((b) => b.textContent.trim()).filter(Boolean).slice(0, 30)`)))
|
||||
say('clicked Steam', await click('Steam'))
|
||||
await sleep(600)
|
||||
say('clicked QR', await click('Sign in with a QR code'))
|
||||
let last = ''
|
||||
let n = 0
|
||||
for (let t = 0; t < 600 && !fs.existsSync(stopFile); t += 2) {
|
||||
const src = await ev(`document.querySelector('img.qr-image')?.src ?? ''`)
|
||||
if (src && src !== last) {
|
||||
last = src
|
||||
n++
|
||||
await sleep(300)
|
||||
await shot(`qr-${n}.png`)
|
||||
say('QR', n, 'drawn', src.slice(-40))
|
||||
}
|
||||
const seen = await ev(`window.__events.splice(0)`).catch(() => [])
|
||||
for (const e of seen) say('event', e.event, e.data)
|
||||
const accounts = await ev(`JSON.stringify(window.__mohoShots.state().accounts.filter((a) => a.service === 'steam').map((a) => [a.id, a.state]))`)
|
||||
if (accounts !== '[]') {
|
||||
say('signed in', accounts)
|
||||
break
|
||||
}
|
||||
const toasts = await ev(`JSON.stringify(window.__mohoShots.state().toasts.map((x) => x.message || x.text || JSON.stringify(x)))`)
|
||||
if (toasts !== '[]' && toasts !== (globalThis.__t ?? '')) { globalThis.__t = toasts; say('toasts', toasts) }
|
||||
await sleep(2000)
|
||||
}
|
||||
await sleep(4000)
|
||||
for (const e of (await ev(`window.__events.splice(0)`).catch(() => []))) say('event', e.event, e.data)
|
||||
say('accounts after', await ev(`JSON.stringify(window.__mohoShots.state().accounts.filter((a) => a.service === 'steam').map((a) => [a.id, a.state, a.displayName]))`))
|
||||
await shot('qr-after.png')
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Steam voice in a group that already exists, with somebody on the official client in the room too: a scratch app signed in as
|
||||
* the throwaway account (never the real one) with a fake microphone joins the group's voice room
|
||||
* and reports what it hears every ten seconds, until ~/.local/share/moho-dev/stop exists.
|
||||
*
|
||||
* GROUP=78995476 ROOM=316282066 node scripts/probe-steam-voice-join.mjs
|
||||
*
|
||||
* npx electron-vite build && node scripts/probe-steam-voice.mjs
|
||||
*
|
||||
* Needs the throwaway account's block in ~/.config/nobilis/accounts.toml. The app is the
|
||||
* one built in out/, with its own home and runtime directory, and signs the account in
|
||||
* for as long as this runs - so nothing else may be signed in as it.
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const live = fs.readFileSync(path.join(os.homedir(), '.config/nobilis/accounts.toml'), 'utf8')
|
||||
const block = live.split(/\n(?=\[)/).find((b) => b.startsWith('[[steam_account]]') && /username\s*=\s*"mohotester"/.test(b))
|
||||
if (!block) throw new Error('the throwaway account is not in accounts.toml')
|
||||
|
||||
const { page, stop, scratch } = await launchApp({
|
||||
env: { MOHO_UI_SHOTS: '1' },
|
||||
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', `--use-file-for-fake-audio-capture=${os.homedir()}/.local/share/moho-dev/speech.wav`],
|
||||
seed: (home) => {
|
||||
fs.mkdirSync(path.join(home, 'config/nobilis'), { recursive: true })
|
||||
fs.writeFileSync(path.join(home, 'config/nobilis/accounts.toml'), block + '\n')
|
||||
}
|
||||
})
|
||||
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
|
||||
const ev = (code) => page.evaluate(code)
|
||||
try {
|
||||
// The window onto workspace 2, where the user does not look.
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
let account = ''
|
||||
for (let i = 0; i < 90 && !account; i++) {
|
||||
account = await ev(`(() => { const a = window.__mohoShots.state().accounts.find((a) => a.id.startsWith('steam:') && a.state === 'connected'); return a ? a.id : '' })()`)
|
||||
if (!account) await sleep(1000)
|
||||
}
|
||||
say('account', account || 'NOT CONNECTED')
|
||||
if (!account) throw new Error('not connected')
|
||||
const rpc = (m, p) => page.evaluate(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
|
||||
page.on('console', (m) => say('console', m.type(), m.text().slice(0, 300)))
|
||||
const group = process.env.GROUP
|
||||
const roomId = process.env.ROOM
|
||||
const stopFile = path.join(os.homedir(), '.local/share/moho-dev/stop')
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const rooms = await rpc('steamVoiceRooms', { accountId: account, groupId: group })
|
||||
if (Array.isArray(rooms) && rooms.length) break
|
||||
await sleep(500)
|
||||
}
|
||||
await ev(`window.__mohoShots.store.joinSteamVoice(${JSON.stringify(account)}, ${JSON.stringify(group)}, ${JSON.stringify(roomId)})`)
|
||||
let phase = ''
|
||||
let lastToast = ''
|
||||
for (let i = 0; i < 80; i++) {
|
||||
const now = await ev(`(() => { const s = window.__mohoShots.state(); return JSON.stringify({ phase: s.steamVoiceNow ? s.steamVoiceNow.phase : 'idle', toasts: s.toasts.map((t) => t.message || t.text || JSON.stringify(t)) }) })()`)
|
||||
if (now !== lastToast) { lastToast = now; say('fast', now) }
|
||||
await sleep(250)
|
||||
}
|
||||
for (let t = 0; t < 1200 && !fs.existsSync(stopFile); t += 10) {
|
||||
const now = await ev(`(() => { const s = window.__mohoShots.state(); return s.steamVoiceNow ? s.steamVoiceNow.phase : 'idle' })()`)
|
||||
if (now !== phase) { phase = now; say('phase', now) }
|
||||
const report = await ev(`(async () => {
|
||||
const v = window.__mohoShots.store.steamVoiceLive
|
||||
if (!v) return { none: true }
|
||||
const stats = []
|
||||
const rep = await v.pc.getStats()
|
||||
rep.forEach((s) => { if (['outbound-rtp', 'inbound-rtp', 'media-source', 'remote-inbound-rtp'].includes(s.type)) stats.push({ type: s.type, packetsSent: s.packetsSent, bytesSent: s.bytesSent, packetsReceived: s.packetsReceived, packetsLost: s.packetsLost, ssrc: s.ssrc, audioLevel: s.audioLevel, energy: s.totalAudioEnergy, rtt: s.roundTripTime }) })
|
||||
return { statuses: v.statuses ? [...v.statuses.entries()] : null, session: v.session, owners: v.owners ? [...v.owners.entries()] : null, track: v.mic ? v.mic.getAudioTracks().map((t) => [t.enabled, t.muted, t.readyState]) : null, phase: v.phase, rtt: v.rttMs, ice: v.pc.iceConnectionState, speakers: window.__mohoShots.state().steamVoiceNow?.speakers, stats }
|
||||
})().catch((e) => ({ error: String(e) }))`)
|
||||
say('report', JSON.stringify(report))
|
||||
say('toasts', await ev(`JSON.stringify(window.__mohoShots.state().toasts.map((t) => t.message || t.text || t))`))
|
||||
say('rooms', JSON.stringify(await rpc('steamVoiceRooms', { accountId: account, groupId: group })))
|
||||
await sleep(3000)
|
||||
}
|
||||
await ev(`window.__mohoShots.store.leaveVoice(${JSON.stringify(account)})`)
|
||||
await sleep(2000)
|
||||
say('left voice; the group is left in place')
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Steam voice against real Steam, through the real window: a scratch app signed in as
|
||||
* the throwaway account (never the real one) with a fake microphone makes a group with a
|
||||
* voice room, joins its voice, reports how it went, leaves, and leaves the group.
|
||||
*
|
||||
* npx electron-vite build && node scripts/probe-steam-voice.mjs
|
||||
*
|
||||
* Needs the throwaway account's block in ~/.config/nobilis/accounts.toml. The app is the
|
||||
* one built in out/, with its own home and runtime directory, and signs the account in
|
||||
* for as long as this runs - so nothing else may be signed in as it.
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const live = fs.readFileSync(path.join(os.homedir(), '.config/nobilis/accounts.toml'), 'utf8')
|
||||
const block = live.split(/\n(?=\[)/).find((b) => b.startsWith('[[steam_account]]') && /username\s*=\s*"mohotester"/.test(b))
|
||||
if (!block) throw new Error('the throwaway account is not in accounts.toml')
|
||||
|
||||
const { page, stop, scratch } = await launchApp({
|
||||
env: { MOHO_UI_SHOTS: '1' },
|
||||
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'],
|
||||
seed: (home) => {
|
||||
fs.mkdirSync(path.join(home, 'config/nobilis'), { recursive: true })
|
||||
fs.writeFileSync(path.join(home, 'config/nobilis/accounts.toml'), block + '\n')
|
||||
}
|
||||
})
|
||||
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
|
||||
const ev = (code) => page.evaluate(code)
|
||||
try {
|
||||
// The window onto workspace 2, where the user does not look.
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
let account = ''
|
||||
for (let i = 0; i < 90 && !account; i++) {
|
||||
account = await ev(`(() => { const a = window.__mohoShots.state().accounts.find((a) => a.id.startsWith('steam:') && a.state === 'connected'); return a ? a.id : '' })()`)
|
||||
if (!account) await sleep(1000)
|
||||
}
|
||||
say('account', account || 'NOT CONNECTED')
|
||||
if (!account) throw new Error('not connected')
|
||||
const rpc = (m, p) => page.evaluate(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
|
||||
const made = await rpc('steamChatGroupAction', { accountId: account, action: 'create', name: 'voice probe' })
|
||||
say('created', JSON.stringify(made))
|
||||
const group = String(made.groupId).split('steamgroup:')[1]
|
||||
say('room', JSON.stringify(await rpc('steamChatGroupAction', { accountId: account, action: 'createRoom', groupId: group, name: 'talk', voice: true })))
|
||||
let rooms = []
|
||||
for (let i = 0; i < 20 && !rooms.length; i++) {
|
||||
rooms = await rpc('steamVoiceRooms', { accountId: account, groupId: group })
|
||||
if (!rooms.length) await sleep(500)
|
||||
}
|
||||
say('voice rooms', JSON.stringify(rooms))
|
||||
const roomId = rooms[0]?.roomId
|
||||
await sleep(1500)
|
||||
await ev(`window.__mohoShots.store.joinSteamVoice(${JSON.stringify(account)}, ${JSON.stringify(group)}, ${JSON.stringify(roomId)})`)
|
||||
let phase = ''
|
||||
const seen = []
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const now = await ev(`(() => { const s = window.__mohoShots.state(); return s.steamVoiceNow ? s.steamVoiceNow.phase : 'idle' })()`)
|
||||
if (now !== phase) { phase = now; seen.push(now); say('phase', now) }
|
||||
if (now === 'connected') break
|
||||
await sleep(500)
|
||||
}
|
||||
await sleep(4000)
|
||||
const report = await ev(`(async () => {
|
||||
const v = window.__mohoShots.store.steamVoiceLive
|
||||
const stats = []
|
||||
const rep = await v.pc.getStats()
|
||||
rep.forEach((s) => { if (['outbound-rtp', 'inbound-rtp', 'candidate-pair'].includes(s.type) && (s.type !== 'candidate-pair' || s.nominated)) stats.push({ type: s.type, kind: s.kind, packetsSent: s.packetsSent, bytesSent: s.bytesSent, packetsReceived: s.packetsReceived, rtt: s.currentRoundTripTime, state: s.state, ssrc: s.ssrc }) })
|
||||
return { phase: v.phase, level: v.level, rtt: v.rttMs, ice: v.pc.iceConnectionState, voiceChatId: v.voiceChatId, session: v.session, stats, mic: !!v.mic, sessions: window.__mohoShots.state().voiceSessions }
|
||||
})().catch((e) => ({ error: String(e) }))`)
|
||||
say('report', JSON.stringify(report, null, 1))
|
||||
say('rooms while in', JSON.stringify(await rpc('steamVoiceRooms', { accountId: account, groupId: group })))
|
||||
await ev(`window.__mohoShots.store.leaveVoice(${JSON.stringify(account)})`)
|
||||
await sleep(2000)
|
||||
say('rooms after leaving', JSON.stringify(await rpc('steamVoiceRooms', { accountId: account, groupId: group })))
|
||||
say('now', await ev(`JSON.stringify(window.__mohoShots.state().steamVoiceNow)`))
|
||||
say('left group', JSON.stringify(await rpc('leaveGroup', { groupId: made.groupId })))
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import fs from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const ev = (c) => page.evaluate(c)
|
||||
try {
|
||||
try { const cl = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString()); for (const c of cl.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`]) } catch {}
|
||||
await sleep(2500)
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
|
||||
const a = await rpc('addKickAccount', {})
|
||||
await rpc('joinBuffer', { accountId: a.accountId, name: 'spreen' })
|
||||
const id = `${a.accountId}|spreen`
|
||||
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
|
||||
await sleep(20000)
|
||||
console.log(await ev(`JSON.stringify({ images: document.querySelectorAll('.sender-badge-image').length, loaded: [...document.querySelectorAll('.sender-badge-image')].filter((i) => i.complete && i.naturalWidth > 0).length, words: [...document.querySelectorAll('.sender-badge')].map((b) => b.textContent).slice(0, 8), titles: [...new Set([...document.querySelectorAll('.sender-badge-image')].map((i) => i.title))].slice(0, 8) })`))
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(process.env.HOME + '/.local/share/moho-dev/shots/kick-badges.png', Buffer.from(data, 'base64'))
|
||||
} finally { await stop() }
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Watches a real live Kick stream in a scratch moho with no account signed in, and checks what the
|
||||
* player does: that it plays, how far behind it runs, that the resolution menu changes the picture, that
|
||||
* the CC button shows the captions, and that the player is built once and not again as the address is
|
||||
* renewed. Photographs the stream, each menu and the captions.
|
||||
*
|
||||
* node scripts/shot-kick-stream.mjs <output directory> [channel] [idle seconds]
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
const channel = process.argv[3] ?? 'spreen'
|
||||
const idle = Number(process.argv[4] ?? 60)
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
|
||||
const ev = (code) => page.evaluate(code)
|
||||
const shot = async (name) => {
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
}
|
||||
const click = (selector) => ev(`(() => { const b = document.querySelector(${JSON.stringify(selector)}); if (!b || b.disabled) return false; b.click(); return true })()`)
|
||||
const video = (expr) => ev(`(() => { const v = document.querySelector('video.video-tile-picture'); return v ? (${expr}) : null })()`)
|
||||
try {
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
await sleep(2500)
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
|
||||
const added = await rpc('addKickAccount', {})
|
||||
say('account', JSON.stringify(added))
|
||||
say('join', JSON.stringify(await rpc('joinBuffer', { accountId: added.accountId, name: channel })))
|
||||
const id = `${added.accountId}|${channel}`
|
||||
let live = null
|
||||
for (let i = 0; i < 40 && !live?.playbackUrl; i++) {
|
||||
await sleep(1000)
|
||||
live = await ev(`window.__mohoShots.state().kickStreams[${JSON.stringify(id)}] || null`)
|
||||
}
|
||||
say('stream', JSON.stringify({ live: live?.live, hasUrl: !!live?.playbackUrl, expires: live?.playbackExpires, livestreamId: live?.livestreamId }))
|
||||
await ev(`(async () => { await window.__mohoShots.store.selectBuffer(${JSON.stringify(id)}); window.__mohoShots.store.watchStream(${JSON.stringify(id)}) })()`)
|
||||
for (let i = 0; i < 40; i++) {
|
||||
if ((await video('v.readyState >= 3 && v.currentTime > 1')) === true) break
|
||||
await sleep(500)
|
||||
}
|
||||
say('playing', await video('({ t: v.currentTime, w: v.videoWidth, h: v.videoHeight, paused: v.paused })').then(JSON.stringify))
|
||||
await sleep(4000)
|
||||
await shot('stream-playing.png')
|
||||
|
||||
// Resolution.
|
||||
say('quality menu', await click('button[title^="Resolution"]'))
|
||||
await sleep(400)
|
||||
say('quality choices', JSON.stringify(await ev(`[...document.querySelectorAll('.stream-menu .context-menu-item')].map((b) => b.textContent.trim())`)))
|
||||
await shot('stream-quality-menu.png')
|
||||
for (const want of ['480p', 'Auto']) {
|
||||
await ev(`(() => { const b = [...document.querySelectorAll('.stream-menu .context-menu-item')].find((x) => x.textContent.trim().startsWith(${JSON.stringify(want)})); b && b.click() })()`)
|
||||
await sleep(8000)
|
||||
say(`after choosing ${want}`, JSON.stringify(await video('({ h: v.videoHeight, t: +v.currentTime.toFixed(1) })')), JSON.stringify(await ev(`document.querySelector('button[title^="Resolution"]')?.title`)))
|
||||
if (want === '480p') await click('button[title^="Resolution"]')
|
||||
}
|
||||
|
||||
// Captions.
|
||||
say('captions button', await ev(`JSON.stringify({ title: document.querySelector('button[title*="aptions"]')?.title, disabled: document.querySelector('button[title*="aptions"]')?.disabled })`))
|
||||
say('captions menu', await click('button[title*="aptions"]'))
|
||||
await sleep(400)
|
||||
say('caption choices', JSON.stringify(await ev(`[...document.querySelectorAll('.stream-menu .context-menu-item')].map((b) => b.textContent.trim())`)))
|
||||
await shot('stream-captions-menu.png')
|
||||
await ev(`(() => { const b = [...document.querySelectorAll('.stream-menu .context-menu-item')].find((x) => x.textContent.trim() === 'English'); b && b.click() })()`)
|
||||
await sleep(15000)
|
||||
say('text tracks', await video(`JSON.stringify([...v.textTracks].map((t) => ({ lang: t.language, mode: t.mode, cues: t.cues ? t.cues.length : 0 })))`))
|
||||
await shot('stream-captions-on.png')
|
||||
say('captions button now', await ev(`document.querySelector('button[title*="aptions"]')?.title`))
|
||||
|
||||
// Built once.
|
||||
const sources = new Set()
|
||||
const began = Date.now()
|
||||
while (Date.now() - began < idle * 1000) {
|
||||
sources.add(await video('v.src'))
|
||||
await sleep(1000)
|
||||
}
|
||||
const later = await ev(`window.__mohoShots.state().kickStreams[${JSON.stringify(id)}]`)
|
||||
say(`over ${idle}s: ${sources.size} player source(s); address expires`, later?.playbackExpires, 'changed:', later?.playbackUrl !== live?.playbackUrl)
|
||||
say('quality', JSON.stringify(await video('(() => { const q = v.getVideoPlaybackQuality(); return { dropped: q.droppedVideoFrames, total: q.totalVideoFrames } })()')))
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Reads a busy live Kick channel in a scratch moho and checks what replies and removals look like: that a
|
||||
* reply carries its thread, that the thread opens from it, and that a removed message is struck through in
|
||||
* red, left out or replaced by a notice according to Settings -> Kick. Removal is the one thing it cannot
|
||||
* wait for, so one is injected through the store the way the daemon's event would arrive.
|
||||
*
|
||||
* node scripts/shot-kick-threads.mjs <output directory> [channel]
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
const channel = process.argv[3] ?? 'xqc'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
|
||||
const ev = (code) => page.evaluate(code)
|
||||
const shot = async (name) => {
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
}
|
||||
try {
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
await sleep(2500)
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
|
||||
const added = await rpc('addKickAccount', {})
|
||||
say('join', JSON.stringify(await rpc('joinBuffer', { accountId: added.accountId, name: channel })))
|
||||
const id = `${added.accountId}|${channel}`
|
||||
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
|
||||
|
||||
// Replies, as the daemon read them: each should name the thread it belongs to.
|
||||
let replies = []
|
||||
for (let i = 0; i < 120 && replies.length < 2; i++) {
|
||||
await sleep(1000)
|
||||
replies = JSON.parse(await ev(`JSON.stringify((window.__mohoShots.state().messagesByBuffer[${JSON.stringify(id)}] || []).filter((m) => m.replyTo).map((m) => ({ id: m.id, to: m.replyTo.id, root: m.replyTo.threadRoot, thread: m.replyTo.thread })))`))
|
||||
}
|
||||
say('replies seen', replies.length, JSON.stringify(replies.slice(0, 4)))
|
||||
say('every reply has a thread root', replies.every((r) => r.root))
|
||||
await sleep(1500)
|
||||
await shot('kick-replies.png')
|
||||
|
||||
await ev(`document.querySelector('.reply-preview')?.scrollIntoView({ block: 'center' })`)
|
||||
await sleep(500)
|
||||
await shot('kick-reply-row.png')
|
||||
// Open a thread from a reply.
|
||||
say('thread button', await ev(`(() => { const b = document.querySelector('.reply-thread-open'); if (!b) return false; b.click(); return true })()`))
|
||||
await sleep(1200)
|
||||
say('thread panel', await ev(`JSON.stringify({ open: !!document.querySelector('.thread-pane'), rows: document.querySelectorAll('.thread-pane .message-row').length, title: document.querySelector('.thread-header')?.textContent })`))
|
||||
await shot('kick-thread.png')
|
||||
await ev(`window.__mohoShots.store.closeThreadPanel()`)
|
||||
await sleep(300)
|
||||
|
||||
// A removal, as the daemon would announce it, in each of the three ways.
|
||||
const victim = await ev(`(() => { const ms = window.__mohoShots.state().messagesByBuffer[${JSON.stringify(id)}] || []; const m = [...ms].reverse().find((x) => x.kind === 'chat' && x.body && !x.replyTo); return m && m.id })()`)
|
||||
await ev(`window.__mohoShots.store.handleEvent({ event: 'messageModerated', data: { bufferId: ${JSON.stringify(id)}, id: ${JSON.stringify(victim)}, moderated: { ai: true, rules: ['hate'] } } })`)
|
||||
await sleep(500)
|
||||
await ev(`document.querySelector('.message-row.removed')?.scrollIntoView({ block: 'center' })`)
|
||||
await sleep(500)
|
||||
say('removed row (default: strike)', await ev(`(() => { const r = document.querySelector('.message-row.removed .message-body'); if (!r) return null; const s = getComputedStyle(r); return JSON.stringify({ text: r.textContent.slice(0, 40), color: s.color, line: s.textDecorationLine, title: r.title }) })()`))
|
||||
await shot('kick-removed-strike.png')
|
||||
|
||||
for (const [label, name] of [['Replace with notice', 'replace'], ['Delete', 'remove'], ['Strikethrough', 'strike']]) {
|
||||
await ev(`window.__mohoShots.store.setActivePanel('settings')`)
|
||||
await sleep(500)
|
||||
await ev(`[...document.querySelectorAll('.settings-rail-item, .settings-rail button')].find((b) => b.textContent.trim().startsWith('Kick'))?.click()`)
|
||||
await sleep(500)
|
||||
say('setting', label, await ev(`(() => { const b = [...document.querySelectorAll('.setting-segmented button')].find((x) => x.textContent.trim() === ${JSON.stringify(label)}); if (!b) return false; b.click(); return true })()`))
|
||||
await sleep(300)
|
||||
if (name === 'replace') await shot('kick-settings.png')
|
||||
await ev(`window.__mohoShots.store.setActivePanel('')`)
|
||||
await sleep(500)
|
||||
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
|
||||
await sleep(700)
|
||||
say(name, await ev(`JSON.stringify({ removedRows: document.querySelectorAll('.message-row.removed').length, text: document.querySelector('.message-row.removed .message-body')?.textContent.slice(0, 50) })`))
|
||||
await ev(`document.querySelector('.message-row.removed')?.scrollIntoView({ block: 'center' })`)
|
||||
await sleep(300)
|
||||
if (name !== 'strike') await shot(`kick-removed-${name}.png`)
|
||||
}
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Every kind of line a service writes rather than somebody saying it, beside ordinary chat and a moderation
|
||||
* card, in each layout: they must all be the same sort of card. Messages are injected; nothing is contacted.
|
||||
*
|
||||
* node scripts/shot-notification-cards.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const ev = (code) => page.evaluate(code)
|
||||
try {
|
||||
await sleep(2500)
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1000, height: 760, deviceScaleFactor: 2, mobile: false })
|
||||
await ev(`(() => {
|
||||
const s = window.__mohoShots.store
|
||||
const acct = { id: 'irc:test', service: 'irc', name: 'test', host: 'irc.example', port: 6667, state: 'connected', hasPassword: false, nick: 'me' }
|
||||
const b = { id: 'irc:test|#room', accountId: 'irc:test', kind: 'channel', name: '#room', lastActivityTs: 5, groupId: 'account:irc:test' }
|
||||
const t = Math.floor(Date.now() / 1000) - 600
|
||||
const m = (i, kind, from, body, extra = {}) => ({ id: 'm' + i, bufferId: b.id, from, body, ts: t + i * 20, kind, isAction: false, isHighlight: false, isOwn: false, edited: false, ...extra })
|
||||
const messages = [
|
||||
m(1, 'join', 'alice', 'alice joined #room'),
|
||||
m(2, 'chat', 'alice', 'hello everyone'),
|
||||
m(3, 'topic', 'bob', 'bob changed the topic to: Release day'),
|
||||
m(4, 'mode', 'ChanServ', 'ChanServ sets mode +o alice'),
|
||||
m(5, 'kick', 'bob', 'bob kicked carol (spamming)'),
|
||||
m(6, 'part', 'carol', 'carol left #room'),
|
||||
m(7, 'system', 'moderation', 'This room is now in slow mode.'),
|
||||
m(8, 'moderation', 'room', 'Slow mode is on: one message every 10 seconds', { embeds: [{ kind: 'moderation', author: 'Slow mode', title: 'is on: one message every 10 seconds', icon: 'timer' }] }),
|
||||
m(9, 'chat', 'bob', 'back to normal chat')
|
||||
]
|
||||
s.set({ accounts: [acct], groups: [{ id: 'account:irc:test', accountId: 'irc:test', service: 'irc', kind: 'account', name: 'test', position: 0 }], buffers: [b], messagesByBuffer: { [b.id]: messages }, activeBufferId: b.id })
|
||||
s.selectGroup('account:irc:test')
|
||||
})()`)
|
||||
await sleep(1200)
|
||||
for (const mode of ['comfy', 'classic', 'bubbles']) {
|
||||
await ev(`window.moho.rpc && 0; window.__mohoShots.store.setPref && window.__mohoShots.store.setPref('ui.messageLayout', ${JSON.stringify(mode)})`).catch(() => {})
|
||||
await sleep(500)
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, `notification-cards-${mode}.png`), Buffer.from(data, 'base64'))
|
||||
}
|
||||
console.log('cards', await ev(`document.querySelectorAll('.kick-event.notification').length`), 'moderation', await ev(`document.querySelectorAll('.kick-event.moderation').length`))
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Sneedchat lines that start with ">" in a scratch moho: the site's own coloured echo and a plain one must both
|
||||
* draw as 4chan's greentext (#789922) and not as a quote block. Messages are injected; nothing is contacted.
|
||||
*
|
||||
* node scripts/shot-sneedchat-greentext.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const ev = (code) => page.evaluate(code)
|
||||
try {
|
||||
await sleep(2500)
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 900, height: 600, deviceScaleFactor: 2, mobile: false })
|
||||
await ev(`(() => {
|
||||
const s = window.__mohoShots.store
|
||||
const acct = { id: 'sneedchat:me', service: 'sneedchat', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, nick: 'me' }
|
||||
const b = { id: 'sneedchat:me|#room', accountId: 'sneedchat:me', kind: 'channel', name: '#room', lastActivityTs: 5, groupId: 'account:sneedchat:me' }
|
||||
const t = Math.floor(Date.now() / 1000) - 300
|
||||
const m = (i, from, body) => ({ id: 'm' + i, bufferId: b.id, from, body, ts: t + i * 20, kind: 'chat', isAction: false, isHighlight: false, isOwn: false, edited: false })
|
||||
const messages = [
|
||||
m(1, 'alice', 'an ordinary line of chat'),
|
||||
m(2, 'bob', '[color=#72ff72]>archlinuxwoman'),
|
||||
m(3, 'carol', '>be me\\nwalk into a room\\nnobody notices'),
|
||||
m(4, 'bob', 'back to normal')
|
||||
]
|
||||
s.set({ accounts: [acct], groups: [{ id: 'account:sneedchat:me', accountId: 'sneedchat:me', service: 'sneedchat', kind: 'account', name: 'me', position: 0 }], buffers: [b], messagesByBuffer: { [b.id]: messages }, activeBufferId: b.id })
|
||||
s.selectGroup('account:sneedchat:me')
|
||||
})()`)
|
||||
await sleep(1200)
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, 'sneedchat-greentext.png'), Buffer.from(data, 'base64'))
|
||||
console.log('green lines', await ev(`[...document.querySelectorAll('.greentext')].map((e) => e.textContent + ' => ' + getComputedStyle(e).color)`))
|
||||
console.log('quote blocks', await ev(`document.querySelectorAll('.quote-block').length`))
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Photographs a Steam chat group as the window shows it: a server on the rail
|
||||
* with its rooms as channels and its members listed, then the group's settings
|
||||
* dialog on each of its tabs. Made-up state on a fake IRC server, in a scratch
|
||||
* home; the daemon's answers for the group are stood in for here.
|
||||
*
|
||||
* node scripts/shot-steam-chatgroups.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const shot = async (name) => {
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
}
|
||||
try {
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
await page.evaluate(`(() => {
|
||||
const group = {
|
||||
id: '7', railId: 'steam:me|steamgroup:7', name: 'Raiders', tagline: 'Friday nights, 8pm', owner: '76561197960265729', myRank: 50, myRankWord: 'Owner',
|
||||
mayModerate: true, mayManage: true, mayInvite: true, mayChangeRoles: true, muted: false, notify: 2,
|
||||
permissions: [{ key: 'chat', label: 'Write messages' }, { key: 'kick', label: 'Remove people' }, { key: 'ban', label: 'Ban people' }, { key: 'invite', label: 'Invite people and make links' }],
|
||||
rooms: [{ id: '70', name: '', voice: false }, { id: '71', name: 'loot', voice: false }],
|
||||
roles: [{ id: '90', name: 'Member' }, { id: '91', name: 'Raid leaders' }],
|
||||
members: [
|
||||
{ steamid: '76561197960265729', name: 'Gabe', rank: 50, rankWord: 'Owner', roles: ['90'] },
|
||||
{ steamid: '76561197960265730', name: 'Alyx', rank: 30, rankWord: 'Moderator', roles: ['90', '91'] },
|
||||
{ steamid: '76561197960265731', name: 'Eli', rank: 20, rankWord: 'Member', roles: ['90'] }
|
||||
],
|
||||
kicked: []
|
||||
}
|
||||
window.__rpcFixture = {
|
||||
steamChatGroups: () => [group],
|
||||
steamChatGroupAction: (params) => {
|
||||
if (params.action === 'links') return { links: [{ code: 'Ga3kODnC', link: 'https://s.team/chat/Ga3kODnC', expires: 1800000000 }] }
|
||||
if (params.action === 'roleActions') return { chat: true, kick: params.roleId === '91', ban: false, invite: true }
|
||||
if (params.action === 'bans') return { bans: [{ steamid: '76561197960265800', name: 'Spammer' }] }
|
||||
return {}
|
||||
}
|
||||
}
|
||||
const s = window.__mohoShots
|
||||
const st = s.state()
|
||||
const irc = st.accounts[0]
|
||||
const account = { ...irc, id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
|
||||
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
|
||||
const room = (name, id, position) => ({ ...channel, id: 'steam:me|' + name, accountId: 'steam:me', name, kind: 'channel', remoteId: '7:' + id, groupId: 'steam:me|steamgroup:7', position, unread: 0, highlight: false, mentions: 0 })
|
||||
const general = room('Raiders/#General', '70', 1)
|
||||
const loot = { ...room('Raiders/#loot', '71', 2), unread: 3 }
|
||||
s.patch({
|
||||
accounts: [...st.accounts, account],
|
||||
groups: [...st.groups, { id: 'steam:me|steamgroup:7', accountId: 'steam:me', service: 'steam', kind: 'guild', name: 'Raiders', position: 3 }],
|
||||
buffers: [...st.buffers, general, loot],
|
||||
activeBufferId: general.id, activeGroupId: 'steam:me|steamgroup:7',
|
||||
presenceByBuffer: { ...st.presenceByBuffer, [general.id]: [
|
||||
{ nick: 'Gabe', status: 'online', userId: '76561197960265729', prefix: '👑', roles: ['Owner'], powerLevel: 50 },
|
||||
{ nick: 'Alyx', status: 'online', userId: '76561197960265730', prefix: '🛡', roles: ['Moderator', 'Raid leaders'], powerLevel: 30, activity: { kind: 'game', text: 'Half-Life: Alyx' } },
|
||||
{ nick: 'Eli', status: 'offline', away: true, userId: '76561197960265731', powerLevel: 20 }
|
||||
] }
|
||||
})
|
||||
})()`)
|
||||
await sleep(1500)
|
||||
await shot('steam-chatgroup.png')
|
||||
console.log('channels:', JSON.stringify(await page.evaluate(`[...document.querySelectorAll('.sidebar')].map((s) => s.innerText).join('|')`)))
|
||||
// The rail entry's menu has the settings.
|
||||
await page.evaluate(`(() => { const tile = [...document.querySelectorAll('.rail-tile')].find((t) => t.getAttribute('aria-label') === 'Raiders' || t.textContent.includes('Ra')); tile.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 40, clientY: 200 })) })()`)
|
||||
await sleep(400)
|
||||
const entries = await page.evaluate(`[...document.querySelectorAll('.context-menu button, .context-menu [role=menuitem]')].map((e) => e.textContent.trim())`)
|
||||
console.log('menu:', JSON.stringify(entries))
|
||||
await page.evaluate(`(() => { const item = [...document.querySelectorAll('.context-menu button, .context-menu [role=menuitem]')].find((e) => e.textContent.includes('Group settings')); item && item.click() })()`)
|
||||
await sleep(600)
|
||||
for (const tab of ['General', 'Rooms', 'People', 'Roles', 'Invites']) {
|
||||
await page.evaluate(`(() => { const t = [...document.querySelectorAll('.steam-group .room-settings-tab')].find((e) => e.textContent.trim() === ${JSON.stringify(tab)}); t && t.click() })()`)
|
||||
await sleep(500)
|
||||
if (tab === 'Roles') await page.evaluate(`(() => { const b = [...document.querySelectorAll('.steam-group button')].find((e) => e.textContent.trim() === 'Edit' ); b && b.click() })()`)
|
||||
await sleep(300)
|
||||
await shot(`steam-chatgroup-${tab.toLowerCase()}.png`)
|
||||
const box = await page.evaluate(`(() => { const r = document.querySelector('.steam-group')?.getBoundingClientRect(); return r ? [Math.round(r.left), Math.round(r.top), Math.round(r.right), Math.round(r.bottom)] : null })()`)
|
||||
console.log(tab, 'dialog box', JSON.stringify(box))
|
||||
}
|
||||
} finally {
|
||||
await stop()
|
||||
irc.close()
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Photographs a Steam account's own page with its friends under the account's folders, then moves a
|
||||
* friend to another folder by dragging and says which changes the window asked Steam for. Made-up
|
||||
* state on a fake IRC server, in a scratch home; the daemon's answers are stood in for here.
|
||||
*
|
||||
* node scripts/shot-steam-folders.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const shot = async (name) => {
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
}
|
||||
try {
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
await page.evaluate(`(() => {
|
||||
window.__calls = []
|
||||
const folders = [
|
||||
{ id: 1, name: 'Steam - ISRAD', members: ['76561197960265704'] },
|
||||
{ id: 3, name: 'Steam - Friends', members: ['76561197960265701', '76561197960265702'] },
|
||||
{ id: 5, name: 'Steam - PL', members: ['76561197960265705'] },
|
||||
{ id: 7, name: 'Steam - Goons', members: ['76561197960265703'] }
|
||||
]
|
||||
window.__folders = folders
|
||||
window.__rpcFixture = { steamFriendGroups: () => folders, steamGroupChange: (p) => { window.__calls.push(p); return {} } }
|
||||
const s = window.__mohoShots
|
||||
const st = s.state()
|
||||
const irc = st.accounts[0]
|
||||
const ircGroup = st.groups.find((g) => g.kind === 'account')
|
||||
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
|
||||
const account = { ...irc, id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
|
||||
const group = { ...ircGroup, id: 'account:steam:me', accountId: 'steam:me', service: 'steam', name: 'Steam - me' }
|
||||
const friend = (name, id, category) => ({ ...channel, id: 'steam:me|' + name, accountId: 'steam:me', name, kind: 'dm', remoteId: id, groupId: group.id, category: category ?? null, position: 0, unread: 0, highlight: false, mentions: 0 })
|
||||
const buffers = [
|
||||
friend('Dandalf', '76561197960265701', 'Friends'), friend('GetCreped', '76561197960265702', 'Friends'),
|
||||
friend('Shaod', '76561197960265703', 'Goons'), friend('WigglyQuimble', '76561197960265704', 'ISRAD'),
|
||||
friend('Hujoppi', '76561197960265705', 'PL'), friend('Stranger', '76561197960265706', null)
|
||||
]
|
||||
s.patch({
|
||||
accounts: [...st.accounts, account],
|
||||
groups: [...st.groups, group],
|
||||
buffers: [...st.buffers, ...buffers],
|
||||
steamGroups: { 'steam:me': folders },
|
||||
activeGroupId: group.id, activeBufferId: buffers[0].id
|
||||
})
|
||||
})()`)
|
||||
await sleep(1500)
|
||||
// The daemon answered the window's own question about the made-up account with nothing; say it again.
|
||||
await page.evaluate(`window.__mohoShots.patch({ steamGroups: { 'steam:me': window.__folders } })`)
|
||||
await sleep(500)
|
||||
await shot('steam-folders.png')
|
||||
console.log('classes:', JSON.stringify(await page.evaluate(`[...document.querySelectorAll('.bufferlist [class*=category]')].filter((e) => e.textContent.trim() === 'expand_morePL').map((e) => e.className + ' ' + e.tagName + ' ' + e.draggable)`)))
|
||||
await page.evaluate(`(() => { const st = window.__mohoShots.store; window.__ranAll = []; const orig = st.moveSteamFriend.bind(st); st.moveSteamFriend = (...a) => { window.__moved = a; return orig(...a) }; const run = st.runSteamGroupCalls.bind(st); st.runSteamGroupCalls = (c) => { window.__ran = c; window.__ranAll.push(...c); return run(c) } })()`)
|
||||
// Drag Shaod (Goons) onto the PL heading.
|
||||
const moved = await page.evaluate(`(async () => {
|
||||
const row = [...document.querySelectorAll('.bufferlist [draggable=true]')].find((e) => e.textContent.trim().endsWith('Shaod') && e.textContent.trim().length < 12)
|
||||
const head = [...document.querySelectorAll('.bufferlist [class*=category]')].find((e) => e.textContent.trim() === 'expand_morePL')
|
||||
if (!row || !head) return { row: !!row, head: !!head }
|
||||
const dt = new DataTransfer()
|
||||
const wait = () => new Promise((r) => setTimeout(r, 150))
|
||||
row.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer: dt }))
|
||||
await wait()
|
||||
head.dispatchEvent(new DragEvent('dragenter', { bubbles: true, cancelable: true, dataTransfer: dt }))
|
||||
head.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt }))
|
||||
await wait()
|
||||
window.__finish = () => { head.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt })) }
|
||||
return { dragged: true }
|
||||
})()`)
|
||||
console.log('drag:', JSON.stringify(moved))
|
||||
await shot('steam-folders-drag.png')
|
||||
await page.evaluate(`window.__finish && window.__finish()`)
|
||||
await sleep(500)
|
||||
console.log('state:', JSON.stringify(await page.evaluate(`(() => { const st = window.__mohoShots.state(); const b = st.buffers.find((x) => x.id === 'steam:me|Shaod'); return { b: b && [b.remoteId, b.category, b.accountId], g: st.steamGroups } })()`)))
|
||||
console.log('moved:', JSON.stringify(await page.evaluate(`[window.__moved, window.__ran]`)))
|
||||
console.log('calls:', JSON.stringify(await page.evaluate(`window.__calls`)))
|
||||
// A new folder: named, and made on Steam.
|
||||
await page.evaluate(`(() => { const t = [...document.querySelectorAll('.bufferlist button')].find((b) => b.textContent.includes('Steam - me')); t && t.click() })()`)
|
||||
await sleep(300)
|
||||
await page.evaluate(`(() => { const i = [...document.querySelectorAll('.context-menu button, .context-menu [role=menuitem]')].find((e) => e.textContent.includes('New category')); i && i.click() })()`)
|
||||
await sleep(400)
|
||||
await shot('steam-folders-naming.png')
|
||||
await page.evaluate(`(() => { const inp = document.querySelector('.reason-prompt-box'); if (!inp) return; const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; set.call(inp, 'Raiders'); inp.dispatchEvent(new Event('input', { bubbles: true })) })()`)
|
||||
await sleep(300)
|
||||
await page.evaluate(`(() => { const inp = document.querySelector('.reason-prompt-box'); inp && inp.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) })()`)
|
||||
await sleep(500)
|
||||
console.log('all calls:', JSON.stringify(await page.evaluate(`window.__ranAll`)))
|
||||
} finally {
|
||||
await stop()
|
||||
irc.close()
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Photographs the direct-messages page when a Steam account has friend groups:
|
||||
* each group a heading, its friends' conversations under it. Then changes one
|
||||
* as Steam would, and reads where the conversation went. Made-up state on a fake
|
||||
* IRC server, in a scratch home.
|
||||
*
|
||||
* node scripts/shot-steam-groups.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
try {
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1000, height: 700, deviceScaleFactor: 1, mobile: false })
|
||||
await page.evaluate(`(() => {
|
||||
const s = window.__mohoShots
|
||||
const st = s.state()
|
||||
const irc = st.accounts[0]
|
||||
const account = { ...irc, id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
|
||||
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
|
||||
const dm = (name, remoteId) => ({ ...channel, id: 'steam:me|' + name, accountId: 'steam:me', name, kind: 'dm', remoteId, unread: 0, highlight: false, mentions: 0 })
|
||||
s.patch({
|
||||
accounts: [...st.accounts, account],
|
||||
buffers: [...st.buffers, dm('Gabe', '76561197960265729'), dm('Alyx', '76561197960287930'), dm('Eli', '76561197960265730')],
|
||||
activeBufferId: 'steam:me|Gabe', activeGroupId: '~dms',
|
||||
steamGroups: { 'steam:me': [{ id: 7, name: 'Raiders', members: ['76561197960265729', '76561197960265730'] }, { id: 8, name: 'Work', members: [] }] }
|
||||
})
|
||||
})()`)
|
||||
await sleep(1500)
|
||||
const headings = () => page.evaluate(`[...document.querySelectorAll('.sidebar')].map((s) => s.innerText).join('|')`)
|
||||
console.log('page:', JSON.stringify(await headings()))
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, 'steam-groups.png'), Buffer.from(data, 'base64'))
|
||||
// Steam moves Alyx into Work.
|
||||
await page.evaluate(`(() => { const s = window.__mohoShots; s.patch({ steamGroups: { 'steam:me': [{ id: 7, name: 'Raiders', members: ['76561197960265729', '76561197960265730'] }, { id: 8, name: 'Work', members: ['76561197960287930'] }] } }) })()`)
|
||||
await sleep(1200)
|
||||
console.log('after Steam moved Alyx into Work:', JSON.stringify(await headings()))
|
||||
} finally {
|
||||
await stop()
|
||||
irc.close()
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Photographs the Invitations tile with a Steam group invitation on it, as the window shows it.
|
||||
* Made-up state on a fake IRC server, in a scratch home.
|
||||
*
|
||||
* node scripts/shot-steam-invite.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
try {
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
await page.evaluate(`(() => {
|
||||
const s = window.__mohoShots
|
||||
const st = s.state()
|
||||
const account = { ...st.accounts[0], id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
|
||||
s.patch({
|
||||
accounts: [...st.accounts, account],
|
||||
matrixInvites: { 'steam:me': [{ roomId: '79003375', name: 'Biggest Newest Moho Test', inviter: 'Salastil', avatarUrl: null, isDirect: false }] },
|
||||
activeGroupId: '~invitations'
|
||||
})
|
||||
})()`)
|
||||
await sleep(1200)
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, 'steam-invite.png'), Buffer.from(data, 'base64'))
|
||||
console.log(await page.evaluate(`document.querySelector('.invite-panel')?.innerText`))
|
||||
} finally {
|
||||
await stop()
|
||||
irc.close()
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Photographs a Steam conversation Steam has warned about: the red banner over
|
||||
* it and the red line in the log, then the banner dismissed. Made-up state on a
|
||||
* fake IRC server, in a scratch home.
|
||||
*
|
||||
* node scripts/shot-steam-notice.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
try {
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1000, height: 620, deviceScaleFactor: 1, mobile: false })
|
||||
await page.evaluate(`(() => {
|
||||
const s = window.__mohoShots
|
||||
const st = s.state()
|
||||
const irc = st.accounts[0]
|
||||
const account = { ...irc, id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
|
||||
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
|
||||
const notice = { kind: 'suspicious', text: 'Steam warns that this person may not be who they appear to be. Be careful with links, offers and requests for anything - and do not give out your account details.' }
|
||||
const dm = { ...channel, id: 'steam:me|Gabe', accountId: 'steam:me', name: 'Gabe', kind: 'dm', remoteId: '76561197960265829', unread: 0, highlight: false, mentions: 0, notice }
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const m = (id, from, body, kind = 'chat') => ({ id, bufferId: dm.id, from, body, ts: now - 600 + Number(id.split(':')[1] || 0), isAction: false, isHighlight: false, kind })
|
||||
s.patch({
|
||||
accounts: [...st.accounts, account], buffers: [...st.buffers, dm], activeBufferId: dm.id, activeGroupId: '~dms',
|
||||
messagesByBuffer: { ...st.messagesByBuffer, [dm.id]: [
|
||||
m('1:1', 'Gabe', 'hey, long time'),
|
||||
m('2:2', 'Steam', notice.text, 'warning'),
|
||||
m('3:3', 'Gabe', 'can you check this link for me')
|
||||
] }
|
||||
})
|
||||
})()`)
|
||||
await sleep(1000)
|
||||
const shot = async (name) => {
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
console.log('wrote', name)
|
||||
}
|
||||
await shot('steam-notice-before.png')
|
||||
console.log('banner:', await page.evaluate(`!!document.querySelector('.notice-banner')`), '| warning lines:', await page.evaluate(`document.querySelectorAll('.message-row.warning').length`))
|
||||
await page.evaluate(`document.querySelector('.notice-banner-dismiss').click()`)
|
||||
await sleep(600)
|
||||
console.log('banner after dismiss:', await page.evaluate(`!!document.querySelector('.notice-banner')`), '| warning lines:', await page.evaluate(`document.querySelectorAll('.message-row.warning').length`))
|
||||
await shot('steam-notice-after.png')
|
||||
} finally {
|
||||
await stop()
|
||||
irc.close()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Photographs the message box of a Steam conversation with its emoji picker
|
||||
* open: no GIF button, and a picker with no Unicode emoji. Made-up state, on a
|
||||
* fake IRC server, in a scratch home.
|
||||
*
|
||||
* node scripts/shot-steam-picker.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
try {
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1000, height: 700, deviceScaleFactor: 1, mobile: false })
|
||||
const shot = async (name) => {
|
||||
await sleep(600)
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
console.log('wrote', name)
|
||||
}
|
||||
// A Steam account and a conversation with a friend, selected.
|
||||
await page.evaluate(`(() => {
|
||||
const s = window.__mohoShots
|
||||
const st = s.state()
|
||||
const irc = st.accounts[0]
|
||||
const account = { ...irc, id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
|
||||
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
|
||||
const dm = { ...channel, id: 'steam:me|Gabe', accountId: 'steam:me', name: 'Gabe', kind: 'dm', remoteId: '76561197960265829', unread: 0, highlight: false, mentions: 0 }
|
||||
s.patch({ accounts: [...st.accounts, account], buffers: [...st.buffers, dm], activeBufferId: dm.id, activeGroupId: '~dms' })
|
||||
})()`)
|
||||
await shot('steam-composer.png')
|
||||
await page.evaluate(`document.querySelector('button[title="Emoji"]').click()`)
|
||||
await shot('steam-picker.png')
|
||||
const gif = await page.evaluate(`!!document.querySelector('button[title="GIFs"]')`)
|
||||
const sticker = await page.evaluate(`!!document.querySelector('button[title="Stickers"]')`)
|
||||
console.log('GIF button:', gif, '| Stickers button:', sticker)
|
||||
} finally {
|
||||
await stop()
|
||||
irc.close()
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Photographs the column about a Steam friend, beside their conversation, and
|
||||
* checks the conversation's right-click menu offers it. Made-up state on a fake
|
||||
* IRC server, in a scratch home.
|
||||
*
|
||||
* node scripts/shot-steam-profile.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
try {
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1100, height: 700, deviceScaleFactor: 1, mobile: false })
|
||||
await page.evaluate(`(() => {
|
||||
const s = window.__mohoShots
|
||||
const st = s.state()
|
||||
const irc = st.accounts[0]
|
||||
const account = { ...irc, id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
|
||||
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
|
||||
const dm = { ...channel, id: 'steam:me|Gabe', accountId: 'steam:me', name: 'Gabe', kind: 'dm', remoteId: '76561197960265829', unread: 0, highlight: false, mentions: 0 }
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const m = (id, body) => ({ id, bufferId: dm.id, from: 'Gabe', body, ts: now - 600 + Number(id.split(':')[1]), isAction: false, isHighlight: false, kind: 'chat' })
|
||||
s.patch({
|
||||
accounts: [...st.accounts, account], buffers: [...st.buffers, dm], activeBufferId: dm.id, activeGroupId: '~dms',
|
||||
messagesByBuffer: { ...st.messagesByBuffer, [dm.id]: [m('1:1', 'want to play later?')] },
|
||||
presenceByBuffer: { ...st.presenceByBuffer, [dm.id]: [{ nick: 'Gabe', status: 'online', userId: '76561197960265829', activity: { kind: 'game', text: 'Counter-Strike 2' }, client: 'desktop', rich: 'Competitive - Dust II' }] }
|
||||
})
|
||||
})()`)
|
||||
await sleep(600)
|
||||
// The right-click menu of the row, and what it offers.
|
||||
const rect = await page.evaluate(`(() => { const r = [...document.querySelectorAll('.buffer-row, .buffer')].find((e) => e.textContent.includes('Gabe')); if (!r) return null; const b = r.getBoundingClientRect(); return { x: b.x + 20, y: b.y + 10 } })()`)
|
||||
console.log('row at', JSON.stringify(rect))
|
||||
await page.call('Input.dispatchMouseEvent', { type: 'mousePressed', x: rect.x, y: rect.y, button: 'right', clickCount: 1 })
|
||||
await page.call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: rect.x, y: rect.y, button: 'right', clickCount: 1 })
|
||||
await sleep(400)
|
||||
console.log('menu:', JSON.stringify(await page.evaluate(`[...document.querySelectorAll('[role=menuitem], .context-menu button')].map((e) => e.textContent.trim()).slice(0, 6)`)))
|
||||
// Chosen from the menu, as a person would.
|
||||
console.log('clicked Profile:', await page.evaluate(`(() => { const e = [...document.querySelectorAll('[role=menuitem], .context-menu button')].find((x) => x.textContent.trim() === 'Profile'); if (!e) return false; e.click(); return true })()`))
|
||||
await sleep(500)
|
||||
// What Steam said, as the daemon would send it: the card every service opens.
|
||||
await page.evaluate(`window.__mohoShots.patch({ profile: {
|
||||
service: 'steam', accountId: 'steam:me', id: '76561197960265729', name: 'Gabe', createdTs: 1300000000,
|
||||
status: 'online', activity: { kind: 'game', text: 'Counter-Strike 2' },
|
||||
nameHistory: [{ name: 'Gabe Newell', when: '3 Mar, 2019 @ 1:00am' }, { name: 'Valve Gabe', when: '12 Jun, 2012 @ 6:40pm' }],
|
||||
extra: [{ label: 'Steam level', value: '42' }, { label: 'Real name', value: 'Gabe N' }, { label: 'Location', value: 'Bellevue, United States' }, { label: 'On', value: 'desktop' }, { label: 'Doing', value: 'Competitive - Dust II' }, { label: 'Headline', value: 'Hello' }]
|
||||
} })`)
|
||||
await sleep(500)
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, 'steam-profile-card.png'), Buffer.from(data, 'base64'))
|
||||
console.log('wrote steam-profile-card.png; card:', await page.evaluate(`!!document.querySelector('.profile-card')`), '| previous names:', await page.evaluate(`document.querySelectorAll('.profile-name-history li').length`))
|
||||
// Offline: last seen instead of playing.
|
||||
await page.evaluate(`(() => { const s = window.__mohoShots; const st = s.state(); s.patch({ profile: { ...st.profile, status: 'offline', activity: null, lastActiveTs: Math.floor(Date.now() / 1000) - 7200 } }) })()`)
|
||||
await sleep(400)
|
||||
console.log('offline card:', JSON.stringify(await page.evaluate(`document.querySelector('.profile-card')?.innerText.replace(/\\n+/g, ' | ').slice(0, 300)`)))
|
||||
} finally {
|
||||
await stop()
|
||||
irc.close()
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Photographs a Steam conversation holding a picture and a video, each plain
|
||||
* and each sent as a spoiler. Made-up state on a fake IRC server in a scratch
|
||||
* home; Steam's addresses are answered from a generated picture and clip.
|
||||
*
|
||||
* node scripts/shot-steam-spoiler.mjs <output directory> <picture.png> <clip.webm>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
|
||||
|
||||
const [out = '.', picture, clip] = process.argv.slice(2)
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
try {
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1000, height: 900, deviceScaleFactor: 1, mobile: false })
|
||||
// Steam's addresses answered here: the picture and the clip, whatever the path.
|
||||
await page.call('Fetch.enable', { patterns: [{ urlPattern: '*steamusercontent.com*' }, { urlPattern: '*steamstatic.com*' }, { urlPattern: '*example.com*' }] })
|
||||
page.on('Fetch.requestPaused', async (e) => {
|
||||
const video = e.request.url.includes('/cdn-video/') || e.request.url.includes('cdn.steamusercontent.com')
|
||||
const body = fs.readFileSync(video ? clip : picture).toString('base64')
|
||||
await page.call('Fetch.fulfillRequest', {
|
||||
requestId: e.requestId,
|
||||
responseCode: 200,
|
||||
responseHeaders: [{ name: 'Content-Type', value: video ? 'video/webm' : 'image/png' }, { name: 'Access-Control-Allow-Origin', value: '*' }],
|
||||
body
|
||||
})
|
||||
})
|
||||
const img = (n) => `https://images.steamusercontent.com/ugc/${n}/AAAA/`
|
||||
const vid = (n) => `https://cdn.steamusercontent.com/ugc/${n}/BBBB/`
|
||||
await page.evaluate(`(() => {
|
||||
const s = window.__mohoShots
|
||||
const st = s.state()
|
||||
const irc = st.accounts[0]
|
||||
const account = { ...irc, id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
|
||||
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
|
||||
const dm = { ...channel, id: 'steam:me|Gabe', accountId: 'steam:me', name: 'Gabe', kind: 'dm', remoteId: '76561197960265829', unread: 0, highlight: false, mentions: 0 }
|
||||
const m = (id, body) => ({ id, bufferId: dm.id, from: 'Gabe', body, ts: Math.floor(Date.now() / 1000) - 600 + Number(id), isAction: false, isHighlight: false, kind: 'chat' })
|
||||
s.patch({
|
||||
accounts: [...st.accounts, account], buffers: [...st.buffers, dm], activeBufferId: dm.id, activeGroupId: '~dms',
|
||||
messagesByBuffer: { ...st.messagesByBuffer, [dm.id]: [
|
||||
m('1', 'a plain picture'), m('2', '\\n[steam:image:${img(1)}]\\n'),
|
||||
m('3', 'a picture sent as a spoiler'), m('4', '||\\n[steam:image:${img(2)}]\\n||'),
|
||||
m('5', 'a plain video'), m('6', '\\n[steam:video:${vid(3)}]\\n'),
|
||||
m('7', 'a video sent as a spoiler'), m('8', '||\\n[steam:video:${vid(4)}]\\n||'),
|
||||
m('9', 'an emoticon [steam:emote:steamhappy] in a line, then a sticker'), m('10', '[steam:sticker:Nyan GLaDOS]'),
|
||||
m('11', 'a link with words: [look at this](https://links.invalid/a?x=1&y=2) and a bare one https://links.invalid/b, then a picture https://example.com/p.png'),
|
||||
{ ...m('12', '[x.salastil.com](http://x.salastil.com)'), embeds: [{ url: 'http://x.salastil.com', title: 'Home', description: 'A privacy-focused Twitter/X frontend', provider: 'x.salastil.com', imageUrl: 'https://community.steamstatic.com/chat/image/abc/share_image' }] }
|
||||
] }
|
||||
})
|
||||
})()`)
|
||||
await sleep(1500)
|
||||
const shot = async (name) => {
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
console.log('wrote', name)
|
||||
}
|
||||
await shot('steam-spoiler-before.png')
|
||||
console.log('links:', JSON.stringify(await page.evaluate(`[...document.querySelectorAll('.message-body a, .message-text a, .message-row a')].map((a) => [a.textContent, a.getAttribute('href')]).filter((l) => l[1] && !l[1].startsWith('spoiler'))`)))
|
||||
console.log('covers:', await page.evaluate(`document.querySelectorAll('.spoiler-cover').length`), '| embeds:', await page.evaluate(`document.querySelectorAll('.media-embed-wrap').length`))
|
||||
await page.evaluate(`document.querySelector('.spoiler-cover')?.click()`)
|
||||
await sleep(500)
|
||||
console.log('covers after one click:', await page.evaluate(`document.querySelectorAll('.spoiler-cover').length`))
|
||||
await shot('steam-spoiler-after.png')
|
||||
} finally {
|
||||
await stop()
|
||||
irc.close()
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Photographs Steam voice as the window shows it: a group's voice room with people in
|
||||
* it, the panel for the call you are in, and a call ringing. Made-up state on a fake IRC
|
||||
* server, in a scratch home.
|
||||
*
|
||||
* node scripts/shot-steam-voice.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
try {
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
await page.evaluate(`(() => {
|
||||
const s = window.__mohoShots
|
||||
const st = s.state()
|
||||
const account = { ...st.accounts[0], id: 'steam:me', service: 'steam', displayName: 'me', state: 'connected' }
|
||||
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
|
||||
const room = (name, id, position) => ({ ...channel, id: 'steam:me|' + name, accountId: 'steam:me', name, kind: 'channel', remoteId: '7:' + id, groupId: 'steam:me|steamgroup:7', position, unread: 0, highlight: false, mentions: 0 })
|
||||
const dm = { ...channel, id: 'steam:me|Gabe', accountId: 'steam:me', name: 'Gabe', kind: 'dm', remoteId: '76561197960265729', unread: 0, highlight: false, mentions: 0 }
|
||||
const general = room('Raiders/#General', '70', 1)
|
||||
const talk = room('Raiders/#Lounge', '71', 2)
|
||||
const live = s.store.steamVoiceLive
|
||||
live.speakers = new Set(['76561197960265730'])
|
||||
live.statuses = new Map([['76561197960265731', { micMuted: true, outputMuted: false, noMic: false }]])
|
||||
live.level = 0.4
|
||||
live.rttMs = 23
|
||||
s.patch({
|
||||
accounts: [...st.accounts, account],
|
||||
groups: [...st.groups, { id: 'steam:me|steamgroup:7', accountId: 'steam:me', service: 'steam', kind: 'guild', name: 'Raiders', position: 3 }],
|
||||
buffers: [...st.buffers, general, talk, dm],
|
||||
activeBufferId: general.id, activeGroupId: 'steam:me|steamgroup:7',
|
||||
steamVoiceRooms: { 'steam:me|7': [{ roomId: '71', bufferId: talk.id, name: 'Lounge', members: [
|
||||
{ userId: '76561197960265729', nick: 'Gabe' },
|
||||
{ userId: '76561197960265730', nick: 'Alyx' },
|
||||
{ userId: '76561197960265731', nick: 'Eli' },
|
||||
{ userId: '76561197960287930', nick: 'me', isSelf: true }
|
||||
] }] },
|
||||
steamVoiceNow: { accountId: 'steam:me', phase: 'connected', target: { kind: 'room', groupId: '7', roomId: '71', bufferId: talk.id, name: 'Lounge', groupName: 'Raiders', railId: 'steam:me|steamgroup:7' } },
|
||||
voiceSessions: [{ accountId: 'steam:me', channelId: '71', channelName: 'Lounge', isDirect: false, bufferId: talk.id, railId: 'steam:me|steamgroup:7' }],
|
||||
steamRings: [{ accountId: 'steam:me', steamid: '76561197960265729', voiceChatId: '99' }]
|
||||
})
|
||||
})()`)
|
||||
// The window asks the daemon who is in the voice rooms when the group opens, and the
|
||||
// daemon knows no such group: the made-up answer goes in after that.
|
||||
await sleep(1200)
|
||||
await page.evaluate(`(() => { const s = window.__mohoShots; s.patch({ steamVoiceRooms: { 'steam:me|7': [{ roomId: '71', bufferId: 'steam:me|Raiders/#Lounge', name: 'Lounge', members: [{ userId: '76561197960265729', nick: 'Gabe' }, { userId: '76561197960265730', nick: 'Alyx' }, { userId: '76561197960265731', nick: 'Eli' }, { userId: '76561197960287930', nick: 'me', isSelf: true }] }] } }) })()`)
|
||||
await sleep(800)
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, 'steam-voice.png'), Buffer.from(data, 'base64'))
|
||||
console.log('panel:', JSON.stringify(await page.evaluate(`document.querySelector('.voice-panel')?.innerText`)))
|
||||
console.log('ring:', JSON.stringify(await page.evaluate(`document.querySelector('.incoming-call')?.innerText`)))
|
||||
} finally {
|
||||
await stop()
|
||||
irc.close()
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Reads a busy live Twitch channel in a scratch moho with no sign-in, and checks what the window shows:
|
||||
* that the channel joins, messages arrive with their colours, badges and emote pictures, replies carry
|
||||
* their quote, the room's modes are in the header and the chatters are counted.
|
||||
*
|
||||
* node scripts/shot-twitch-chat.mjs <output directory> [channel] [seconds]
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
const channel = process.argv[3] ?? 'xqc'
|
||||
const seconds = Number(process.argv[4] ?? 40)
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
|
||||
const ev = (code) => page.evaluate(code)
|
||||
try {
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
await sleep(2500)
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
|
||||
// HISTORY=1 turns on the third-party recent messages, which is off until a person says so.
|
||||
if (process.env.HISTORY) await ev(`window.moho.prefs.set('twitch.recentHistory', true)`)
|
||||
const added = await rpc('addTwitchAccount', {})
|
||||
say('account', JSON.stringify(added))
|
||||
await sleep(2500)
|
||||
say('accounts', await ev(`JSON.stringify(window.__mohoShots.state().accounts.map((a) => ({ id: a.id, service: a.service, state: a.state, name: a.displayName, hasPassword: a.hasPassword })))`))
|
||||
say('join', JSON.stringify(await rpc('joinBuffer', { accountId: added.accountId, name: channel })))
|
||||
const id = `${added.accountId}|${channel}`
|
||||
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
|
||||
say('a channel that does not exist', JSON.stringify(await rpc('joinBuffer', { accountId: added.accountId, name: 'zzzz_no_such_channel_zzzz' })))
|
||||
if (process.env.HISTORY) {
|
||||
await sleep(6000)
|
||||
say('rows six seconds after opening, with the past', await ev(`(window.__mohoShots.state().messagesByBuffer[${JSON.stringify(id)}] || []).length`))
|
||||
}
|
||||
await sleep(seconds * 1000)
|
||||
say('buffers', await ev(`JSON.stringify(window.__mohoShots.state().buffers.filter((b) => b.accountId === ${JSON.stringify(added.accountId)}).map((b) => ({ id: b.id, modes: b.channelModes })))`))
|
||||
say('page', await ev(`JSON.stringify({
|
||||
rows: document.querySelectorAll('.message-row').length,
|
||||
emotes: document.querySelectorAll('.message-row img.custom-emoji').length,
|
||||
emotesLoaded: [...document.querySelectorAll('.message-row img.custom-emoji')].filter((i) => i.complete && i.naturalWidth > 0).length,
|
||||
badges: [...new Set([...document.querySelectorAll('.message-row .sender-badge')].map((b) => b.textContent))],
|
||||
badgePictures: document.querySelectorAll('.message-row .sender-badge-image').length,
|
||||
badgePicturesLoaded: [...document.querySelectorAll('.message-row .sender-badge-image')].filter((i) => i.complete && i.naturalWidth > 0).length,
|
||||
badgeTitles: [...new Set([...document.querySelectorAll('.message-row .sender-badge-image')].map((i) => i.title))].slice(0, 6),
|
||||
emoteSources: [...new Set([...document.querySelectorAll('.message-row img.custom-emoji')].map((i) => new URL(i.src).hostname))],
|
||||
replies: document.querySelectorAll('.message-row .reply-preview').length,
|
||||
header: document.querySelector('.nicklist-header span')?.textContent,
|
||||
coloured: document.querySelectorAll('.message-row .message-from[style*="color"]').length
|
||||
})`))
|
||||
await shot('twitch-chat.png')
|
||||
async function shot(name) {
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
}
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Opens the Accounts pane in a scratch moho and starts a Twitch sign-in: checks that one window of twitch.tv's
|
||||
* login opens (nothing is typed into it), then closes that window as a person giving up would, and checks the
|
||||
* form comes back with nothing saved. No account is signed in.
|
||||
*
|
||||
* node scripts/shot-twitch-signin.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const { page, stop, port } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
|
||||
const ev = (code) => page.evaluate(code)
|
||||
const shot = async (name) => {
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
}
|
||||
const clickText = (selector, text) => ev(`(() => { const b = [...document.querySelectorAll(${JSON.stringify(selector)})].find((x) => x.textContent.trim().startsWith(${JSON.stringify(text)})); if (!b) return false; b.click(); return true })()`)
|
||||
try {
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
await sleep(2500)
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1100, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
await ev(`window.__mohoShots.store.setActivePanel('accounts')`)
|
||||
await sleep(600)
|
||||
say('twitch chip', await clickText('.service-chip', 'Twitch'))
|
||||
await sleep(500)
|
||||
await shot('twitch-signin-form.png')
|
||||
say('sign in', await clickText('button', 'Sign in with Twitch'))
|
||||
// The sign-in window is another page of the same app, so the debugging port lists it.
|
||||
const targets = async () => (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()).filter((t) => t.type === 'page' && /twitch\.tv/.test(t.url))
|
||||
let found = []
|
||||
for (let i = 0; i < 40 && found.length === 0; i++) {
|
||||
await sleep(500)
|
||||
found = await targets()
|
||||
}
|
||||
say('sign-in window', JSON.stringify(found.map((t) => t.url)))
|
||||
say('waiting text', await ev(`document.querySelector('.add-form')?.textContent.slice(0, 120)`))
|
||||
await shot('twitch-signin-waiting.png')
|
||||
// Giving up, as a person does by closing it.
|
||||
for (const t of found) await fetch(`http://127.0.0.1:${port}/json/close/${t.id}`)
|
||||
await sleep(2000)
|
||||
say('window gone', (await targets()).length === 0)
|
||||
say('back to the form', await ev(`!!([...document.querySelectorAll('button')].find((b) => b.textContent.trim() === 'Sign in with Twitch'))`))
|
||||
say('accounts saved', await ev(`window.__mohoShots.state().accounts.filter((a) => a.service === 'twitch').length`))
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* A Twitch channel and a whisper conversation side by side in the channel list, to see that they can be told
|
||||
* apart. Injects the two buffers; nothing is contacted.
|
||||
*
|
||||
* node scripts/shot-twitch-whisper.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const ev = (code) => page.evaluate(code)
|
||||
try {
|
||||
await sleep(2500)
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1100, height: 700, deviceScaleFactor: 2, mobile: false })
|
||||
await ev(`(() => {
|
||||
const s = window.__mohoShots.store
|
||||
const acct = { id: 'twitch:me', service: 'twitch', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, nick: 'me' }
|
||||
const pic = 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#7b5cd6"/><circle cx="32" cy="26" r="12" fill="#fff"/><rect x="14" y="42" width="36" height="16" rx="8" fill="#fff"/></svg>')
|
||||
const buffers = [
|
||||
{ id: 'twitch:me|skitzy_va', accountId: 'twitch:me', kind: 'channel', name: 'skitzy_va', lastActivityTs: 5, avatarUrl: pic, category: 'Channels', groupId: 'account:twitch:me' },
|
||||
{ id: 'twitch:me|kotbarsik_twt', accountId: 'twitch:me', kind: 'channel', name: 'kotbarsik_twt', lastActivityTs: 4, avatarUrl: pic, category: 'Channels', groupId: 'account:twitch:me' },
|
||||
{ id: 'twitch:me|@anon7of10', accountId: 'twitch:me', kind: 'dm', name: '@anon7of10', lastActivityTs: 6, avatarUrl: pic, category: 'Direct Messages', groupId: 'account:twitch:me' }
|
||||
]
|
||||
s.set({ accounts: [acct], groups: [{ id: 'account:twitch:me', accountId: 'twitch:me', service: 'twitch', kind: 'account', name: 'me', position: 0 }], buffers, activeBufferId: 'twitch:me|skitzy_va' })
|
||||
s.selectGroup('account:twitch:me')
|
||||
})()`)
|
||||
await sleep(800)
|
||||
await sleep(400)
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, 'twitch-whisper-vs-channel.png'), Buffer.from(data, 'base64'))
|
||||
console.log('rows', await ev(`[...document.querySelectorAll('.buffer-row')].map((r) => r.innerText.replace(/\\n/g, ' ') + (r.querySelector('.avatar-badge') ? ' [badge]' : ''))`))
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Photographs the member list and the direct-message rows in comfy and in
|
||||
* classic, with made-up presence on them (a game, a track, words somebody
|
||||
* wrote), so the two looks can be compared by eye.
|
||||
*
|
||||
* node scripts/shot-userlist.mjs <output directory>
|
||||
*
|
||||
* Runs the built app against a scratch home with the screenshot hook on, and
|
||||
* a fake IRC server for something to be in.
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
|
||||
const PEOPLE = ['Crujix', 'Currington', 'dakota', 'Gremlin_magos', 'onkell', 'Salastil', 'Taylaigh']
|
||||
|
||||
const MEMBERS = [
|
||||
{ nick: 'Crujix', status: 'idle', activity: { kind: 'game', text: 'Songs of Syx Demo' } },
|
||||
{ nick: 'Currington', status: 'online', activity: { kind: 'game', text: 'Philippians 2' } },
|
||||
{ nick: 'dakota', status: 'online' },
|
||||
{ nick: 'Gremlin_magos', status: 'dnd', activity: { kind: 'custom', text: 'ask questions' } },
|
||||
{ nick: 'onkell', status: 'online', activity: { kind: 'game', text: 'Down to Dungeon' } },
|
||||
{ nick: 'Salastil', status: 'online', activity: { kind: 'music', text: 'Ramones' } },
|
||||
{ nick: 'Taylaigh', status: 'offline', away: true }
|
||||
]
|
||||
|
||||
const irc = await fakeIrc({
|
||||
people: PEOPLE,
|
||||
onJoin: (channel, _nick, send) => send(`:Crujix!u@h PRIVMSG ${channel} :hello there`)
|
||||
})
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
try {
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1100, height: 760, deviceScaleFactor: 1, mobile: false })
|
||||
const shot = async (name) => {
|
||||
await sleep(600)
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
console.log('wrote', name)
|
||||
}
|
||||
|
||||
const dress = async () => {
|
||||
await page.evaluate(`(() => {
|
||||
const s = window.__mohoShots
|
||||
const st = s.state()
|
||||
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
|
||||
const dm = (name, extra = {}) => ({ ...channel, id: 'irc:x|' + name, name, kind: 'dm', unread: 0, highlight: false, mentions: 0, ...extra })
|
||||
const gamer = dm('k. mardam bae')
|
||||
const quiet = dm('Alexcellence')
|
||||
s.patch({
|
||||
buffers: [...st.buffers, gamer, quiet],
|
||||
presenceByBuffer: {
|
||||
...st.presenceByBuffer,
|
||||
[channel.id]: ${JSON.stringify(MEMBERS)},
|
||||
[gamer.id]: [{ nick: 'k. mardam bae', status: 'idle', activity: { kind: 'game', text: "Assassin's Creed Valhalla" } }],
|
||||
[quiet.id]: [{ nick: 'Alexcellence', status: 'online' }]
|
||||
}
|
||||
})
|
||||
})()`)
|
||||
}
|
||||
await dress()
|
||||
await page.evaluate(`window.moho.prefs.set('display.messageMode', 'comfy')`)
|
||||
await shot('comfy-channel.png')
|
||||
await page.evaluate(`(() => {
|
||||
const s = window.__mohoShots
|
||||
const channel = s.state().buffers.find((b) => b.id.endsWith('|#ui'))
|
||||
s.patch({ profile: { service: 'irc', accountId: channel.accountId, name: 'onkell', handle: 'onkell', status: 'online', extra: [{ label: 'Client', value: 'moho' }] } })
|
||||
})()`)
|
||||
await shot('profile-card.png')
|
||||
await page.evaluate(`(() => { window.__mohoShots.patch({ profile: null }) })()`)
|
||||
await page.evaluate(`(() => { const s = window.__mohoShots; s.store.selectGroup('~dms') })()`)
|
||||
await shot('comfy-dms.png')
|
||||
// A window is not told about its own preference change, so the new mode is
|
||||
// set, the page reloaded to read it, and the made-up state put back.
|
||||
await page.evaluate(`window.moho.prefs.set('display.messageMode', 'classic')`)
|
||||
await sleep(300)
|
||||
await page.call('Page.reload', {})
|
||||
await sleep(2500)
|
||||
await dress()
|
||||
await page.evaluate(`(() => { const s = window.__mohoShots; s.store.selectGroup('~dms') })()`)
|
||||
await shot('classic-dms.png')
|
||||
await page.evaluate(`(() => { const s = window.__mohoShots; const b = s.state().buffers.find((b) => b.id.endsWith('|#ui')); s.store.selectBuffer(b.id) })()`)
|
||||
await shot('classic-channel.png')
|
||||
} finally {
|
||||
await stop()
|
||||
irc.close()
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Reads a busy live Kick channel in a scratch moho and reports how the chatters count grows against the
|
||||
* names listed, and whether drawing a long list holds the window up.
|
||||
*
|
||||
* node scripts/watch-kick-roster.mjs [channel] [seconds]
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const channel = process.argv[2] ?? 'spreen'
|
||||
const seconds = Number(process.argv[3] ?? 150)
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const ev = (code) => page.evaluate(code)
|
||||
try {
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
await sleep(2500)
|
||||
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
|
||||
const added = await rpc('addKickAccount', {})
|
||||
await rpc('joinBuffer', { accountId: added.accountId, name: channel })
|
||||
const id = `${added.accountId}|${channel}`
|
||||
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
|
||||
await ev(`(() => { window.__long = 0; window.__longMs = 0; new PerformanceObserver((l) => { for (const e of l.getEntries()) { window.__long++; window.__longMs += e.duration } }).observe({ entryTypes: ['longtask'] }) })()`)
|
||||
const began = Date.now()
|
||||
while (Date.now() - began < seconds * 1000) {
|
||||
await sleep(15000)
|
||||
console.log(await ev(`JSON.stringify({ header: document.querySelector('.nicklist-header span')?.textContent, rows: document.querySelectorAll('.nicklist .member-row, .nicklist-scroll > div > div:not(.nicklist-group)').length, state: (() => { const s = window.__mohoShots.state(); return { listed: (s.presenceByBuffer[${JSON.stringify(id)}] || []).length, total: s.presenceTotalByBuffer[${JSON.stringify(id)}] } })(), longTasks: window.__long, longMs: Math.round(window.__longMs) })`))
|
||||
}
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Watches a live Kick stream in a scratch moho and reports every time the picture waits for data: when,
|
||||
* for how long, and how much was buffered ahead of the playhead just before.
|
||||
*
|
||||
* node scripts/watch-kick-stalls.mjs [channel] [seconds]
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const channel = process.argv[2] ?? 'spreen'
|
||||
const seconds = Number(process.argv[3] ?? 90)
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const ev = (code) => page.evaluate(code)
|
||||
try {
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
await sleep(2500)
|
||||
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
|
||||
const added = await rpc('addKickAccount', {})
|
||||
await rpc('joinBuffer', { accountId: added.accountId, name: channel })
|
||||
const id = `${added.accountId}|${channel}`
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await sleep(1000)
|
||||
if (await ev(`!!window.__mohoShots.state().kickStreams[${JSON.stringify(id)}]?.playbackUrl`)) break
|
||||
}
|
||||
await ev(`(async () => { await window.__mohoShots.store.selectBuffer(${JSON.stringify(id)}); window.__mohoShots.store.watchStream(${JSON.stringify(id)}) })()`)
|
||||
for (let i = 0; i < 40; i++) {
|
||||
if (await ev(`!!document.querySelector('video.video-tile-picture')`)) break
|
||||
await sleep(500)
|
||||
}
|
||||
await ev(`(() => {
|
||||
const v = document.querySelector('video.video-tile-picture')
|
||||
const log = (window.__stalls = { events: [], samples: [], t0: performance.now() })
|
||||
const at = () => +((performance.now() - log.t0) / 1000).toFixed(1)
|
||||
const ahead = () => { const b = v.buffered; return b.length ? +(b.end(b.length - 1) - v.currentTime).toFixed(2) : 0 }
|
||||
for (const n of ['waiting', 'stalled', 'playing', 'seeking', 'seeked', 'ratechange']) v.addEventListener(n, () => log.events.push({ t: at(), n, ahead: ahead(), rate: v.playbackRate, ct: +v.currentTime.toFixed(2) }))
|
||||
setInterval(() => log.samples.push({ t: at(), ahead: ahead(), ct: +v.currentTime.toFixed(2), rate: v.playbackRate }), 500)
|
||||
})()`)
|
||||
await sleep(seconds * 1000)
|
||||
const r = JSON.parse(await ev('JSON.stringify(window.__stalls)'))
|
||||
const waits = r.events.filter((e) => e.n === 'waiting')
|
||||
console.log(`${waits.length} waiting events in ${seconds}s`)
|
||||
for (const e of r.events) console.log(JSON.stringify(e))
|
||||
const a = r.samples.map((s) => s.ahead)
|
||||
console.log('buffer ahead: min', Math.min(...a), 'max', Math.max(...a), 'mean', +(a.reduce((x, y) => x + y, 0) / a.length).toFixed(2))
|
||||
console.log('rates seen', [...new Set(r.samples.map((s) => s.rate))])
|
||||
console.log('quality', await ev(`JSON.stringify((() => { const q = document.querySelector('video.video-tile-picture').getVideoPlaybackQuality(); return { dropped: q.droppedVideoFrames, total: q.totalVideoFrames } })())`))
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Watches a live stream of either service in a scratch moho and reports how it plays: every time the
|
||||
* picture waits for data, how much was buffered ahead of the playhead, how far behind the stream's own
|
||||
* clock it runs, what rendition it settled on, and whether an ad break was marked.
|
||||
*
|
||||
* node scripts/watch-stream-stalls.mjs <kick|twitch> <channel> [seconds]
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const service = process.argv[2] ?? 'twitch'
|
||||
const channel = process.argv[3] ?? 'xqc'
|
||||
const seconds = Number(process.argv[4] ?? 90)
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const ev = (code) => page.evaluate(code)
|
||||
try {
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
await sleep(2500)
|
||||
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
|
||||
const added = await rpc(service === 'twitch' ? 'addTwitchAccount' : 'addKickAccount', {})
|
||||
const accountId = added.accountId
|
||||
console.log('join', JSON.stringify(await rpc('joinBuffer', { accountId, name: channel })))
|
||||
const id = `${accountId}|${channel}`
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await sleep(1000)
|
||||
if (await ev(`!!window.__mohoShots.state().kickStreams[${JSON.stringify(id)}]?.playbackUrl`)) break
|
||||
}
|
||||
const stream = JSON.parse(await ev(`JSON.stringify((({ live, title, category, viewers, playbackExpires, adFree, playbackError }) => ({ live, title, category, viewers, playbackExpires, adFree, playbackError }))(window.__mohoShots.state().kickStreams[${JSON.stringify(id)}] || {}))`))
|
||||
console.log('stream', JSON.stringify(stream))
|
||||
if (!stream.live) { console.log('not live; nothing to watch'); process.exit(0) }
|
||||
await ev(`(async () => { await window.__mohoShots.store.selectBuffer(${JSON.stringify(id)}); window.__mohoShots.store.watchStream(${JSON.stringify(id)}) })()`)
|
||||
for (let i = 0; i < 40; i++) {
|
||||
if (await ev(`!!document.querySelector('video.video-tile-picture') && window.__mohoHls?.levels?.length > 0`)) break
|
||||
await sleep(500)
|
||||
}
|
||||
// The stream's own clock against ours, from the playlist's session data.
|
||||
const offset = await ev(`(async () => { const u = window.__mohoShots.state().kickStreams[${JSON.stringify(id)}].playbackUrl; const t0 = Date.now(); const r = await fetch(u); const m = /SERVER-TIME",VALUE="([0-9.]+)"/.exec(await r.text()); return m ? Number(m[1]) * 1000 - (t0 + Date.now()) / 2 : null })().catch(() => null)`)
|
||||
console.log('clock offset ms', offset)
|
||||
await ev(`(() => {
|
||||
const v = document.querySelector('video.video-tile-picture')
|
||||
const log = (window.__stalls = { events: [], samples: [], t0: performance.now() })
|
||||
const at = () => +((performance.now() - log.t0) / 1000).toFixed(1)
|
||||
const ahead = () => { const b = v.buffered; return b.length ? +(b.end(b.length - 1) - v.currentTime).toFixed(2) : 0 }
|
||||
for (const n of ['waiting', 'stalled', 'seeking', 'ratechange']) v.addEventListener(n, () => log.events.push({ t: at(), n, ahead: ahead(), ct: +v.currentTime.toFixed(2) }))
|
||||
setInterval(() => {
|
||||
const h = window.__mohoHls
|
||||
log.samples.push({ t: at(), ahead: ahead(), latency: h?.playingDate && ${offset === null ? 'null' : offset} !== null ? +((Date.now() + ${offset ?? 0} - h.playingDate.getTime()) / 1000).toFixed(1) : null, level: h?.currentLevel, height: v.videoHeight, ad: !!document.querySelector('.stream-ad') })
|
||||
}, 500)
|
||||
})()`)
|
||||
if (process.env.SHOT_DIR) {
|
||||
// Photographs the stage, then its resolution menu, part-way through.
|
||||
const fs = await import('node:fs')
|
||||
const shot = async (name) => {
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(`${process.env.SHOT_DIR}/${name}`, Buffer.from(data, 'base64'))
|
||||
}
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
await sleep(Math.min(20, seconds / 2) * 1000)
|
||||
await shot(`${service}-stream.png`)
|
||||
await ev(`document.querySelector('button[title^="Resolution"]')?.click()`)
|
||||
await sleep(500)
|
||||
console.log('quality choices', await ev(`JSON.stringify([...document.querySelectorAll('.stream-menu .context-menu-item')].map((b) => b.textContent.trim()))`))
|
||||
await shot(`${service}-stream-quality.png`)
|
||||
await ev(`document.querySelector('.stream-menu-scrim')?.click()`)
|
||||
await sleep((seconds - Math.min(20, seconds / 2)) * 1000 - 500)
|
||||
} else {
|
||||
await sleep(seconds * 1000)
|
||||
}
|
||||
const r = JSON.parse(await ev('JSON.stringify(window.__stalls)'))
|
||||
const waits = r.events.filter((e) => e.n === 'waiting' && e.t > 6)
|
||||
console.log(`${waits.length} waiting events after the first six seconds, over ${seconds}s`)
|
||||
for (const e of r.events.filter((e) => e.t > 6)) console.log(JSON.stringify(e))
|
||||
const a = r.samples.map((s) => s.ahead)
|
||||
const lat = r.samples.map((s) => s.latency).filter((x) => x !== null)
|
||||
console.log('buffer ahead: min', Math.min(...a), 'max', Math.max(...a), 'mean', +(a.reduce((x, y) => x + y, 0) / a.length).toFixed(2))
|
||||
if (lat.length) console.log('behind the stream: min', Math.min(...lat), 'max', Math.max(...lat), 'mean', +(lat.reduce((x, y) => x + y, 0) / lat.length).toFixed(1))
|
||||
console.log('heights', [...new Set(r.samples.map((s) => s.height))], 'ad breaks seen', r.samples.some((s) => s.ad))
|
||||
console.log('quality', await ev(`JSON.stringify((() => { const q = document.querySelector('video.video-tile-picture').getVideoPlaybackQuality(); return { dropped: q.droppedVideoFrames, total: q.totalVideoFrames } })())`))
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -30,7 +30,16 @@ export type Capture =
|
||||
/** A request header the page starts sending - Discord's Authorization. */
|
||||
| { kind: 'header'; name: string }
|
||||
/** A cookie the service sets - the usual shape for forum software. */
|
||||
| { kind: 'cookie'; name: string; url: string }
|
||||
| {
|
||||
kind: 'cookie'
|
||||
name: string
|
||||
url: string
|
||||
/**
|
||||
* Where the same value is to be had if the named cookie is not there: another cookie holding JSON with
|
||||
* the value under a key. Twitch's page-readable `twilight-user` carries the token its `auth-token` cookie does.
|
||||
*/
|
||||
fallback?: { name: string; key: string }
|
||||
}
|
||||
/** A parameter on a redirect the service lands on - SSO, mostly. */
|
||||
| { kind: 'query'; param: string }
|
||||
/**
|
||||
@@ -126,6 +135,17 @@ export const LOGIN_FLOWS: Record<string, LoginFlow> = {
|
||||
"(document.querySelector('.p-navgroup-user-linkText') || document.querySelector('.p-navgroup-link--user .p-navgroup-linkText'))?.textContent?.trim() || null"
|
||||
}
|
||||
},
|
||||
twitch: {
|
||||
label: 'Twitch',
|
||||
url: 'https://www.twitch.tv/login',
|
||||
// Nothing: the credential is a cookie the site sets, not a request to read.
|
||||
watch: [],
|
||||
// The website's own session token, which is what Twitch's player calls accept for playing a stream as the
|
||||
// person who signed in - their Turbo, their subscriptions - and which a token from moho's own sign-in
|
||||
// (made for chat and the official API) is not. It goes straight to the daemon, for the account named.
|
||||
capture: { kind: 'cookie', name: 'auth-token', url: 'https://www.twitch.tv', fallback: { name: 'twilight-user', key: 'authToken' } },
|
||||
finish: { method: 'setTwitchWebToken', param: 'token' }
|
||||
},
|
||||
kick: {
|
||||
label: 'Kick',
|
||||
url: 'https://kick.com/',
|
||||
@@ -165,7 +185,20 @@ let openWindow: BrowserWindow | null = null
|
||||
* be signing in, and the second would inherit the first's half-finished
|
||||
* session.
|
||||
*/
|
||||
export async function browserLogin(service: string): Promise<LoginOutcome> {
|
||||
export async function browserLogin(
|
||||
service: string,
|
||||
options?: {
|
||||
/**
|
||||
* Keeps the window open once the credential is captured, for a flow that goes on in it: called with the
|
||||
* credential and the window, and the window closes when what it returns settles. Twitch's: signing in on
|
||||
* twitch.tv and then, in the same window and the same session, approving the code that gives the chat
|
||||
* connection its token.
|
||||
*/
|
||||
hold?: (value: string, win: BrowserWindow) => Promise<void>
|
||||
/** Said at each step, without any credential in it, for the log. */
|
||||
trace?: (message: string) => void
|
||||
}
|
||||
): Promise<LoginOutcome> {
|
||||
const flow = LOGIN_FLOWS[service]
|
||||
if (!flow) return { ok: false, error: `No browser sign-in is defined for ${service}` }
|
||||
if (openWindow && !openWindow.isDestroyed()) {
|
||||
@@ -219,6 +252,17 @@ export async function browserLogin(service: string): Promise<LoginOutcome> {
|
||||
e.preventDefault()
|
||||
retitle()
|
||||
})
|
||||
if (options?.trace) {
|
||||
const trace = options.trace
|
||||
win.webContents.on('did-navigate', (_e, url) => {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
trace(`the window is at ${u.host}${u.pathname}`)
|
||||
} catch {
|
||||
/* not a URL */
|
||||
}
|
||||
})
|
||||
}
|
||||
win.webContents.on('did-navigate', retitle)
|
||||
win.webContents.on('did-navigate-in-page', retitle)
|
||||
retitle()
|
||||
@@ -244,7 +288,19 @@ export async function browserLogin(service: string): Promise<LoginOutcome> {
|
||||
TIMEOUT_MS
|
||||
)
|
||||
|
||||
let holding = false
|
||||
watch(ses, win, flow, (value) => {
|
||||
if (options?.hold) {
|
||||
// The credential shows up more than once as cookies change; the flow is told once.
|
||||
if (holding) return
|
||||
holding = true
|
||||
options.trace?.('signed in on twitch.tv; the session was found')
|
||||
void options
|
||||
.hold(value, win)
|
||||
.then(() => finish({ ok: true, value }))
|
||||
.catch((e: Error) => finish({ ok: false, error: e.message }))
|
||||
return
|
||||
}
|
||||
// Read before finishing, because finishing destroys the window - and
|
||||
// best-effort, because this is a nicety and the credential is not.
|
||||
const also = flow.alsoRead
|
||||
@@ -320,14 +376,31 @@ function watch(ses: Session, win: BrowserWindow, flow: LoginFlow, found: (value:
|
||||
}
|
||||
|
||||
if (flow.capture.kind === 'cookie') {
|
||||
const { name, url } = flow.capture
|
||||
const { name, url, fallback } = flow.capture
|
||||
const check = (): void => {
|
||||
void ses.cookies.get({ url, name }).then((cookies) => {
|
||||
const value = cookies[0]?.value
|
||||
void ses.cookies.get({ url }).then((cookies) => {
|
||||
let value = cookies.find((c) => c.name === name)?.value
|
||||
if (!value && fallback) {
|
||||
const holder = cookies.find((c) => c.name === fallback.name)?.value
|
||||
if (holder) {
|
||||
try {
|
||||
const parsed = JSON.parse(decodeURIComponent(holder)) as Record<string, unknown>
|
||||
const token = parsed[fallback.key]
|
||||
if (typeof token === 'string') value = token
|
||||
} catch {
|
||||
/* not JSON yet; the next change will be */
|
||||
}
|
||||
}
|
||||
}
|
||||
if (value) found(value)
|
||||
})
|
||||
}
|
||||
ses.cookies.on('changed', check)
|
||||
// Also after each page, in case the sign-in ended in a redirect that the cookie jar's own events did not announce.
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.on('did-navigate', check)
|
||||
win.webContents.on('did-finish-load', check)
|
||||
}
|
||||
check()
|
||||
return
|
||||
}
|
||||
|
||||
+28
-2
@@ -29,6 +29,8 @@ import { Notifier } from './notifications'
|
||||
import { IdleWatcher } from './idle'
|
||||
import { NOTHING_WAITING, trayIconFile, trayMenu, trayTooltip, type TrayState, type TrayStatus } from './tray'
|
||||
import { browserLogin, LOGIN_FLOWS } from './browser-login'
|
||||
import { feedTwitchSignIn, twitchSignIn } from './twitch-signin'
|
||||
import { answerIntegrity, mintIntegrity } from './twitch-integrity'
|
||||
import { EDIT_ACTIONS, IPC, POPOUT_FLAG, UI_SHOTS_FLAG, type EditAction, type EditMenuRequest, type PopoutState } from '../shared/ipc'
|
||||
import { clearnetLinks } from '../shared/clearnet'
|
||||
import { readCapped, pictureNamedIn } from './imagepage'
|
||||
@@ -902,10 +904,19 @@ function wireIpc(): void {
|
||||
return res.canceled || !res.filePath ? null : res.filePath
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.pickFile, async (e) => {
|
||||
ipcMain.handle(IPC.pickFile, async (e, extensions?: string[]) => {
|
||||
const parent = callerWindow(e) ?? mainWindow
|
||||
if (!parent) return null
|
||||
const res = await dialog.showOpenDialog(parent, { properties: ['openFile'] })
|
||||
// Where a service takes only some kinds of file, the dialog shows only
|
||||
// those and has no "all files" to widen it: a file that would be refused
|
||||
// is not offered. Extensions only, as the dialog wants them - no dots.
|
||||
const taken = Array.isArray(extensions)
|
||||
? extensions.filter((x): x is string => typeof x === 'string' && /^[A-Za-z0-9]{1,8}$/.test(x))
|
||||
: []
|
||||
const res = await dialog.showOpenDialog(parent, {
|
||||
properties: ['openFile'],
|
||||
...(taken.length ? { filters: [{ name: 'Pictures and videos', extensions: taken }] } : {})
|
||||
})
|
||||
if (res.canceled || !res.filePaths[0]) return null
|
||||
// The renderer draws a thumbnail of what was staged, and that goes back
|
||||
// through the guarded media scheme like every other local file. Where
|
||||
@@ -1101,6 +1112,13 @@ function wireIpc(): void {
|
||||
}
|
||||
})
|
||||
|
||||
// Twitch's sign-in, both halves in one window (see twitch-signin.ts). Only whether it worked comes back.
|
||||
ipcMain.handle(IPC.twitchSignIn, (_e, options?: { useTor?: boolean }) =>
|
||||
twitchSignIn((method, params) => client.request(method, params), {
|
||||
useTor: typeof options?.useTor === 'boolean' ? options.useTor : undefined
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.smiliesDir, () => smiliesPath())
|
||||
|
||||
// Chromium's HTTP cache, which Settings reports beside the daemon's.
|
||||
@@ -1311,6 +1329,13 @@ app.whenReady().then(() => {
|
||||
} else if (frame.event === 'netSettings') {
|
||||
void applyTunnel((method, params) => client.request(method, params))
|
||||
resetRoute()
|
||||
} else if (frame.event.startsWith('twitchLogin')) {
|
||||
feedTwitchSignIn(frame as { event: string; data?: Record<string, unknown> })
|
||||
} else if (frame.event === 'twitchIntegrityNeeded') {
|
||||
// Twitch wants proof that a browser is asking for a stream; only a page of its own can make it.
|
||||
void answerIntegrity(frame.data as { requestId?: string; deviceId?: string; authToken?: string }, (method, params) => client.request(method, params))
|
||||
// Not forwarded: it carries the session, which only main and the daemon are to hold.
|
||||
return
|
||||
}
|
||||
send(IPC.event, frame)
|
||||
})
|
||||
@@ -1325,6 +1350,7 @@ app.whenReady().then(() => {
|
||||
// For the UI harness, which has no way to look inside a tray: what it shows
|
||||
// and offers, and the entries themselves to be clicked. Only when it asks.
|
||||
if (process.env.MOHO_UI_SHOTS === '1') {
|
||||
;(globalThis as Record<string, unknown>).__mohoTwitchIntegrity = { mint: mintIntegrity }
|
||||
;(globalThis as Record<string, unknown>).__mohoTray = {
|
||||
update: updateTray,
|
||||
idle: (v: boolean | null) => idleWatcher.forceForTesting(v),
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { BrowserWindow, session } from 'electron'
|
||||
import { routeSession } from './tunnel'
|
||||
|
||||
/**
|
||||
* An integrity token for Twitch's GraphQL, which only a real page on twitch.tv can make.
|
||||
*
|
||||
* Twitch asks, now and then, for proof that the one asking for a stream is a browser and not a program: a
|
||||
* token its own page computes with bot-detection script that nobody else can run. Not asked for today, and
|
||||
* for a week in May 2023 it was. When it is asked for again the daemon cannot make one, so it says so (the
|
||||
* `twitchIntegrityNeeded` event) and this makes it: a window nobody sees, on a throwaway session, that loads
|
||||
* a twitch.tv page, waits for that script to be ready, and calls the same endpoint the page does. The token
|
||||
* comes back valid for about sixteen hours and is for the device id the daemon names.
|
||||
*
|
||||
* Nothing is signed in to, nothing is kept (the session is memory-only and wiped), and it goes through the
|
||||
* route like every other window does.
|
||||
*/
|
||||
|
||||
/** The Client ID twitch.tv's own pages send; the integrity endpoint answers only to it. */
|
||||
const SITE_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'
|
||||
|
||||
export interface Integrity {
|
||||
token: string
|
||||
/** When it stops being good, in Unix milliseconds. */
|
||||
expiration: number
|
||||
}
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
export async function mintIntegrity(deviceId: string, authToken = '', timeoutMs = 45_000): Promise<Integrity> {
|
||||
const partition = `moho-twitch-integrity-${Date.now()}`
|
||||
const ses = session.fromPartition(partition)
|
||||
await routeSession(ses)
|
||||
const win = new BrowserWindow({
|
||||
show: false,
|
||||
width: 900,
|
||||
height: 700,
|
||||
webPreferences: { partition, contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true }
|
||||
})
|
||||
try {
|
||||
// A page with Twitch's own scripts on it: the bot check is one of them, and it patches fetch.
|
||||
await win.loadURL('https://www.twitch.tv/directory')
|
||||
const script = `(async () => {
|
||||
const r = await fetch('https://gql.twitch.tv/integrity', { method: 'POST', headers: { 'Client-ID': ${JSON.stringify(SITE_CLIENT_ID)}, 'X-Device-Id': ${JSON.stringify(deviceId)}${authToken ? `, Authorization: ${JSON.stringify('OAuth ' + authToken)}` : ''} }, credentials: 'omit' })
|
||||
if (r.status !== 200) throw new Error('Twitch answered ' + r.status)
|
||||
const j = await r.json()
|
||||
if (!j.token) throw new Error('Twitch sent no token')
|
||||
return { token: j.token, expiration: j.expiration }
|
||||
})()`
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let last = ''
|
||||
// The script that makes the call valid loads after the page does, so the first tries may be refused.
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const made = (await win.webContents.executeJavaScript(script, true)) as Integrity
|
||||
if (made?.token) return made
|
||||
} catch (e) {
|
||||
last = (e as Error).message
|
||||
}
|
||||
await sleep(2000)
|
||||
}
|
||||
throw new Error(last || 'the page never became ready')
|
||||
} finally {
|
||||
if (!win.isDestroyed()) win.destroy()
|
||||
void ses.clearStorageData()
|
||||
}
|
||||
}
|
||||
|
||||
/** Answers the daemon's request: makes the token and hands it over, or says why it could not. */
|
||||
export async function answerIntegrity(
|
||||
data: { requestId?: string; deviceId?: string; authToken?: string },
|
||||
request: (method: string, params: Record<string, unknown>) => Promise<unknown>
|
||||
): Promise<void> {
|
||||
if (!data.requestId || !data.deviceId) return
|
||||
try {
|
||||
// As the account when it names one: Twitch takes for an account's call only a token made as that account.
|
||||
const made = await mintIntegrity(data.deviceId, data.authToken ?? '')
|
||||
await request('provideTwitchIntegrity', { requestId: data.requestId, token: made.token, expiration: made.expiration })
|
||||
} catch (e) {
|
||||
await request('provideTwitchIntegrity', { requestId: data.requestId, error: (e as Error).message }).catch(() => {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// The window itself is Electron's; what is under test is the order of things around it.
|
||||
const login = vi.hoisted(() => ({ browserLogin: vi.fn() }))
|
||||
vi.mock('./browser-login', () => login)
|
||||
vi.mock('./log', () => ({ log: { info: () => undefined } }))
|
||||
|
||||
import { feedTwitchSignIn, twitchSignIn } from './twitch-signin'
|
||||
|
||||
describe('Twitch sign-in as one action', () => {
|
||||
beforeEach(() => {
|
||||
login.browserLogin.mockReset()
|
||||
})
|
||||
|
||||
it('hands the session to the daemon, takes the same window to the code, and finishes when the code is approved', async () => {
|
||||
const calls: string[] = []
|
||||
const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
|
||||
calls.push(method)
|
||||
if (method === 'startTwitchLogin') {
|
||||
// The code is heard in the time it takes the person to sign in.
|
||||
setTimeout(() => feedTwitchSignIn({ event: 'twitchLoginCode', data: { loginId: 'L1', verificationUri: 'https://www.twitch.tv/activate?device-code=ABCD' } }), 0)
|
||||
return { loginId: 'L1' }
|
||||
}
|
||||
if (method === 'setTwitchLoginWebToken') expect(params).toEqual({ loginId: 'L1', token: 'web-session' })
|
||||
return {}
|
||||
})
|
||||
const loaded: string[] = []
|
||||
login.browserLogin.mockImplementation(async (_service: string, options: { hold: (v: string, w: unknown) => Promise<void> }) => {
|
||||
const win = { loadURL: async (u: string) => void loaded.push(u), webContents: { isLoading: () => false, on: () => undefined } }
|
||||
const held = options.hold('web-session', win)
|
||||
// Approving it on the page.
|
||||
setTimeout(() => feedTwitchSignIn({ event: 'twitchLoginResult', data: { loginId: 'L1', success: true } }), 20)
|
||||
await held
|
||||
return { ok: true }
|
||||
})
|
||||
const outcome = await twitchSignIn(request, {})
|
||||
expect(outcome).toEqual({ ok: true })
|
||||
expect(calls).toEqual(['startTwitchLogin', 'setTwitchLoginWebToken'])
|
||||
expect(loaded).toEqual(['https://www.twitch.tv/activate?device-code=ABCD'])
|
||||
expect(request).toHaveBeenCalledWith('startTwitchLogin', {})
|
||||
})
|
||||
|
||||
it('ends the daemon\'s wait when the window is closed before the code was approved', async () => {
|
||||
const calls: string[] = []
|
||||
const request = vi.fn(async (method: string) => {
|
||||
calls.push(method)
|
||||
return method === 'startTwitchLogin' ? { loginId: 'L2' } : {}
|
||||
})
|
||||
login.browserLogin.mockResolvedValue({ ok: false, error: 'cancelled' })
|
||||
expect(await twitchSignIn(request, {})).toEqual({ ok: false, error: 'cancelled' })
|
||||
expect(calls).toEqual(['startTwitchLogin', 'cancelTwitchLogin'])
|
||||
})
|
||||
|
||||
it('does not run two at once, and says a refused sign-in', async () => {
|
||||
let release: (v: { ok: boolean }) => void = () => {}
|
||||
login.browserLogin.mockImplementation(() => new Promise((r) => (release = r)))
|
||||
const request = vi.fn(async () => ({ loginId: 'L3' }))
|
||||
const first = twitchSignIn(request, {})
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
expect(await twitchSignIn(request, {})).toEqual({ ok: false, error: 'A Twitch sign-in is already under way' })
|
||||
release({ ok: true })
|
||||
await first
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,189 @@
|
||||
import { browserLogin } from './browser-login'
|
||||
import { log } from './log'
|
||||
|
||||
/**
|
||||
* Signing in to Twitch: one action that gives the account both things it needs.
|
||||
*
|
||||
* Twitch has two sign-ins that cannot be had from each other. Its official API (chat, sending, follows,
|
||||
* moderation) takes a token made by approving a short code on twitch.tv; its player takes only the session
|
||||
* of someone signed in to twitch.tv itself. Asking a person to do those as two flows was asking them to sign
|
||||
* in twice. Here it is one window: the person signs in to twitch.tv (typed there, never seen by moho), and
|
||||
* the same window, with the session it now holds, is taken to the page that approves the code. Approving it
|
||||
* is Twitch's own consent screen - what moho will be allowed to do is listed there - and is left to the
|
||||
* person; it is the one click that is theirs to make.
|
||||
*
|
||||
* The session found on the way goes straight to the daemon, to be saved with the account the code becomes,
|
||||
* and is never returned to the page that asked.
|
||||
*/
|
||||
|
||||
type Request = (method: string, params: Record<string, unknown>) => Promise<unknown>
|
||||
|
||||
interface Waiting {
|
||||
loginId: string
|
||||
userCode: string
|
||||
gotCode: ((uri: string) => void) | null
|
||||
codePromise: Promise<string>
|
||||
result: ((r: { success: boolean; error?: string }) => void) | null
|
||||
resultPromise: Promise<{ success: boolean; error?: string }>
|
||||
/** The code was approved or the sign-in is over: the window may go where it likes. */
|
||||
finished: boolean
|
||||
}
|
||||
|
||||
let active: Waiting | null = null
|
||||
|
||||
/** Daemon events, offered here by main for the sign-in that is waiting on them. */
|
||||
export function feedTwitchSignIn(frame: { event: string; data?: Record<string, unknown> }): void {
|
||||
if (!active || !frame.event.startsWith('twitchLogin')) return
|
||||
const d = frame.data ?? {}
|
||||
// The first events can be heard before the daemon's answer naming the sign-in; nothing else is signing in.
|
||||
if (active.loginId && d.loginId !== active.loginId) return
|
||||
if (frame.event === 'twitchLoginCode' && typeof d.verificationUri === 'string') {
|
||||
if (typeof d.userCode === 'string') active.userCode = d.userCode
|
||||
active.gotCode?.(d.verificationUri)
|
||||
} else if (frame.event === 'twitchLoginResult') {
|
||||
active.result?.({ success: d.success === true, error: typeof d.error === 'string' ? d.error : undefined })
|
||||
}
|
||||
}
|
||||
|
||||
export interface SignInOutcome {
|
||||
ok: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function twitchSignIn(request: Request, options: { useTor?: boolean }): Promise<SignInOutcome> {
|
||||
if (active) return { ok: false, error: 'A Twitch sign-in is already under way' }
|
||||
let gotCode: (uri: string) => void = () => {}
|
||||
let result: (r: { success: boolean; error?: string }) => void = () => {}
|
||||
const w: Waiting = {
|
||||
loginId: '',
|
||||
userCode: '',
|
||||
gotCode: null,
|
||||
codePromise: new Promise<string>((r) => (gotCode = r)),
|
||||
result: null,
|
||||
finished: false,
|
||||
resultPromise: new Promise((r) => (result = r))
|
||||
}
|
||||
w.gotCode = gotCode
|
||||
w.result = result
|
||||
active = w
|
||||
try {
|
||||
const started = (await request('startTwitchLogin', {
|
||||
...(typeof options.useTor === 'boolean' ? { useTor: options.useTor } : {})
|
||||
})) as { loginId: string }
|
||||
w.loginId = started.loginId
|
||||
|
||||
const outcome = await browserLogin('twitch', {
|
||||
trace: (m) => log.info(`twitch sign-in: ${m}`),
|
||||
hold: async (token, win) => {
|
||||
// To the daemon, for the account the code is about to become. Never anywhere else.
|
||||
await request('setTwitchLoginWebToken', { loginId: w.loginId, token })
|
||||
let giveUp: NodeJS.Timeout | undefined
|
||||
const uri = await Promise.race([
|
||||
w.codePromise,
|
||||
new Promise<string>((_, no) => (giveUp = setTimeout(() => no(new Error('Twitch sent no code')), 30_000)))
|
||||
]).finally(() => clearTimeout(giveUp))
|
||||
// Signed in. The page is still on its way somewhere (twitch.tv sends a new login to its front page), so
|
||||
// that is let finish before the same window is taken to the page that approves the code - and brought
|
||||
// back to it if the site's own redirect lands after, which would leave the person on the front page.
|
||||
await settled(win)
|
||||
const go = (): void => {
|
||||
log.info('twitch sign-in: taking the window to the approval page')
|
||||
win.loadURL(uri).catch(() => undefined)
|
||||
}
|
||||
go()
|
||||
let tries = 0
|
||||
const wander = (_e: unknown, url: string): void => {
|
||||
let here = ''
|
||||
try {
|
||||
here = new URL(url).pathname
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!here.startsWith('/activate') && !w.finished && tries < 4) {
|
||||
tries++
|
||||
log.info(`twitch sign-in: the window went to ${here}; back to the approval page (${tries})`)
|
||||
setTimeout(go, 600)
|
||||
}
|
||||
}
|
||||
// Twitch's page does not take the code from the address, so it is typed into the page's field for the
|
||||
// person, and nothing more: pressing Activate and then Authorize on Twitch's own screens is theirs.
|
||||
const filled = (): void => {
|
||||
if (w.finished || !w.userCode || !win.webContents.getURL().includes('/activate')) return
|
||||
void win.webContents
|
||||
.executeJavaScript(fillCode(w.userCode), true)
|
||||
.then((how: unknown) => log.info(`twitch sign-in: the code field: ${String(how)}`))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
win.webContents.on('did-finish-load', filled)
|
||||
win.webContents.on('did-navigate', wander)
|
||||
win.webContents.on('did-navigate-in-page', wander)
|
||||
const done = await w.resultPromise
|
||||
w.finished = true
|
||||
if (!done.success) throw new Error(done.error || 'Twitch sign-in failed')
|
||||
}
|
||||
})
|
||||
if (!outcome.ok) {
|
||||
// The window was closed or timed out before the code was approved: nobody is waiting for it any more.
|
||||
await request('cancelTwitchLogin', { loginId: w.loginId }).catch(() => undefined)
|
||||
return { ok: false, error: outcome.error }
|
||||
}
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
if (w.loginId) await request('cancelTwitchLogin', { loginId: w.loginId }).catch(() => undefined)
|
||||
return { ok: false, error: (e as Error).message }
|
||||
} finally {
|
||||
active = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Waits for the window's page to finish loading and stay quiet for a moment. */
|
||||
async function settled(win: { webContents: { isLoading(): boolean } }): Promise<void> {
|
||||
const quiet = 1500
|
||||
let quietFor = 0
|
||||
for (let waited = 0; waited < 15_000 && quietFor < quiet; waited += 250) {
|
||||
await new Promise((r) => setTimeout(r, 250))
|
||||
quietFor = win.webContents.isLoading() ? 0 : quietFor + 250
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A script that finds the code field on Twitch's "Activate your device" page and types the code in, the way a
|
||||
* person would (so the page's own scripts see it). Waits for the page, which draws it after it loads; says what it
|
||||
* found. It presses the page's Activate button, which only submits the code, and nothing after it.
|
||||
*/
|
||||
export function fillCode(code: string): string {
|
||||
return `(async (code) => {
|
||||
const type = (el, v) => {
|
||||
const set = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value').set
|
||||
el.focus()
|
||||
set.call(el, v)
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
const shown = (el) => el.getClientRects().length > 0 && !el.disabled && !el.readOnly
|
||||
const deadline = Date.now() + 20000
|
||||
while (Date.now() < deadline) {
|
||||
const inputs = [...document.querySelectorAll('input')].filter((i) => ['text', 'tel', 'search', ''].includes(i.type) && shown(i))
|
||||
const boxes = inputs.filter((i) => i.maxLength === 1)
|
||||
if (boxes.length >= code.length) {
|
||||
code.split('').forEach((c, i) => type(boxes[i], c))
|
||||
return 'filled ' + code.length + ' boxes'
|
||||
}
|
||||
const named = inputs.find((i) => /code/i.test([i.name, i.id, i.placeholder, i.getAttribute('aria-label')].join(' ')))
|
||||
const field = named || (inputs.length === 1 ? inputs[0] : null)
|
||||
if (field) {
|
||||
if (field.value !== code) type(field, code)
|
||||
// Submitting the code is the page's own "Activate" button; approving what moho may do, next, is not.
|
||||
const go = field.form && [...field.form.querySelectorAll('button, input[type=submit]')].find((b) => /^activate$/i.test((b.innerText || b.value || '').trim()))
|
||||
if (go && !window.__mohoSubmitted) {
|
||||
window.__mohoSubmitted = true
|
||||
go.click()
|
||||
return 'filled one field and pressed Activate'
|
||||
}
|
||||
return 'filled one field'
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 400))
|
||||
}
|
||||
return 'no code field found'
|
||||
})(${JSON.stringify(code)})`
|
||||
}
|
||||
@@ -44,6 +44,11 @@ const api = {
|
||||
return ipcRenderer.invoke(IPC.browserLogin, service, accountId, options)
|
||||
},
|
||||
|
||||
/** Signs in to Twitch for chat and video at once, in one window. Resolves when it is done or given up. */
|
||||
twitchSignIn(options: { useTor?: boolean }): Promise<{ ok: boolean; error?: string }> {
|
||||
return ipcRenderer.invoke(IPC.twitchSignIn, options)
|
||||
},
|
||||
|
||||
onEvent(cb: (frame: NobilisEvent) => void): () => void {
|
||||
const handler = (_e: unknown, frame: NobilisEvent): void => cb(frame)
|
||||
ipcRenderer.on(IPC.event, handler)
|
||||
@@ -156,7 +161,7 @@ const api = {
|
||||
*/
|
||||
pathForFile: (file: File): string => webUtils.getPathForFile(file),
|
||||
|
||||
pickFile: (): Promise<string | null> => ipcRenderer.invoke(IPC.pickFile),
|
||||
pickFile: (extensions?: string[]): Promise<string | null> => ipcRenderer.invoke(IPC.pickFile, extensions),
|
||||
/**
|
||||
* Answers the main process when it needs a screen or window chosen.
|
||||
*
|
||||
|
||||
@@ -7,13 +7,15 @@ import { ForumPane } from './components/ForumPane'
|
||||
import { MessageList } from './components/MessageList'
|
||||
import { MentionsInbox } from './components/MentionsInbox'
|
||||
import { MentionsPage } from './components/MentionsPage'
|
||||
import { INVITE_PREFIX, MENTIONS_GROUP_ID } from './lib/groups'
|
||||
import { INVITATIONS_GROUP_ID, MENTIONS_GROUP_ID } from './lib/groups'
|
||||
import { trayCount } from './lib/traycount'
|
||||
import { Composer } from './components/Composer'
|
||||
import { MembershipGate } from './components/MembershipGate'
|
||||
import { NickList } from './components/NickList'
|
||||
import { ColumnResizer, useColumnWidth } from './components/ColumnResizer'
|
||||
import { ThreadPanel } from './components/ThreadPanel'
|
||||
import { InvitePanel } from './components/InvitePanel'
|
||||
import { SteamFriendDialogs } from './components/SteamFriendDialogs'
|
||||
import { VerificationDialog } from './components/VerificationDialog'
|
||||
import { ProfileCard } from './components/ProfileCard'
|
||||
import { ConversationTools } from './components/ConversationTools'
|
||||
@@ -34,6 +36,7 @@ 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 { useDmCategories } from './state/useDmCategories'
|
||||
import { bufferDisplayName, setHourFormat } from './lib/util'
|
||||
import type { BufferEntry } from './state/store'
|
||||
import { loadLocalEmotes, recoverMissingEmotes } from './lib/emotecache'
|
||||
@@ -53,6 +56,8 @@ export default function App(): JSX.Element {
|
||||
useEscapeLayer(() => store.setActivePanel(''), panelOpen)
|
||||
const store = useStore()
|
||||
const prefsReady = usePrefsReady()
|
||||
// Every account's direct messages under a heading of its own.
|
||||
useDmCategories()
|
||||
const [booted, setBooted] = useState(false)
|
||||
|
||||
const accounts = useChat((s) => s.accounts)
|
||||
@@ -81,6 +86,9 @@ export default function App(): JSX.Element {
|
||||
const [zoom] = usePref<number>('appearance.zoom', 1)
|
||||
const [density] = usePref<string>('appearance.density', 'cozy')
|
||||
const [reduceMotion] = usePref<boolean>('appearance.reduceMotion', false)
|
||||
// The two side columns, as they were last dragged.
|
||||
const [sidebarWidth, setSidebarWidth] = useColumnWidth('ui.sidebarWidth', 220)
|
||||
const [nicklistWidth, setNicklistWidth] = useColumnWidth('ui.nicklistWidth', 180)
|
||||
const [hourFormat] = usePref<string>('display.hourFormat', '24')
|
||||
setHourFormat(hourFormat)
|
||||
useEffect(() => window.moho.setZoom(zoom), [zoom])
|
||||
@@ -192,7 +200,7 @@ export default function App(): JSX.Element {
|
||||
: ''
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="app" style={{ '--sidebar-width': `${sidebarWidth}px`, '--nicklist-width': `${nicklistWidth}px` } as React.CSSProperties}>
|
||||
<TitleBar />
|
||||
|
||||
<div className="app-body">
|
||||
@@ -206,7 +214,7 @@ export default function App(): JSX.Element {
|
||||
<div className="sidebar">
|
||||
<BufferList />
|
||||
</div>
|
||||
<div className="divider-v" />
|
||||
<ColumnResizer width={sidebarWidth} onWidth={setSidebarWidth} min={160} max={520} grows="right" label="Channel list width" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -304,7 +312,7 @@ export default function App(): JSX.Element {
|
||||
|
||||
{!openThread && showNickList && (
|
||||
<>
|
||||
<div className="divider-v" />
|
||||
<ColumnResizer width={nicklistWidth} onWidth={setNicklistWidth} min={140} max={420} grows="left" label="Member list width" />
|
||||
<div className="nicklist-pane">
|
||||
<NickList />
|
||||
</div>
|
||||
@@ -320,6 +328,7 @@ export default function App(): JSX.Element {
|
||||
room you share - and a dialog only the account panel can show is a
|
||||
request nobody can answer. */}
|
||||
<VerificationDialog />
|
||||
<SteamFriendDialogs />
|
||||
|
||||
{/* Over everything: a call arrives while you are looking elsewhere. */}
|
||||
<IncomingCallPanel />
|
||||
@@ -410,7 +419,7 @@ function Body({
|
||||
if (activeGroupId === MENTIONS_GROUP_ID) return <MentionsPage />
|
||||
// An invitation stands where the conversation would, because it is the
|
||||
// conversation being offered.
|
||||
if (activeGroupId.startsWith(INVITE_PREFIX)) return <InvitePanel groupId={activeGroupId} />
|
||||
if (activeGroupId === INVITATIONS_GROUP_ID) return <InvitePanel />
|
||||
// A forum is a list of posts, not a log.
|
||||
if (hasBuffer && activeBuffer?.forum) return <ForumPane buffer={activeBuffer} />
|
||||
if (hasBuffer) return <MessageList />
|
||||
|
||||
+589
-73
@@ -71,6 +71,37 @@
|
||||
|
||||
/* --- shell ------------------------------------------------------------ */
|
||||
|
||||
/* A divider between columns that can be dragged. Its line stays one pixel; the part that takes the pointer is wider. */
|
||||
.divider-v.resizer {
|
||||
position: relative;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
z-index: var(--z-raised);
|
||||
}
|
||||
|
||||
.divider-v.resizer::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -4px;
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
.divider-v.resizer:hover,
|
||||
.divider-v.resizer.dragging,
|
||||
.divider-v.resizer:focus-visible {
|
||||
background: var(--primary);
|
||||
box-shadow: 0 0 0 1px var(--primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* While a column is being dragged: the cursor stays, and nothing underneath is selected. */
|
||||
body.resizing-column {
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.app-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -949,6 +980,7 @@
|
||||
* conversation and people type into a channel while talking in it.
|
||||
*/
|
||||
.call-stage {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: none;
|
||||
@@ -1115,6 +1147,35 @@ button.video-tile {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* The live captions of a stream, drawn by the video element itself. */
|
||||
.video-tile-picture::cue {
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
color: #fff;
|
||||
font-size: clamp(13px, 2.2vw, 20px);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* A list over the picture, in place of a menu in the window: a stream popped out is another document. */
|
||||
.stream-menu-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.context-menu.stream-menu {
|
||||
position: absolute;
|
||||
top: var(--spacing-s);
|
||||
right: var(--spacing-s);
|
||||
z-index: 3;
|
||||
max-height: calc(100% - 2 * var(--spacing-s));
|
||||
overflow-y: auto;
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
.stream-menu-title {
|
||||
padding: 2px var(--spacing-s) var(--spacing-xs);
|
||||
}
|
||||
|
||||
/* Nothing to look at: the name and a face, which is what a call without
|
||||
cameras actually is. */
|
||||
.video-tile-picture.audio-only {
|
||||
@@ -2546,6 +2607,45 @@ img.composer-emoji {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.nick-row.comfy {
|
||||
gap: var(--spacing-s);
|
||||
padding: 4px var(--spacing-s);
|
||||
}
|
||||
|
||||
/* The name over what they are doing, in one column that gives way to the
|
||||
badge at the right rather than pushing it off. */
|
||||
.nick-text,
|
||||
.buffer-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* A game, a track, or words they wrote: small, under the name, with a picture
|
||||
only where there is a kind of thing to show. */
|
||||
.activity-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.activity-line .icon {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Steam draws a sticker as a picture of its own, about the size it is in Steam's
|
||||
own chat. Qualified with the element because `img.custom-emoji` sizes every
|
||||
emoji to the line, and wins over a bare class. */
|
||||
img.custom-emoji.steam-sticker {
|
||||
height: 128px;
|
||||
width: auto;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.nick-prefix {
|
||||
color: var(--surface-variant-text);
|
||||
flex: none;
|
||||
@@ -2686,6 +2786,13 @@ img.composer-emoji {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-row > .grow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-self: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.field.port {
|
||||
flex: 0 0 80px;
|
||||
}
|
||||
@@ -5589,6 +5696,18 @@ video.lightbox-media {
|
||||
color: var(--surface-text);
|
||||
}
|
||||
|
||||
/* A conversation over the heading that will take it: the whole category is lit, heading and
|
||||
rows, since the heading is where it is going to be and not what it is going to be above. */
|
||||
.category.drop-into {
|
||||
border-radius: var(--corner-radius-small);
|
||||
background: color-mix(in srgb, var(--primary) 16%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 55%, transparent);
|
||||
}
|
||||
|
||||
.category.drop-into .category-head {
|
||||
color: var(--surface-text);
|
||||
}
|
||||
|
||||
/* A channel being carried. Faded like the heading, and for the same reason:
|
||||
the row stays in place so the list does not reflow under the cursor and
|
||||
move the heading being aimed at. */
|
||||
@@ -6864,7 +6983,12 @@ video.lightbox-media {
|
||||
}
|
||||
|
||||
.header-nameplate .main-header-title {
|
||||
flex: 1;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-nameplate .header-status {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -6993,67 +7117,6 @@ video.lightbox-media {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Capped and scrolling: a channel may offer more rewards than the window is
|
||||
tall, and a list that runs past the top edge is a list with no top. */
|
||||
.kick-rewards {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: min(60vh, 420px);
|
||||
border: 1px solid var(--outline-medium);
|
||||
border-radius: var(--corner-radius) var(--corner-radius) 0 0;
|
||||
background: var(--surface-container-high);
|
||||
box-shadow: var(--shadow-up);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kick-rewards-head {
|
||||
padding: 8px 10px 4px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.kick-rewards-list {
|
||||
overflow-y: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.kick-rewards-empty {
|
||||
padding: 6px 10px 10px;
|
||||
}
|
||||
|
||||
.kick-reward {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.kick-reward:hover:not(:disabled) {
|
||||
background: var(--surface-container-highest);
|
||||
}
|
||||
|
||||
/* Shown rather than hidden, because the list is also how somebody learns what
|
||||
a channel does - but plainly not pressable. */
|
||||
.kick-reward:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.kick-reward-cost {
|
||||
flex: none;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* --- Exporting a conversation ----------------------------------------- */
|
||||
|
||||
@@ -7667,6 +7730,35 @@ video.lightbox-media {
|
||||
|
||||
/* Label and value on one line, labels aligned, so the card is read down its
|
||||
left edge rather than hunted through. */
|
||||
.profile-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xxs);
|
||||
padding: var(--spacing-s);
|
||||
margin-bottom: var(--spacing-s);
|
||||
border-radius: var(--corner-radius-small);
|
||||
background: var(--surface-container-high);
|
||||
}
|
||||
|
||||
.profile-section-title {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: var(--font-size-xs, 11px);
|
||||
}
|
||||
|
||||
.profile-status-presence {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.profile-status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.profile-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -8452,14 +8544,30 @@ video.lightbox-media {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
bottom: -3px;
|
||||
z-index: 3;
|
||||
height: 6px;
|
||||
height: 10px;
|
||||
cursor: ns-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.stage-resize:hover {
|
||||
background: var(--overlay-control);
|
||||
/* A short bar in the middle of the edge, so it can be seen to be a handle. */
|
||||
.stage-resize::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 3px;
|
||||
width: 36px;
|
||||
height: 3px;
|
||||
margin-left: -18px;
|
||||
border-radius: 2px;
|
||||
background: var(--outline-strong);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.stage-resize:hover::after {
|
||||
opacity: 1;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
/* The line left above the conversation while the call is in its own window. */
|
||||
@@ -8715,6 +8823,12 @@ video.lightbox-media {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* A channel's own picture, as the server rail draws one. */
|
||||
.emoji-rail-item img.emoji-rail-avatar {
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.emoji-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -8827,19 +8941,49 @@ video.lightbox-media {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* The corner panel's resize grip, in its inside (top-left) corner. */
|
||||
/* The corner panel is resized from any edge or corner. The inside (top-left) corner keeps its visible grip. */
|
||||
.call-stage.pip .call-stage-resize {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
z-index: 2;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: nwse-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.call-stage.pip .call-stage-resize::after {
|
||||
.call-stage.pip .edge-t,
|
||||
.call-stage.pip .edge-b {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
height: 8px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.call-stage.pip .edge-t { top: -4px; }
|
||||
.call-stage.pip .edge-b { bottom: -4px; }
|
||||
|
||||
.call-stage.pip .edge-l,
|
||||
.call-stage.pip .edge-r {
|
||||
top: 12px;
|
||||
bottom: 12px;
|
||||
width: 8px;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.call-stage.pip .edge-l { left: -4px; }
|
||||
.call-stage.pip .edge-r { right: -4px; }
|
||||
|
||||
.call-stage.pip .edge-tl,
|
||||
.call-stage.pip .edge-tr,
|
||||
.call-stage.pip .edge-bl,
|
||||
.call-stage.pip .edge-br {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.call-stage.pip .edge-tl { top: -4px; left: -4px; cursor: nwse-resize; }
|
||||
.call-stage.pip .edge-br { bottom: -4px; right: -4px; cursor: nwse-resize; }
|
||||
.call-stage.pip .edge-tr { top: -4px; right: -4px; cursor: nesw-resize; }
|
||||
.call-stage.pip .edge-bl { bottom: -4px; left: -4px; cursor: nesw-resize; }
|
||||
|
||||
.call-stage.pip .edge-tl::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
@@ -9366,6 +9510,57 @@ video.lightbox-media {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* A warning the service put on the conversation, over it, in red, until it is
|
||||
dismissed. Louder than the interruption banner above: that is about the
|
||||
connection, and this is about who is on the other end. */
|
||||
.notice-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
padding: var(--spacing-s) var(--spacing-m);
|
||||
border-bottom: 1px solid var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 22%, var(--surface));
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.notice-banner .icon {
|
||||
flex: none;
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.notice-banner-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
.notice-banner-dismiss {
|
||||
flex: none;
|
||||
padding: var(--spacing-xs) var(--spacing-m);
|
||||
border-radius: var(--corner-radius-small);
|
||||
background: var(--danger);
|
||||
color: var(--on-accent);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.notice-banner-dismiss:hover {
|
||||
background: var(--danger-hover);
|
||||
}
|
||||
|
||||
/* The same warning, said in the log where it stays: red, so it is not scrolled
|
||||
past, and not the italic grey of an ordinary system line. */
|
||||
.message-row.warning {
|
||||
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||
box-shadow: inset 3px 0 0 var(--danger);
|
||||
}
|
||||
|
||||
.message-row.warning .message-body {
|
||||
color: var(--danger-text);
|
||||
font-style: normal;
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
/* A rail entry whose service has not answered yet - on start, or after an
|
||||
outage. The face dims and a spinner sits over it until it connects. */
|
||||
.rail-tile.connecting .rail-face {
|
||||
@@ -10708,3 +10903,324 @@ html[data-reduce-motion] *::after {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* The names somebody has had, on their card: newest first, each with when. */
|
||||
.profile-name-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-name-history li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-s);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* A Steam chat group's settings: tabs over a scrolling body, as a room's are. */
|
||||
.steam-group {
|
||||
width: 560px;
|
||||
max-width: calc(100vw - var(--spacing-xl));
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-s);
|
||||
padding: var(--spacing-m);
|
||||
border: 1px solid var(--outline-medium);
|
||||
border-radius: var(--corner-radius);
|
||||
background: var(--surface-container-high);
|
||||
box-shadow: var(--shadow-3);
|
||||
}
|
||||
|
||||
.steam-group-body {
|
||||
padding-top: var(--spacing-s);
|
||||
}
|
||||
|
||||
.steam-group-row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.steam-group-person {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
padding-bottom: var(--spacing-xs);
|
||||
border-bottom: 1px solid var(--outline-medium);
|
||||
}
|
||||
|
||||
.steam-group-perms {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-xs) 0 var(--spacing-xs) var(--spacing-m);
|
||||
}
|
||||
|
||||
.steam-group-x {
|
||||
margin-left: var(--spacing-xs);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* A message the service took down, kept so that what it said can still be read. */
|
||||
.message-row.removed .message-body,
|
||||
.message-row.removed .message-body * {
|
||||
color: var(--danger-text);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
/* The notice that stands where the words were: nothing to strike through. */
|
||||
.message-row.removed.replaced .message-body,
|
||||
.message-row.removed.replaced .message-body * {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
text-decoration: none;
|
||||
}
|
||||
/* The way into a thread from a reply, at the end of the line it quotes. */
|
||||
.reply-preview .reply-thread-open {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
padding: 0 4px;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
.reply-preview .reply-thread-open:hover {
|
||||
color: var(--surface-variant-text);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
/* A badge Kick sends as a picture, at the height of the line's small text. */
|
||||
.sender-badge-image {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: var(--spacing-xxs);
|
||||
object-fit: contain;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Over the picture while the stream is playing one of Twitch's ads: said, because a picture that
|
||||
changes to something else for half a minute with nothing to say why looks like a fault. */
|
||||
.stream-ad {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: var(--on-accent);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.stream-volume {
|
||||
width: 72px;
|
||||
accent-color: var(--primary, currentColor);
|
||||
}
|
||||
|
||||
/* The number beside a cheermote, in the colour Twitch draws it. */
|
||||
.cheer-amount {
|
||||
margin: 0 4px 0 2px;
|
||||
font-weight: var(--weight-bold);
|
||||
color: var(--mention-edge);
|
||||
}
|
||||
|
||||
/* The code Twitch's "Activate your device" page asks for, shown in case the page's field has to be filled by hand. */
|
||||
.twitch-code {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--surface-variant-text);
|
||||
border-radius: var(--radius-xs);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.24em;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
/* The corner mark on a conversation with a person where the service's channels have pictures too (Twitch's
|
||||
whispers): it says "direct message" where there is no presence dot to say it. */
|
||||
.avatar-badge {
|
||||
position: absolute;
|
||||
right: -3px;
|
||||
bottom: -3px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: var(--on-primary, #fff);
|
||||
border: 2px solid var(--ring, var(--surface-container));
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.header-face .avatar-badge,
|
||||
.header-nameplate .avatar-badge {
|
||||
border-color: var(--surface);
|
||||
}
|
||||
|
||||
/* A notification the service wrote (a join, a topic, a notice): the same card the events are, quieter - a
|
||||
neutral bar and less room - so a long run of them does not outweigh the conversation. */
|
||||
.kick-event.notification {
|
||||
--kick-accent: var(--outline-strong);
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
padding-top: var(--spacing-xs);
|
||||
padding-bottom: var(--spacing-xs);
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
|
||||
.kick-event.notification .kick-event-line {
|
||||
color: var(--text-muted, var(--surface-variant-text));
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.kick-event.notification.warning {
|
||||
--kick-accent: var(--danger);
|
||||
}
|
||||
|
||||
.kick-event.notification.warning .kick-event-line {
|
||||
color: var(--danger);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* A moderation card that is not a penalty - a room's mode, someone let back, a shoutout - takes the accent of
|
||||
the window rather than the red of a ban. */
|
||||
.kick-event.moderation.soft {
|
||||
--kick-accent: var(--primary);
|
||||
}
|
||||
|
||||
/* Sneedchat's greentext: a line that starts with ">" in the green 4chan has always used for it. */
|
||||
.greentext {
|
||||
color: #789922;
|
||||
}
|
||||
|
||||
/* The rewards panel: the quick switcher's card, with a row per reward and what it costs at the right. */
|
||||
.rewards .switcher-input {
|
||||
font-size: var(--font-size-medium);
|
||||
}
|
||||
|
||||
.rewards-balance {
|
||||
flex: none;
|
||||
color: var(--surface-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reward-item.armed {
|
||||
background: var(--surface-container);
|
||||
border-radius: var(--corner-radius-small);
|
||||
}
|
||||
|
||||
.reward-row.locked {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.reward-image {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.reward-cost {
|
||||
flex: none;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-container-highest, var(--surface-container));
|
||||
color: var(--surface-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.reward-confirm {
|
||||
display: flex;
|
||||
gap: var(--spacing-s);
|
||||
align-items: center;
|
||||
padding: var(--spacing-xs) var(--spacing-s) var(--spacing-s) calc(22px + var(--spacing-s) * 2);
|
||||
}
|
||||
|
||||
.reward-confirm .text-field {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The two groups of the rewards panel, each under its own small heading. */
|
||||
.reward-group {
|
||||
padding: var(--spacing-s) var(--spacing-s) 2px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* The emotes a reward offers, as pictures to press: a scrolling grid under a search, inside the armed reward. */
|
||||
.reward-confirm {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.reward-chooser {
|
||||
flex: 1 1 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.reward-chooser-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
|
||||
.reward-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(44px, 1fr));
|
||||
gap: 4px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.reward-emote {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-height: 44px;
|
||||
padding: 4px;
|
||||
border-radius: var(--corner-radius-small);
|
||||
background: transparent;
|
||||
color: var(--surface-text);
|
||||
}
|
||||
|
||||
.reward-emote:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.reward-emote.selected {
|
||||
background: var(--primary-hover);
|
||||
box-shadow: inset 0 0 0 2px var(--primary);
|
||||
}
|
||||
|
||||
.reward-emote img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.reward-emote-caption {
|
||||
font-size: var(--font-size-xxs);
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reward-picked {
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<!-- Steam's mark, drawn as a plain silhouette: a disc, the small gear on its
|
||||
arm, and the cut-out ring for the larger one. -->
|
||||
<path fill="#fff" fill-rule="evenodd" d="M12 1.5A10.5 10.5 0 0 0 1.55 11.1l5.6 2.3a3 3 0 0 1 1.7-.5h.2l2.5-3.6v-.05a4 4 0 1 1 4 4h-.1l-3.55 2.55a3 3 0 0 1-5.95.5L1.8 14.9A10.5 10.5 0 1 0 12 1.5zm-4 17.1-1.3-.55a2.25 2.25 0 1 0 1.25-3.1l1.35.55a1.65 1.65 0 1 1-1.3 3.1zm7.05-6.2a2.65 2.65 0 1 0-2.65-2.65 2.65 2.65 0 0 0 2.65 2.65zm0-.95a1.7 1.7 0 1 1 1.7-1.7 1.7 1.7 0 0 1-1.7 1.7z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 615 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<!-- Twitch's mark as a plain silhouette: the speech bubble with its two bars. -->
|
||||
<path fill="#fff" d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0 1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143-3.428 3.428h-3.43l-3 3v-3H6.857V1.714h13.714z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 383 B |
@@ -1,8 +1,10 @@
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { SwitchRow } from './Switch'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Icon, IconButton, ServiceMark } from './Icon'
|
||||
import { MatrixAccountTools } from './MatrixAccountTools'
|
||||
import { SteamAccountTools } from './SteamAccountTools'
|
||||
import { TwitchSignIn } from './TwitchSignIn'
|
||||
import { IgnoreList } from './IgnoreList'
|
||||
import { TunnelAllSwitch } from './settings/Tunnel'
|
||||
import { useChat, useConnectionDetail, useMapPref, usePref, useStore } from '../state/hooks'
|
||||
@@ -23,7 +25,7 @@ import type { Account } from '../../../shared/wire'
|
||||
* that gains a backend appears here as soon as it gains a form, and one the
|
||||
* daemon stops offering disappears without this file being touched.
|
||||
*/
|
||||
const FORMS = ['irc', 'discord', 'sneedchat', 'matrix', 'kick'] as const
|
||||
const FORMS = ['irc', 'discord', 'sneedchat', 'matrix', 'kick', 'steam', 'twitch'] as const
|
||||
type AddableService = (typeof FORMS)[number]
|
||||
|
||||
/** What `listProtocols` answers with. */
|
||||
@@ -134,6 +136,8 @@ export function AccountsPanel(): JSX.Element {
|
||||
{adding === 'sneedchat' && <SneedChatForm />}
|
||||
{adding === 'matrix' && <MatrixForm />}
|
||||
{adding === 'kick' && <KickForm onDone={() => setAdding(null)} />}
|
||||
{adding === 'steam' && <SteamForm onDone={() => setAdding(null)} />}
|
||||
{adding === 'twitch' && <TwitchForm onDone={() => setAdding(null)} />}
|
||||
{adding && (
|
||||
<button type="button" className="button subtle small" onClick={() => setAdding(null)}>
|
||||
Cancel
|
||||
@@ -456,6 +460,9 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
|
||||
|
||||
{account.service === 'matrix' && <MatrixAccountTools account={account} />}
|
||||
|
||||
{account.service === 'steam' && <SteamAccountTools account={account} />}
|
||||
{account.service === 'twitch' && <TwitchAccountTools account={account} />}
|
||||
|
||||
{account.service === 'sneedchat' && (
|
||||
<>
|
||||
<RouteLevel account={account} onChange={(level) => call('setAccountRoute', { accountId: account.id, level })} />
|
||||
@@ -1192,6 +1199,402 @@ function KickForm({ onDone }: { onDone: () => void }): JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
/** What Steam can ask for to let a sign-in through; any one of them will do. */
|
||||
interface SteamOption {
|
||||
kind: 'deviceCode' | 'emailCode' | 'approval' | 'emailApproval'
|
||||
hint?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Signing in to Steam.
|
||||
*
|
||||
* A conversation rather than a request, because Steam may want something from
|
||||
* the person's phone or inbox: the daemon answers with a login id and the rest
|
||||
* arrives as events (see nobilis backend/steam/login.rs). So the form has
|
||||
* three shapes - the credentials, then whatever Steam is waiting for, then
|
||||
* nothing, because the account exists and the form closes itself.
|
||||
*
|
||||
* The password is typed here, goes to the daemon once, and is forgotten when
|
||||
* this form is: the account keeps the token Steam hands back, never the
|
||||
* password.
|
||||
*/
|
||||
function SteamForm({ onDone }: { onDone: () => void }): JSX.Element {
|
||||
const store = useStore()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [useTor, setUseTor] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [loginId, setLoginId] = useState('')
|
||||
const [detail, setDetail] = useState('')
|
||||
const [needs, setNeeds] = useState<{ options: SteamOption[]; error?: string } | null>(null)
|
||||
const [code, setCode] = useState('')
|
||||
// The picture of the code a phone scans, while a sign-in by scanning waits.
|
||||
const [qr, setQr] = useState<{ path: string; at: number } | null>(null)
|
||||
|
||||
// The id lives in a ref as well as state, because the first event can be
|
||||
// handled before the render that follows the answer to the request that
|
||||
// started the sign-in. Nothing else is signing in from this form, so an
|
||||
// event arriving before the id is known can only be this one's.
|
||||
const current = useRef('')
|
||||
const setId = (id: string): void => {
|
||||
current.current = id
|
||||
setLoginId(id)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const off = window.moho.onEvent((e) => {
|
||||
const d = e.data as {
|
||||
loginId?: string
|
||||
detail?: string
|
||||
options?: SteamOption[]
|
||||
error?: string
|
||||
success?: boolean
|
||||
path?: string
|
||||
}
|
||||
if (!e.event.startsWith('steamLogin')) return
|
||||
if (current.current && d.loginId !== current.current) return
|
||||
if (e.event === 'steamLoginQr' && d.path) {
|
||||
// The same file is written again when the code is replaced, so the
|
||||
// moment is part of what is drawn.
|
||||
setQr({ path: d.path, at: Date.now() })
|
||||
} else if (e.event === 'steamLoginStatus') {
|
||||
setDetail(d.detail || '')
|
||||
} else if (e.event === 'steamLoginNeeds') {
|
||||
setNeeds({ options: d.options ?? [], error: d.error })
|
||||
setCode('')
|
||||
} else if (e.event === 'steamLoginResult') {
|
||||
setNeeds(null)
|
||||
setQr(null)
|
||||
setId('')
|
||||
setBusy(false)
|
||||
setDetail('')
|
||||
if (d.success) {
|
||||
setPassword('')
|
||||
void store.refreshAccounts()
|
||||
onDone()
|
||||
} else {
|
||||
store.toast('error', d.error || 'Steam sign-in failed')
|
||||
}
|
||||
}
|
||||
})
|
||||
return off
|
||||
}, [onDone, store])
|
||||
|
||||
// Closing the form on a sign-in that is still waiting tells the daemon
|
||||
// nobody is, so it stops asking Steam and frees what it was holding.
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (current.current) void window.moho.rpc('cancelSteamLogin', { loginId: current.current }).catch(() => {})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const begin = (): void => {
|
||||
setBusy(true)
|
||||
setDetail('')
|
||||
setNeeds(null)
|
||||
current.current = ''
|
||||
void window.moho
|
||||
.rpc<{ loginId: string }>('addSteamAccount', { username, password, useTor })
|
||||
.then((r) => setId(r.loginId))
|
||||
.catch((e: Error) => {
|
||||
setBusy(false)
|
||||
store.toast('error', e.message)
|
||||
})
|
||||
}
|
||||
|
||||
// Signing in by scanning a code with the Steam app: nothing is typed.
|
||||
const beginQr = (): void => {
|
||||
setBusy(true)
|
||||
setDetail('')
|
||||
setNeeds(null)
|
||||
setQr(null)
|
||||
current.current = ''
|
||||
void window.moho
|
||||
.rpc<{ loginId: string }>('addSteamAccountQr', { useTor })
|
||||
.then((r) => setId(r.loginId))
|
||||
.catch((e: Error) => {
|
||||
setBusy(false)
|
||||
store.toast('error', e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const send = (): void => {
|
||||
const typed = code.trim()
|
||||
if (!typed || !loginId) return
|
||||
void window.moho
|
||||
.rpc('submitSteamGuardCode', { loginId, code: typed })
|
||||
.then(() => setDetail('checking the code'))
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
|
||||
const cancel = (): void => {
|
||||
if (current.current) void window.moho.rpc('cancelSteamLogin', { loginId: current.current }).catch(() => {})
|
||||
setId('')
|
||||
setQr(null)
|
||||
setNeeds(null)
|
||||
setDetail('')
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
if (qr) {
|
||||
return (
|
||||
<div className="add-form">
|
||||
<p>Scan this with the Steam app on your phone.</p>
|
||||
<p className="small muted">
|
||||
In the app, open Steam Guard and choose the scan option (the QR icon), point the phone
|
||||
at the code, and approve the sign-in. moho keeps only the token Steam hands back.
|
||||
</p>
|
||||
<img className="qr-image" src={resolveMediaUrl(`file://${qr.path}#${qr.at}`)} alt="Steam sign-in QR code" />
|
||||
{detail && <p className="small muted">{detail}…</p>}
|
||||
<div className="field-row">
|
||||
<button type="button" className="button" onClick={cancel}>
|
||||
Cancel sign-in
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const takesCode = !!needs?.options.some((o) => o.kind === 'deviceCode' || o.kind === 'emailCode')
|
||||
const approvals = needs?.options.filter((o) => o.kind === 'approval' || o.kind === 'emailApproval') ?? []
|
||||
|
||||
if (needs) {
|
||||
return (
|
||||
<div className="add-form">
|
||||
<p>Steam wants to check that this is you.</p>
|
||||
{approvals.map((o) => (
|
||||
<p key={o.kind} className="small muted">
|
||||
{o.kind === 'approval'
|
||||
? `Approve the sign-in in the Steam app on your phone${o.hint ? ` (${o.hint})` : ''}.`
|
||||
: `Approve the sign-in from the email Steam sent${o.hint ? ` to ${o.hint}` : ''}.`}
|
||||
</p>
|
||||
))}
|
||||
{takesCode && (
|
||||
<label className="field">
|
||||
<span className="small muted">
|
||||
{needs.options.some((o) => o.kind === 'deviceCode')
|
||||
? 'Code from your Steam Guard authenticator'
|
||||
: 'Code Steam emailed you'}
|
||||
</span>
|
||||
<input
|
||||
className="text-field"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') send()
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{needs.error && (
|
||||
<div className="warning-note">
|
||||
<Icon name="warning" size={16} />
|
||||
<span>{needs.error}</span>
|
||||
</div>
|
||||
)}
|
||||
{detail && <p className="small muted">{detail}…</p>}
|
||||
<div className="field-row">
|
||||
{takesCode && (
|
||||
<button type="button" className="button primary" disabled={!code.trim()} onClick={send}>
|
||||
Submit code
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="button" onClick={cancel}>
|
||||
Cancel sign-in
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="add-form">
|
||||
<p className="small muted">
|
||||
Sign in with your Steam account name — the one you type at the Steam login, not the
|
||||
display name your friends see. moho signs in as the Steam mobile app's chat, so it runs
|
||||
beside the Steam launcher without signing it out. Your password goes to Steam and is not
|
||||
kept; moho keeps only the token Steam hands back.
|
||||
</p>
|
||||
<TorSwitch service="steam" on={useTor} onChange={setUseTor} />
|
||||
<div className="field-row">
|
||||
<label className="field">
|
||||
<span className="small muted">Account name</span>
|
||||
<input
|
||||
className="text-field"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={username}
|
||||
disabled={busy}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="small muted">Password</span>
|
||||
<input
|
||||
className="text-field"
|
||||
type="password"
|
||||
value={password}
|
||||
disabled={busy}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && username.trim() && password && !busy) begin()
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<button
|
||||
type="button"
|
||||
className="button primary"
|
||||
disabled={busy || !username.trim() || !password}
|
||||
onClick={begin}
|
||||
>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button subtle"
|
||||
disabled={busy}
|
||||
title="Sign in by scanning a code with the Steam app on your phone, with no password typed here"
|
||||
onClick={beginQr}
|
||||
>
|
||||
Sign in with a QR code
|
||||
</button>
|
||||
{busy && (
|
||||
<button type="button" className="button" onClick={cancel}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{busy && detail && <p className="small muted">{detail}…</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adding a Twitch account: sign in, or only read.
|
||||
*
|
||||
* Twitch's chat is public, so an account that never signs in still reads every
|
||||
* channel it is pointed at - the same choice Kick's form offers. Signing in is
|
||||
* what buys sending, the channels you follow and the emotes you can use.
|
||||
*/
|
||||
function TwitchForm({ onDone }: { onDone: () => void }): JSX.Element {
|
||||
const store = useStore()
|
||||
const [useTor, setUseTor] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const readOnly = (): void => {
|
||||
setBusy(true)
|
||||
void window.moho
|
||||
.rpc('addTwitchAccount', { useTor })
|
||||
.then(() => {
|
||||
void store.refreshAccounts()
|
||||
onDone()
|
||||
})
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
.finally(() => setBusy(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="add-form">
|
||||
<TorSwitch service="twitch" on={useTor} onChange={setUseTor} />
|
||||
<TwitchSignIn useTor={useTor} onDone={onDone}>
|
||||
<button
|
||||
type="button"
|
||||
className="button subtle"
|
||||
disabled={busy}
|
||||
title="Read channels without an account. Twitch's chat is public; sending needs a sign-in."
|
||||
onClick={readOnly}
|
||||
>
|
||||
Read without signing in
|
||||
</button>
|
||||
</TwitchSignIn>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What a Twitch account can be asked to do from its own card.
|
||||
*
|
||||
* Signing in (for one that only reads, or whose sign-in Twitch has stopped accepting; the new sign-in is a
|
||||
* Twitch account of its own under the login that approved it, so an anonymous reader is left where it
|
||||
* was), opening the channels the account follows.
|
||||
*
|
||||
* Video needs the website's own session, because Twitch's player takes only a token its own website made: the one
|
||||
* moho gets for chat and the official API is refused there. It is not a second sign-in - one window signs in on
|
||||
* twitch.tv and approves the code, and the session it saw on the way is kept with the account (see TwitchSignIn).
|
||||
* It is the person's own website session, and says so - what it can do is what being signed in there can do - and
|
||||
* it is what makes their Turbo or their subscriptions count, so ads they are not meant to see are not shown.
|
||||
*/
|
||||
function TwitchAccountTools({ account }: { account: Account }): JSX.Element {
|
||||
const store = useStore()
|
||||
const refused = account.state === 'auth_failed'
|
||||
const [busy, setBusy] = useState('')
|
||||
|
||||
const run = (what: string, work: () => Promise<void>): void => {
|
||||
setBusy(what)
|
||||
void work()
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
.finally(() => setBusy(''))
|
||||
}
|
||||
|
||||
const follows = (liveOnly: boolean): void =>
|
||||
run('follows', async () => {
|
||||
const r = await window.moho.rpc<{ count: number }>('syncTwitchFollows', { accountId: account.id, liveOnly })
|
||||
store.toast('info', r.count === 0 ? 'You follow no channels that are on air right now' : `Opening ${r.count} channel${r.count === 1 ? '' : 's'}`)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="account-tools">
|
||||
{(!account.hasPassword || refused) && (
|
||||
<>
|
||||
<p className="small muted">
|
||||
{refused
|
||||
? 'Twitch no longer accepts this sign-in. Signing in again keeps its channels and settings.'
|
||||
: 'This account only reads. Sign in to send messages and to see the channels you follow.'}
|
||||
</p>
|
||||
<TwitchSignIn useTor={account.useTor} onDone={() => undefined} label={refused ? 'Sign in again' : 'Sign in'} />
|
||||
</>
|
||||
)}
|
||||
{account.hasPassword && !refused && (
|
||||
<div className="field-row">
|
||||
<button type="button" className="button subtle" disabled={busy !== ''} onClick={() => follows(true)} title="Open the channels you follow that are streaming now">
|
||||
Open followed channels that are live
|
||||
</button>
|
||||
<button type="button" className="button subtle" disabled={busy !== ''} onClick={() => follows(false)} title="Open everyone you follow, up to 200">
|
||||
Open everyone I follow
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{account.hasPassword && !refused && (!account.moderation || !account.webSession) && (
|
||||
<>
|
||||
<p className="small muted">
|
||||
This sign-in was made before {account.moderation ? 'video' : account.webSession ? 'moderation, whispers, shoutouts and blocking' : 'video, moderation, whispers, shoutouts and blocking'}{' '}
|
||||
{account.moderation || account.webSession ? 'was' : 'were'} part of signing in. Signing in again adds {account.moderation || account.webSession ? 'it' : 'them'}, in the same one
|
||||
window; nothing else changes.
|
||||
</p>
|
||||
<TwitchSignIn useTor={account.useTor} onDone={() => undefined} label="Sign in again" />
|
||||
</>
|
||||
)}
|
||||
<p className="small muted">
|
||||
{account.webSession
|
||||
? 'Video plays as you: signed in on twitch.tv, so your Turbo or your subscriptions count and their ads are not shown.'
|
||||
: 'Video plays without a twitch.tv sign-in, so Twitch puts ads in streams. Signing in again signs in on twitch.tv too, which lets your Turbo or your subscriptions count.'}
|
||||
</p>
|
||||
{account.webSession && (
|
||||
<p className="small muted">
|
||||
It is your twitch.tv login session, so what it can do is what being signed in there can do. Removing this account
|
||||
forgets it here; to end it on Twitch, use Sign out everywhere in twitch.tv's security settings.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an account's connections go through Tor or a SOCKS5 proxy.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Icon } from './Icon'
|
||||
import { activityIcon } from '../lib/activity'
|
||||
import type { Activity } from '../../../shared/wire'
|
||||
|
||||
/**
|
||||
* What somebody is doing, as the small line under their name: a gamepad and a
|
||||
* game, a note and a track, or - with no picture at all - words they wrote
|
||||
* themselves, which is how Discord draws a custom status.
|
||||
*/
|
||||
export function ActivityLine({ activity }: { activity: Activity }): JSX.Element {
|
||||
const icon = activityIcon(activity)
|
||||
return (
|
||||
<span className="activity-line small muted">
|
||||
{icon && <Icon name={icon} size={13} />}
|
||||
<span className="ellipsis">{activity.text}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { nickColor } from '../lib/util'
|
||||
import { presenceClass, presenceLabel } from '../lib/presence'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
/**
|
||||
* Somebody's face, with a fallback for the many people who have no picture.
|
||||
@@ -21,12 +22,19 @@ export function Avatar({
|
||||
url,
|
||||
size = 24,
|
||||
status,
|
||||
badge,
|
||||
accountId
|
||||
}: {
|
||||
name: string
|
||||
url?: string
|
||||
size?: number
|
||||
status?: string
|
||||
/**
|
||||
* A glyph in the corner that says what kind of thing this is, for a face that would otherwise look like
|
||||
* a channel's: a conversation with a person on a service whose channels have pictures too, and which
|
||||
* has no presence dot to tell them apart by.
|
||||
*/
|
||||
badge?: string
|
||||
/** Whose picture: a strictly routed account's is fetched through its route
|
||||
* (#257). Taken from the surrounding conversation when not given. */
|
||||
accountId?: string
|
||||
@@ -60,6 +68,11 @@ export function Avatar({
|
||||
{initial(name)}
|
||||
</span>
|
||||
)}
|
||||
{badge && !status && (
|
||||
<span className="avatar-badge" title="Direct message" aria-label="Direct message">
|
||||
<Icon name={badge} size={Math.max(8, Math.round(size * 0.4))} />
|
||||
</span>
|
||||
)}
|
||||
{status && (
|
||||
<span className={`presence-dot ${presenceClass(status)}`} title={presenceLabel(status)} />
|
||||
)}
|
||||
|
||||
@@ -6,16 +6,21 @@ import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu'
|
||||
import { bufferMenuEntries } from '../lib/buffermenu'
|
||||
import { UserFooter } from './UserFooter'
|
||||
import { VoiceChannels } from './VoiceChannels'
|
||||
import { SteamVoiceRooms } from './SteamVoiceRooms'
|
||||
import { VoicePanel } from './VoicePanel'
|
||||
import { presenceClass, presenceLabel } from '../lib/presence'
|
||||
import { useChat, useIdSetPref, useMapPref, usePref, useStore } from '../state/hooks'
|
||||
import { activityLabel, dmActivity } from '../lib/activity'
|
||||
import { ActivityLine } from './ActivityLine'
|
||||
import { steamCategoryId, parseSteamCategory } from '../lib/steamgroups'
|
||||
import { NamePrompt } from './NamePrompt'
|
||||
import { useChat, useComfy, useIdSetPref, useInvitations, useMapPref, usePref, useStore } from '../state/hooks'
|
||||
import {
|
||||
compareInBand,
|
||||
dmGroup,
|
||||
DM_GROUP_ID,
|
||||
isDirectMessage,
|
||||
isMutedBuffer,
|
||||
INVITE_PREFIX,
|
||||
INVITATIONS_GROUP_ID,
|
||||
MENTIONS_GROUP_ID,
|
||||
PINNED_GROUP_ID,
|
||||
pinnedGroup,
|
||||
@@ -34,6 +39,7 @@ import {
|
||||
} from '../lib/util'
|
||||
import type { Account, Member } from '../../../shared/wire'
|
||||
import { Avatar } from './Avatar'
|
||||
import { currentInvitation } from '../lib/invitations'
|
||||
import {
|
||||
categoryKey,
|
||||
reorderCategories,
|
||||
@@ -72,6 +78,7 @@ export function BufferList(): JSX.Element {
|
||||
const voiceSessions = useChat((s) => s.voiceSessions)
|
||||
const presence = useChat((s) => s.presenceByBuffer)
|
||||
const popouts = useChat((s) => s.popouts)
|
||||
const activeInvitationKey = useChat((s) => s.activeInvitationKey)
|
||||
|
||||
const [pinned, togglePin, isPinned] = useIdSetPref('pinnedBuffers')
|
||||
const [muted, toggleMute] = useIdSetPref('mutedBuffers')
|
||||
@@ -129,12 +136,15 @@ export function BufferList(): JSX.Element {
|
||||
return isDirectMessage(open) ? DM_GROUP_ID : open.groupId
|
||||
}, [buffers, activeBufferId])
|
||||
|
||||
// An invitation is selected the same way the mentions page is: a rail entry
|
||||
// with no group behind it. Without this the lookup below fails to find it,
|
||||
// falls back to some real group, and the effect writes that back - so
|
||||
// choosing an invitation landed on whichever server happened to be first,
|
||||
// which is exactly what it looked like.
|
||||
const onInvite = activeGroupId.startsWith(INVITE_PREFIX)
|
||||
// The invitations page is selected the same way the mentions page is: a rail
|
||||
// entry with no group behind it. Without this the lookup below fails to find
|
||||
// it, falls back to some real group, and the effect writes that back - so
|
||||
// choosing it landed on whichever server happened to be first. Only while
|
||||
// something is waiting: with nothing left the page has nothing to say, and
|
||||
// falling back is the way off it.
|
||||
const invitations = useInvitations()
|
||||
const onInvite = activeGroupId === INVITATIONS_GROUP_ID && invitations.length > 0
|
||||
const shownInvitation = currentInvitation(invitations, activeInvitationKey)
|
||||
|
||||
const activeGroup =
|
||||
onMentionsPage || onInvite
|
||||
@@ -158,6 +168,8 @@ export function BufferList(): JSX.Element {
|
||||
|
||||
const isPinnedPage = activeGroup?.id === PINNED_GROUP_ID
|
||||
const isDmPage = activeGroup?.id === DM_GROUP_ID
|
||||
// A Steam account's own page: its friends sit under the folders Steam keeps for the account.
|
||||
const steamPage = activeGroup?.kind === 'account' && accounts.find((a) => a.id === activeGroup.accountId)?.service === 'steam'
|
||||
/**
|
||||
* Whose identity the plaque shows.
|
||||
*
|
||||
@@ -178,6 +190,7 @@ export function BufferList(): JSX.Element {
|
||||
onMentionsPage && voiceSessions.length === 0
|
||||
? undefined
|
||||
: (accounts.find((a) => a.id === activeGroup?.accountId) ??
|
||||
(onInvite ? accounts.find((a) => a.id === shownInvitation?.accountId) : undefined) ??
|
||||
accounts.find((a) => a.id === activeBuffer?.accountId) ??
|
||||
accounts[0])
|
||||
|
||||
@@ -231,10 +244,48 @@ export function BufferList(): JSX.Element {
|
||||
* since that is the order the server arranged them in and the reason it
|
||||
* supplies one. The gathered pages keep the order they were sorted into.
|
||||
*/
|
||||
/**
|
||||
* The headings this page draws under its own, and where each conversation is filed.
|
||||
*
|
||||
* A Steam account's own page has Steam's folders for the account, as its own client lists them
|
||||
* (without the "Steam - " some carry), and each friend is under the one Steam has them in:
|
||||
* what Steam says wins over anything filed here, and a change made here is made on Steam. Every
|
||||
* other page has the headings somebody made.
|
||||
*/
|
||||
const [naming, setNaming] = useState<{ id: string; name: string } | null>(null)
|
||||
const [makingFolder, setMakingFolder] = useState(false)
|
||||
const steamGroups = useChat((s) => s.steamGroups)
|
||||
const steamFolders = useMemo<CustomCategory[]>(
|
||||
() =>
|
||||
steamPage && activeGroup
|
||||
? (steamGroups[activeGroup.accountId] ?? [])
|
||||
.map((g) => ({ id: steamCategoryId(activeGroup.accountId, g.id), name: g.name.replace(/^Steam - /, '').trim() }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
: [],
|
||||
[steamPage, activeGroup, steamGroups]
|
||||
)
|
||||
const myCategories = steamPage ? steamFolders : activeGroup ? (customCats[activeGroup.id] ?? []) : []
|
||||
const viewAssignment = useMemo(() => {
|
||||
if (!steamPage || !activeGroup) return assignment
|
||||
const folders = [...(steamGroups[activeGroup.accountId] ?? [])].sort((a, b) => a.id - b.id)
|
||||
const next = { ...assignment }
|
||||
for (const b of groupBuffers) {
|
||||
if (b.kind !== 'dm' || !b.remoteId) continue
|
||||
const home = folders.find((g) => g.members.includes(b.remoteId!))
|
||||
if (home) next[b.id] = steamCategoryId(activeGroup.accountId, home.id)
|
||||
else delete next[b.id]
|
||||
}
|
||||
return next
|
||||
}, [steamPage, activeGroup, steamGroups, assignment, groupBuffers])
|
||||
/** Files a conversation under a heading: on Steam for a Steam page, in the preferences for the rest. */
|
||||
const fileBuffer = (bufferId: string, categoryId: string): void => {
|
||||
if (steamPage) store.moveSteamFriend(bufferId, parseSteamCategory(categoryId)?.groupId ?? null)
|
||||
else setAssignment(bufferId, categoryId)
|
||||
}
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
if (!activeGroup) return null
|
||||
const mine = customCats[activeGroup.id] ?? []
|
||||
const list = sections(groupBuffers, mine, assignment, categoryOrder[activeGroup.id] ?? [])
|
||||
const list = sections(groupBuffers, myCategories, viewAssignment, categoryOrder[activeGroup.id] ?? [])
|
||||
if (!isPinnedPage && !isDmPage) {
|
||||
for (const s of list) {
|
||||
s.buffers.sort((a, b) => (a.position || 0) - (b.position || 0))
|
||||
@@ -244,7 +295,7 @@ export function BufferList(): JSX.Element {
|
||||
// they just made it, and a heading that vanishes until something is put
|
||||
// in it cannot have anything put in it.
|
||||
return list.filter((s) => s.custom || s.buffers.length > 0)
|
||||
}, [activeGroup, isPinnedPage, isDmPage, groupBuffers, customCats, assignment, categoryOrder])
|
||||
}, [activeGroup, isPinnedPage, isDmPage, steamPage, groupBuffers, myCategories, viewAssignment, categoryOrder])
|
||||
|
||||
// The menu still toggles a buffer's *own* flag independently of a muted
|
||||
// rail entry above it, the same way muting a channel inside an
|
||||
@@ -252,16 +303,14 @@ export function BufferList(): JSX.Element {
|
||||
/** The call that sets a mute on the account itself, for a service that keeps one. */
|
||||
const accountMute = (accountId: string): string | null => {
|
||||
const service = accounts.find((a) => a.id === accountId)?.service
|
||||
return service === 'matrix' ? 'setMatrixRoomMuted' : service === 'discord' ? 'setDiscordChannelMuted' : null
|
||||
return service === 'matrix' ? 'setMatrixRoomMuted' : service === 'discord' ? 'setDiscordChannelMuted' : service === 'steam' ? 'steamSetFriendMuted' : null
|
||||
}
|
||||
const isEffectivelyMuted = (buffer: BufferEntry): boolean =>
|
||||
isMutedBuffer(buffer, muted, mutedGroups)
|
||||
|
||||
const [naming, setNaming] = useState<{ id: string; name: string } | null>(null)
|
||||
const { menu: groupMenu, open: openGroupMenuAt, close: closeGroupMenu } = useContextMenu()
|
||||
const [catMenu, setCatMenu] = useState<{ x: number; y: number; section: CategorySection } | null>(null)
|
||||
|
||||
const myCategories = activeGroup ? (customCats[activeGroup.id] ?? []) : []
|
||||
const writeCategories = (next: CustomCategory[]): void => {
|
||||
if (activeGroup) setCustomCats(activeGroup.id, next)
|
||||
}
|
||||
@@ -354,7 +403,7 @@ export function BufferList(): JSX.Element {
|
||||
status={dmStatus(b, presence, buffers)}
|
||||
onCall={() => void store.callBuffer(b.id)}
|
||||
onHangUp={() => void store.leaveVoice(b.accountId)}
|
||||
onFile={(categoryId) => setAssignment(b.id, categoryId)}
|
||||
onFile={(categoryId) => fileBuffer(b.id, categoryId)}
|
||||
poppedOut={popouts.open.includes(b.id)}
|
||||
onPopOut={() => store.popOut(b.id)}
|
||||
onDock={() => store.dock(b.id)}
|
||||
@@ -418,7 +467,7 @@ export function BufferList(): JSX.Element {
|
||||
return
|
||||
}
|
||||
if (!section.custom) return
|
||||
setAssignment(held.id, section.key)
|
||||
fileBuffer(held.id, section.key)
|
||||
}
|
||||
|
||||
const hideBuffer = (id: string): void => {
|
||||
@@ -437,10 +486,31 @@ export function BufferList(): JSX.Element {
|
||||
{/* Not on the mentions page, which is deliberately server-less: the
|
||||
pane is empty there because there is nothing to list, not because
|
||||
something still needs picking. */}
|
||||
{accounts.length > 0 && !activeGroup && !onMentionsPage && (
|
||||
{accounts.length > 0 && !activeGroup && !onMentionsPage && !onInvite && (
|
||||
<div className="bufferlist-empty muted small">Select a server on the left.</div>
|
||||
)}
|
||||
|
||||
{onInvite && (
|
||||
<>
|
||||
<div className="group-title">
|
||||
<span className="ellipsis group-title-name">Invitations</span>
|
||||
</div>
|
||||
{invitations.map((invitation) => (
|
||||
<button
|
||||
key={invitation.key}
|
||||
type="button"
|
||||
className={classes('buffer-row', invitation.key === shownInvitation?.key && 'active', 'unread')}
|
||||
title={`${invitation.name} — ${invitation.detail}`}
|
||||
onClick={() => store.selectInvitation(invitation.key)}
|
||||
>
|
||||
<Avatar name={invitation.name} url={invitation.avatarUrl ?? undefined} size={22} accountId={invitation.accountId} />
|
||||
<span className="ellipsis buffer-name">{invitation.name}</span>
|
||||
<ServiceMark icon={serviceIcon(invitation.service)} size={15} />
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeGroup && (
|
||||
<>
|
||||
<div className="group-title">
|
||||
@@ -533,14 +603,23 @@ export function BufferList(): JSX.Element {
|
||||
const key = categoryKey(activeGroup.id, section)
|
||||
const folded = isCollapsed(key)
|
||||
return (
|
||||
<div key={section.key} className="category">
|
||||
<div
|
||||
key={section.key}
|
||||
// A conversation held over a heading that will take it lights the whole
|
||||
// category, so what is about to happen is the heading and its people
|
||||
// being added to - not a line that could be read as "put it above".
|
||||
className={classes('category', dragging?.kind === 'buffer' && overCategory === section.key && 'drop-into')}
|
||||
>
|
||||
{naming?.id === section.key ? (
|
||||
<CategoryNameField
|
||||
value={naming.name}
|
||||
onChange={(name) => setNaming({ id: naming.id, name })}
|
||||
onCommit={() => {
|
||||
const name = naming.name.trim()
|
||||
if (name) {
|
||||
if (name && steamPage && activeGroup) {
|
||||
const group = parseSteamCategory(naming.id)?.groupId
|
||||
if (group !== undefined) store.renameSteamFolder(activeGroup.accountId, group, name)
|
||||
} else if (name) {
|
||||
writeCategories(
|
||||
myCategories.map((c) => (c.id === naming.id ? { ...c, name } : c))
|
||||
)
|
||||
@@ -557,7 +636,7 @@ export function BufferList(): JSX.Element {
|
||||
'category-head',
|
||||
'small',
|
||||
dragging?.kind === 'category' && dragging.id === section.key && 'lifted',
|
||||
overCategory === section.key && 'drop-target'
|
||||
overCategory === section.key && dragging?.kind !== 'buffer' && 'drop-target'
|
||||
)}
|
||||
onClick={() => toggleCollapsed(key)}
|
||||
onContextMenu={(e) => openCategoryMenu(e, section)}
|
||||
@@ -640,6 +719,7 @@ export function BufferList(): JSX.Element {
|
||||
|
||||
{/* Below the text channels, as everywhere else that has both. */}
|
||||
<VoiceChannels group={activeGroup} />
|
||||
<SteamVoiceRooms group={activeGroup} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -665,6 +745,11 @@ export function BufferList(): JSX.Element {
|
||||
label: 'New category',
|
||||
icon: 'create_new_folder',
|
||||
onClick: () => {
|
||||
// On Steam it is made on Steam, once it has a name.
|
||||
if (steamPage) {
|
||||
setMakingFolder(true)
|
||||
return
|
||||
}
|
||||
const id = `cat-${Date.now().toString(36)}`
|
||||
// At the top, not the end. The command was given from the
|
||||
// group's own title at the top of this list, and a heading
|
||||
@@ -713,7 +798,11 @@ export function BufferList(): JSX.Element {
|
||||
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))
|
||||
onClick: () => {
|
||||
const group = parseSteamCategory(catMenu.section.key)
|
||||
if (steamPage && activeGroup && group) store.deleteSteamFolder(activeGroup.accountId, group.groupId)
|
||||
else writeCategories(myCategories.filter((c) => c.id !== catMenu.section.key))
|
||||
}
|
||||
}
|
||||
] as MenuEntry[])
|
||||
: [])
|
||||
@@ -722,6 +811,20 @@ export function BufferList(): JSX.Element {
|
||||
/>
|
||||
)}
|
||||
|
||||
{makingFolder && activeGroup && (
|
||||
<NamePrompt
|
||||
title="New category"
|
||||
detail="Made on Steam, so it is on this account in every Steam client. Steam's own client only reads its folders when it signs in, so it shows a folder or a move made here after it is restarted."
|
||||
placeholder="Name"
|
||||
confirmLabel="Create"
|
||||
onCancel={() => setMakingFolder(false)}
|
||||
onConfirm={(name) => {
|
||||
setMakingFolder(false)
|
||||
store.createSteamFolder(activeGroup.accountId, name)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Outside the group above, so a call stays visible and hangable-up
|
||||
wherever you navigate. */}
|
||||
<VoicePanel />
|
||||
@@ -937,6 +1040,12 @@ function BufferRow({
|
||||
// Whether this channel's stream is the one in the window. Selected down to
|
||||
// a boolean for the same reason as `live` above.
|
||||
const watched = useChat((s) => s.watching?.bufferId === buffer.id)
|
||||
// What the person is doing: under their name in comfy, and in the tooltip
|
||||
// either way. Selected as the one object rather than a copy of it, so the row
|
||||
// redraws when it changes and not on every presence update in the window.
|
||||
const activity = useChat((s) => dmActivity(buffer, s.presenceByBuffer))
|
||||
const comfy = useComfy()
|
||||
const doing = activity ? activityLabel(activity) : undefined
|
||||
|
||||
// Under an account header the row's own kind is what's worth showing (a
|
||||
// channel vs a DM vs the server buffer). A pinned row has no header above
|
||||
@@ -950,7 +1059,7 @@ function BufferRow({
|
||||
// A conversation with a person is headed by that person, whatever
|
||||
// protocol they are on: their picture where there is one, their initial
|
||||
// where there is not.
|
||||
<Avatar name={buffer.name} url={buffer.avatarUrl} size={22} status={status} accountId={buffer.accountId} />
|
||||
<Avatar name={buffer.name} url={buffer.avatarUrl} size={22} status={account?.service === 'twitch' ? undefined : status} badge="mail" accountId={buffer.accountId} />
|
||||
) : buffer.avatarUrl ? (
|
||||
<img className="buffer-avatar" src={media(buffer.avatarUrl)} alt="" />
|
||||
) : buffer.serviceRoom ? (
|
||||
@@ -980,7 +1089,7 @@ function BufferRow({
|
||||
// Calling from the row it belongs to, as well as from the header of the
|
||||
// conversation once it is open - the list is where you look for somebody
|
||||
// you want to reach, so it is where reaching them should be offered.
|
||||
const canCall = account?.service === 'discord' && buffer.kind === 'dm'
|
||||
const canCall = (account?.service === 'discord' || account?.service === 'steam') && buffer.kind === 'dm'
|
||||
|
||||
// The same entries the header's nameplate opens - see lib/buffermenu.ts for
|
||||
// why they live there rather than here.
|
||||
@@ -1015,7 +1124,9 @@ function BufferRow({
|
||||
onMarkRead: () => void store.markBuffersRead([buffer.id]),
|
||||
onMarkUnread: account?.service === 'matrix' ? onMarkUnread : undefined,
|
||||
onTag: account?.service === 'matrix' ? onTag : undefined,
|
||||
markedUnread: buffer.markedUnread
|
||||
markedUnread: buffer.markedUnread,
|
||||
onSteamFriend: account?.service === 'steam' && buffer.kind === 'dm' && buffer.remoteId ? (what) => store.askSteamFriend(what, buffer.id) : undefined,
|
||||
onProfile: account?.service === 'steam' && buffer.kind === 'dm' && buffer.remoteId ? () => store.showProfile(buffer.id, buffer.name, buffer.remoteId) : undefined
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -1043,10 +1154,17 @@ function BufferRow({
|
||||
onDragStart()
|
||||
}}
|
||||
onDragEnd={onDragEnd}
|
||||
title={link ? `${buffer.name} — ${link.detail}` : draggable ? `${buffer.name} — drag onto a heading to file it` : buffer.name}
|
||||
title={link ? `${buffer.name} — ${link.detail}` : draggable ? `${buffer.name} — drag onto a heading to file it` : doing ? `${buffer.name} — ${doing}` : buffer.name}
|
||||
>
|
||||
{leading}
|
||||
<span className="ellipsis buffer-name">{rowName}</span>
|
||||
{comfy && activity ? (
|
||||
<span className="buffer-text">
|
||||
<span className="ellipsis buffer-name">{rowName}</span>
|
||||
<ActivityLine activity={activity} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="ellipsis buffer-name">{rowName}</span>
|
||||
)}
|
||||
{/* So a conversation with no unread count and no traffic in it is
|
||||
explained rather than merely quiet: it is being read elsewhere. */}
|
||||
{/* Still filling in - a Matrix room between joining it and the server
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState, type KeyboardEvent, type PointerEvent } from 'react'
|
||||
import { usePref } from '../state/hooks'
|
||||
|
||||
/**
|
||||
* A column's width as somebody left it: followed while a drag is under way and written down once, when it ends,
|
||||
* because a preference goes to disk over IPC and a drag is a hundred moves.
|
||||
*/
|
||||
export function useColumnWidth(key: string, fallback: number): [number, (width: number | null, commit: boolean) => void] {
|
||||
const [saved, setSaved] = usePref<number>(key, fallback)
|
||||
const [live, setLive] = useState<number | null>(null)
|
||||
const apply = (width: number | null, commit: boolean): void => {
|
||||
if (!commit) return setLive(width)
|
||||
setLive(null)
|
||||
setSaved(width ?? fallback)
|
||||
}
|
||||
return [live ?? saved, apply]
|
||||
}
|
||||
|
||||
/**
|
||||
* The line between two columns, which can be dragged to change the width of one of them.
|
||||
*
|
||||
* `grows` says which way a drag makes the column wider: `right` for a column on the left of the line (the channel
|
||||
* list), `left` for one on its right (the member list). Double-click puts it back to its usual width, and the
|
||||
* arrow keys move it for somebody who is not using a pointer.
|
||||
*/
|
||||
export function ColumnResizer({
|
||||
width,
|
||||
onWidth,
|
||||
min,
|
||||
max,
|
||||
grows,
|
||||
label
|
||||
}: {
|
||||
width: number
|
||||
onWidth: (width: number | null, commit: boolean) => void
|
||||
min: number
|
||||
max: number
|
||||
grows: 'left' | 'right'
|
||||
label: string
|
||||
}): JSX.Element {
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [from, setFrom] = useState<{ x: number; w: number } | null>(null)
|
||||
const fit = (w: number): number => Math.round(Math.min(Math.max(w, min), Math.min(max, window.innerWidth * 0.45)))
|
||||
const at = (x: number, start: { x: number; w: number }): number => fit(start.w + (grows === 'right' ? x - start.x : start.x - x))
|
||||
|
||||
const down = (e: PointerEvent<HTMLDivElement>): void => {
|
||||
setFrom({ x: e.clientX, w: width })
|
||||
setDragging(true)
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
document.body.classList.add('resizing-column')
|
||||
e.preventDefault()
|
||||
}
|
||||
const move = (e: PointerEvent<HTMLDivElement>): void => {
|
||||
if (from) onWidth(at(e.clientX, from), false)
|
||||
}
|
||||
const up = (e: PointerEvent<HTMLDivElement>): void => {
|
||||
if (!from) return
|
||||
const done = at(e.clientX, from)
|
||||
setFrom(null)
|
||||
setDragging(false)
|
||||
document.body.classList.remove('resizing-column')
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
onWidth(done, true)
|
||||
}
|
||||
const key = (e: KeyboardEvent<HTMLDivElement>): void => {
|
||||
const step = e.shiftKey ? 40 : 10
|
||||
const toward = (e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0) * (grows === 'right' ? 1 : -1)
|
||||
if (!toward) return
|
||||
e.preventDefault()
|
||||
onWidth(fit(width + toward * step), true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`divider-v resizer${dragging ? ' dragging' : ''}`}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={label}
|
||||
aria-valuenow={width}
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
tabIndex={0}
|
||||
title="Drag to resize · double-click to reset"
|
||||
onPointerDown={down}
|
||||
onPointerMove={move}
|
||||
onPointerUp={up}
|
||||
onPointerCancel={up}
|
||||
onDoubleClick={() => onWidth(null, true)}
|
||||
onKeyDown={key}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from '../lib/mentions'
|
||||
import { STAGE_FILES, type StagedFile } from '../lib/staging'
|
||||
import { withTone } from '../lib/skintone'
|
||||
import { STEAM_FILE_TYPES, steamFileRefusal } from '../lib/steamfiles'
|
||||
import { usePref } from '../state/hooks'
|
||||
import { rankShortcodes, shortcodeQuery, unicodeTargets, type ShortcodeTarget } from '../lib/shortcodes'
|
||||
|
||||
@@ -37,7 +38,7 @@ interface StagedAttachment {
|
||||
preview?: string
|
||||
/** In bytes, once it has been asked. */
|
||||
size?: number | null
|
||||
/** Sent hidden behind a spoiler: Discord only. */
|
||||
/** Sent hidden behind a spoiler: Discord and Steam. */
|
||||
spoiler: boolean
|
||||
}
|
||||
|
||||
@@ -239,7 +240,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
const route = useChat((s) => s.route)
|
||||
const routed = tunnelAll || !!account?.useTor
|
||||
const media = useMediaUrl(account?.id)
|
||||
const hasStickers = service === 'matrix' || service === 'discord'
|
||||
const hasStickers = service === 'matrix' || service === 'discord' || service === 'steam'
|
||||
const formats = formatsFor(service)
|
||||
/**
|
||||
* The account's stickers, asked for when the picker opens - from either
|
||||
@@ -249,7 +250,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
const loadStickers = (): void => {
|
||||
if (!hasStickers || !buffer) return
|
||||
void window.moho
|
||||
.rpc<StickerEntry[]>(service === 'discord' ? 'listDiscordStickers' : 'listMatrixStickers', { bufferId: buffer.id })
|
||||
.rpc<StickerEntry[]>(service === 'discord' ? 'listDiscordStickers' : service === 'steam' ? 'listSteamStickers' : 'listMatrixStickers', { bufferId: buffer.id })
|
||||
.then(setStickers)
|
||||
.catch(() => setStickers([]))
|
||||
}
|
||||
@@ -257,8 +258,9 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
// of an attachment at all, so a file there is uploaded and the link sent -
|
||||
// which is what people do by hand on IRC anyway, and is why it is offered
|
||||
// here rather than left as the one service where the button is dead.
|
||||
// Steam takes a picture or a short video, and says which and how big.
|
||||
const supportsAttachments =
|
||||
service === 'discord' || service === 'sneedchat' || service === 'matrix' || service === 'irc'
|
||||
service === 'discord' || service === 'sneedchat' || service === 'matrix' || service === 'irc' || service === 'steam'
|
||||
|
||||
// Starting a reply puts the cursor in the box: the next thing anybody does
|
||||
// is write the answer.
|
||||
@@ -436,12 +438,29 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
|
||||
const stage = (path: string, file?: File): void => {
|
||||
const name = fileNameOf(path)
|
||||
// Steam refuses most files, and what it takes is known: said now, at the
|
||||
// box, rather than once the message has gone and failed.
|
||||
if (service === 'steam') {
|
||||
const why = steamFileRefusal(name)
|
||||
if (why) {
|
||||
store.toast('error', why)
|
||||
return
|
||||
}
|
||||
}
|
||||
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))))
|
||||
void window.moho.fileSize(path).then((size) => {
|
||||
const tooBig = service === 'steam' ? steamFileRefusal(name, size ?? undefined) : null
|
||||
if (tooBig) {
|
||||
store.toast('error', tooBig)
|
||||
setStaged((s) => s.filter((a) => a.id !== id))
|
||||
return
|
||||
}
|
||||
setStaged((s) => s.map((a) => (a.id === id ? { ...a, size } : a)))
|
||||
})
|
||||
}
|
||||
takeFiles.current = (files) => {
|
||||
if (!supportsAttachments) {
|
||||
@@ -452,7 +471,10 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const pickAndStage = (): void => void window.moho.pickFile().then((p) => p && stage(p))
|
||||
// Steam takes a short list of kinds of file, so the dialog offers those and
|
||||
// nothing else.
|
||||
const pickAndStage = (): void =>
|
||||
void window.moho.pickFile(service === 'steam' ? STEAM_FILE_TYPES : undefined).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.
|
||||
@@ -527,6 +549,15 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
batch.filter((att) => att.spoiler).map((att) => att.path)
|
||||
)
|
||||
}
|
||||
} else if (service === 'steam') {
|
||||
// Steam sends the words and then each file as messages of their own,
|
||||
// all of them from one request, so one upload to watch and a caption
|
||||
// that always comes first.
|
||||
void post(
|
||||
body,
|
||||
staged.map((att) => att.path),
|
||||
staged.filter((att) => att.spoiler).map((att) => att.path)
|
||||
)
|
||||
} else {
|
||||
staged.forEach((att, i) => {
|
||||
void post(i === 0 ? body : '', att.path)
|
||||
@@ -833,13 +864,13 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
|
||||
// A Kick account with no sign-in watches; it cannot talk. Offering the
|
||||
// box anyway meant typing a message the service was always going to refuse.
|
||||
if (account && account.service === 'kick' && !account.hasPassword) {
|
||||
if (account && (account.service === 'kick' || account.service === 'twitch') && !account.hasPassword) {
|
||||
return (
|
||||
<div className="composer">
|
||||
<div className="divider-h" />
|
||||
<div className="composer-watching small muted">
|
||||
<Icon name="visibility" size={16} />
|
||||
<span>Watching signed out. Sign in to Kick to talk here.</span>
|
||||
<span>Watching signed out. Sign in to {account.service === 'twitch' ? 'Twitch' : 'Kick'} to talk here.</span>
|
||||
<button type="button" className="button" onClick={() => store.setActivePanel('accounts')}>
|
||||
Sign in
|
||||
</button>
|
||||
@@ -1017,7 +1048,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
{/* On the card's corner, and there only while it is pointed at
|
||||
or focused - they would be a dozen buttons otherwise. */}
|
||||
<div className="staged-actions">
|
||||
{service === 'discord' && (
|
||||
{(service === 'discord' || service === 'steam') && (
|
||||
<button
|
||||
type="button"
|
||||
className={classes('staged-action', att.spoiler && 'on')}
|
||||
@@ -1272,8 +1303,8 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
|
||||
{/* 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') && (
|
||||
be sent in any conversation - except Twitch's, whose chat takes no pictures. */}
|
||||
{!thread && service !== 'steam' && service !== 'twitch' && accounts.some((a) => a.service === 'discord') && (
|
||||
<button
|
||||
ref={gifButtonRef}
|
||||
type="button"
|
||||
@@ -1293,7 +1324,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
gesture: an emoji goes into the line being written and a sticker
|
||||
is the message. Only where the service has them: Matrix, where a
|
||||
pack supplies them, and Discord, where each guild has its own. */}
|
||||
{!thread && (service === 'matrix' || service === 'discord') && (
|
||||
{!thread && (service === 'matrix' || service === 'discord' || service === 'steam') && (
|
||||
<button
|
||||
ref={stickerButtonRef}
|
||||
type="button"
|
||||
@@ -1367,14 +1398,19 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
// Only where the service has stickers, which is what decides
|
||||
// whether there is a Stickers tab at all.
|
||||
stickers={hasStickers ? stickers : undefined}
|
||||
// Steam gives an account a short set of its own and takes nothing else.
|
||||
fixedSet={service === 'steam'}
|
||||
onSticker={
|
||||
hasStickers
|
||||
? (sticker) => {
|
||||
setStickerPicker(false)
|
||||
setPickerOpen(false)
|
||||
const sent = sticker.id
|
||||
? window.moho.rpc('sendDiscordSticker', { bufferId: buffer.id, stickerId: sticker.id })
|
||||
: window.moho.rpc('sendMatrixSticker', { bufferId: buffer.id, mxc: sticker.mxc, body: sticker.body })
|
||||
const sent =
|
||||
service === 'steam'
|
||||
? window.moho.rpc('sendSteamSticker', { bufferId: buffer.id, name: sticker.name })
|
||||
: sticker.id
|
||||
? window.moho.rpc('sendDiscordSticker', { bufferId: buffer.id, stickerId: sticker.id })
|
||||
: window.moho.rpc('sendMatrixSticker', { bufferId: buffer.id, mxc: sticker.mxc, body: sticker.body })
|
||||
void sent.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
: undefined
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ReasonPrompt } from './ReasonPrompt'
|
||||
import { Avatar } from './Avatar'
|
||||
import { bufferMenuEntries } from '../lib/buffermenu'
|
||||
import { dmStatus } from './BufferList'
|
||||
import { activityLabel, dmActivity } from '../lib/activity'
|
||||
import { bufferDisplayName, classes } from '../lib/util'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import { useIdSetPref, useMapPref, usePref } from '../state/hooks'
|
||||
@@ -51,6 +52,8 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
|
||||
|
||||
const account = accounts.find((a) => a.id === buffer.accountId)
|
||||
const name = bufferDisplayName(buffer.name)
|
||||
const activity = dmActivity(buffer, presence)
|
||||
const statusText = activity ? activityLabel(activity) : undefined
|
||||
|
||||
const entries: MenuEntry[] = [
|
||||
...bufferMenuEntries({
|
||||
@@ -61,7 +64,7 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
|
||||
filed: !!assignment[buffer.id],
|
||||
inCall: voiceSessions.some((s) => s.bufferId === buffer.id),
|
||||
poppedOut: popouts.open.includes(buffer.id),
|
||||
canCall: account?.service === 'discord' && buffer.kind === 'dm',
|
||||
canCall: (account?.service === 'discord' || account?.service === 'steam') && buffer.kind === 'dm',
|
||||
live,
|
||||
watched,
|
||||
autojoins: store.autojoins(buffer.accountId, name),
|
||||
@@ -69,17 +72,17 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
|
||||
onTogglePin: () => togglePin(buffer.id),
|
||||
onToggleMute: () => {
|
||||
toggleMute(buffer.id)
|
||||
if (account?.service === 'matrix') {
|
||||
if (account?.service === 'matrix' || account?.service === 'steam') {
|
||||
void window.moho
|
||||
.rpc('setMatrixRoomMuted', { bufferId: buffer.id, muted: !isMuted(buffer.id) })
|
||||
.rpc(account.service === 'steam' ? 'steamSetFriendMuted' : 'setMatrixRoomMuted', { bufferId: buffer.id, muted: !isMuted(buffer.id) })
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
},
|
||||
onUnmuteAccount:
|
||||
account?.service === 'matrix'
|
||||
account?.service === 'matrix' || account?.service === 'steam'
|
||||
? () =>
|
||||
void window.moho
|
||||
.rpc('setMatrixRoomMuted', { bufferId: buffer.id, muted: false })
|
||||
.rpc(account.service === 'steam' ? 'steamSetFriendMuted' : 'setMatrixRoomMuted', { bufferId: buffer.id, muted: false })
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
: undefined,
|
||||
onToggleAutojoin:
|
||||
@@ -108,7 +111,9 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
|
||||
account?.service === 'matrix'
|
||||
? (tag, on) => void store.setRoomTag(buffer.id, tag, on)
|
||||
: undefined,
|
||||
markedUnread: buffer.markedUnread
|
||||
markedUnread: buffer.markedUnread,
|
||||
onSteamFriend: account?.service === 'steam' && buffer.kind === 'dm' && buffer.remoteId ? (what) => store.askSteamFriend(what, buffer.id) : undefined,
|
||||
onProfile: account?.service === 'steam' && buffer.kind === 'dm' && buffer.remoteId ? () => store.showProfile(buffer.id, buffer.name, buffer.remoteId) : undefined
|
||||
}),
|
||||
{ separator: true },
|
||||
// Last, and on its own. It is the one entry here that starts something
|
||||
@@ -162,10 +167,17 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
|
||||
name={buffer.name}
|
||||
url={buffer.avatarUrl}
|
||||
size={24}
|
||||
status={dmStatus(buffer, presence, buffers)}
|
||||
// Twitch has no presence for a whisper's other end; the badge says what it is instead.
|
||||
status={buffer.accountId.startsWith('twitch:') ? undefined : dmStatus(buffer, presence, buffers)}
|
||||
badge={buffer.kind === 'dm' ? 'mail' : undefined}
|
||||
/>
|
||||
) : null}
|
||||
<span className="main-header-title ellipsis">{name}</span>
|
||||
{/* What they are doing, beside the name: the game, or a state the dot's
|
||||
colour does not tell apart. */}
|
||||
{statusText && <span className="header-status small muted ellipsis">{statusText}</span>}
|
||||
{/* A whisper on Twitch is headed like a channel is - a picture and a name - so it says what it is. */}
|
||||
{buffer.kind === 'dm' && buffer.accountId.startsWith('twitch:') && <span className="header-status small muted">Whisper</span>}
|
||||
</button>
|
||||
|
||||
{buffer.topic && <HeaderTopic topic={buffer.topic} />}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Icon } from './Icon'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
|
||||
/**
|
||||
* A warning the service has put on a conversation, over the top of it, in red,
|
||||
* until the person dismisses it.
|
||||
*
|
||||
* Steam's "this person may not be who they appear to be" is the one there is.
|
||||
* Said in the log as well, where it stays in its place; this is what stops it
|
||||
* being missed by somebody who opens the conversation scrolled to the bottom.
|
||||
* Dismissing tells the service, which does not raise it again - and if it does
|
||||
* raise one, the banner is back, because that is a new warning.
|
||||
*/
|
||||
export function ConversationNotice({ bufferId }: { bufferId: string }): JSX.Element | null {
|
||||
const notice = useChat((s) => s.buffers.find((b) => b.id === bufferId)?.notice)
|
||||
const store = useStore()
|
||||
if (!notice) return null
|
||||
return (
|
||||
<div className="notice-banner" role="alert">
|
||||
<Icon name="warning" size={18} />
|
||||
<span className="notice-banner-text">{notice.text}</span>
|
||||
<button type="button" className="notice-banner-dismiss" onClick={() => void store.dismissNotice(bufferId)}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -615,6 +615,7 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
|
||||
// to a name half written.
|
||||
const filterMenu = suggestions(query)
|
||||
const isMatrix = buffer.accountId.startsWith('matrix:')
|
||||
const isSteam = buffer.accountId.startsWith('steam:')
|
||||
const stream = useChat((s) => s.kickStreams)[buffer.id]
|
||||
// Whether this window is the one playing this channel. Per window rather
|
||||
// than per channel: a popped-out chat is its own window with its own
|
||||
@@ -717,8 +718,10 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
|
||||
// are polled as a group by the daemon; this is the one channel on screen,
|
||||
// which may not be followed at all, asked about on its own minute.
|
||||
const isKick = buffer.accountId.startsWith('kick:')
|
||||
const isTwitch = buffer.accountId.startsWith('twitch:')
|
||||
const hasStream = isKick || buffer.accountId.startsWith('twitch:')
|
||||
useEffect(() => {
|
||||
if (!isKick || buffer.kind !== 'channel') return
|
||||
if (!hasStream || buffer.kind !== 'channel') return
|
||||
const tick = (): void => {
|
||||
void window.moho.rpc('refreshKickStream', { bufferId: buffer.id }).catch(() => {
|
||||
// Nothing to say: the header keeps the last count it had, which is
|
||||
@@ -727,7 +730,7 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
|
||||
}
|
||||
const timer = setInterval(tick, 60_000)
|
||||
return () => clearInterval(timer)
|
||||
}, [isKick, buffer.id, buffer.kind])
|
||||
}, [hasStream, buffer.id, buffer.kind])
|
||||
|
||||
const rerun = async (): Promise<void> => {
|
||||
setSearching(true)
|
||||
@@ -949,7 +952,7 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
|
||||
/>
|
||||
)}
|
||||
|
||||
{isDiscord && isDm && (
|
||||
{(isDiscord || isSteam) && isDm && (
|
||||
<IconButton
|
||||
name={inCall ? 'call_end' : 'call'}
|
||||
title={inCall ? 'Hang up' : `Call ${buffer.name}`}
|
||||
@@ -997,8 +1000,8 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
|
||||
have polls and no predictions, IRC has neither, and an icon that
|
||||
opens an empty list on a service that could never fill it is an
|
||||
icon that says the feature is missing rather than absent. */}
|
||||
{(isKick || isMatrix) && <CardHistory buffer={buffer} kind="poll" icon="help" label="Past polls" />}
|
||||
{isKick && <CardHistory buffer={buffer} kind="prediction" icon="casino" label="Past predictions" />}
|
||||
{(isKick || isMatrix || isTwitch) && <CardHistory buffer={buffer} kind="poll" icon="help" label="Past polls" />}
|
||||
{(isKick || isTwitch) && <CardHistory buffer={buffer} kind="prediction" icon="casino" label="Past predictions" />}
|
||||
|
||||
{/* An icon until it is being used. A box wide enough to type into is
|
||||
the single widest thing in this row, and a header that always
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { Icon } from './Icon'
|
||||
import { discordEmojiUrl, emojiPreview, type SmilieEntry } from '../lib/format'
|
||||
import { Icon, ServiceMark } from './Icon'
|
||||
import { serviceIcon } from '../lib/util'
|
||||
import { discordEmojiUrl, emojiPreview, sourcePick, type SmilieEntry } from '../lib/format'
|
||||
import { recentsHere } from '../lib/recents'
|
||||
import { drawableEmoteUrl, onLocalEmotes } from '../lib/emotecache'
|
||||
import { LottieSticker } from './LottieSticker'
|
||||
import type { CustomEmoji } from '../../../shared/wire'
|
||||
@@ -72,6 +74,10 @@ export const COMMON_EMOJI: { emoji: string; name: string }[] = [
|
||||
* round and flags animation. Neither is negotiable - the server parses it.
|
||||
*/
|
||||
export function emojiToken(e: CustomEmoji): string {
|
||||
// Steam's, named rather than numbered: the id carries the service.
|
||||
if (e.id.startsWith('steam:')) return `[steam:emote:${e.id.slice(6)}]`
|
||||
// Twitch's, which the daemon names by the token its messages carry: the box draws the picture and Twitch is sent the word.
|
||||
if (e.id.startsWith('[twitch:')) return e.id
|
||||
if (e.url) return `[emote:${e.id}:${e.name}]`
|
||||
return `<${e.animated ? 'a' : ''}:${e.name}:${e.id}>`
|
||||
}
|
||||
@@ -205,6 +211,12 @@ interface Props {
|
||||
accountId?: string
|
||||
onSelect: (text: string) => void
|
||||
onClose: () => void
|
||||
/**
|
||||
* The service has a short set of its own and nothing else is accepted - Steam
|
||||
* - so there is no Unicode emoji to offer, only what the account was given.
|
||||
* Recent picks that are not from that set are left out too.
|
||||
*/
|
||||
fixedSet?: boolean
|
||||
}
|
||||
|
||||
export function EmojiPicker({
|
||||
@@ -217,7 +229,8 @@ export function EmojiPicker({
|
||||
accountId,
|
||||
onSelect,
|
||||
onSticker,
|
||||
onClose
|
||||
onClose,
|
||||
fixedSet = false
|
||||
}: Props): JSX.Element {
|
||||
// The account's emoji and stickers through its route when it is strictly
|
||||
// routed (#257) - Discord's come straight off its CDN otherwise.
|
||||
@@ -248,11 +261,13 @@ export function EmojiPicker({
|
||||
/** 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))
|
||||
const [recent, setRecent] = useState<string[]>(() => readRecent(accountId).filter((r) => !fixedSet || r.startsWith('[steam:')))
|
||||
// Everything this account can send, wherever it came from. Asked of the
|
||||
// daemon rather than assembled here, because the answer depends on things
|
||||
// only it knows - a Nitro subscription, a Kick channel's standing.
|
||||
const [sources, setSources] = useState<EmojiSource[]>([])
|
||||
/** Whether the daemon has said yet what can be sent here, which decides what the recent picks may offer. */
|
||||
const [sourcesLoaded, setSourcesLoaded] = useState(false)
|
||||
const scroll = useRef<HTMLDivElement>(null)
|
||||
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
|
||||
@@ -263,7 +278,10 @@ export function EmojiPicker({
|
||||
// An older daemon has no such method; the buffer's own emoji below are
|
||||
// what this showed before it asked, so that is what it falls back to.
|
||||
.catch(() => [])
|
||||
.then(setSources)
|
||||
.then((list) => {
|
||||
setSources(list)
|
||||
setSourcesLoaded(true)
|
||||
})
|
||||
}, [bufferId, stickersOnly])
|
||||
|
||||
// The picker is normally opened fresh, but it stays up across a buffer
|
||||
@@ -350,8 +368,8 @@ export function EmojiPicker({
|
||||
const q = query.trim().toLowerCase()
|
||||
|
||||
const unicode = useMemo(
|
||||
() => (q ? COMMON_EMOJI.filter((e) => e.name.includes(q) || e.emoji === q) : COMMON_EMOJI),
|
||||
[q]
|
||||
() => (fixedSet ? [] : q ? COMMON_EMOJI.filter((e) => e.name.includes(q) || e.emoji === q) : COMMON_EMOJI),
|
||||
[q, fixedSet]
|
||||
)
|
||||
const custom = useMemo(
|
||||
() => (q ? customEmoji.filter((e) => e.name.toLowerCase().includes(q)) : customEmoji),
|
||||
@@ -398,6 +416,26 @@ export function EmojiPicker({
|
||||
return groups
|
||||
}, [custom, sources])
|
||||
|
||||
// What can be sent in this conversation, as the text each is picked as. A recent pick is shown only if it is in
|
||||
// here (or is an ordinary Unicode emoji): one from another server, another channel, an unlock that has lapsed or
|
||||
// an account without Nitro is not something this conversation can take, so it is not offered - on every service.
|
||||
// The stored list is left as it is, so a pick comes back where it works again.
|
||||
const onOffer = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
for (const src of sources) {
|
||||
if (!src.usableHere) continue
|
||||
for (const e of src.emoji) {
|
||||
if (e.locked) continue
|
||||
set.add(sourcePick(src.service, e))
|
||||
set.add(emojiToken({ id: e.id, name: e.name, url: e.url, animated: e.animated } as CustomEmoji))
|
||||
}
|
||||
}
|
||||
for (const e of customEmoji) set.add(emojiToken(e))
|
||||
for (const s of smilies) for (const a of s.aliases ?? []) set.add(a)
|
||||
return set
|
||||
}, [sources, customEmoji, smilies])
|
||||
const recentHere = useMemo(() => recentsHere(recent, onOffer, sourcesLoaded, fixedSet), [recent, onOffer, sourcesLoaded, fixedSet])
|
||||
|
||||
const jumpTo = (id: string): void => {
|
||||
sectionRefs.current[id]?.scrollIntoView({ block: 'start', behavior: 'smooth' })
|
||||
}
|
||||
@@ -518,12 +556,14 @@ export function EmojiPicker({
|
||||
}))
|
||||
]
|
||||
: [
|
||||
...(!q && recent.length > 0 ? [{ id: 'recent', name: 'Recent', icon: <Icon name="schedule" size={20} /> }] : []),
|
||||
...(!q && recentHere.length > 0 ? [{ id: 'recent', name: 'Recent', icon: <Icon name="schedule" size={20} /> }] : []),
|
||||
...sourceSections.map((src) => ({
|
||||
id: src.id,
|
||||
name: src.name,
|
||||
icon: src.iconUrl ? (
|
||||
<img src={media(src.iconUrl)} alt="" />
|
||||
<img src={media(src.iconUrl)} alt="" className="emoji-rail-avatar" />
|
||||
) : src.service === 'twitch' && src.name === 'Twitch' ? (
|
||||
<ServiceMark icon={serviceIcon('twitch')} size={20} />
|
||||
) : (
|
||||
<span className="emoji-jump-initial">{src.name.slice(0, 1).toUpperCase()}</span>
|
||||
)
|
||||
@@ -733,9 +773,9 @@ export function EmojiPicker({
|
||||
}}
|
||||
>
|
||||
<PickerScroll.Provider value={scroll}>
|
||||
{!stickersOnly && !q && recent.length > 0 && (
|
||||
{!stickersOnly && !q && recentHere.length > 0 && (
|
||||
<Section title="Recent" anchorRef={(el) => (sectionRefs.current.recent = el)}>
|
||||
{recent.map((r) => {
|
||||
{recentHere.map((r) => {
|
||||
// A recent is stored as the text that gets sent, which for a
|
||||
// custom emoji or a shortcode is a stand-in rather than the
|
||||
// thing. Resolved back to a picture here, so the row of recents
|
||||
@@ -773,7 +813,7 @@ export function EmojiPicker({
|
||||
title={src.name}
|
||||
anchorRef={(el) => (sectionRefs.current[src.id] = el)}
|
||||
>
|
||||
{src.emoji.map((e) => {
|
||||
{src.emoji.map((e, at) => {
|
||||
// Only one reason left: a source that could not be used here
|
||||
// at all was dropped above, so a cell that cannot be pressed is
|
||||
// one this account has not paid for - in a room it is reading.
|
||||
@@ -793,7 +833,7 @@ export function EmojiPicker({
|
||||
: emojiPreview(e.id, smilies)?.src
|
||||
return (
|
||||
<button
|
||||
key={`${src.id}:${e.id}`}
|
||||
key={`${src.id}:${e.id}:${at}`}
|
||||
type="button"
|
||||
// A cell with no picture shows its name, and the styling
|
||||
// that keeps a name inside its square is `.emoji-cell.
|
||||
@@ -802,7 +842,7 @@ export function EmojiPicker({
|
||||
className={`emoji-cell${why ? ' locked' : ''}${art ? '' : ' emoji-token'}`}
|
||||
disabled={!!why}
|
||||
title={why || e.name}
|
||||
onClick={() => pick(e.id)}
|
||||
onClick={() => pick(sourcePick(src.service, e))}
|
||||
>
|
||||
{art ? <img src={media(art)} alt={e.name} loading="lazy" /> : e.name}
|
||||
</button>
|
||||
|
||||
@@ -37,11 +37,25 @@ export function IncomingCallPanel({ silent = false }: { silent?: boolean } = {})
|
||||
const store = useStore()
|
||||
const calls = useChat((s) => s.incomingCalls)
|
||||
const matrix = useChat((s) => s.ringingCall)
|
||||
const steamRings = useChat((s) => s.steamRings)
|
||||
const buffers = useChat((s) => s.buffers)
|
||||
const accounts = useChat((s) => s.accounts)
|
||||
const ringtone = useRef<Ringtone>()
|
||||
|
||||
const rings: Ring[] = [
|
||||
...steamRings.map((ring): Ring => {
|
||||
const buffer = buffers.find((b) => b.accountId === ring.accountId && b.remoteId === ring.steamid)
|
||||
const account = accounts.find((a) => a.id === ring.accountId)
|
||||
return {
|
||||
key: `s:${ring.voiceChatId}`,
|
||||
name: buffer ? bufferDisplayName(buffer.name) : 'Someone',
|
||||
avatarUrl: buffer?.avatarUrl,
|
||||
accountId: ring.accountId,
|
||||
detail: `Incoming call${account ? ` · ${account.displayName || account.id}` : ''}`,
|
||||
answer: () => void store.answerSteamCall(ring),
|
||||
decline: () => void store.declineSteamCall(ring)
|
||||
}
|
||||
}),
|
||||
...calls.map((call): Ring => {
|
||||
const buffer = buffers.find((b) => b.id === call.bufferId)
|
||||
const account = accounts.find((a) => a.id === call.accountId)
|
||||
|
||||
@@ -1,44 +1,36 @@
|
||||
import { Avatar } from './Avatar'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import { readInviteGroupId } from '../lib/groups'
|
||||
import { useChat, useInvitations, useStore } from '../state/hooks'
|
||||
import { currentInvitation } from '../lib/invitations'
|
||||
import { serviceLabel } from '../lib/util'
|
||||
|
||||
/**
|
||||
* A room somebody has asked you into, and the two answers to it.
|
||||
* The invitation being looked at, and the two answers to it.
|
||||
*
|
||||
* Shown where the conversation would be, because that is what is being
|
||||
* offered: the tile in the rail says one has arrived, and this says what it
|
||||
* is and who from. Both disappear together the moment it is answered - an
|
||||
* invitation is the one thing in the rail that stops existing once it has
|
||||
* been dealt with.
|
||||
* offered: the list on the left says which have arrived, and this says what
|
||||
* the chosen one is and who from. Accepting turns this pane into the
|
||||
* conversation it was offering; declining takes the invitation away. Either
|
||||
* way it disappears from the list - an invitation is the one thing here that
|
||||
* stops existing once it has been dealt with.
|
||||
*/
|
||||
export function InvitePanel({ groupId }: { groupId: string }): JSX.Element | null {
|
||||
export function InvitePanel(): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const invites = useChat((s) => s.matrixInvites)
|
||||
const accounts = useChat((s) => s.accounts)
|
||||
const named = readInviteGroupId(groupId)
|
||||
if (!named) return null
|
||||
const key = useChat((s) => s.activeInvitationKey)
|
||||
const invitation = currentInvitation(useInvitations(), key)
|
||||
// Answered, here or in another client. The page leaves with the last one.
|
||||
if (!invitation) return null
|
||||
|
||||
const invite = (invites[named.accountId] || []).find((i) => i.roomId === named.roomId)
|
||||
// Answered, here or in another client. The tile goes with it, so this is
|
||||
// only ever seen for the moment between the two.
|
||||
if (!invite) return null
|
||||
|
||||
const account = accounts.find((a) => a.id === named.accountId)
|
||||
const answer = (accept: boolean): void => {
|
||||
store.answerMatrixInvite(named.accountId, named.roomId, accept)
|
||||
// Back to the account the invitation was for: accepting puts the room in
|
||||
// its list a moment later, and declining leaves nothing to look at here.
|
||||
if (account) store.selectGroup(`account:${account.id}`)
|
||||
}
|
||||
const account = accounts.find((a) => a.id === invitation.accountId)
|
||||
const answer = (accept: boolean): void => void store.answerInvitation(invitation, accept)
|
||||
|
||||
return (
|
||||
<div className="invite-panel">
|
||||
<Avatar name={invite.name} url={invite.avatarUrl ?? undefined} size={64} />
|
||||
<h2 className="invite-panel-name ellipsis">{invite.name}</h2>
|
||||
<Avatar name={invitation.name} url={invitation.avatarUrl ?? undefined} size={64} />
|
||||
<h2 className="invite-panel-name ellipsis">{invitation.name}</h2>
|
||||
<p className="small muted">
|
||||
{invite.inviter ? `${invite.inviter} invited you` : 'You have been invited'}
|
||||
{invite.isDirect ? ' to a direct message' : ' to this room'}
|
||||
{account ? ` · ${account.displayName || account.id}` : ''}
|
||||
{invitation.detail}
|
||||
{account ? ` · ${serviceLabel(account.service)} · ${account.displayName || account.id}` : ''}
|
||||
</p>
|
||||
<div className="button-row">
|
||||
<button type="button" className="button primary" onClick={() => answer(true)}>
|
||||
@@ -49,8 +41,15 @@ export function InvitePanel({ groupId }: { groupId: string }): JSX.Element | nul
|
||||
</button>
|
||||
</div>
|
||||
<p className="small muted invite-panel-note">
|
||||
Accepting joins the room and starts syncing it. Declining leaves it, and the invitation
|
||||
goes away for good.
|
||||
{invitation.kind === 'friend'
|
||||
? 'Accepting makes you friends: the conversation with them opens here, and they can see when you are online. Declining removes the request.'
|
||||
: invitation.kind === 'verification'
|
||||
? 'Accepting starts the check: you will be shown emoji to compare with the other side. Only accept one you started or expected. Declining cancels it.'
|
||||
: invitation.direct
|
||||
? 'Accepting starts the conversation and opens it here. Declining leaves it, and the invitation goes away for good.'
|
||||
: invitation.service === 'steam'
|
||||
? 'Accepting joins the group chat and opens it here. Declining takes the invitation off this list; the message that carried it stays in your conversation with them.'
|
||||
: 'Accepting joins the room and starts syncing it. Declining leaves it, and the invitation goes away for good.'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ContextMenu } from './ContextMenu'
|
||||
import { RoomSearch } from './RoomSearch'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import { classes, serviceLabel } from '../lib/util'
|
||||
import { steamGroupInviteCode } from '../lib/steamchat'
|
||||
import type { Account, DiscordFriend } from '../../../shared/wire'
|
||||
|
||||
/**
|
||||
@@ -44,15 +45,6 @@ const ON_DISCORD_ONLY: { label: string; icon: string; why: string }[] = [
|
||||
}
|
||||
]
|
||||
|
||||
/** A room this account has been invited to and not yet answered. */
|
||||
type MatrixInvite = {
|
||||
roomId: string
|
||||
name: string
|
||||
inviter: string | null
|
||||
avatarUrl: string | null
|
||||
isDirect: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-protocol join pages rather than one generic "join by name" field: each
|
||||
* service's mechanism is genuinely different (IRC joins by channel name,
|
||||
@@ -82,13 +74,17 @@ export function JoinPanel(): JSX.Element {
|
||||
return <DiscordJoin account={account} />
|
||||
case 'kick':
|
||||
return <KickJoin account={account} />
|
||||
case 'twitch':
|
||||
return <TwitchJoin account={account} />
|
||||
case 'sneedchat':
|
||||
return <SneedchatRooms account={account} />
|
||||
case 'steam':
|
||||
return <SteamJoin 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))
|
||||
const joinable = all.filter((a) => ['irc', 'matrix', 'discord', 'kick', 'sneedchat', 'steam', 'twitch'].includes(a.service))
|
||||
|
||||
return (
|
||||
<div className="join-frame">
|
||||
@@ -367,6 +363,291 @@ function KickJoin({ account }: { account: Account }): JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
function TwitchJoin({ account }: { account: Account }): JSX.Element {
|
||||
const store = useStore()
|
||||
return (
|
||||
<div className="panel join-panel">
|
||||
<SubmitField
|
||||
label="Open a streamer's chat"
|
||||
placeholder="channel name, or a twitch.tv link"
|
||||
onSubmit={(name) => void store.joinAndOpen(account.id, name)}
|
||||
/>
|
||||
<p className="small muted">
|
||||
Twitch chat is public, so this works without signing in. Signing in adds talking and the
|
||||
channels you follow. A channel that does not exist is said so and not kept.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One person in a friend request. */
|
||||
interface SteamPerson {
|
||||
steamid: string
|
||||
name: string | null
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
interface SteamRequests {
|
||||
incoming: SteamPerson[]
|
||||
outgoing: SteamPerson[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Friends on Steam: asking somebody, and taking a request back.
|
||||
*
|
||||
* Steam makes you friends by one side asking and the other agreeing. Those who
|
||||
* have asked you are not here: each waits as a tile in the server list, beside
|
||||
* a Matrix room's invitations, and is answered there. What is here is the asking,
|
||||
* and the requests still waiting on the other side.
|
||||
*/
|
||||
function SteamJoin({ account }: { account: Account }): JSX.Element {
|
||||
const store = useStore()
|
||||
const [target, setTarget] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [requests, setRequests] = useState<SteamRequests>({ incoming: [], outgoing: [] })
|
||||
const [blocked, setBlocked] = useState<{ steamid: string; name: string }[]>([])
|
||||
const [links, setLinks] = useState<{ token: string; link: string; created: number }[] | null>(null)
|
||||
const connected = account.state === 'connected'
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
void window.moho
|
||||
.rpc<SteamRequests>('steamFriendRequests', { accountId: account.id })
|
||||
.then((r) => live && setRequests(r))
|
||||
.catch(() => {})
|
||||
const off = window.moho.onEvent((e) => {
|
||||
if (e.event !== 'steamFriendRequests') return
|
||||
const d = e.data as { accountId?: string; requests?: SteamRequests }
|
||||
if (d.accountId === account.id && d.requests) setRequests(d.requests)
|
||||
})
|
||||
// Who is blocked, and what links have been given out: neither has a place
|
||||
// in the conversation list, so they are here, where friends are added.
|
||||
void window.moho
|
||||
.rpc<{ steamid: string; name: string }[]>('steamBlockedList', { accountId: account.id })
|
||||
.then((b) => live && setBlocked(b))
|
||||
.catch(() => {})
|
||||
const offBlocked = window.moho.onEvent((e) => {
|
||||
if (e.event !== 'steamBlocked') return
|
||||
const d = e.data as { accountId?: string; blocked?: { steamid: string; name: string }[] }
|
||||
if (d.accountId === account.id && d.blocked) setBlocked(d.blocked)
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
off()
|
||||
offBlocked()
|
||||
}
|
||||
}, [account.id])
|
||||
|
||||
// The links, once the account is connected: they come from Steam's website.
|
||||
useEffect(() => {
|
||||
if (!connected) return
|
||||
let live = true
|
||||
void window.moho
|
||||
.rpc<{ token: string; link: string; created: number }[]>('steamInviteLinks', { accountId: account.id, action: 'list' })
|
||||
.then((l) => live && setLinks(l))
|
||||
.catch(() => live && setLinks([]))
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [account.id, connected])
|
||||
|
||||
const changeLinks = (action: 'create' | 'revoke', token?: string): void => {
|
||||
void window.moho
|
||||
.rpc<{ token: string; link: string; created: number }[]>('steamInviteLinks', { accountId: account.id, action, token })
|
||||
.then(setLinks)
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
|
||||
const copy = (text: string): void => {
|
||||
// Through the main process, as everywhere else in moho: the page's own clipboard is refused here.
|
||||
void window.moho
|
||||
.copyText(text)
|
||||
.then(() => store.toast('info', 'Copied'))
|
||||
.catch(() => store.toast('error', 'Could not copy'))
|
||||
}
|
||||
|
||||
const unblock = (p: { steamid: string; name: string }): void => {
|
||||
void window.moho
|
||||
.rpc('steamUnblock', { accountId: account.id, steamid: p.steamid })
|
||||
.then(() => store.toast('info', `Unblocked ${p.name}`))
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
|
||||
const label = (p: SteamPerson): string => p.name || p.steamid
|
||||
|
||||
const [groupLink, setGroupLink] = useState('')
|
||||
const [groupName, setGroupName] = useState('')
|
||||
const joinGroup = (): void => {
|
||||
const typed = groupLink.trim()
|
||||
if (!typed) return
|
||||
setBusy(true)
|
||||
void window.moho
|
||||
.rpc<{ groupId: string; name?: string }>('steamChatGroupAction', { accountId: account.id, action: 'join', invite: typed })
|
||||
.then((r) => {
|
||||
setGroupLink('')
|
||||
store.toast('info', `Joined ${r.name || 'the group'}`)
|
||||
store.selectGroup(r.groupId)
|
||||
})
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
.finally(() => setBusy(false))
|
||||
}
|
||||
const makeGroup = (): void => {
|
||||
const typed = groupName.trim()
|
||||
if (!typed) return
|
||||
setBusy(true)
|
||||
void window.moho
|
||||
.rpc<{ groupId: string }>('steamChatGroupAction', { accountId: account.id, action: 'create', name: typed })
|
||||
.then((r) => {
|
||||
setGroupName('')
|
||||
store.selectGroup(r.groupId)
|
||||
})
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
.finally(() => setBusy(false))
|
||||
}
|
||||
|
||||
const ask = (): void => {
|
||||
const typed = target.trim()
|
||||
if (!typed) return
|
||||
// A link to a group, pasted where a friend goes, is joined as the group it is.
|
||||
if (steamGroupInviteCode(typed)) {
|
||||
setGroupLink(typed)
|
||||
setTarget('')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
void window.moho
|
||||
.rpc<{ steamid: string; name: string | null }>('steamAddFriend', { accountId: account.id, target: typed })
|
||||
.then((r) => {
|
||||
setTarget('')
|
||||
store.toast('info', `Friend request sent to ${r.name || typed}`)
|
||||
})
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
.finally(() => setBusy(false))
|
||||
}
|
||||
|
||||
const withdraw = (p: SteamPerson): void => {
|
||||
void window.moho
|
||||
.rpc('steamRemoveFriend', { accountId: account.id, steamid: p.steamid })
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel join-panel">
|
||||
<span className="small muted">Add a friend</span>
|
||||
<div className="field-row">
|
||||
<input
|
||||
className="text-field"
|
||||
placeholder="Account name, Steam id, friend code or invite link"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
value={target}
|
||||
disabled={!connected || busy}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') ask()
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="button" disabled={!connected || busy || !target.trim()} onClick={ask}>
|
||||
Add friend
|
||||
</button>
|
||||
</div>
|
||||
{!connected && <p className="small muted">Friends can be added once this account is connected.</p>}
|
||||
|
||||
<span className="small muted">Join a group</span>
|
||||
<div className="field-row">
|
||||
<input
|
||||
className="text-field"
|
||||
placeholder="An invite link, like s.team/chat/…"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
value={groupLink}
|
||||
disabled={!connected || busy}
|
||||
onChange={(e) => setGroupLink(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') joinGroup()
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="button" disabled={!connected || busy || !groupLink.trim()} onClick={joinGroup}>
|
||||
Join
|
||||
</button>
|
||||
</div>
|
||||
<span className="small muted">Start a group</span>
|
||||
<div className="field-row">
|
||||
<input
|
||||
className="text-field"
|
||||
placeholder="A name for it"
|
||||
value={groupName}
|
||||
disabled={!connected || busy}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') makeGroup()
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="button" disabled={!connected || busy || !groupName.trim()} onClick={makeGroup}>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{requests.outgoing.length > 0 && (
|
||||
<>
|
||||
<span className="small muted">Waiting for them to accept</span>
|
||||
{requests.outgoing.map((p) => (
|
||||
<div key={p.steamid} className="field-row">
|
||||
<span className="grow">{label(p)}</span>
|
||||
<button type="button" className="button subtle small" onClick={() => withdraw(p)}>
|
||||
Cancel request
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* A link anybody can add this account by, even if it takes no requests
|
||||
from strangers: good for a month, and taken back here. */}
|
||||
{connected && links !== null && (
|
||||
<>
|
||||
<span className="small muted">Your invite links</span>
|
||||
{links.length === 0 && <p className="small muted">None yet. A link lets whoever has it add you, for thirty days.</p>}
|
||||
{links.map((l) => (
|
||||
<div key={l.token} className="field-row">
|
||||
<span className="grow ellipsis" title={l.link}>
|
||||
{l.link}
|
||||
</span>
|
||||
<button type="button" className="button subtle small" onClick={() => copy(l.link)}>
|
||||
Copy
|
||||
</button>
|
||||
<button type="button" className="button subtle small" onClick={() => changeLinks('revoke', l.token)}>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="field-row">
|
||||
<button type="button" className="button subtle small" onClick={() => changeLinks('create')}>
|
||||
Make a link
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{blocked.length > 0 && (
|
||||
<>
|
||||
<span className="small muted">Blocked</span>
|
||||
{blocked.map((p) => (
|
||||
<div key={p.steamid} className="field-row">
|
||||
<span className="grow ellipsis" title={p.steamid}>
|
||||
{p.name}
|
||||
</span>
|
||||
<button type="button" className="button subtle small" disabled={!connected} onClick={() => unblock(p)}>
|
||||
Unblock
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Making a room, or a space.
|
||||
*
|
||||
@@ -465,101 +746,10 @@ function CreateMatrixRoom({ account }: { account: Account }): JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rooms somebody has asked this account to join.
|
||||
*
|
||||
* Shown above the join field because it is the one thing here that arrived
|
||||
* on its own: everything else on this page is a room you already know the
|
||||
* name of and went looking for. An invite was invisible until recently -
|
||||
* the daemon never read them out of sync - so it is worth being plain about
|
||||
* who sent it, since a room with no name of its own shows as its raw id and
|
||||
* the inviter is the only part that makes it answerable.
|
||||
*/
|
||||
function MatrixInvites({ account }: { account: Account }): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const [invites, setInvites] = useState<MatrixInvite[]>([])
|
||||
const [answering, setAnswering] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const load = (): void => {
|
||||
void window.moho
|
||||
.rpc<MatrixInvite[]>('listMatrixInvites', { accountId: account.id })
|
||||
// An older daemon has no such method, and no invites to show.
|
||||
.then(setInvites)
|
||||
.catch(() => setInvites([]))
|
||||
}
|
||||
load()
|
||||
// The daemon pushes the whole set whenever it changes, so accepting from
|
||||
// another client empties this without anybody refreshing.
|
||||
return window.moho.onEvent((frame) => {
|
||||
if (frame.event !== 'matrixInvites') return
|
||||
const data = frame.data as { accountId: string; invites: MatrixInvite[] }
|
||||
if (data.accountId === account.id) setInvites(data.invites ?? [])
|
||||
})
|
||||
}, [account.id])
|
||||
|
||||
if (invites.length === 0) return null
|
||||
|
||||
const answer = (invite: MatrixInvite, accept: boolean): void => {
|
||||
setAnswering(invite.roomId)
|
||||
void window.moho
|
||||
.rpc(accept ? 'acceptMatrixInvite' : 'declineMatrixInvite', {
|
||||
accountId: account.id,
|
||||
roomId: invite.roomId
|
||||
})
|
||||
// Not removed from the list here: the next sync stops listing it,
|
||||
// which is what actually confirms the server agreed.
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
.finally(() => setAnswering(null))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="field">
|
||||
<span className="small muted">
|
||||
{invites.length === 1 ? 'You have been invited to' : `You have ${invites.length} invitations`}
|
||||
</span>
|
||||
<div className="invite-list">
|
||||
{invites.map((invite) => (
|
||||
<div className="invite-row" key={invite.roomId}>
|
||||
<div className="invite-what">
|
||||
<span className="invite-name ellipsis">{invite.name}</span>
|
||||
{invite.inviter && (
|
||||
<span className="small muted ellipsis">
|
||||
from {invite.inviter}
|
||||
{invite.isDirect ? ' · direct message' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="invite-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button primary"
|
||||
disabled={answering === invite.roomId}
|
||||
onClick={() => answer(invite, true)}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
disabled={answering === invite.roomId}
|
||||
onClick={() => answer(invite, false)}
|
||||
>
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MatrixJoin({ account }: { account: Account }): JSX.Element {
|
||||
const store = useStore()
|
||||
return (
|
||||
<div className="panel join-panel">
|
||||
<MatrixInvites account={account} />
|
||||
<SubmitField
|
||||
label="Join a room or space"
|
||||
// A Space is just a room with an m.space creation type, joined through
|
||||
@@ -762,11 +952,12 @@ function DiscordJoin({ account }: { account: Account }): JSX.Element {
|
||||
if (live) setFriends(live)
|
||||
}, [live])
|
||||
|
||||
// Requests are their own list above the friends. They are not people you
|
||||
// can message and they are the thing somebody opened this panel to deal
|
||||
// with, so filtering them by whether they happen to be online - which is
|
||||
// what the toggle below does - would hide the ones that matter most.
|
||||
const waiting = friends.filter((f) => f.kind === 'incoming' || f.kind === 'outgoing')
|
||||
// Requests this account has sent are their own list above the friends: not
|
||||
// people you can message yet, so filtering them by whether they happen to be
|
||||
// online - which is what the toggle below does - would hide them. The ones
|
||||
// sent to this account are not here: they wait on the Invitations tile in
|
||||
// the server list with every other question that wants a yes or a no.
|
||||
const waiting = friends.filter((f) => f.kind === 'outgoing')
|
||||
const settled = friends.filter((f) => !f.kind || f.kind === 'friend')
|
||||
const shown = onlineOnly ? settled.filter((f) => f.status && f.status !== 'offline') : settled
|
||||
|
||||
@@ -822,7 +1013,7 @@ function DiscordJoin({ account }: { account: Account }): JSX.Element {
|
||||
{waiting.length > 0 && (
|
||||
<>
|
||||
<div className="join-friends-header">
|
||||
<span className="setting-text">Requests</span>
|
||||
<span className="setting-text">Waiting for them to accept</span>
|
||||
</div>
|
||||
{waiting.map((f) => (
|
||||
<div key={f.userId} className="friend-row request">
|
||||
@@ -832,18 +1023,8 @@ function DiscordJoin({ account }: { account: Account }): JSX.Element {
|
||||
<span className="friend-avatar placeholder">{(f.globalName || f.username).slice(0, 1)}</span>
|
||||
)}
|
||||
<span className="ellipsis">{f.globalName || f.username}</span>
|
||||
<span className="small muted">
|
||||
{f.kind === 'incoming' ? 'wants to be friends' : 'asked, waiting'}
|
||||
</span>
|
||||
{f.kind === 'incoming' && (
|
||||
<button type="button" className="button" onClick={() => answer(f.userId, true)}>
|
||||
Accept
|
||||
</button>
|
||||
)}
|
||||
{/* Refusing one and taking back your own are the same call, and
|
||||
read differently enough to be worth different words. */}
|
||||
<button type="button" className="button subtle" onClick={() => answer(f.userId, false)}>
|
||||
{f.kind === 'incoming' ? 'Decline' : 'Cancel'}
|
||||
Cancel request
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Icon } from './Icon'
|
||||
import { useStore } from '../state/hooks'
|
||||
import { embedColor } from '../lib/format'
|
||||
import { nickColor } from '../lib/util'
|
||||
import type { Embed } from '../../../shared/wire'
|
||||
|
||||
/** The embed kinds the daemon draws as these cards instead of as a link preview. */
|
||||
export const KICK_EVENT_KINDS = ['kicks', 'gift-sub', 'raid', 'redeem', 'subscribe', 'moderation', 'stream']
|
||||
export const KICK_EVENT_KINDS = ['kicks', 'gift-sub', 'raid', 'redeem', 'subscribe', 'moderation', 'stream', 'raid-out']
|
||||
|
||||
/** The glyphs of a moderation card that is a penalty: red. The rest (a mode, an unban, a shoutout) are not. */
|
||||
const PUNISHING = ['gavel', 'timer_off', 'delete', 'block']
|
||||
|
||||
export function isKickEvent(embed: Embed): boolean {
|
||||
return !!embed.kind && KICK_EVENT_KINDS.includes(embed.kind)
|
||||
@@ -30,7 +34,8 @@ function KicksMark({ size = 16 }: { size?: number }): JSX.Element {
|
||||
* streamer gave it - a ban, a timeout or a chat cleared, and the stream going
|
||||
* live or ending.
|
||||
*/
|
||||
export function KickEventCard({ embed }: { embed: Embed }): JSX.Element | null {
|
||||
export function KickEventCard({ embed, bufferId }: { embed: Embed; bufferId?: string }): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const who = embed.author
|
||||
if (!who) return null
|
||||
const name = (colour?: string): JSX.Element => (
|
||||
@@ -86,6 +91,39 @@ export function KickEventCard({ embed }: { embed: Embed }): JSX.Element | null {
|
||||
)
|
||||
}
|
||||
|
||||
// Twitch: the channel being watched is sending its viewers to another (the embed's title is how many, its
|
||||
// description the other channel's login), and the card offers to go along.
|
||||
if (embed.kind === 'raid-out') {
|
||||
const account = bufferId ? store.accountFor(bufferId) : undefined
|
||||
return (
|
||||
<div className="kick-event raid selectable">
|
||||
<span className="kick-event-badge lead">
|
||||
<Icon name="groups" size={20} />
|
||||
</span>
|
||||
<div className="kick-event-main">
|
||||
<div className="kick-event-line">
|
||||
<span className="kick-event-verb">Raiding</span>
|
||||
{name()}
|
||||
{embed.title && (
|
||||
<>
|
||||
<span className="kick-event-verb">with</span>
|
||||
<strong className="kick-event-amount">{embed.title}</strong>
|
||||
<span className="kick-event-verb">{embed.title === '1' ? 'viewer.' : 'viewers.'}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{account && embed.description && (
|
||||
<div className="kick-event-message">
|
||||
<button type="button" className="button subtle" onClick={() => void store.joinAndOpen(account.id, embed.description ?? '')}>
|
||||
Open {who}'s chat
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (embed.kind === 'redeem') {
|
||||
return (
|
||||
<div className="kick-event redeem selectable" style={tint}>
|
||||
@@ -127,9 +165,9 @@ export function KickEventCard({ embed }: { embed: Embed }): JSX.Element | null {
|
||||
|
||||
if (embed.kind === 'moderation') {
|
||||
return (
|
||||
<div className="kick-event moderation selectable">
|
||||
<div className={`kick-event moderation selectable${PUNISHING.includes(embed.icon ?? 'gavel') ? '' : ' soft'}`}>
|
||||
<span className="kick-event-badge lead">
|
||||
<Icon name="gavel" size={20} />
|
||||
<Icon name={embed.icon || 'gavel'} size={20} />
|
||||
</span>
|
||||
<div className="kick-event-line">
|
||||
<strong className="kick-event-name">{who}</strong>
|
||||
@@ -192,3 +230,20 @@ export function KickEventCard({ embed }: { embed: Embed }): JSX.Element | null {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A line the service wrote rather than somebody saying it - a join, a topic change, a notice about the
|
||||
* conversation - as the same card the events above are, so every protocol's notifications look alike and a
|
||||
* protocol added later gets them by sending a line that is not speech. It carries the words as the log draws
|
||||
* them (links and emoji in place), a mark for what kind of line it was, and takes the red of a warning.
|
||||
*/
|
||||
export function NotificationCard({ icon, warning, children }: { icon: string; warning?: boolean; children: React.ReactNode }): JSX.Element {
|
||||
return (
|
||||
<div className={`kick-event notification selectable${warning ? ' warning' : ''}`}>
|
||||
<span className="kick-event-badge lead">
|
||||
<Icon name={icon} size={18} />
|
||||
</span>
|
||||
<div className="kick-event-line">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,70 +1,33 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { useStore } from '../state/hooks'
|
||||
import { useEscapeLayer } from '../lib/layers'
|
||||
import { useChat } from '../state/hooks'
|
||||
import { RewardsPanel } from './RewardsPanel'
|
||||
|
||||
/**
|
||||
* Channel points, and what the streamer will trade them for.
|
||||
* A Kick channel's points, and the way into what the streamer will trade them for.
|
||||
*
|
||||
* A plaque at the foot of the viewer list, because that is where Kick puts it
|
||||
* and because it belongs to the channel rather than to the conversation - it
|
||||
* is a fact about standing in this room, like the viewer count above it.
|
||||
*
|
||||
* The list opens upward. The plaque sits at the bottom of a full-height
|
||||
* column, so a pane growing downward from it would be drawn mostly off the
|
||||
* window; anchoring it to the plaque's top edge and letting it grow up is the
|
||||
* only direction with room. Its height is capped and it scrolls, because a
|
||||
* channel may offer more rewards than the window is tall.
|
||||
* A plaque at the foot of the viewer list, because that is where Kick puts it and because it belongs to the
|
||||
* channel rather than to the conversation - it is a fact about standing in this room, like the viewer count
|
||||
* above it. Pressing it opens the rewards panel over the chat (RewardsPanel.tsx), the same one Twitch's plaque
|
||||
* opens, instead of a menu of its own.
|
||||
*/
|
||||
interface Reward {
|
||||
id: string
|
||||
title: string
|
||||
cost: number
|
||||
description?: string
|
||||
enabled: boolean
|
||||
paused: boolean
|
||||
needsInput: boolean
|
||||
}
|
||||
|
||||
/** How often the balance is asked for while this channel is on screen. */
|
||||
const POINTS_INTERVAL_MS = 60_000
|
||||
|
||||
interface Answer {
|
||||
rewards: Reward[]
|
||||
points: number | null
|
||||
}
|
||||
|
||||
export function KickPoints({ bufferId }: { bufferId: string }): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [answer, setAnswer] = useState<Answer | null>(null)
|
||||
const [busy, setBusy] = useState('')
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [points, setPoints] = useState<number | null>(null)
|
||||
|
||||
const refresh = (): void => {
|
||||
void window.moho
|
||||
.rpc<Answer>('listKickRewards', { bufferId })
|
||||
.then((a) => {
|
||||
setAnswer(a)
|
||||
if (a.points !== null) setPoints(a.points)
|
||||
})
|
||||
.catch(() => setAnswer(null))
|
||||
}
|
||||
// Told by Kick the moment it changes, where the daemon is following it; the timer below is for a channel that
|
||||
// is not live, which is not followed.
|
||||
const pushed = useChat((s) => s.kickPointsByBuffer[bufferId])
|
||||
useEffect(() => {
|
||||
if (pushed !== undefined) setPoints(pushed)
|
||||
}, [pushed])
|
||||
|
||||
// The balance, shown whether or not the pane is open, and kept current
|
||||
// while somebody is actually looking at this channel.
|
||||
//
|
||||
// Only while looking: this component exists only for the conversation on
|
||||
// screen, so a channel scrolled away from stops costing anything the moment
|
||||
// it is closed. A poll per open channel would be a request a minute for
|
||||
// every Kick channel an account watches, forever, to keep numbers nobody is
|
||||
// reading up to date.
|
||||
//
|
||||
// Paused with the window too. A minimised client is not somebody watching,
|
||||
// and points that are a few minutes stale when the window comes back are
|
||||
// corrected by the next tick before anyone could spend them.
|
||||
// The balance, shown whether or not the panel is open, and kept current while somebody is actually looking at
|
||||
// this channel: this component exists only for the conversation on screen, and is paused with the window.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const ask = (): void => {
|
||||
@@ -72,15 +35,12 @@ export function KickPoints({ bufferId }: { bufferId: string }): JSX.Element | nu
|
||||
void window.moho
|
||||
.rpc<{ points: number }>('kickPoints', { bufferId })
|
||||
.then((a) => !cancelled && setPoints(a.points))
|
||||
// A channel that will not say leaves the last number rather than
|
||||
// blanking the plaque - it was true a minute ago, which is closer
|
||||
// than nothing.
|
||||
// A channel that will not say leaves the last number rather than blanking the plaque.
|
||||
.catch(() => {})
|
||||
}
|
||||
setPoints(null)
|
||||
ask()
|
||||
const timer = setInterval(ask, POINTS_INTERVAL_MS)
|
||||
// Coming back to the window should not wait out the rest of a minute.
|
||||
document.addEventListener('visibilitychange', ask)
|
||||
return () => {
|
||||
cancelled = true
|
||||
@@ -89,99 +49,17 @@ export function KickPoints({ bufferId }: { bufferId: string }): JSX.Element | nu
|
||||
}
|
||||
}, [bufferId])
|
||||
|
||||
// The rest - what the points can buy - only when the pane is opened. A
|
||||
// channel's rewards are set up once and edited rarely, so asking for them
|
||||
// every minute would be a request spent on an answer that does not change.
|
||||
useEffect(() => {
|
||||
if (open) refresh()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, bufferId])
|
||||
|
||||
// Closing the channel takes the pane with it, so it cannot be left open
|
||||
// over a conversation it is not about.
|
||||
// Closing the channel takes the panel with it, so it cannot be left open over a conversation it is not about.
|
||||
useEffect(() => setOpen(false), [bufferId])
|
||||
|
||||
useEscapeLayer(() => setOpen(false), open)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const away = (e: MouseEvent): void => {
|
||||
if (!box.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', away)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', away)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
|
||||
const redeem = (reward: Reward): void => {
|
||||
setBusy(reward.id)
|
||||
void window.moho
|
||||
.rpc<{ points: number | null }>('redeemKickReward', { bufferId, rewardId: reward.id })
|
||||
.then((a) => {
|
||||
store.toast('info', `Redeemed ${reward.title}`)
|
||||
// The balance the server now holds, rather than this one minus the
|
||||
// cost - a redeem that was refunded or cost something else should not
|
||||
// leave the plaque disagreeing with Kick.
|
||||
if (a.points !== null) setPoints(a.points)
|
||||
})
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
.finally(() => setBusy(''))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="kick-points" ref={box}>
|
||||
{open && (
|
||||
<div className="kick-rewards" role="menu">
|
||||
<div className="kick-rewards-head small muted">
|
||||
{answer ? `${answer.rewards.length} rewards` : 'Asking Kick…'}
|
||||
</div>
|
||||
<div className="kick-rewards-list">
|
||||
{answer?.rewards.map((r) => {
|
||||
// Shown whatever their state - the list is also how somebody
|
||||
// learns what this channel does - but only one of them can
|
||||
// actually be pressed.
|
||||
const why = !r.enabled
|
||||
? 'The streamer has this turned off'
|
||||
: r.paused
|
||||
? 'Paused right now'
|
||||
: r.needsInput
|
||||
? 'This one asks you to type something, which moho cannot do yet'
|
||||
: points !== null && points < r.cost
|
||||
? `You have ${points.toLocaleString()} of ${r.cost.toLocaleString()}`
|
||||
: ''
|
||||
return (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
className={`kick-reward${why ? ' locked' : ''}`}
|
||||
disabled={!!why || busy === r.id}
|
||||
title={why || r.description || r.title}
|
||||
onClick={() => redeem(r)}
|
||||
>
|
||||
<span className="ellipsis">{r.title}</span>
|
||||
<span className="kick-reward-cost small">{r.cost.toLocaleString()}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{answer?.rewards.length === 0 && (
|
||||
<div className="small muted kick-rewards-empty">This channel offers nothing for points.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`kick-points-plaque${open ? ' open' : ''}`}
|
||||
title="Channel points, and what they buy"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<div className="kick-points">
|
||||
<button type="button" className={`kick-points-plaque${open ? ' open' : ''}`} title="Channel points, and what they buy" onClick={() => setOpen(true)}>
|
||||
<Icon name="toll" size={15} />
|
||||
<span className="ellipsis">{points === null ? 'Points' : points.toLocaleString()}</span>
|
||||
<Icon name={open ? 'expand_more' : 'expand_less'} size={15} />
|
||||
<Icon name="redeem" size={15} />
|
||||
</button>
|
||||
{open && <RewardsPanel bufferId={bufferId} service="kick" onClose={() => setOpen(false)} onBalance={setPoints} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ function Card({ card, reviewing = false }: { card: LiveCard; reviewing?: boolean
|
||||
{/* Ending it is the streamer's own gesture, and the service refuses
|
||||
it for anybody else - so it is offered and allowed to fail there
|
||||
rather than guessed at from a role this client may not know. */}
|
||||
{!reviewing && !isPrediction && open && (
|
||||
{!reviewing && !isPrediction && open && !card.readOnly && !card.noEnd && (
|
||||
<IconButton name="delete" size={16} title="End this poll" onClick={() => store.endPoll(card.bufferId, card.id)} />
|
||||
)}
|
||||
<IconButton
|
||||
@@ -175,7 +175,7 @@ function Card({ card, reviewing = false }: { card: LiveCard; reviewing?: boolean
|
||||
)}
|
||||
// A poll row is the vote; a prediction row only chooses what the
|
||||
// points go on, because the amount is the other half of a bet.
|
||||
disabled={reviewing || !open || (!isPrediction && card.hasVoted)}
|
||||
disabled={reviewing || !open || card.readOnly || (!isPrediction && card.hasVoted)}
|
||||
onClick={() =>
|
||||
isPrediction ? setPicked(option.id) : store.votePoll(card.bufferId, option.id)
|
||||
}
|
||||
@@ -216,7 +216,7 @@ function Card({ card, reviewing = false }: { card: LiveCard; reviewing?: boolean
|
||||
{/* The bet itself. Only while it is taking them, and only where the
|
||||
service says what this account has to spend - offering to bet
|
||||
points that cannot be counted is offering a button that fails. */}
|
||||
{isPrediction && !reviewing && open && (
|
||||
{isPrediction && !reviewing && open && !card.readOnly && (
|
||||
<div className="poll-bet">
|
||||
<input
|
||||
className="poll-bet-amount"
|
||||
|
||||
@@ -113,7 +113,7 @@ export function MediaEmbed({
|
||||
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 spoiler = !!attachment?.filename?.startsWith(SPOILER_PREFIX) || !!item?.spoiler
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
const hidden = spoiler && !revealed
|
||||
// What it is called, less the marker: that is not part of the name.
|
||||
|
||||
@@ -7,10 +7,12 @@ import { CallView } from './CallView'
|
||||
import { CallStage, RoomCallBar } from './CallStage'
|
||||
import { StreamStage } from './StreamStage'
|
||||
import { ServiceBanner } from './ServiceBanner'
|
||||
import { ConversationNotice } from './ConversationNotice'
|
||||
import { RouteAccount, useStrictRoute } from '../lib/route'
|
||||
import { useChat, usePref, useStore } from '../state/hooks'
|
||||
import { bufferDisplayName, dayLabel, isChatKind, startsNewDay } from '../lib/util'
|
||||
import type { ChannelIndex } from '../lib/format'
|
||||
import { applyRemoved, type RemovedMessages } from '../lib/moderation'
|
||||
import type { ChatMessage } from '../state/store'
|
||||
|
||||
/**
|
||||
@@ -233,6 +235,8 @@ export function MessageList(): JSX.Element {
|
||||
const [showPolls] = usePref<boolean>('kick.showPolls', true)
|
||||
const [showStream] = usePref<boolean>('kick.showStreamEvents', true)
|
||||
const [showKickMod] = usePref<boolean>('kick.showModeration', true)
|
||||
// What Kick took down: left out, struck through, or replaced by a notice.
|
||||
const [removedMode] = usePref<RemovedMessages>('kick.removedMessages', 'strike')
|
||||
const [showReaders] = usePref<boolean>('matrix.showReadReceipts', true)
|
||||
const [blockedNicks] = usePref<string[]>('blockedNicks', [])
|
||||
const readers = useChat((s) => s.readersByBuffer)[bufferId]
|
||||
@@ -255,7 +259,8 @@ export function MessageList(): JSX.Element {
|
||||
const threadReplies = useMemo(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const m of all) {
|
||||
if (m.replyTo?.thread) counts[m.replyTo.id] = (counts[m.replyTo.id] ?? 0) + 1
|
||||
const root = m.replyTo?.thread ? m.replyTo.id : m.replyTo?.threadRoot
|
||||
if (root) counts[root] = (counts[root] ?? 0) + 1
|
||||
}
|
||||
return counts
|
||||
}, [all])
|
||||
@@ -278,14 +283,17 @@ export function MessageList(): JSX.Element {
|
||||
stream: showStream,
|
||||
moderation: showKickMod
|
||||
}
|
||||
return all.filter((m) => {
|
||||
if (accountId && blockedNicks.includes(`${accountId}|${m.from}`)) return false
|
||||
const kind = m.kind || 'chat'
|
||||
if (isChatKind(kind) || kind === 'system') return true
|
||||
return allowed[kind] !== false
|
||||
})
|
||||
return applyRemoved(
|
||||
all.filter((m) => {
|
||||
if (accountId && blockedNicks.includes(`${accountId}|${m.from}`)) return false
|
||||
const kind = m.kind || 'chat'
|
||||
if (isChatKind(kind) || kind === 'system') return true
|
||||
return allowed[kind] !== false
|
||||
}),
|
||||
removedMode
|
||||
)
|
||||
}, [
|
||||
all, accountId, blockedNicks,
|
||||
all, accountId, blockedNicks, removedMode,
|
||||
showJoin, showPart, showNick, showTopic, showMode,
|
||||
showMxJoin, showMxInvite, showMxKick, showMxQuit,
|
||||
showRedemptions, showSubs, showRaids, showPolls, showStream, showKickMod
|
||||
@@ -778,6 +786,8 @@ export function MessageList(): JSX.Element {
|
||||
{/* First, because it explains everything under it: a conversation that
|
||||
is not receiving looks like a quiet one otherwise. */}
|
||||
<ServiceBanner bufferId={bufferId} />
|
||||
{/* A warning the service put on this conversation, in red, until it is dismissed. */}
|
||||
<ConversationNotice bufferId={bufferId} />
|
||||
{/* Above the log and outside the scroller. Who is talking is only
|
||||
useful while it can be seen, and a panel that scrolled away with
|
||||
the backlog would be gone the moment anybody read anything. */}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { steamRoomIds } from '../lib/steamchat'
|
||||
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Icon } from './Icon'
|
||||
@@ -7,8 +8,8 @@ import { EventSource } from './EventSource'
|
||||
import { ForwardPicker } from './ForwardPicker'
|
||||
import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu'
|
||||
import { MediaEmbed } from './MediaEmbed'
|
||||
import { KickEventCard, isKickEvent } from './KickEventCard'
|
||||
import { COMMON_EMOJI, EmojiPicker, readRecent } from './EmojiPicker'
|
||||
import { KickEventCard, NotificationCard, isKickEvent } from './KickEventCard'
|
||||
import { COMMON_EMOJI, EmojiPicker, readRecent, type StickerEntry } from './EmojiPicker'
|
||||
import { reactionLabel, reactorsSentence } from '../lib/reactions'
|
||||
import { UploadMeter } from './UploadMeter'
|
||||
import { RichText } from '../lib/richtext'
|
||||
@@ -30,16 +31,20 @@ import {
|
||||
embedColor,
|
||||
extractCodeBlocks,
|
||||
extractMedia,
|
||||
markSpoilered,
|
||||
extractQuoteBlocks,
|
||||
formatMessage,
|
||||
normalizeBBCode,
|
||||
clearnetLinks,
|
||||
sneedGreentext,
|
||||
thumbnailLinks,
|
||||
youtubeId,
|
||||
stripCodeBlocks,
|
||||
stripEmbeddedUrls,
|
||||
stripQuoteBlocks,
|
||||
discordEmojiUrl,
|
||||
steamPictureUrl,
|
||||
steamTokenOf,
|
||||
type ChannelIndex
|
||||
} from '../lib/format'
|
||||
import {
|
||||
@@ -51,11 +56,14 @@ import {
|
||||
hasDirectMessages,
|
||||
isChatKind,
|
||||
isNotice,
|
||||
isWarning,
|
||||
isReward,
|
||||
isWhisper,
|
||||
nickColor
|
||||
nickColor,
|
||||
readableColor
|
||||
} from '../lib/util'
|
||||
import { useEscapeLayer } from '../lib/layers'
|
||||
import { removedReason } from '../lib/moderation'
|
||||
|
||||
/**
|
||||
* A badge, short enough to sit beside a name.
|
||||
@@ -66,6 +74,11 @@ import { useEscapeLayer } from '../lib/layers'
|
||||
* being dropped - an unfamiliar badge still says the person has one.
|
||||
*/
|
||||
function badgeLabel(badge: MessageBadge): string {
|
||||
return ROLE_WORDS[badge.type] ?? badge.text.slice(0, 3).toUpperCase()
|
||||
}
|
||||
|
||||
/** The words that stand for the roles worth looking for in a name's badges. */
|
||||
const ROLE_WORDS: Record<string, string> = ((): Record<string, string> => {
|
||||
const short: Record<string, string> = {
|
||||
broadcaster: 'HOST',
|
||||
moderator: 'MOD',
|
||||
@@ -79,10 +92,17 @@ function badgeLabel(badge: MessageBadge): string {
|
||||
// Not a title like the rest of these - a warning that whoever sent this
|
||||
// was using a device nobody has vouched for. Without its own short form
|
||||
// the fallback below would abbreviate the explanation into "SEN".
|
||||
unverified: '⚠'
|
||||
unverified: '⚠',
|
||||
// Twitch's: somebody's first message in a channel, and a message mirrored
|
||||
// from another channel of a shared chat.
|
||||
'first-message': 'NEW',
|
||||
'shared-chat': 'SHARED',
|
||||
// A cheer: bits sent with the message.
|
||||
cheer: 'BITS',
|
||||
lead_moderator: 'MOD'
|
||||
}
|
||||
return short[badge.type] ?? badge.text.slice(0, 3).toUpperCase()
|
||||
}
|
||||
return short
|
||||
})()
|
||||
|
||||
/** A handful of one-click reactions on the hover toolbar. */
|
||||
/** What a line that is not speech is marked with, by the daemon's word for it. */
|
||||
@@ -345,6 +365,12 @@ function MessageRowBody({
|
||||
// preview. The object itself, so the row only re-renders when that one
|
||||
// message changes.
|
||||
const replyId = message.replyTo && !message.replyTo.thread && !message.replyTo.forwarded ? message.replyTo.id : ''
|
||||
// The thread open beside the room, if any: a reply to its first message has
|
||||
// nothing to say that the panel's own heading does not.
|
||||
const openRoot = useChat((s) => s.openThread?.rootId)
|
||||
const [showBadgePictures] = usePref<boolean>('kick.showBadgePictures', true)
|
||||
const twitchModerator = useChat((s) => s.twitchRights[bufferId] === true)
|
||||
const twitchBlocker = useChat((s) => s.accounts.find((a) => bufferId.startsWith(`${a.id}|`))?.moderation === true)
|
||||
const original = useChat((s) => (replyId ? s.messagesByBuffer[bufferId]?.find((m) => m.id === replyId) : undefined))
|
||||
// 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.
|
||||
@@ -384,7 +410,16 @@ function MessageRowBody({
|
||||
// messages; IRC has no such concept.
|
||||
const canEditDelete =
|
||||
!!message.isOwn && (service === 'discord' || service === 'sneedchat' || service === 'matrix')
|
||||
const canReact = service === 'discord' || service === 'matrix'
|
||||
const canReact = service === 'discord' || service === 'matrix' || service === 'steam'
|
||||
// Steam's stickers, asked for when the reaction picker opens: it reacts with them too.
|
||||
const [steamStickers, setSteamStickers] = useState<StickerEntry[]>([])
|
||||
useEffect(() => {
|
||||
if (!pickerOpen || service !== 'steam') return
|
||||
void window.moho
|
||||
.rpc<StickerEntry[]>('listSteamStickers', { bufferId })
|
||||
.then(setSteamStickers)
|
||||
.catch(() => setSteamStickers([]))
|
||||
}, [pickerOpen, service, bufferId])
|
||||
// Read again after the picker has been used, which is when it changes.
|
||||
const quick = useMemo(
|
||||
() => quickReactions(store.accountFor(bufferId)?.id),
|
||||
@@ -410,6 +445,9 @@ function MessageRowBody({
|
||||
// KICKs sent, or subscriptions gifted: the daemon says which and to whom in
|
||||
// a card, and the line of text is what a copy or a search finds.
|
||||
const kickEvent = (message.embeds ?? []).find(isKickEvent)
|
||||
// Any other line the service wrote rather than somebody saying it - a join, a topic, a notice - is drawn as the
|
||||
// same card the events are, whatever protocol it came from (see ARCHITECTURE.md, "Notifications are cards").
|
||||
const systemCard = isSystem && !kickEvent && !!message.body
|
||||
// Both of the modes that draw an avatar column. Bubbles is otherwise
|
||||
// nothing like comfy, but it wants the same picture beside the same first
|
||||
// line of a group.
|
||||
@@ -457,18 +495,20 @@ function MessageRowBody({
|
||||
// because it is about what this reader's browser can reach, and it has to
|
||||
// hold for an edited line and for backlog alike - both of which arrive at
|
||||
// this function again and neither of which is re-stored.
|
||||
const raw = clearnetLinks(message.body || '')
|
||||
// Sneedchat's own greentext is put back to the plain ">" first, so it is one thing however it arrived.
|
||||
const raw = isSneedchat ? sneedGreentext(clearnetLinks(message.body || '')) : clearnetLinks(message.body || '')
|
||||
const normalized = normalizeBBCode(raw)
|
||||
const media = extractMedia(normalized, {
|
||||
const media = markSpoilered(extractMedia(normalized, {
|
||||
contentSniffing,
|
||||
sniffed,
|
||||
onNeedSniff: (url) => sniffUrl(url, strictRoute),
|
||||
// Read from the un-normalised body: the pairing lives in the BBCode,
|
||||
// and normalising unwraps both tags into two unrelated bare URLs.
|
||||
thumbnails: thumbnailLinks(raw)
|
||||
})
|
||||
}), normalized)
|
||||
const codeBlocks = extractCodeBlocks(normalized)
|
||||
const quoteBlocks = extractQuoteBlocks(stripCodeBlocks(normalized))
|
||||
// Not on Sneedchat, where a line starting with ">" is greentext and stays in the line it is in.
|
||||
const quoteBlocks = isSneedchat ? [] : extractQuoteBlocks(stripCodeBlocks(normalized))
|
||||
|
||||
// The page a GIF was linked from is not shown as well as the GIF: it is the
|
||||
// same thing said twice, and is how a posted GIF read as an address.
|
||||
@@ -477,7 +517,7 @@ function MessageRowBody({
|
||||
.map((e) => ({ url: e.url!, kind: 'image' as const }))
|
||||
let body = stripEmbeddedUrls(normalized, [...media, ...gifPages])
|
||||
body = stripCodeBlocks(body)
|
||||
body = stripQuoteBlocks(body)
|
||||
if (!isSneedchat) body = stripQuoteBlocks(body)
|
||||
|
||||
return {
|
||||
// A sender's own formatting, where the protocol carried any: Matrix
|
||||
@@ -556,7 +596,8 @@ function MessageRowBody({
|
||||
}
|
||||
] as MenuEntry[])
|
||||
: []),
|
||||
{ label: 'Reply', icon: 'reply', onClick: () => reply() },
|
||||
// Steam chat has no replies - a message is a line of text and nothing points at another.
|
||||
...(service === 'steam' ? [] : ([{ label: 'Reply', icon: 'reply', onClick: () => reply() }] as MenuEntry[])),
|
||||
// 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.
|
||||
@@ -646,6 +687,17 @@ function MessageRowBody({
|
||||
}
|
||||
] as MenuEntry[])
|
||||
: []),
|
||||
// Your own words in a room of a Steam group can be taken back.
|
||||
...(service === 'steam' && message.isOwn && steamRoomIds(store.getSnapshot().buffers.find((b) => b.id === bufferId))
|
||||
? ([
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: 'delete',
|
||||
danger: true,
|
||||
onClick: () => void store.deleteMessage(bufferId, message.id)
|
||||
}
|
||||
] as MenuEntry[])
|
||||
: []),
|
||||
...(permissions.canRedactOthers && !message.isOwn
|
||||
? ([
|
||||
{
|
||||
@@ -660,6 +712,19 @@ function MessageRowBody({
|
||||
? ([{ separator: true } as MenuEntry] as MenuEntry[])
|
||||
: []),
|
||||
...moderationEntries(message, permissions, bufferId, store),
|
||||
// Twitch's own block list, for whoever turned those rights on: server side, so it holds in every
|
||||
// app and channel, which the window's own Ignore does not.
|
||||
...(twitchBlocker && service === 'twitch' && !message.isOwn && !isSystem && message.from
|
||||
? ([
|
||||
{ label: `Block ${message.from} on Twitch`, icon: 'block', danger: true, onClick: () => twitchAct(store, bufferId, message, 'block', {}, `${message.from} is blocked`) },
|
||||
{ label: `Unblock ${message.from}`, icon: 'check_circle', onClick: () => twitchAct(store, bufferId, message, 'unblock', {}, `${message.from} is unblocked`) }
|
||||
] as MenuEntry[])
|
||||
: []),
|
||||
// A Twitch moderator's: only where Twitch said this account is one for the channel, and not for their
|
||||
// own lines. Timing out is by the sender's login; removing a message is by its id.
|
||||
...(twitchModerator && service === 'twitch' && !message.isOwn && !isSystem && message.from
|
||||
? twitchModerationEntries(message, bufferId, store)
|
||||
: []),
|
||||
// Telling whoever runs the homeserver. Beside the moderation entries and
|
||||
// deliberately not gated like them: those need power in the room and this
|
||||
// needs none, which is the point of it - it is the answer available to
|
||||
@@ -778,6 +843,36 @@ function MessageRowBody({
|
||||
return { claimed, loose: parts.media.filter((m) => !taken.has(m.url)) }
|
||||
}, [message.embeds, parts.media])
|
||||
|
||||
/** The words of the line itself: its text with its links, emoji and marks. */
|
||||
const renderBody = (): React.ReactNode =>
|
||||
parts.html && (
|
||||
<span className="message-body selectable" title={message.moderated && !message.moderated.replaced ? removedReason(message) : undefined}>
|
||||
<RichText
|
||||
html={parts.html}
|
||||
// Ours unless the sender sent their own formatting, which
|
||||
// is the one case where a picture it names is a stranger's
|
||||
// server being told this message was read.
|
||||
ownMarkup={!message.html}
|
||||
onRevealSpoiler={(i) => setRevealed((r) => ({ ...r, [i]: true }))}
|
||||
// Following a channel link moves the rail too: the channel
|
||||
// is usually in the same guild, but a cross-guild link is
|
||||
// exactly the case where landing on a list that isn't
|
||||
// showing it would be baffling.
|
||||
onOpenChannel={(id) => void store.selectBuffer(id, true)}
|
||||
onOpenLink={(url) => store.followDeepLink(url)}
|
||||
/>
|
||||
{message.edited && (
|
||||
<span
|
||||
className="edited-tag small muted"
|
||||
title={message.editedTs ? `Edited ${formatFullTime(message.editedTs)}` : 'Edited'}
|
||||
>
|
||||
{' '}
|
||||
(edited)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -786,11 +881,13 @@ function MessageRowBody({
|
||||
message.isHighlight && 'highlight',
|
||||
whispered && 'whisper',
|
||||
notice && highlightNotices && 'notice',
|
||||
isWarning(message.kind, message.id) && 'warning',
|
||||
message.pending && 'pending',
|
||||
message.failed && 'failed',
|
||||
message.moderated && (message.moderated.replaced ? 'removed replaced' : 'removed'),
|
||||
isSystem && 'system',
|
||||
reward && 'reward',
|
||||
kickEvent && 'has-kick-event',
|
||||
(kickEvent || systemCard) && 'has-kick-event',
|
||||
grouped && 'grouped',
|
||||
fresh && 'fresh',
|
||||
comfy && 'comfy',
|
||||
@@ -833,7 +930,7 @@ function MessageRowBody({
|
||||
{/* A join, a part, a topic change: not somebody speaking, so no face -
|
||||
but a small mark in the picture's own column, so the line's text
|
||||
starts where every other line's does instead of at the edge. */}
|
||||
{mode === 'comfy' && isSystem && !kickEvent && (
|
||||
{mode === 'comfy' && isSystem && !kickEvent && !systemCard && (
|
||||
<span className="message-avatar system-mark" aria-hidden>
|
||||
<Icon name={SYSTEM_MARKS[message.kind] ?? 'info'} size={16} />
|
||||
</span>
|
||||
@@ -887,7 +984,7 @@ function MessageRowBody({
|
||||
<span className="message-byline">
|
||||
<span
|
||||
className={classes('message-from', canOpenProfile && 'clickable')}
|
||||
style={{ color: message.senderColor || nickColor(message.from) }}
|
||||
style={{ color: message.senderColor ? readableColor(message.senderColor) : nickColor(message.from) }}
|
||||
onClick={canOpenProfile ? openProfile : undefined}
|
||||
role={canOpenProfile ? 'button' : undefined}
|
||||
title={canOpenProfile ? `Profile of ${message.from}` : undefined}
|
||||
@@ -901,15 +998,30 @@ function MessageRowBody({
|
||||
{timeLabel}
|
||||
</span>
|
||||
)}
|
||||
{message.badges?.map((badge) => (
|
||||
<span
|
||||
key={badge.type}
|
||||
className={`sender-badge small ${badge.type}`}
|
||||
title={badge.count ? `${badge.text} — ${badge.count} months` : badge.text}
|
||||
>
|
||||
{badgeLabel(badge)}
|
||||
</span>
|
||||
))}
|
||||
{message.badges?.map((badge) =>
|
||||
badge.image && showBadgePictures ? (
|
||||
// The service's own pictures, unless they are turned off. The tooltip
|
||||
// and the alt text say what it is for anybody who cannot see it.
|
||||
<img
|
||||
key={badge.type}
|
||||
className="sender-badge-image"
|
||||
src={media(badge.image)}
|
||||
alt={badge.text}
|
||||
title={badge.count ? `${badge.text} — ${badge.count} months` : badge.text}
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
/>
|
||||
) : // Turned off, a badge that has a word for it is that word and one that has none is nothing.
|
||||
badge.image && !(badge.type in ROLE_WORDS) ? null : (
|
||||
<span
|
||||
key={badge.type}
|
||||
className={`sender-badge small ${badge.type}`}
|
||||
title={badge.count ? `${badge.text} — ${badge.count} months` : badge.text}
|
||||
>
|
||||
{badgeLabel(badge)}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -950,6 +1062,7 @@ function MessageRowBody({
|
||||
the panel's title already says. */}
|
||||
{message.replyTo &&
|
||||
!(inThread && message.replyTo.thread) &&
|
||||
!(inThread && message.replyTo.id === openRoot) &&
|
||||
(message.replyTo.thread && !inThread ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -991,12 +1104,27 @@ function MessageRowBody({
|
||||
style={
|
||||
message.replyTo.forwarded
|
||||
? undefined
|
||||
: { color: original?.senderColor || nickColor(message.replyTo.from) }
|
||||
: { color: original?.senderColor ? readableColor(original.senderColor) : nickColor(message.replyTo.from) }
|
||||
}
|
||||
>
|
||||
{message.replyTo.from}
|
||||
</span>
|
||||
<span className="ellipsis">{message.replyTo.body}</span>
|
||||
{/* Kick groups a chain of replies under the message it began
|
||||
with; a reply is a way into that conversation. */}
|
||||
{!inThread && message.replyTo.threadRoot && (
|
||||
<button
|
||||
type="button"
|
||||
className="reply-thread-open small"
|
||||
title="Open the thread"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void store.openThreadPanel(bufferId, message.replyTo!.threadRoot!)
|
||||
}}
|
||||
>
|
||||
<Icon name="forum" size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1064,35 +1192,13 @@ function MessageRowBody({
|
||||
)}
|
||||
</div>
|
||||
) : kickEvent ? (
|
||||
<KickEventCard embed={kickEvent} />
|
||||
<KickEventCard embed={kickEvent} bufferId={bufferId} />
|
||||
) : systemCard ? (
|
||||
<NotificationCard icon={SYSTEM_MARKS[message.kind] ?? 'info'} warning={isWarning(message.kind, message.id)}>
|
||||
{renderBody()}
|
||||
</NotificationCard>
|
||||
) : (
|
||||
parts.html && (
|
||||
<span className="message-body selectable">
|
||||
<RichText
|
||||
html={parts.html}
|
||||
// Ours unless the sender sent their own formatting, which
|
||||
// is the one case where a picture it names is a stranger's
|
||||
// server being told this message was read.
|
||||
ownMarkup={!message.html}
|
||||
onRevealSpoiler={(i) => setRevealed((r) => ({ ...r, [i]: true }))}
|
||||
// Following a channel link moves the rail too: the channel
|
||||
// is usually in the same guild, but a cross-guild link is
|
||||
// exactly the case where landing on a list that isn't
|
||||
// showing it would be baffling.
|
||||
onOpenChannel={(id) => void store.selectBuffer(id, true)}
|
||||
onOpenLink={(url) => store.followDeepLink(url)}
|
||||
/>
|
||||
{message.edited && (
|
||||
<span
|
||||
className="edited-tag small muted"
|
||||
title={message.editedTs ? `Edited ${formatFullTime(message.editedTs)}` : 'Edited'}
|
||||
>
|
||||
{' '}
|
||||
(edited)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
renderBody()
|
||||
)}
|
||||
|
||||
{parts.quoteBlocks.map((quote, i) => (
|
||||
@@ -1368,7 +1474,10 @@ function MessageRowBody({
|
||||
answer. */}
|
||||
{!isSystem && !message.pending && !message.failed && (
|
||||
<div className="hover-toolbar">
|
||||
{/* Steam reacts with its own emoticons and stickers, so the
|
||||
one-click row of Unicode emoji is not offered there. */}
|
||||
{canReact &&
|
||||
service !== 'steam' &&
|
||||
quick.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
@@ -1391,9 +1500,11 @@ function MessageRowBody({
|
||||
<Icon name="add_reaction" size={15} />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="toolbar-button" title="Reply" onClick={reply}>
|
||||
<Icon name="reply" size={15} />
|
||||
</button>
|
||||
{service !== 'steam' && (
|
||||
<button type="button" className="toolbar-button" title="Reply" onClick={reply}>
|
||||
<Icon name="reply" size={15} />
|
||||
</button>
|
||||
)}
|
||||
{canEditDelete && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1429,6 +1540,19 @@ function MessageRowBody({
|
||||
anchor={reactButtonRef.current}
|
||||
customEmoji={bufferEmoji}
|
||||
accountId={store.accountFor(bufferId)?.id}
|
||||
// Steam reacts with what the account was given and nothing else: its
|
||||
// emoticons, and its stickers on their own tab.
|
||||
bufferId={service === 'steam' ? bufferId : undefined}
|
||||
fixedSet={service === 'steam'}
|
||||
stickers={service === 'steam' ? steamStickers : undefined}
|
||||
onSticker={
|
||||
service === 'steam'
|
||||
? (sticker) => {
|
||||
void store.toggleReaction(bufferId, message.id, `[steam:sticker:${sticker.name}]`, true)
|
||||
setPickerOpen(false)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSelect={(emoji) => {
|
||||
void store.toggleReaction(bufferId, message.id, emoji, true)
|
||||
setPickerOpen(false)
|
||||
@@ -1564,11 +1688,50 @@ function ReactionPill({
|
||||
*/
|
||||
function ReactionEmoji({ emoji }: { emoji: string }): JSX.Element {
|
||||
const media = useMediaUrl()
|
||||
// Steam's, held as a token naming the emoticon or sticker.
|
||||
const steam = steamTokenOf(emoji)
|
||||
if (steam) return <img className="reaction-emoji" src={steamPictureUrl(steam.kind, steam.name)} alt={steam.name} />
|
||||
const custom = emoji.match(/^<?a?:?([A-Za-z0-9_~]{2,32}):(\d+)>?$/)
|
||||
if (!custom) return <span>{emoji}</span>
|
||||
return <img className="reaction-emoji" src={media(discordEmojiUrl(custom[2]))} alt={custom[1]} />
|
||||
}
|
||||
|
||||
function twitchAct(
|
||||
store: ReturnType<typeof useStore>,
|
||||
bufferId: string,
|
||||
message: ChatMessage,
|
||||
action: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
done?: string
|
||||
): void {
|
||||
void window.moho
|
||||
.rpc('twitchModerate', { bufferId, action, login: message.from.toLowerCase(), userId: message.senderId, ...extra })
|
||||
.then(() => done && store.toast('info', done))
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
|
||||
function twitchModerationEntries(message: ChatMessage, bufferId: string, store: ReturnType<typeof useStore>): MenuEntry[] {
|
||||
const act = (action: string, extra: Record<string, unknown> = {}, done?: string): void => twitchAct(store, bufferId, message, action, extra, done)
|
||||
const timeout = (label: string, seconds: number): MenuEntry => ({
|
||||
label: `Time out ${label}`,
|
||||
icon: 'timer',
|
||||
onClick: () => act('timeout', { seconds }, `${message.from} timed out for ${label}`)
|
||||
})
|
||||
return [
|
||||
{ separator: true },
|
||||
...(message.id
|
||||
? ([{ label: 'Remove message', icon: 'delete', danger: true, onClick: () => act('delete', { messageId: message.id }) }] as MenuEntry[])
|
||||
: []),
|
||||
timeout('1 minute', 60),
|
||||
timeout('10 minutes', 600),
|
||||
timeout('1 hour', 3600),
|
||||
timeout('1 day', 86400),
|
||||
{ label: `Shout out ${message.from}`, icon: 'campaign', onClick: () => act('shoutout', {}, `Shouted out ${message.from}`) },
|
||||
{ label: `Ban ${message.from}`, icon: 'gavel', danger: true, onClick: () => act('ban', {}, `${message.from} banned`) },
|
||||
{ label: `Unban ${message.from}`, icon: 'lock_open', onClick: () => act('unban', {}, `${message.from} can chat again`) }
|
||||
] as MenuEntry[]
|
||||
}
|
||||
|
||||
function moderationEntries(
|
||||
message: ChatMessage,
|
||||
permissions: { canKick?: boolean; canBan?: boolean; canMute?: boolean },
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Modal } from './Modal'
|
||||
|
||||
/**
|
||||
* A name asked for before something is made: a folder on a service that keeps
|
||||
* its own, which is made the moment it has one. Enter makes it, Escape leaves.
|
||||
*
|
||||
* A dialog and not a field in the list, because what is made is not a heading
|
||||
* of ours that can be renamed afterwards for free - it is a change to the
|
||||
* account on the service, and one that is called "New category" because the
|
||||
* field lost focus is a folder every other client of the account then shows.
|
||||
*/
|
||||
export function NamePrompt({
|
||||
title,
|
||||
detail,
|
||||
placeholder,
|
||||
confirmLabel,
|
||||
icon = 'create_new_folder',
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: {
|
||||
title: string
|
||||
detail: string
|
||||
placeholder: string
|
||||
confirmLabel: string
|
||||
icon?: string
|
||||
onConfirm: (name: string) => void
|
||||
onCancel: () => void
|
||||
}): JSX.Element {
|
||||
const [name, setName] = useState('')
|
||||
const box = useRef<HTMLInputElement>(null)
|
||||
useEffect(() => {
|
||||
box.current?.focus()
|
||||
}, [])
|
||||
const ready = name.trim().length > 0
|
||||
return (
|
||||
<Modal title={title} icon={icon} onClose={onCancel} className="reason-prompt">
|
||||
<p className="small muted">{detail}</p>
|
||||
<input
|
||||
ref={box}
|
||||
className="reason-prompt-box"
|
||||
value={name}
|
||||
placeholder={placeholder}
|
||||
maxLength={64}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && ready) {
|
||||
e.preventDefault()
|
||||
onConfirm(name.trim())
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="reason-prompt-actions">
|
||||
<button type="button" className="button subtle" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="button" disabled={!ready} onClick={() => onConfirm(name.trim())}>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { activityLabel } from '../lib/activity'
|
||||
import { ActivityLine } from './ActivityLine'
|
||||
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'
|
||||
import { useActiveBuffer, useChat, useComfy, useIdSetPref, useStore } from '../state/hooks'
|
||||
import { classes, hasDirectMessages, nickColor } from '../lib/util'
|
||||
import { TwitchPoints } from './TwitchPoints'
|
||||
import { KickPoints } from './KickPoints'
|
||||
import { ReasonPrompt } from './ReasonPrompt'
|
||||
import type { Member } from '../../../shared/wire'
|
||||
import { steamRoomIds, type SteamChatGroup } from '../lib/steamchat'
|
||||
|
||||
/**
|
||||
* The member list for the active channel. Two grouping modes, both driven off
|
||||
@@ -48,6 +52,7 @@ export type ModerationAction = 'kick' | 'ban' | 'mute' | 'op' | 'deop' | 'voice'
|
||||
*/
|
||||
function memberTitle(member: Member): string {
|
||||
const lines = [member.nick]
|
||||
if (member.activity) lines.push(activityLabel(member.activity))
|
||||
if (member.account) lines.push(`identified as ${member.account}`)
|
||||
if (member.host) lines.push(member.host)
|
||||
if (lines.length === 1 && member.userId) return member.userId
|
||||
@@ -84,10 +89,10 @@ function kickPermissions(members: Member[], me: string): Record<string, boolean>
|
||||
/**
|
||||
* Why the list says what it says, for the one service where it needs saying.
|
||||
*/
|
||||
function rosterNote(service: string | undefined): string | undefined {
|
||||
return service === 'kick'
|
||||
? 'Kick has no viewer list — this is everybody who has spoken here recently'
|
||||
: undefined
|
||||
function rosterNote(service: string | undefined, total: number, listed: number): string | undefined {
|
||||
if (service !== 'kick' && service !== 'twitch') return undefined
|
||||
const base = `${service === 'twitch' ? 'Twitch' : 'Kick'} has no viewer list — this counts everybody who has spoken here since you opened it`
|
||||
return total > listed ? `${base}. The ${listed.toLocaleString()} most recent are listed.` : base
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,6 +131,7 @@ interface DiscordPowers {
|
||||
|
||||
export function NickList(): JSX.Element {
|
||||
const store = useStore()
|
||||
const comfy = useComfy()
|
||||
const buffer = useActiveBuffer()
|
||||
const presenceByBuffer = useChat((s) => s.presenceByBuffer)
|
||||
const accounts = useChat((s) => s.accounts)
|
||||
@@ -135,6 +141,7 @@ export function NickList(): JSX.Element {
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const members = (buffer && presenceByBuffer[buffer.id]) || []
|
||||
const total = useChat((s) => (buffer ? s.presenceTotalByBuffer[buffer.id] : undefined)) ?? members.length
|
||||
const account = buffer && accounts.find((a) => a.id === buffer.accountId)
|
||||
// Any protocol that reports presence gets the online/offline split.
|
||||
const hasPresence = members.some((m) => m.status !== undefined)
|
||||
@@ -174,8 +181,28 @@ export function NickList(): JSX.Element {
|
||||
})
|
||||
}, [account?.service, account?.id, buffer?.id])
|
||||
|
||||
// Steam answers from the group: what the account's roles allow there.
|
||||
const [steamPowers, setSteamPowers] = useState<Record<string, boolean>>({})
|
||||
const steamRoom = account?.service === 'steam' ? steamRoomIds(buffer) : null
|
||||
useEffect(() => {
|
||||
setSteamPowers({})
|
||||
if (!steamRoom || !account) return
|
||||
let live = true
|
||||
void window.moho
|
||||
.rpc<SteamChatGroup[]>('steamChatGroups', { accountId: account.id, groupId: steamRoom.group })
|
||||
.then((g) => live && g[0] && setSteamPowers({ canKick: g[0].mayModerate, canBan: g[0].mayModerate, canMute: false }))
|
||||
.catch(() => {
|
||||
/* advisory only: no answer means no moderation entries */
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [account?.id, steamRoom?.group])
|
||||
|
||||
const perms =
|
||||
account?.service === 'irc'
|
||||
account?.service === 'steam'
|
||||
? steamPowers
|
||||
: account?.service === 'irc'
|
||||
? ircPermissions(members, account.currentNick)
|
||||
: account?.service === 'kick'
|
||||
? kickPermissions(members, account.currentNick)
|
||||
@@ -238,8 +265,8 @@ export function NickList(): JSX.Element {
|
||||
// Named for what the list actually is. Kick has no viewer list to
|
||||
// ask for - it is built from who has spoken - and calling that
|
||||
// "members" would claim to know something nobody here can know.
|
||||
<span className="small muted ellipsis" title={rosterNote(account?.service)}>
|
||||
{members.length} {account?.service === 'kick' ? 'recently talking' : 'members'}
|
||||
<span className="small muted ellipsis" title={rosterNote(account?.service, total, members.length)}>
|
||||
{total.toLocaleString()} {account?.service === 'kick' || account?.service === 'twitch' ? 'recently talking' : 'members'}
|
||||
</span>
|
||||
)}
|
||||
<IconButton
|
||||
@@ -263,6 +290,7 @@ export function NickList(): JSX.Element {
|
||||
<MemberRow
|
||||
key={member.userId || member.nick}
|
||||
member={member}
|
||||
comfy={comfy}
|
||||
blockKey={`${buffer?.accountId}|${member.nick}`}
|
||||
blocked={isBlocked(`${buffer?.accountId}|${member.nick}`)}
|
||||
canOpenDm={hasDirectMessages(account?.service)}
|
||||
@@ -353,6 +381,24 @@ export function NickList(): JSX.Element {
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
return
|
||||
}
|
||||
if (account.service === 'steam') {
|
||||
const group = steamRoomIds(buffer)?.group
|
||||
if (!group || !member.userId || (action !== 'kick' && action !== 'ban' && action !== 'mute')) return
|
||||
// Mute is ten minutes, as it is on Discord; kick and ban are
|
||||
// the group's own.
|
||||
void window.moho
|
||||
.rpc('steamChatGroupAction', {
|
||||
accountId: account.id,
|
||||
groupId: group,
|
||||
action,
|
||||
steamid: member.userId,
|
||||
seconds: action === 'mute' ? 600 : 3600,
|
||||
banned: true
|
||||
})
|
||||
.then(() => store.toast('info', `${action === 'ban' ? 'Banned' : action === 'mute' ? 'Muted' : 'Removed'} ${member.nick}`))
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
return
|
||||
}
|
||||
if (account.service === 'discord') {
|
||||
// Discord's own three, and "mute" is a timeout there -
|
||||
// the same word this menu uses everywhere, meaning what
|
||||
@@ -434,12 +480,22 @@ export function NickList(): JSX.Element {
|
||||
{account?.service === 'kick' && buffer?.kind === 'channel' && buffer && (
|
||||
<KickPoints bufferId={buffer.id} />
|
||||
)}
|
||||
{/* Twitch's, for an account that has the twitch.tv session that can see them. */}
|
||||
{account?.service === 'twitch' && account.webSession && buffer?.kind === 'channel' && buffer && (
|
||||
<TwitchPoints bufferId={buffer.id} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MemberRowProps {
|
||||
member: Member
|
||||
/**
|
||||
* Comfy draws the list as Discord does - a face on everybody, with what
|
||||
* they are doing under their name - on every service. Classic draws names,
|
||||
* as IRC and Sneedchat always have.
|
||||
*/
|
||||
comfy: boolean
|
||||
blockKey: string
|
||||
blocked: boolean
|
||||
/** The protocol can start a conversation from a member list. */
|
||||
@@ -487,6 +543,7 @@ interface MemberRowProps {
|
||||
|
||||
function MemberRow({
|
||||
member,
|
||||
comfy,
|
||||
blockKey,
|
||||
blocked,
|
||||
canOpenDm,
|
||||
@@ -638,24 +695,17 @@ function MemberRow({
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={classes('nick-row', member.away && 'away', blocked && 'blocked')}
|
||||
className={classes('nick-row', comfy && 'comfy', member.away && 'away', blocked && 'blocked')}
|
||||
onContextMenu={open}
|
||||
// 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}
|
||||
/>
|
||||
)}
|
||||
{/* A face on everybody in comfy, with a dot where the service has
|
||||
presence to report; classic is a list of names, and a column of
|
||||
initials on those would be decoration. */}
|
||||
{comfy && <Avatar name={member.nick} url={member.avatarUrl} size={32} 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
|
||||
@@ -664,9 +714,18 @@ function MemberRow({
|
||||
{member.prefix && member.prefix.length === 1 && (
|
||||
<span className="nick-prefix">{member.prefix}</span>
|
||||
)}
|
||||
<span className="ellipsis" style={{ color: nickColor(member.nick) }}>
|
||||
{member.nick}
|
||||
</span>
|
||||
{comfy ? (
|
||||
<span className="nick-text">
|
||||
<span className="ellipsis" style={{ color: nickColor(member.nick) }}>
|
||||
{member.nick}
|
||||
</span>
|
||||
{member.activity && <ActivityLine activity={member.activity} />}
|
||||
</span>
|
||||
) : (
|
||||
<span className="ellipsis" style={{ color: nickColor(member.nick) }}>
|
||||
{member.nick}
|
||||
</span>
|
||||
)}
|
||||
{member.prefix && member.prefix.length > 1 && (
|
||||
<span className={`sender-badge small ${member.prefix}`} title={member.prefix}>
|
||||
{shortBadge(member.prefix)}
|
||||
|
||||
@@ -2,8 +2,10 @@ import { Modal } from './Modal'
|
||||
import { Avatar } from './Avatar'
|
||||
import { Icon, IconButton } from './Icon'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import { formatFullTime } from '../lib/util'
|
||||
import { formatFullTime, serviceLabel } from '../lib/util'
|
||||
import { presenceColor, presenceLabel } from '../lib/presence'
|
||||
import { profileActivity } from '../lib/activity'
|
||||
import { ActivityLine } from './ActivityLine'
|
||||
|
||||
/**
|
||||
* Who somebody is, whichever service they belong to.
|
||||
@@ -20,13 +22,20 @@ import { presenceColor, presenceLabel } from '../lib/presence'
|
||||
*/
|
||||
export function ProfileCard(): JSX.Element | null {
|
||||
const profile = useChat((s) => s.profile)
|
||||
const presence = useChat((s) => s.presenceByBuffer)
|
||||
const buffers = useChat((s) => s.buffers)
|
||||
const store = useStore()
|
||||
|
||||
if (!profile) return null
|
||||
|
||||
// Their status, as its own section: how they are showing and what they are
|
||||
// doing. An away message is words they wrote for this, so it stands in for
|
||||
// an activity where the service gave none.
|
||||
const activity = profileActivity(profile, presence, buffers) ?? (profile.away ? ({ kind: 'custom', text: profile.away } as const) : undefined)
|
||||
const hasStatus = !!profile.status || !!activity
|
||||
|
||||
const rows: [string, string][] = []
|
||||
if (profile.handle && profile.handle !== profile.name) rows.push(['Address', profile.handle])
|
||||
if (profile.away) rows.push(['Away', profile.away])
|
||||
if (profile.createdTs) rows.push(['Account made', formatFullTime(profile.createdTs)])
|
||||
if (profile.joinedTs) rows.push([joinedLabel(profile.service), formatFullTime(profile.joinedTs)])
|
||||
if (profile.lastActiveTs) rows.push(['Last seen', formatFullTime(profile.lastActiveTs)])
|
||||
@@ -40,7 +49,7 @@ export function ProfileCard(): JSX.Element | null {
|
||||
<div className="profile-title">
|
||||
<span className="profile-name ellipsis">{profile.name}</span>
|
||||
<span className="small muted">
|
||||
{profile.status ? presenceLabel(profile.status) : profile.service}
|
||||
{serviceLabel(profile.service)}
|
||||
{profile.isModerator ? ' · moderator here' : ''}
|
||||
</span>
|
||||
</div>
|
||||
@@ -65,6 +74,21 @@ export function ProfileCard(): JSX.Element | null {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status, first of the facts: it is the one that changes while the
|
||||
card is open, and the one somebody opens it to see. */}
|
||||
{hasStatus && (
|
||||
<section className="profile-status" aria-label="Status">
|
||||
<span className="small muted profile-section-title">Status</span>
|
||||
{profile.status && (
|
||||
<span className="profile-status-presence">
|
||||
<span className="profile-status-dot" style={{ background: presenceColor(profile.status) }} />
|
||||
{presenceLabel(profile.status)}
|
||||
</span>
|
||||
)}
|
||||
{activity && <ActivityLine activity={activity} />}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<dl className="profile-rows">
|
||||
{rows.map(([label, value]) => (
|
||||
<div key={label + value} className="profile-row">
|
||||
@@ -76,6 +100,24 @@ export function ProfileCard(): JSX.Element | null {
|
||||
))}
|
||||
</dl>
|
||||
|
||||
{/* What they have been called, where the service keeps it: Steam does,
|
||||
and a friend who renamed is otherwise somebody you no longer recognise. */}
|
||||
{!!profile.nameHistory?.length && (
|
||||
<section className="profile-status" aria-label="Previous names">
|
||||
<span className="small muted profile-section-title">Previous names</span>
|
||||
<ul className="profile-name-history">
|
||||
{profile.nameHistory.map((n) => (
|
||||
<li key={n.name + n.when}>
|
||||
<span className="ellipsis" title={n.name}>
|
||||
{n.name}
|
||||
</span>
|
||||
{n.when && <span className="small muted">{n.when}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Shared channels are the useful half of an IRC lookup, and on a
|
||||
well-connected nick there are dozens - so they wrap. */}
|
||||
{!!profile.channels?.length && (
|
||||
@@ -121,7 +163,7 @@ export function ProfileCard(): JSX.Element | null {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!profile.pending && rows.length === 0 && !profile.roles?.length && !profile.channels?.length && (
|
||||
{!profile.pending && rows.length === 0 && !hasStatus && !profile.roles?.length && !profile.channels?.length && (
|
||||
<p className="small muted">
|
||||
<Icon name="info" size={14} /> {profile.service} keeps nothing else about them.
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Icon } from './Icon'
|
||||
import { useStore } from '../state/hooks'
|
||||
import { classes } from '../lib/util'
|
||||
import { useEscapeLayer } from '../lib/layers'
|
||||
|
||||
/**
|
||||
* What a channel will trade its points for, in a panel over the chat like the quick switcher (Ctrl+K).
|
||||
*
|
||||
* One panel for every service that has channel points - Kick and Twitch - instead of a menu or a dialog of its
|
||||
* own for each. A reward is a row: its name, what it asks, what it costs. Pressing one arms it (a second press,
|
||||
* or the Redeem button, spends the points; Escape takes it back), so an errant click never costs anything. A
|
||||
* reward that asks the person to type something shows its field when it is armed.
|
||||
*/
|
||||
export interface Reward {
|
||||
id: string
|
||||
title: string
|
||||
cost: number
|
||||
description?: string | null
|
||||
enabled: boolean
|
||||
paused: boolean
|
||||
needsInput: boolean
|
||||
color?: string | null
|
||||
image?: string | null
|
||||
/** Why it cannot be had from here, where that is known beforehand. */
|
||||
unavailable?: string | null
|
||||
/** What it asks the person to pick from: an emote to unlock. */
|
||||
choices?: Choice[]
|
||||
}
|
||||
|
||||
/** One thing a reward asks the person to pick: an emote, or a change to one. */
|
||||
export interface Choice {
|
||||
id: string
|
||||
label: string
|
||||
image?: string | null
|
||||
/** For a change to an emote: the emote, and its picture. */
|
||||
group?: string | null
|
||||
groupImage?: string | null
|
||||
}
|
||||
|
||||
interface Answer {
|
||||
rewards: Reward[]
|
||||
points: number | null
|
||||
name?: string | null
|
||||
}
|
||||
|
||||
/** The daemon's answer - or, in the screenshot harness only, a fixture standing in for it (see SteamGroupSettings). */
|
||||
function ask<T>(method: string, params: Record<string, unknown>): Promise<T> {
|
||||
const w = window as unknown as { __mohoShots?: unknown; __rpcFixture?: Record<string, (p: Record<string, unknown>) => unknown> }
|
||||
const stub = w.__mohoShots ? w.__rpcFixture?.[method] : undefined
|
||||
return stub ? Promise.resolve(stub(params) as T) : window.moho.rpc<T>(method, params)
|
||||
}
|
||||
|
||||
/** How each service lists and redeems, named once here so the panel need not know who it is for. */
|
||||
const SERVICES = {
|
||||
kick: { list: 'listKickRewards', redeem: 'redeemKickReward', canType: false },
|
||||
twitch: { list: 'listTwitchRewards', redeem: 'redeemTwitchReward', canType: true }
|
||||
} as const
|
||||
|
||||
/** Why a reward cannot be pressed, in words; empty when it can. */
|
||||
export function whyNot(r: Reward, points: number | null, canType: boolean): string {
|
||||
if (r.unavailable) return r.unavailable
|
||||
if (!r.enabled) return 'The streamer has this turned off'
|
||||
if (r.paused) return 'Paused right now'
|
||||
if (r.needsInput && !canType) return 'This one asks you to type something, which moho cannot do for this service yet'
|
||||
if (points !== null && points < r.cost) return `You have ${points.toLocaleString()} of ${r.cost.toLocaleString()}`
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* The emotes a reward offers, shown as the emotes - pictures to press, with a search - the way the emoji picker shows
|
||||
* them, and not as a list of names. A change to an emote (Modify a Single Emote) is two steps: the emote, then what
|
||||
* to do to it.
|
||||
*/
|
||||
function EmoteChooser({ choices, picked, onPick }: { choices: Choice[]; picked: string; onPick: (id: string) => void }): JSX.Element {
|
||||
const [filter, setFilter] = useState('')
|
||||
const [base, setBase] = useState<string | null>(null)
|
||||
const grouped = choices.some((c) => c.group)
|
||||
const q = filter.trim().toLowerCase()
|
||||
const bases = useMemo(() => {
|
||||
const seen = new Map<string, string | null | undefined>()
|
||||
for (const c of choices) if (c.group && !seen.has(c.group)) seen.set(c.group, c.groupImage)
|
||||
return [...seen.entries()].map(([name, image]) => ({ name, image }))
|
||||
}, [choices])
|
||||
const cell = (key: string, label: string, image: string | null | undefined, selected: boolean, onClick: () => void, caption?: string): JSX.Element => (
|
||||
<button key={key} type="button" className={classes('reward-emote', selected && 'selected')} title={label} onClick={onClick}>
|
||||
{image ? <img src={image} alt={label} loading="lazy" /> : <span>{label}</span>}
|
||||
{caption && <span className="reward-emote-caption small muted">{caption}</span>}
|
||||
</button>
|
||||
)
|
||||
const inBase = base ? choices.filter((c) => c.group === base) : []
|
||||
return (
|
||||
<div className="reward-chooser">
|
||||
{!(grouped && base) && (
|
||||
<input className="text-field" placeholder="Search emotes" value={filter} spellCheck={false} onChange={(e) => setFilter(e.target.value)} />
|
||||
)}
|
||||
{grouped && base ? (
|
||||
<>
|
||||
<div className="reward-chooser-head small">
|
||||
<button type="button" className="link-button" onClick={() => setBase(null)}>
|
||||
← {base}
|
||||
</button>
|
||||
<span className="muted">Choose what is done to it</span>
|
||||
</div>
|
||||
<div className="reward-grid">{inBase.map((c) => cell(c.id, c.label, c.image, picked === c.id, () => onPick(c.id), c.label.slice(base.length + 2)))}</div>
|
||||
</>
|
||||
) : grouped ? (
|
||||
<div className="reward-grid">{bases.filter((b) => !q || b.name.toLowerCase().includes(q)).map((b) => cell(b.name, b.name, b.image, false, () => setBase(b.name)))}</div>
|
||||
) : (
|
||||
<div className="reward-grid">{choices.filter((c) => !q || c.label.toLowerCase().includes(q)).map((c) => cell(c.id, c.label, c.image, picked === c.id, () => onPick(c.id)))}</div>
|
||||
)}
|
||||
{picked && <div className="small muted reward-picked">{choices.find((c) => c.id === picked)?.label}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RewardsPanel({
|
||||
bufferId,
|
||||
service,
|
||||
onClose,
|
||||
onBalance
|
||||
}: {
|
||||
bufferId: string
|
||||
service: keyof typeof SERVICES
|
||||
onClose: () => void
|
||||
/** The balance after a redeem, for the plaque behind the panel. */
|
||||
onBalance: (points: number) => void
|
||||
}): JSX.Element {
|
||||
const store = useStore()
|
||||
const api = SERVICES[service]
|
||||
const [answer, setAnswer] = useState<Answer | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
const [index, setIndex] = useState(0)
|
||||
const [armed, setArmed] = useState<string | null>(null)
|
||||
const [typed, setTyped] = useState('')
|
||||
const [picked, setPicked] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const input = useRef<HTMLInputElement>(null)
|
||||
const list = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void ask<Answer>(api.list, { bufferId })
|
||||
.then((a) => !cancelled && setAnswer(a))
|
||||
.catch((e: Error) => !cancelled && setError(e.message))
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [api.list, bufferId])
|
||||
|
||||
useEffect(() => input.current?.focus(), [])
|
||||
// Escape takes an armed reward back first, then closes the panel - wherever focus is.
|
||||
useEscapeLayer(() => (armed ? setArmed(null) : onClose()), true)
|
||||
|
||||
// Cheapest first, the ones that can be had now above the ones that cannot (switched off, paused, too dear).
|
||||
const { shown, firstDisabled } = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const all = answer?.rewards ?? []
|
||||
const found = q ? all.filter((r) => r.title.toLowerCase().includes(q) || (r.description ?? '').toLowerCase().includes(q)) : all
|
||||
const byCost = (a: Reward, b: Reward): number => a.cost - b.cost || a.title.localeCompare(b.title)
|
||||
const pressable = (r: Reward): boolean => !whyNot(r, answer?.points ?? null, api.canType)
|
||||
const active = found.filter(pressable).sort(byCost)
|
||||
const disabled = found.filter((r) => !pressable(r)).sort(byCost)
|
||||
return { shown: [...active, ...disabled], firstDisabled: active.length }
|
||||
}, [answer, query, api.canType])
|
||||
|
||||
useEffect(() => {
|
||||
list.current?.querySelector('.switcher-row.selected')?.scrollIntoView({ block: 'nearest' })
|
||||
}, [index, shown.length])
|
||||
|
||||
const points = answer?.points ?? null
|
||||
const unit = answer?.name || 'points'
|
||||
|
||||
const redeem = (r: Reward): void => {
|
||||
setBusy(true)
|
||||
void ask<{ points: number | null }>(api.redeem, { bufferId, rewardId: r.id, ...(r.needsInput ? { input: typed } : {}), ...(r.choices?.length ? { choice: picked } : {}) })
|
||||
.then((a) => {
|
||||
store.toast('info', `Redeemed ${r.title}`)
|
||||
if (a.points !== null && a.points !== undefined) {
|
||||
setAnswer((cur) => (cur ? { ...cur, points: a.points } : cur))
|
||||
onBalance(a.points)
|
||||
}
|
||||
setArmed(null)
|
||||
setTyped('')
|
||||
setPicked('')
|
||||
// An emote unlock changes what there is left to unlock: asked again.
|
||||
if (r.choices?.length) {
|
||||
void ask<Answer>(api.list, { bufferId }).then(setAnswer).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
.finally(() => setBusy(false))
|
||||
}
|
||||
|
||||
const press = (r: Reward): void => {
|
||||
if (whyNot(r, points, api.canType) || busy) return
|
||||
if (armed === r.id && (!r.needsInput || typed.trim()) && (!r.choices?.length || picked)) redeem(r)
|
||||
else {
|
||||
setArmed(r.id)
|
||||
setTyped('')
|
||||
setPicked('')
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="modal-scrim switcher-scrim" onMouseDown={onClose}>
|
||||
<div className="switcher rewards" role="dialog" aria-modal="true" aria-label="Channel points rewards" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="switcher-field">
|
||||
<Icon name="toll" size={18} />
|
||||
<input
|
||||
ref={input}
|
||||
className="switcher-input"
|
||||
placeholder="Spend channel points on…"
|
||||
value={query}
|
||||
spellCheck={false}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setIndex(0)
|
||||
setArmed(null)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown' || (e.key === 'Tab' && !e.shiftKey)) {
|
||||
e.preventDefault()
|
||||
setIndex((i) => Math.min(shown.length - 1, i + 1))
|
||||
setArmed(null)
|
||||
} else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
|
||||
e.preventDefault()
|
||||
setIndex((i) => Math.max(0, i - 1))
|
||||
setArmed(null)
|
||||
} else if (e.key === 'Enter' && shown[index]) {
|
||||
e.preventDefault()
|
||||
press(shown[index])
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="rewards-balance small" title={`Your ${unit} here`}>
|
||||
{points === null ? '' : `${points.toLocaleString()} ${unit === 'points' ? '' : unit}`.trim()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="switcher-list" ref={list} role="listbox">
|
||||
{error && <div className="switcher-empty muted">{error}</div>}
|
||||
{!error && !answer && <div className="switcher-empty muted">Asking {service === 'kick' ? 'Kick' : 'Twitch'}…</div>}
|
||||
{answer && shown.length === 0 && <div className="switcher-empty muted">{answer.rewards.length === 0 ? 'This channel offers nothing for points.' : 'Nothing by that name.'}</div>}
|
||||
{shown.map((r, i) => {
|
||||
const why = whyNot(r, points, api.canType)
|
||||
const isArmed = armed === r.id
|
||||
return (
|
||||
<div key={r.id} className={classes('reward-item', isArmed && 'armed')}>
|
||||
{i === 0 && firstDisabled > 0 && <div className="reward-group small muted">Available</div>}
|
||||
{i === firstDisabled && shown.length > firstDisabled && <div className="reward-group small muted">Unavailable</div>}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === index}
|
||||
disabled={!!why || busy}
|
||||
title={why || r.description || r.title}
|
||||
className={classes('switcher-row', 'reward-row', i === index && 'selected', why && 'locked')}
|
||||
onMouseMove={() => i !== index && setIndex(i)}
|
||||
onClick={() => {
|
||||
setIndex(i)
|
||||
press(r)
|
||||
}}
|
||||
>
|
||||
<span className="switcher-lead">
|
||||
{r.image ? <img className="reward-image" src={r.image} alt="" /> : <Icon name="redeem" size={16} color={r.color ?? undefined} />}
|
||||
</span>
|
||||
<span className="switcher-title ellipsis">{r.title}</span>
|
||||
<span className="switcher-where muted small ellipsis">{why || r.description || ''}</span>
|
||||
<span className="reward-cost small">{r.cost.toLocaleString()}</span>
|
||||
</button>
|
||||
{isArmed && (
|
||||
<div className="reward-confirm">
|
||||
{r.needsInput && (
|
||||
<input
|
||||
className="text-field"
|
||||
autoFocus
|
||||
placeholder={r.description || 'What it asks for'}
|
||||
value={typed}
|
||||
maxLength={500}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && typed.trim()) redeem(r)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!!r.choices?.length && <EmoteChooser choices={r.choices} picked={picked} onPick={setPicked} />}
|
||||
<button type="button" className="button primary" disabled={busy || (r.needsInput && !typed.trim()) || (!!r.choices?.length && !picked)} onClick={() => redeem(r)}>
|
||||
Redeem for {r.cost.toLocaleString()}
|
||||
</button>
|
||||
<button type="button" className="button subtle" onClick={() => setArmed(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="switcher-hint muted small">
|
||||
<span>↑↓ to move</span>
|
||||
<span>Enter to choose, again to spend</span>
|
||||
<span>Esc to close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -8,13 +8,15 @@ 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 { useChat, useInvitations, 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 { SteamGroupSettings } from './SteamGroupSettings'
|
||||
import { steamGroupOfRail } from '../lib/steamchat'
|
||||
import {
|
||||
isFixedEntry,
|
||||
inviteGroup,
|
||||
invitationsGroup,
|
||||
orderedGroups,
|
||||
railEntries,
|
||||
fileInFolder,
|
||||
@@ -75,6 +77,8 @@ interface TileProps {
|
||||
onLeave?: () => void
|
||||
/** Only a Matrix space has rooms to look through. */
|
||||
onBrowse?: () => void
|
||||
/** Only a Steam chat group keeps settings of its own: rooms, roles, invite links. */
|
||||
onSettings?: () => void
|
||||
onSelect: () => void
|
||||
onDragStart: () => void
|
||||
onDragOver: (merge: boolean) => void
|
||||
@@ -107,7 +111,7 @@ function GroupFace({ group, customIcon }: { group: RailGroup; customIcon?: strin
|
||||
// An invitation reads as one rather than as an unfamiliar server: the room
|
||||
// usually has no picture and often no name, and initials taken off a raw
|
||||
// room id say nothing at all.
|
||||
if (group.kind === 'invite') return <Icon name="mark_email_unread" size={22} />
|
||||
if (group.kind === 'invitations') return <Icon name="mark_email_unread" size={22} />
|
||||
if (group.iconUrl) return <img className="rail-icon" src={media(group.iconUrl)} alt="" draggable={false} />
|
||||
if (group.kind === 'dms') return <Icon name="forum" size={22} />
|
||||
if (group.kind === 'account' && service.mark && service.colour) {
|
||||
@@ -284,6 +288,15 @@ function RailTile(props: TileProps): JSX.Element {
|
||||
} as const
|
||||
]
|
||||
: []),
|
||||
...(props.onSettings
|
||||
? [
|
||||
{
|
||||
label: 'Group settings',
|
||||
icon: 'settings',
|
||||
onClick: props.onSettings
|
||||
} as const
|
||||
]
|
||||
: []),
|
||||
// Only where there is something to leave. An account's own entry
|
||||
// stands for the connection itself, which is disconnected from
|
||||
// the accounts pane rather than left.
|
||||
@@ -369,7 +382,7 @@ export function ServerRail(): JSX.Element | null {
|
||||
// the selector's result by identity, so returning a new object each render
|
||||
// reports a change every time and loops until React gives up.
|
||||
const groups = useChat((s) => s.groups)
|
||||
const matrixInvites = useChat((s) => s.matrixInvites)
|
||||
const invitations = useInvitations()
|
||||
const activeGroupId = useChat((s) => s.activeGroupId)
|
||||
const activeBufferId = useChat((s) => s.activeBufferId)
|
||||
const buffers = useChat((s) => s.buffers)
|
||||
@@ -404,9 +417,12 @@ export function ServerRail(): JSX.Element | null {
|
||||
// The tile a drop would merge into, as opposed to land beside.
|
||||
const [merging, setMerging] = useState('')
|
||||
/** The rail entry a leave has been asked about, pending confirmation. */
|
||||
const [leaving, setLeaving] = useState<RailGroup | null>(null)
|
||||
const leaving = useChat((s) => s.leaveRequest)
|
||||
const setLeaving = (g: { id: string; name: string; kind: string } | null): void => store.requestLeave(g)
|
||||
/** The space whose contents are being looked through, if any. */
|
||||
const [browsing, setBrowsing] = useState<RailGroup | null>(null)
|
||||
/** The Steam chat group whose settings are open, if any. */
|
||||
const [configuring, setConfiguring] = useState<RailGroup | null>(null)
|
||||
/** Hovering the space below the column, which means "put it last". */
|
||||
const [tailTarget, setTailTarget] = useState(false)
|
||||
|
||||
@@ -457,11 +473,9 @@ export function ServerRail(): JSX.Element | null {
|
||||
// inbox in the header, which is where the unread count already is, and a
|
||||
// permanent tile for it only competed with the servers this column is for.
|
||||
if (pinned.length > 0) extras.push(pinnedGroup())
|
||||
// An invitation arrives as its own tile and leaves when it is answered -
|
||||
// it behaves like a new conversation, not like a mark on an account.
|
||||
for (const [accountId, list] of Object.entries(matrixInvites)) {
|
||||
for (const invite of list) extras.push(inviteGroup(accountId, invite))
|
||||
}
|
||||
// Invitations from every service share one tile, which is there while
|
||||
// anything waits on an answer and gone once nothing does.
|
||||
if (invitations.length > 0) extras.push(invitationsGroup(invitations.length))
|
||||
const ordered = orderedGroups([...shown, ...extras], railOrder)
|
||||
|
||||
// The rail always draws, even with nothing in it.
|
||||
@@ -561,6 +575,7 @@ export function ServerRail(): JSX.Element | null {
|
||||
g.kind === 'guild' || g.kind === 'space' ? () => setLeaving(g) : undefined
|
||||
}
|
||||
onBrowse={g.kind === 'space' ? () => setBrowsing(g) : undefined}
|
||||
onSettings={steamGroupOfRail(g.id) ? () => setConfiguring(g) : undefined}
|
||||
unread={t?.unread ?? 0}
|
||||
highlight={t?.highlight ?? false}
|
||||
mentions={t?.mentions ?? 0}
|
||||
@@ -734,6 +749,7 @@ export function ServerRail(): JSX.Element | null {
|
||||
/>
|
||||
)}
|
||||
|
||||
{configuring && <SteamGroupSettings accountId={configuring.accountId} railId={configuring.id} onClose={() => setConfiguring(null)} />}
|
||||
{browsing && (
|
||||
<SpaceRooms
|
||||
accountId={browsing.accountId}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { useStore } from '../state/hooks'
|
||||
import { formatFullTime } from '../lib/util'
|
||||
import type { Account } from '../../../shared/wire'
|
||||
|
||||
/** What Steam says of the account, as the daemon sends it. */
|
||||
interface Facts {
|
||||
limited?: boolean
|
||||
familyView?: { enabled: boolean; holdsFriendsBack: boolean } | null
|
||||
textFilter?: { on: boolean; setting: string; ignoreFriends: boolean; words: number } | null
|
||||
privacy?: { profile?: string; inventory?: string; games?: string; playtime?: string; friends?: string } | null
|
||||
}
|
||||
|
||||
interface Session {
|
||||
tokenId: string
|
||||
description: string
|
||||
platform: number
|
||||
lastSeen: number
|
||||
place: string
|
||||
current: boolean
|
||||
}
|
||||
|
||||
/** Which kind of place a session is, in the words Steam's own list uses. */
|
||||
function platformLabel(platform: number): string {
|
||||
return platform === 3 ? 'Mobile app' : platform === 2 ? 'Web browser' : platform === 1 ? 'Steam client' : 'Signed in'
|
||||
}
|
||||
|
||||
function privacyWord(state?: string | null): string {
|
||||
return state === 'public' ? 'public' : state === 'friends' ? 'friends only' : state === 'private' ? 'private' : 'not said'
|
||||
}
|
||||
|
||||
/**
|
||||
* What the account is, and where it is signed in.
|
||||
*
|
||||
* Steam holds things back from an account without saying so where it matters: a
|
||||
* limited account cannot write to everybody, Family View can hold the friends
|
||||
* list back. Said here in words, with the privacy the account has set, the text
|
||||
* filter it uses, and the places it is signed in - any of which, other than the
|
||||
* one in use, can be signed out.
|
||||
*/
|
||||
export function SteamAccountTools({ account }: { account: Account }): JSX.Element {
|
||||
const store = useStore()
|
||||
const [facts, setFacts] = useState<Facts | null>(null)
|
||||
const [sessions, setSessions] = useState<Session[] | null>(null)
|
||||
const [confirming, setConfirming] = useState('')
|
||||
const connected = account.state === 'connected'
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
void window.moho
|
||||
.rpc<Facts>('steamAccountFacts', { accountId: account.id })
|
||||
.then((f) => live && setFacts(f))
|
||||
.catch(() => {})
|
||||
const off = window.moho.onEvent((e) => {
|
||||
if (e.event !== 'steamAccountFacts') return
|
||||
const d = e.data as { accountId?: string; facts?: Facts }
|
||||
if (d.accountId === account.id && d.facts) setFacts(d.facts)
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
off()
|
||||
}
|
||||
}, [account.id, connected])
|
||||
|
||||
const loadSessions = (): void => {
|
||||
void window.moho
|
||||
.rpc<Session[]>('steamSessions', { accountId: account.id })
|
||||
.then(setSessions)
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
|
||||
const signOut = (s: Session): void => {
|
||||
setConfirming('')
|
||||
void window.moho
|
||||
.rpc<Session[]>('steamRevokeSession', { accountId: account.id, tokenId: s.tokenId })
|
||||
.then((list) => {
|
||||
setSessions(list)
|
||||
store.toast('info', `Signed out ${s.description}`)
|
||||
})
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
|
||||
const notes: string[] = []
|
||||
if (facts?.limited) {
|
||||
notes.push('This is a limited account: Steam stops it from messaging anyone who is not a friend, and from sending files or many links, until it has spent enough on Steam.')
|
||||
}
|
||||
if (facts?.familyView?.enabled) {
|
||||
notes.push(
|
||||
facts.familyView.holdsFriendsBack
|
||||
? 'Family View is on and holds friends and chat back. Unlock it in Steam to chat from here.'
|
||||
: 'Family View is on. It does not hold friends and chat back.'
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sasl-block">
|
||||
<span className="small muted">What Steam says about this account</span>
|
||||
{!facts && <p className="small muted">{connected ? 'Asking Steam…' : 'Connect the account to read this.'}</p>}
|
||||
{notes.map((n) => (
|
||||
<div key={n} className="warning-note">
|
||||
<Icon name="info" size={16} />
|
||||
<span>{n}</span>
|
||||
</div>
|
||||
))}
|
||||
{facts && !facts.limited && !facts.familyView?.enabled && <p className="small muted">Not limited, and Family View is off.</p>}
|
||||
{facts?.privacy && (
|
||||
<p className="small muted">
|
||||
Privacy: profile {privacyWord(facts.privacy.profile)}, games {privacyWord(facts.privacy.games)}, friends list{' '}
|
||||
{privacyWord(facts.privacy.friends)}, inventory {privacyWord(facts.privacy.inventory)}. Changed on Steam, not here.
|
||||
</p>
|
||||
)}
|
||||
{facts?.textFilter && (
|
||||
<p className="small muted">
|
||||
Text filter: {facts.textFilter.setting}
|
||||
{facts.textFilter.on
|
||||
? `. ${facts.textFilter.words > 0 ? `The ${facts.textFilter.words} words you added are hidden` : 'No words of your own are added'}${facts.textFilter.ignoreFriends ? ', for people who are not friends' : ''}. Steam's own list of what to hide is not applied here.`
|
||||
: '.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<span className="small muted">Where this account is signed in</span>
|
||||
{sessions === null ? (
|
||||
<div className="field-row">
|
||||
<button type="button" className="button subtle small" disabled={!connected} onClick={loadSessions}>
|
||||
Show where it is signed in
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((s) => (
|
||||
<div key={s.tokenId} className="field-row">
|
||||
<span className="grow">
|
||||
{s.description}
|
||||
<span className="small muted">
|
||||
{' '}
|
||||
· {platformLabel(s.platform)}
|
||||
{s.place ? ` · ${s.place}` : ''}
|
||||
{s.lastSeen ? ` · ${formatFullTime(s.lastSeen)}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
{s.current ? (
|
||||
<span className="small muted">this one</span>
|
||||
) : confirming === s.tokenId ? (
|
||||
<>
|
||||
<button type="button" className="button danger small" onClick={() => signOut(s)}>
|
||||
Sign it out
|
||||
</button>
|
||||
<button type="button" className="button subtle small" onClick={() => setConfirming('')}>
|
||||
Keep
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className="button subtle small" onClick={() => setConfirming(s.tokenId)}>
|
||||
Sign out
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Modal } from './Modal'
|
||||
import { ReasonPrompt } from './ReasonPrompt'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import { bufferDisplayName } from '../lib/util'
|
||||
|
||||
/**
|
||||
* The questions about a Steam friend that want an answer before anything is
|
||||
* done: what to call them, and the two that cannot be quietly undone.
|
||||
*
|
||||
* One place, mounted once, because the menus that ask them are in two places -
|
||||
* a conversation's row and its header - and a dialog belongs to neither.
|
||||
*/
|
||||
export function SteamFriendDialogs(): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const dialog = useChat((s) => s.steamDialog)
|
||||
const buffer = useChat((s) => s.buffers.find((b) => b.id === dialog?.bufferId))
|
||||
if (!dialog || !buffer) return null
|
||||
const name = bufferDisplayName(buffer.name)
|
||||
|
||||
if (dialog.what === 'nickname') {
|
||||
return (
|
||||
<ReasonPrompt
|
||||
title={`Nickname for ${name}`}
|
||||
detail="What you call them. It is kept by Steam for your account, so it is the name on your other devices too. Leave it empty to go back to the name they chose."
|
||||
placeholder="A name for them"
|
||||
confirmLabel="Save"
|
||||
optional
|
||||
onCancel={() => store.closeSteamDialog()}
|
||||
onConfirm={(nickname) => void store.doSteamFriend('nickname', dialog.bufferId, nickname)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const removing = dialog.what === 'remove'
|
||||
return (
|
||||
<Modal title={removing ? `Remove ${name}?` : `Block ${name}?`} icon="warning" iconColor="var(--warning)" onClose={() => store.closeSteamDialog()}>
|
||||
<p className="small">
|
||||
{removing
|
||||
? `${name} will no longer be on your friends list, and you will no longer see when they are online. They can ask again.`
|
||||
: `${name} will be removed from your friends and will not be able to message you or ask to be your friend again, until you unblock them in Steam.`}
|
||||
</p>
|
||||
<div className="leave-confirm-actions">
|
||||
<button type="button" className="button subtle" autoFocus onClick={() => store.closeSteamDialog()}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="button danger" onClick={() => void store.doSteamFriend(dialog.what, dialog.bufferId)}>
|
||||
{removing ? 'Remove friend' : 'Block'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Modal } from './Modal'
|
||||
import { Icon } from './Icon'
|
||||
import { Switch, SwitchRow } from './Switch'
|
||||
import { ConfirmButton } from './ConfirmButton'
|
||||
import { useStore } from '../state/hooks'
|
||||
import { STEAM_KICK_LENGTHS, STEAM_NOTIFY_LEVELS, steamGroupOfRail, type SteamChatGroup } from '../lib/steamchat'
|
||||
|
||||
/**
|
||||
* Asks the daemon; or, in a window the screenshot harness started, takes the
|
||||
* answer it left in `__rpcFixture`, since the harness has no Steam to ask.
|
||||
*/
|
||||
function ask<T>(method: string, params: Record<string, unknown>): Promise<T> {
|
||||
const w = window as unknown as { __mohoShots?: unknown; __rpcFixture?: Record<string, (p: Record<string, unknown>) => unknown> }
|
||||
const stub = w.__mohoShots ? w.__rpcFixture?.[method] : undefined
|
||||
return stub ? Promise.resolve(stub(params) as T) : window.moho.rpc<T>(method, params)
|
||||
}
|
||||
|
||||
type Tab = 'general' | 'rooms' | 'people' | 'roles' | 'invites'
|
||||
|
||||
interface Link {
|
||||
code: string
|
||||
link: string
|
||||
expires?: number | null
|
||||
}
|
||||
|
||||
/** What a role may do, by the names the daemon gives each permission. */
|
||||
type Permissions = Record<string, boolean>
|
||||
|
||||
const LINK_LIFETIMES: { label: string; seconds: number }[] = [
|
||||
{ label: 'An hour', seconds: 3600 },
|
||||
{ label: 'A day', seconds: 86400 },
|
||||
{ label: 'A week', seconds: 604800 }
|
||||
]
|
||||
|
||||
/**
|
||||
* Everything about one Steam chat group that the group itself keeps: its name
|
||||
* and tagline, its rooms, who is in it and what they may do, and the links
|
||||
* that let people in.
|
||||
*
|
||||
* Read from the daemon each time it is opened and after every change, so what
|
||||
* is shown is what Steam holds rather than what this thought it asked for.
|
||||
* What the account may not change is shown but not offered; Steam checks again
|
||||
* and refuses, which is said in its own words.
|
||||
*/
|
||||
export function SteamGroupSettings({
|
||||
accountId,
|
||||
railId,
|
||||
onClose
|
||||
}: {
|
||||
accountId: string
|
||||
railId: string
|
||||
onClose: () => void
|
||||
}): JSX.Element {
|
||||
const store = useStore()
|
||||
const groupId = steamGroupOfRail(railId) ?? ''
|
||||
const [group, setGroup] = useState<SteamChatGroup | null>(null)
|
||||
const [tab, setTab] = useState<Tab>('general')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const all = await ask<SteamChatGroup[]>('steamChatGroups', { accountId, groupId })
|
||||
setGroup(all[0] ?? null)
|
||||
}, [accountId, groupId])
|
||||
|
||||
useEffect(() => {
|
||||
void reload().catch((e: Error) => store.toast('error', e.message))
|
||||
}, [reload, store])
|
||||
|
||||
/** One change, then the group read again; the error is Steam's own. */
|
||||
const act = useCallback(
|
||||
async <T,>(action: string, extra: Record<string, unknown> = {}): Promise<T | undefined> => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const done = await ask<T>('steamChatGroupAction', { accountId, groupId, action, ...extra })
|
||||
await reload()
|
||||
return done
|
||||
} catch (e) {
|
||||
store.toast('error', (e as Error).message)
|
||||
return undefined
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
},
|
||||
[accountId, groupId, reload, store]
|
||||
)
|
||||
|
||||
if (!group) {
|
||||
return (
|
||||
<Modal title="Group" icon="groups" onClose={onClose} className="steam-group">
|
||||
<p className="small muted">
|
||||
<span className="spinner" /> Asking Steam…
|
||||
</p>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const tabs: [Tab, string][] = [
|
||||
['general', 'General'],
|
||||
['rooms', 'Rooms'],
|
||||
['people', 'People'],
|
||||
['roles', 'Roles'],
|
||||
['invites', 'Invites']
|
||||
]
|
||||
|
||||
return (
|
||||
<Modal title={group.name || 'Group'} icon="groups" onClose={onClose} className="steam-group">
|
||||
<div className="room-settings-tabs" role="tablist">
|
||||
{tabs.map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === id}
|
||||
className={`room-settings-tab${tab === id ? ' active' : ''}`}
|
||||
onClick={() => setTab(id)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="room-settings-body steam-group-body">
|
||||
{tab === 'general' && <General group={group} busy={busy} act={act} onLeft={onClose} />}
|
||||
{tab === 'rooms' && <Rooms group={group} busy={busy} act={act} />}
|
||||
{tab === 'people' && <People group={group} busy={busy} act={act} />}
|
||||
{tab === 'roles' && <Roles group={group} busy={busy} act={act} />}
|
||||
{tab === 'invites' && <Invites group={group} act={act} />}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
type Act = <T>(action: string, extra?: Record<string, unknown>) => Promise<T | undefined>
|
||||
|
||||
function General({ group, busy, act, onLeft }: { group: SteamChatGroup; busy: boolean; act: Act; onLeft: () => void }): JSX.Element {
|
||||
const store = useStore()
|
||||
const [name, setName] = useState(group.name)
|
||||
const [tagline, setTagline] = useState(group.tagline)
|
||||
const mayChange = group.mayManage
|
||||
return (
|
||||
<>
|
||||
<div className="field">
|
||||
<span className="small muted">Picture</span>
|
||||
<div className="field-row steam-group-row">
|
||||
<span className="grow small muted">{group.mayManage ? 'Shown on the rail and in the channel list.' : 'Only those who may change the group can change it.'}</span>
|
||||
{/* Sent to Steam's site and then named to the group; the new picture
|
||||
arrives on the rail when Steam says the group changed. */}
|
||||
<button
|
||||
type="button"
|
||||
className="button subtle small"
|
||||
disabled={!group.mayManage || busy}
|
||||
onClick={() => {
|
||||
void window.moho.pickFile(['jpg', 'jpeg', 'png', 'gif']).then((path) => path && void act('avatar', { path }))
|
||||
}}
|
||||
>
|
||||
Change picture
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="small muted">Name</span>
|
||||
<div className="field-row">
|
||||
<input className="text-field" value={name} disabled={!mayChange || busy} onChange={(e) => setName(e.target.value)} />
|
||||
<button type="button" className="button" disabled={!mayChange || busy || !name.trim() || name === group.name} onClick={() => void act('rename', { name })}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="small muted">Tagline</span>
|
||||
<div className="field-row">
|
||||
<input className="text-field" value={tagline} disabled={!mayChange || busy} onChange={(e) => setTagline(e.target.value)} />
|
||||
<button type="button" className="button" disabled={!mayChange || busy || tagline === group.tagline} onClick={() => void act('tagline', { text: tagline })}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!mayChange && <p className="small muted">Only {group.myRankWord === 'Member' ? 'its officers' : 'someone with more say'} can change these.</p>}
|
||||
<div className="field">
|
||||
<span className="small muted">Tell me about</span>
|
||||
<div className="segmented" role="radiogroup" aria-label="Notifications">
|
||||
{STEAM_NOTIFY_LEVELS.map((l) => (
|
||||
<button key={l.value} type="button" role="radio" aria-checked={group.notify === l.value} className={group.notify === l.value ? 'active' : ''} disabled={busy} onClick={() => void act('notify', { level: l.value })}>
|
||||
{l.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Separate from the level, as Steam keeps them: being told of everything shows the marker again. */}
|
||||
{group.notify !== 4 && <SwitchRow title="Show unread messages" description="The marker on the group when something was written in it" checked={!group.muted} disabled={busy} onChange={(show) => void act('notify', { muted: !show })} />}
|
||||
<p className="small muted">Kept by Steam for your account, so it is the same on your other devices.</p>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="small muted">You are</span>
|
||||
<span>{group.myRankWord}</span>
|
||||
</div>
|
||||
<div className="leave-confirm-actions">
|
||||
<ConfirmButton
|
||||
label="Leave group"
|
||||
question={`Leave ${group.name}?`}
|
||||
confirmLabel="Leave"
|
||||
danger
|
||||
onConfirm={() => {
|
||||
// The window's own leave: it takes the rail entry away with it.
|
||||
void store.leaveGroup(group.railId).then((left) => left && onLeft())
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Rooms({ group, busy, act }: { group: SteamChatGroup; busy: boolean; act: Act }): JSX.Element {
|
||||
const [fresh, setFresh] = useState('')
|
||||
const [withVoice, setWithVoice] = useState(false)
|
||||
const [editing, setEditing] = useState<{ id: string; name: string } | null>(null)
|
||||
return (
|
||||
<>
|
||||
{group.rooms.map((r) => (
|
||||
<div key={r.id} className="field-row steam-group-row">
|
||||
{editing?.id === r.id ? (
|
||||
<>
|
||||
<input className="text-field grow" autoFocus value={editing.name} onChange={(e) => setEditing({ id: r.id, name: e.target.value })} />
|
||||
<button
|
||||
type="button"
|
||||
className="button small"
|
||||
disabled={busy}
|
||||
onClick={() => void act('renameRoom', { roomId: r.id, name: editing.name }).then(() => setEditing(null))}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button type="button" className="button subtle small" onClick={() => setEditing(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name={r.voice ? 'volume_up' : 'tag'} size={16} />
|
||||
<span className="grow ellipsis">{r.name || 'General'}</span>
|
||||
{r.voice && <span className="small muted">voice</span>}
|
||||
{group.mayManage && (
|
||||
<>
|
||||
<button type="button" className="button subtle small" onClick={() => setEditing({ id: r.id, name: r.name })}>
|
||||
Rename
|
||||
</button>
|
||||
{group.rooms.length > 1 && (
|
||||
<ConfirmButton label="Delete" question={`Delete ${r.name || 'General'} and what was said in it?`} confirmLabel="Delete" danger onConfirm={() => void act('deleteRoom', { roomId: r.id })} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{group.mayManage && (
|
||||
<>
|
||||
<div className="field-row">
|
||||
<input className="text-field" placeholder="A new room" value={fresh} onChange={(e) => setFresh(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && fresh.trim() && void act('createRoom', { name: fresh, voice: withVoice }).then(() => setFresh(''))} />
|
||||
<button type="button" className="button" disabled={busy || !fresh.trim()} onClick={() => void act('createRoom', { name: fresh, voice: withVoice }).then(() => setFresh(''))}>
|
||||
Add room
|
||||
</button>
|
||||
</div>
|
||||
<SwitchRow title="With voice" description="People in it can talk, as well as write; it is chosen when the room is made" checked={withVoice} onChange={setWithVoice} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function People({ group, busy, act }: { group: SteamChatGroup; busy: boolean; act: Act }): JSX.Element {
|
||||
const store = useStore()
|
||||
const [bans, setBans] = useState<{ steamid: string; name: string }[] | null>(null)
|
||||
const [kickFor, setKickFor] = useState(3600)
|
||||
const roleName = (id: string): string => group.roles.find((r) => r.id === id)?.name ?? id
|
||||
const loadBans = (): void => {
|
||||
void act<{ bans: { steamid: string; name: string }[] }>('bans').then((r) => r && setBans(r.bans))
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{group.members.map((m) => {
|
||||
const own = m.steamid === group.owner
|
||||
return (
|
||||
<div key={m.steamid} className="steam-group-person">
|
||||
<div className="field-row steam-group-row">
|
||||
<span className="grow ellipsis" title={m.steamid}>
|
||||
{m.name}
|
||||
</span>
|
||||
<span className="small muted">{m.rankWord}</span>
|
||||
{group.mayModerate && !own && (
|
||||
<>
|
||||
<button type="button" className="button subtle small" disabled={busy} onClick={() => void act('mute', { steamid: m.steamid, seconds: 600 }).then(() => store.toast('info', `Muted ${m.name} for 10 minutes`))}>
|
||||
Mute 10 min
|
||||
</button>
|
||||
<select className="text-field small" value={kickFor} aria-label="How long a removal lasts" onChange={(e) => setKickFor(Number(e.target.value))}>
|
||||
{STEAM_KICK_LENGTHS.map((l) => (
|
||||
<option key={l.seconds} value={l.seconds}>
|
||||
{l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ConfirmButton label="Remove" question={`Remove ${m.name} for ${STEAM_KICK_LENGTHS.find((l) => l.seconds === kickFor)?.label.toLowerCase()}?`} confirmLabel="Remove" danger onConfirm={() => void act('kick', { steamid: m.steamid, seconds: kickFor })} />
|
||||
<ConfirmButton label="Ban" question={`Ban ${m.name}?`} confirmLabel="Ban" danger onConfirm={() => void act('ban', { steamid: m.steamid, banned: true }).then(() => store.toast('info', `Banned ${m.name}`))} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{m.roles.length > 0 && (
|
||||
<div className="profile-roles">
|
||||
{m.roles.map((r) => (
|
||||
<span key={r} className="profile-role small">
|
||||
{roleName(r)}
|
||||
{group.mayAssignRoles && !own && r !== group.defaultRole && (
|
||||
<button type="button" className="steam-group-x" aria-label={`Take ${roleName(r)} from ${m.name}`} onClick={() => void act('takeRole', { steamid: m.steamid, roleId: r })}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{group.mayAssignRoles && group.roles.some((r) => r.id !== group.defaultRole && !m.roles.includes(r.id)) && (
|
||||
<select
|
||||
className="text-field small"
|
||||
value=""
|
||||
aria-label={`Give ${m.name} a role`}
|
||||
onChange={(e) => e.target.value && void act('giveRole', { steamid: m.steamid, roleId: e.target.value })}
|
||||
>
|
||||
<option value="">Give a role…</option>
|
||||
{group.roles
|
||||
.filter((r) => r.id !== group.defaultRole && !m.roles.includes(r.id))
|
||||
.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{group.mayModerate && (
|
||||
<>
|
||||
<span className="small muted">Banned</span>
|
||||
{bans === null ? (
|
||||
<button type="button" className="button subtle small" onClick={loadBans}>
|
||||
Show who is banned
|
||||
</button>
|
||||
) : bans.length === 0 ? (
|
||||
<p className="small muted">Nobody.</p>
|
||||
) : (
|
||||
bans.map((b) => (
|
||||
<div key={b.steamid} className="field-row steam-group-row">
|
||||
<span className="grow ellipsis">{b.name}</span>
|
||||
<button type="button" className="button subtle small" onClick={() => void act('ban', { steamid: b.steamid, banned: false }).then(loadBans)}>
|
||||
Unban
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Roles({ group, busy, act }: { group: SteamChatGroup; busy: boolean; act: Act }): JSX.Element {
|
||||
const [fresh, setFresh] = useState('')
|
||||
const [open, setOpen] = useState<string | null>(null)
|
||||
const [perms, setPerms] = useState<Permissions | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const expand = (id: string, current: string): void => {
|
||||
if (open === id) {
|
||||
setOpen(null)
|
||||
return
|
||||
}
|
||||
setOpen(id)
|
||||
setPerms(null)
|
||||
setName(current)
|
||||
void act<Permissions>('roleActions', { roleId: id }).then((p) => p && setPerms(p))
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{group.roles.map((r) => (
|
||||
<div key={r.id} className="steam-group-person">
|
||||
<div className="field-row steam-group-row">
|
||||
<Icon name="shield" size={16} />
|
||||
<span className="grow ellipsis">{r.name}</span>
|
||||
<button type="button" className="button subtle small" onClick={() => expand(r.id, r.name)}>
|
||||
{open === r.id ? 'Close' : 'Edit'}
|
||||
</button>
|
||||
</div>
|
||||
{open === r.id && (
|
||||
<div className="steam-group-perms">
|
||||
{group.mayChangeRoles && r.id !== group.defaultRole && (
|
||||
<div className="field-row">
|
||||
<input className="text-field" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<button type="button" className="button small" disabled={busy || !name.trim() || name === r.name} onClick={() => void act('renameRole', { roleId: r.id, name })}>
|
||||
Rename
|
||||
</button>
|
||||
<ConfirmButton label="Delete" question={`Delete ${r.name}?`} confirmLabel="Delete" danger onConfirm={() => void act('deleteRole', { roleId: r.id }).then(() => setOpen(null))} />
|
||||
</div>
|
||||
)}
|
||||
{perms === null ? (
|
||||
<p className="small muted">
|
||||
<span className="spinner" /> Asking Steam…
|
||||
</p>
|
||||
) : (
|
||||
group.permissions.map((p) => (
|
||||
<div key={p.key} className="switch-row">
|
||||
<span className="switch-text">
|
||||
<span>{p.label}</span>
|
||||
</span>
|
||||
<Switch
|
||||
checked={!!perms[p.key]}
|
||||
label={p.label}
|
||||
disabled={!group.mayChangeRoles || busy}
|
||||
onChange={(next) => {
|
||||
const updated = { ...perms, [p.key]: next }
|
||||
setPerms(updated)
|
||||
void act('setRoleActions', { roleId: r.id, actions: updated })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{group.mayChangeRoles && (
|
||||
<div className="field-row">
|
||||
<input className="text-field" placeholder="A new role" value={fresh} onChange={(e) => setFresh(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && fresh.trim() && void act('createRole', { name: fresh }).then(() => setFresh(''))} />
|
||||
<button type="button" className="button" disabled={busy || !fresh.trim()} onClick={() => void act('createRole', { name: fresh }).then(() => setFresh(''))}>
|
||||
Add role
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Invites({ group, act }: { group: SteamChatGroup; act: Act }): JSX.Element {
|
||||
const store = useStore()
|
||||
const [links, setLinks] = useState<Link[] | null>(null)
|
||||
const [seconds, setSeconds] = useState(86400)
|
||||
const load = useCallback(() => {
|
||||
void act<{ links: Link[] }>('links').then((r) => r && setLinks(r.links))
|
||||
// act reads the group again, which is not needed here but is harmless.
|
||||
}, [act])
|
||||
useEffect(() => {
|
||||
load()
|
||||
// Once per opening of the tab.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
const copy = (text: string): void => {
|
||||
// Through the main process, as everywhere else in moho: the page's own clipboard is refused here.
|
||||
void window.moho
|
||||
.copyText(text)
|
||||
.then(() => store.toast('info', 'Copied'))
|
||||
.catch(() => store.toast('error', 'Could not copy'))
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{links === null && (
|
||||
<p className="small muted">
|
||||
<span className="spinner" /> Asking Steam…
|
||||
</p>
|
||||
)}
|
||||
{links?.length === 0 && <p className="small muted">No links. Make one to let people in.</p>}
|
||||
{links?.map((l) => (
|
||||
<div key={l.code} className="field-row steam-group-row">
|
||||
<span className="grow ellipsis" title={l.link}>
|
||||
{l.link}
|
||||
</span>
|
||||
{l.expires ? <span className="small muted">until {new Date(l.expires * 1000).toLocaleString()}</span> : null}
|
||||
<button type="button" className="button subtle small" onClick={() => copy(l.link)}>
|
||||
Copy
|
||||
</button>
|
||||
{group.mayInvite && (
|
||||
<button type="button" className="button subtle small" onClick={() => void act('revokeInvite', { code: l.code }).then(load)}>
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{group.mayInvite && (
|
||||
<div className="field-row">
|
||||
<select className="text-field" value={seconds} aria-label="How long a link lasts" onChange={(e) => setSeconds(Number(e.target.value))}>
|
||||
{LINK_LIFETIMES.map((l) => (
|
||||
<option key={l.seconds} value={l.seconds}>
|
||||
{l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="button" onClick={() => void act('makeInvite', { seconds }).then(load)}>
|
||||
Make a link
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Avatar } from './Avatar'
|
||||
import { Icon, IconButton } from './Icon'
|
||||
import { useChat, useIdSetPref, useStore } from '../state/hooks'
|
||||
import { classes } from '../lib/util'
|
||||
import { steamGroupOfRail } from '../lib/steamchat'
|
||||
import type { RailGroup } from '../lib/groups'
|
||||
|
||||
/**
|
||||
* The rooms of a Steam group that have voice, listed under its text ones the
|
||||
* way a Discord server's voice channels are, with who is in each shown before
|
||||
* you join: that is what anyone decides on.
|
||||
*
|
||||
* Steam keeps who is in a room's voice chat with the room, so the list is what
|
||||
* the daemon last said, and joining is this window's: see lib/steamvoice.ts.
|
||||
*/
|
||||
export function SteamVoiceRooms({ group }: { group: RailGroup }): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const groupId = steamGroupOfRail(group.id)
|
||||
const rooms = useChat((s) => (groupId ? s.steamVoiceRooms[`${group.accountId}|${groupId}`] : undefined)) ?? []
|
||||
const now = useChat((s) => s.steamVoiceNow)
|
||||
const [, toggleFolded, isFolded] = useIdSetPref('collapsedCategories')
|
||||
const foldKey = `steamvoice|${group.id}`
|
||||
const here = (roomId: string): boolean => now?.target.kind === 'room' && now.accountId === group.accountId && now.target.groupId === groupId && now.target.roomId === roomId
|
||||
const inThisGroup = rooms.some((r) => here(r.roomId))
|
||||
const live = useLive(inThisGroup)
|
||||
|
||||
useEffect(() => {
|
||||
if (groupId) void store.refreshSteamVoiceRooms(group.accountId, groupId)
|
||||
}, [store, group.accountId, groupId])
|
||||
|
||||
if (!groupId || rooms.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="bufferlist-section category-head"
|
||||
onClick={() => toggleFolded(foldKey)}
|
||||
title={isFolded(foldKey) ? 'Show voice rooms' : 'Hide voice rooms'}
|
||||
>
|
||||
<Icon name="expand_more" size={14} className={classes('fold-chevron', isFolded(foldKey) && 'folded')} />
|
||||
<span>Voice</span>
|
||||
</button>
|
||||
|
||||
{rooms
|
||||
.filter((r) => !isFolded(foldKey) || here(r.roomId))
|
||||
.map((r) => {
|
||||
const connected = here(r.roomId)
|
||||
return (
|
||||
<div key={r.roomId} className={`voice-channel${connected ? ' active' : ''}`}>
|
||||
<div className="voice-channel-row">
|
||||
<button
|
||||
type="button"
|
||||
className="voice-channel-main"
|
||||
title={connected ? `Connected to ${r.name}` : `Join ${r.name}`}
|
||||
onClick={() => (connected ? void store.leaveVoice(group.accountId) : void store.joinSteamVoice(group.accountId, groupId, r.roomId))}
|
||||
>
|
||||
<Icon name="volume_up" size={16} />
|
||||
<span className="ellipsis">{r.name}</span>
|
||||
{connected && now?.phase !== 'connected' && <span className="spinner" />}
|
||||
</button>
|
||||
{connected && <IconButton name="call_end" size={16} title="Disconnect" onClick={() => void store.leaveVoice(group.accountId)} />}
|
||||
</div>
|
||||
{r.members.map((m) => {
|
||||
const status = live.statuses.get(m.userId)
|
||||
return (
|
||||
<div key={m.userId} className={classes('voice-member small', live.speakers.has(m.userId) && 'speaking')}>
|
||||
<span className="voice-member-face">
|
||||
<Avatar name={m.nick} url={m.avatarUrl ?? undefined} size={20} accountId={group.accountId} />
|
||||
</span>
|
||||
<span className="ellipsis">{m.nick}</span>
|
||||
{m.isSelf && <span className="muted voice-you">you</span>}
|
||||
{status?.outputMuted ? (
|
||||
<span className="voice-state" title={`${m.nick} has deafened themselves`}>
|
||||
<Icon name="headset_off" size={13} />
|
||||
</span>
|
||||
) : (
|
||||
(status?.micMuted || status?.noMic) && (
|
||||
<span className="voice-state" title={status?.noMic ? `${m.nick} has no microphone` : `${m.nick} is muted`}>
|
||||
<Icon name="mic_off" size={13} />
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Who is audible and how each person says they are, read from the call this window holds. */
|
||||
function useLive(inCall: boolean): { speakers: Set<string>; statuses: Map<string, { micMuted: boolean; outputMuted: boolean; noMic: boolean }> } {
|
||||
const store = useStore()
|
||||
const [, tick] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!inCall) return
|
||||
// Often enough that the ring follows speech; not while nobody can see it.
|
||||
const timer = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') tick((n) => n + 1)
|
||||
}, 200)
|
||||
return () => clearInterval(timer)
|
||||
}, [inCall])
|
||||
const v = store.steamVoiceLive
|
||||
return { speakers: inCall ? v.speakers : new Set(), statuses: inCall ? v.statuses : new Map() }
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Hls from 'hls.js'
|
||||
import { IconButton } from './Icon'
|
||||
import { Icon, IconButton } from './Icon'
|
||||
import { ivsAbr, ivsLoader } from '../lib/ivsplaylist'
|
||||
import { autoCeiling, captionTrack, menuOrder, renditionLabel, renditionOf, type CaptionTrack, type Rendition } from '../lib/streamplayer'
|
||||
import { useEscapeLayer } from '../lib/layers'
|
||||
import { VideoStage } from './VideoStage'
|
||||
import { fitTiles } from './CallStage'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import { useChat, usePref, useStore } from '../state/hooks'
|
||||
import { bufferDisplayName } from '../lib/util'
|
||||
import { StageWindow, STREAM_WINDOW_NAME } from './stage/StageWindow'
|
||||
|
||||
/**
|
||||
* A Kick channel's stream, in the surface a call uses.
|
||||
* A Kick or a Twitch channel's stream, in the surface a call uses.
|
||||
*
|
||||
* The other half of the picture this window can show. A call is real-time
|
||||
* media negotiated with somebody and arrives as a `MediaStream` on
|
||||
@@ -24,8 +27,71 @@ import { StageWindow, STREAM_WINDOW_NAME } from './stage/StageWindow'
|
||||
* stream has one picture, and the grid a call draws would be a grid of one.
|
||||
*/
|
||||
|
||||
/** The rendition ceiling. See the comment where it is used. */
|
||||
const MAX_HEIGHT = 720
|
||||
/** What automatic choice of rendition may go up to: a picture this size beside a chat has no use for more. */
|
||||
const AUTO_MAX_HEIGHT = 720
|
||||
|
||||
/**
|
||||
* How far behind the newest segment the player settles, in seconds.
|
||||
*
|
||||
* In seconds because hls.js counts the other way in multiples of the playlist's target duration, and Kick's
|
||||
* playlists say six seconds for segments that are two: "three segments" was eighteen seconds behind, which is
|
||||
* where most of the delay between the broadcaster and the picture came from. With the prefetched segments
|
||||
* (see lib/ivsplaylist.ts) this is a few seconds behind the broadcaster, and no catch-up speed: playing a little
|
||||
* fast to close the gap was measured to stall.
|
||||
*/
|
||||
const LIVE_SYNC_SECONDS = 4
|
||||
const LIVE_MAX_LATENCY_SECONDS = 10
|
||||
|
||||
/** Where the numbers of captions carried inside the video start, clear of the playlist's own. */
|
||||
const IN_VIDEO = 1000
|
||||
|
||||
/** The renditions of a player's playlist, as the choices are made from them. */
|
||||
function renditionsOf(player: Hls): Rendition[] {
|
||||
return player.levels.map((l) => ({ height: l.height, frameRate: Number(l.attrs?.['FRAME-RATE']) || undefined, bitrate: l.bitrate }))
|
||||
}
|
||||
|
||||
interface MenuChoice {
|
||||
label: string
|
||||
detail?: string
|
||||
checked: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
/** A short list drawn over the picture, since a popped-out window is a document of its own and a menu in this one would open in the wrong place. */
|
||||
function StreamMenu({ title, choices, onClose }: { title: string; choices: MenuChoice[]; onClose: () => void }): JSX.Element {
|
||||
useEscapeLayer(onClose)
|
||||
return (
|
||||
<>
|
||||
<div className="stream-menu-scrim" onClick={onClose} />
|
||||
<div className="context-menu stream-menu" role="menu" aria-label={title}>
|
||||
<div className="stream-menu-title small muted">{title}</div>
|
||||
{choices.map((c) => (
|
||||
<button
|
||||
key={c.label}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={c.checked}
|
||||
className="context-menu-item"
|
||||
onClick={() => {
|
||||
c.onClick()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<span className="context-menu-label">{c.label}</span>
|
||||
{c.detail && <span className="small muted">{c.detail}</span>}
|
||||
{c.checked && <Icon name="check" size={16} className="context-menu-check" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface Choice {
|
||||
index: number
|
||||
label: string
|
||||
height: number
|
||||
}
|
||||
|
||||
export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'window' }): JSX.Element | null {
|
||||
const store = useStore()
|
||||
@@ -36,17 +102,46 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
|
||||
const video = useRef<HTMLVideoElement>(null)
|
||||
const frame = useRef<HTMLDivElement>(null)
|
||||
const hls = useRef<Hls | null>(null)
|
||||
/** The newest playback URL, for the error path to reach without being
|
||||
* rebuilt every time one arrives. */
|
||||
/** The newest playback URL, for a player that has to begin again to reach without being rebuilt every time
|
||||
* one arrives. */
|
||||
const latest = useRef('')
|
||||
const [room, setRoom] = useState({ width: 640, height: 260 })
|
||||
const [muted, setMuted] = useState(false)
|
||||
// How tall the picture is above the conversation, as it was left by dragging its bottom edge; none is the default
|
||||
// (nearly half the window, to a limit). Followed while dragging and remembered when it is let go.
|
||||
const [savedHeight, setSavedHeight] = usePref<number | null>('ui.streamHeight', null)
|
||||
const [liveHeight, setLiveHeight] = useState<number | null | undefined>(undefined)
|
||||
const inlineHeight = liveHeight === undefined ? savedHeight : liveHeight
|
||||
// Remembered between channels and between sessions: turning it down for one
|
||||
// stream and being deafened by the next is how a player loses a person.
|
||||
const [muted, setMuted] = usePref<boolean>('stream.muted', false)
|
||||
const [volume, setVolume] = usePref<number>('stream.volume', 1)
|
||||
/** Seconds left of an ad the stream is playing, or null when it is not playing one. */
|
||||
const [ad, setAd] = useState<number | null>(null)
|
||||
const adRanges = useRef<{ start: number; end: number }[]>([])
|
||||
/** What the stream offers, once its playlist has been read. */
|
||||
const [choices, setChoices] = useState<Choice[]>([])
|
||||
const [listed, setListed] = useState<CaptionTrack[]>([])
|
||||
/** Captions carried inside the video itself (CEA-608/708), which hls.js draws as text tracks of its own. */
|
||||
const [inVideo, setInVideo] = useState<CaptionTrack[]>([])
|
||||
const captions = [...listed, ...inVideo]
|
||||
/** What automatic choice is playing now, for the menu to say. */
|
||||
const [playing, setPlaying] = useState('')
|
||||
const [menu, setMenu] = useState<'quality' | 'captions' | null>(null)
|
||||
// Remembered between streams, apart for each service because their ladders differ: 0 is automatic, and an
|
||||
// empty language is captions off.
|
||||
const service = useChat((s) => s.accounts.find((a) => a.id === (watching?.accountId ?? ''))?.service)
|
||||
const [quality, setQuality] = usePref<number>(service === 'twitch' ? 'stream.quality.twitch' : 'stream.quality', 0)
|
||||
const [captionLanguage, setCaptionLanguage] = usePref<string>('stream.captions', '')
|
||||
|
||||
const stream = watching ? streams[watching.bufferId] : undefined
|
||||
// The current one rather than the one this started with: the signature in a
|
||||
// playback URL expires, and the refresh already running is what replaces it.
|
||||
// The newest address rather than the one this began with: the signature in it expires, and the daemon asks for
|
||||
// a new one before it does. The player is not rebuilt for it - it is only where a player that has to start
|
||||
// over, after being refused, starts from.
|
||||
const url = stream?.playbackUrl ?? ''
|
||||
latest.current = url
|
||||
// A different picture is a different stream or a different broadcast; the same one with a new address is not.
|
||||
const picture = `${watching?.bufferId ?? ''}|${stream?.livestreamId ?? ''}`
|
||||
const hasUrl = url !== ''
|
||||
|
||||
// The same measuring the call stage does, for the same reason: this is a
|
||||
// different shape in a narrow window, a full-screen one, and the corner.
|
||||
@@ -66,7 +161,7 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
|
||||
? // The corner: as tall as its width makes the picture, so
|
||||
// resizing the panel resizes the picture.
|
||||
(el.clientWidth * 9) / 16
|
||||
: Math.min(view.innerHeight * 0.45, 460)
|
||||
: (inlineHeight ?? Math.min(view.innerHeight * 0.45, 460))
|
||||
})
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
@@ -76,51 +171,72 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
|
||||
observer.disconnect()
|
||||
view.removeEventListener('resize', measure)
|
||||
}
|
||||
}, [mode, minimized, watching])
|
||||
}, [mode, minimized, watching, inlineHeight])
|
||||
|
||||
// The player itself, torn down and rebuilt only when the picture changes -
|
||||
// not when the viewer count ticks over, which it does every minute.
|
||||
// The player itself, torn down and rebuilt only when the picture changes - not when the viewer count ticks over,
|
||||
// and not when the address is renewed, which it is every few minutes.
|
||||
useEffect(() => {
|
||||
const el = video.current
|
||||
if (!el || !url || minimized) return
|
||||
if (!el || !hasUrl || minimized) return
|
||||
if (!Hls.isSupported()) {
|
||||
store.toast('error', 'This build cannot play a stream')
|
||||
return
|
||||
}
|
||||
const player = new Hls({
|
||||
// Enough buffer to ride out a hiccup, not so much that "live" means two
|
||||
// minutes ago: this is a chat window beside the stream, and people talk
|
||||
// about what just happened.
|
||||
liveSyncDurationCount: 3,
|
||||
// Never fetch a rendition larger than the box it is drawn in.
|
||||
capLevelToPlayerSize: true
|
||||
// Kick's playlists, which list what is being made as well as what is made.
|
||||
loader: ivsLoader(2),
|
||||
abrController: ivsAbr(),
|
||||
// How far behind to play, in seconds: see LIVE_SYNC_SECONDS.
|
||||
liveSyncDuration: LIVE_SYNC_SECONDS,
|
||||
liveMaxLatencyDuration: LIVE_MAX_LATENCY_SECONDS,
|
||||
// Never fetch a rendition larger than the box it is drawn in, when the choice is automatic.
|
||||
capLevelToPlayerSize: true,
|
||||
// A stream that runs for hours must not keep them all.
|
||||
backBufferLength: 20
|
||||
})
|
||||
hls.current = player
|
||||
// For the harness that measures how far behind the broadcaster the picture runs; see scripts/watch-stream-stalls.mjs.
|
||||
if (window.moho.uiShots) (window as unknown as { __mohoHls: Hls }).__mohoHls = player
|
||||
player.attachMedia(el)
|
||||
player.loadSource(url)
|
||||
player.loadSource(latest.current)
|
||||
|
||||
player.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
// 1080p60 is 5.8 Mbps and a real amount of CPU to decode beside a chat
|
||||
// client, and the ladder below it is cheaper for a picture this size.
|
||||
// A ceiling rather than a fixed choice: the bitrate ladder underneath
|
||||
// still adapts to the connection.
|
||||
const ceiling = player.levels.reduce(
|
||||
(best, level, i) => (level.height <= MAX_HEIGHT && level.height >= (player.levels[best]?.height ?? 0) ? i : best),
|
||||
-1
|
||||
)
|
||||
if (ceiling >= 0) player.autoLevelCapping = ceiling
|
||||
setChoices(menuOrder(renditionsOf(player)))
|
||||
void el.play().catch(() => {
|
||||
/* A picture that will not start is not worth an error dialog. */
|
||||
})
|
||||
})
|
||||
player.on(Hls.Events.LEVEL_LOADED, (_e, data) => {
|
||||
// Behind by a segment and a half at the least: where they are 5 seconds long, 4 seconds is less than one.
|
||||
const longest = Math.max(0, ...data.details.fragments.map((f) => f.duration))
|
||||
const behind = Math.max(LIVE_SYNC_SECONDS, longest * 1.5)
|
||||
if (player.config.liveSyncDuration !== behind) {
|
||||
player.config.liveSyncDuration = behind
|
||||
player.config.liveMaxLatencyDuration = Math.max(LIVE_MAX_LATENCY_SECONDS, behind * 2.5)
|
||||
}
|
||||
})
|
||||
// Ads Twitch puts into a stream are marked in its playlist as date ranges of their own, with a start and a
|
||||
// length; which of them the picture is playing is worked out against where the playhead is (see below).
|
||||
player.on(Hls.Events.LEVEL_UPDATED, (_e, data) => {
|
||||
adRanges.current = Object.values(data.details.dateRanges)
|
||||
.filter((r) => r && r.class === 'twitch-stitched-ad')
|
||||
.map((r) => ({ start: r!.startDate.getTime(), end: r!.startDate.getTime() + (r!.duration ?? r!.plannedDuration ?? 0) * 1000 }))
|
||||
})
|
||||
player.on(Hls.Events.LEVEL_SWITCHED, (_e, data) => {
|
||||
const level = player.levels[data.level]
|
||||
if (level) setPlaying(renditionLabel({ height: level.height, frameRate: Number(level.attrs?.['FRAME-RATE']) || undefined }))
|
||||
})
|
||||
player.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_e, data) => {
|
||||
setListed(data.subtitleTracks.map((t) => ({ id: t.id, name: t.name, lang: t.lang })))
|
||||
})
|
||||
|
||||
player.on(Hls.Events.ERROR, (_e, data) => {
|
||||
if (!data.fatal) return
|
||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||
// A refused segment is an expired token, not a dead stream: the
|
||||
// daemon re-asks Kick about the channel every minute, so the next
|
||||
// playback URL is already on its way and this only has to wait for
|
||||
// it. Reloading the source picks up whichever one is current.
|
||||
// A refused segment is an expired token, not a dead stream: the daemon
|
||||
// renews the address before it expires, so the next one is already
|
||||
// here and this only has to begin from it. Reloading the source picks
|
||||
// up whichever one is current.
|
||||
if (latest.current) player.loadSource(latest.current)
|
||||
player.startLoad()
|
||||
return
|
||||
@@ -133,11 +249,105 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
|
||||
store.stopWatching()
|
||||
})
|
||||
|
||||
// Whether the playhead is inside an ad, and how long it has left, said once a second.
|
||||
const adTimer = setInterval(() => {
|
||||
const at = player.playingDate?.getTime()
|
||||
const inside = at === undefined ? undefined : adRanges.current.find((r) => at >= r.start && at < r.end)
|
||||
setAd(inside && at !== undefined ? Math.max(1, Math.ceil((inside.end - at) / 1000)) : null)
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
clearInterval(adTimer)
|
||||
player.destroy()
|
||||
hls.current = null
|
||||
adRanges.current = []
|
||||
setAd(null)
|
||||
setChoices([])
|
||||
setListed([])
|
||||
setInVideo([])
|
||||
setPlaying('')
|
||||
}
|
||||
}, [url, minimized, store, watching?.bufferId])
|
||||
}, [hasUrl, picture, minimized, store])
|
||||
|
||||
// Captions inside the picture. Twitch lists none in its playlist; a streamer whose encoder sends them
|
||||
// (none of the busiest channels sampled did) has them in the video, and hls.js turns them into text tracks.
|
||||
// Numbered from where the listed ones can never reach, so one choice means one track.
|
||||
useEffect(() => {
|
||||
const el = video.current
|
||||
if (!el || !hasUrl || minimized) return
|
||||
const scan = (): void => {
|
||||
const found: CaptionTrack[] = []
|
||||
for (let i = 0; i < el.textTracks.length; i++) {
|
||||
const t = el.textTracks[i]
|
||||
if (t.kind === 'captions') found.push({ id: IN_VIDEO + i, name: t.label || `Captions ${found.length + 1}`, lang: t.language || undefined })
|
||||
}
|
||||
setInVideo((was) => (JSON.stringify(was) === JSON.stringify(found) ? was : found))
|
||||
}
|
||||
scan()
|
||||
el.textTracks.addEventListener('addtrack', scan)
|
||||
el.textTracks.addEventListener('removetrack', scan)
|
||||
return () => {
|
||||
el.textTracks.removeEventListener('addtrack', scan)
|
||||
el.textTracks.removeEventListener('removetrack', scan)
|
||||
}
|
||||
}, [hasUrl, picture, minimized])
|
||||
|
||||
// Twitch gives points for time spent watching and counts it from what the website's player reports once a minute,
|
||||
// which a player of our own does not send. Told to the daemon once a minute while this picture is really
|
||||
// playing and showing; a paused, hidden, minimised or closed stage says nothing, and the daemon sends nothing
|
||||
// for a channel that is not live (see backend/twitch/watch.rs).
|
||||
const [reportWatching] = usePref<boolean>('twitch.reportWatching', true)
|
||||
useEffect(() => {
|
||||
if (service !== 'twitch' || !watching || !hasUrl || minimized || !reportWatching) return
|
||||
const bufferId = watching.bufferId
|
||||
const timer = setInterval(() => {
|
||||
const el = video.current
|
||||
if (!el || el.paused || el.ended || el.readyState < 3 || document.visibilityState !== 'visible') return
|
||||
void window.moho.rpc('reportTwitchWatching', { bufferId }).catch(() => {
|
||||
/* Said nowhere: it is points, and the stream is playing either way. */
|
||||
})
|
||||
}, 60_000)
|
||||
return () => clearInterval(timer)
|
||||
}, [service, watching, hasUrl, picture, minimized, reportWatching])
|
||||
|
||||
// The sound as it was left, whenever there is a picture to apply it to.
|
||||
useEffect(() => {
|
||||
const el = video.current
|
||||
if (!el) return
|
||||
el.muted = muted
|
||||
el.volume = Math.min(1, Math.max(0, volume))
|
||||
}, [muted, volume, hasUrl, picture, minimized])
|
||||
|
||||
// Which rendition: automatic, or the height that was chosen when this stream offers it.
|
||||
useEffect(() => {
|
||||
const player = hls.current
|
||||
if (!player || choices.length === 0) return
|
||||
const wanted = quality > 0 ? renditionOf(renditionsOf(player), quality) : -1
|
||||
if (wanted >= 0) {
|
||||
player.autoLevelCapping = -1
|
||||
player.currentLevel = wanted
|
||||
} else {
|
||||
player.currentLevel = -1
|
||||
player.autoLevelCapping = autoCeiling(renditionsOf(player), AUTO_MAX_HEIGHT)
|
||||
}
|
||||
}, [quality, choices])
|
||||
|
||||
// Which captions: none, or the language that was chosen when this stream has captions in it.
|
||||
useEffect(() => {
|
||||
const player = hls.current
|
||||
if (!player) return
|
||||
const track = captionTrack(captions, captionLanguage, navigator.language)
|
||||
const own = track >= IN_VIDEO
|
||||
player.subtitleDisplay = track >= 0 && !own
|
||||
player.subtitleTrack = own ? -1 : track
|
||||
// The ones inside the video are shown or hidden on the element itself.
|
||||
const el = video.current
|
||||
if (el) {
|
||||
for (let i = 0; i < el.textTracks.length; i++) {
|
||||
if (el.textTracks[i].kind === 'captions') el.textTracks[i].mode = track === IN_VIDEO + i ? 'showing' : 'disabled'
|
||||
}
|
||||
}
|
||||
}, [captionLanguage, listed, inVideo])
|
||||
|
||||
// A stream that has ended is not a picture any more, and the surface should
|
||||
// not sit there showing the last frame of it.
|
||||
@@ -152,6 +362,7 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
|
||||
|
||||
const where = buffers.find((b) => b.id === watching.bufferId)?.name ?? ''
|
||||
const fit = fitTiles(1, room.width, room.height, Infinity)
|
||||
const captionsOn = captionTrack(captions, captionLanguage, navigator.language) >= 0
|
||||
const watchers = stream?.viewers
|
||||
const subtitle = `Live${watchers ? ` · ${watchers.toLocaleString()} watching` : ''}${
|
||||
where ? ` · ${bufferDisplayName(where)}` : ''
|
||||
@@ -170,22 +381,82 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
|
||||
endIcon="stop_circle"
|
||||
onPopOut={mode === 'window' ? undefined : () => store.setStreamPoppedOut(true)}
|
||||
onBringBack={() => store.setStreamPoppedOut(false)}
|
||||
height={Math.round(room.height)}
|
||||
onHeight={(h, commit) => {
|
||||
setLiveHeight(commit ? undefined : h)
|
||||
if (commit) setSavedHeight(h)
|
||||
}}
|
||||
controls={
|
||||
<IconButton
|
||||
name={muted ? 'volume_off' : 'volume_up'}
|
||||
title={muted ? 'Unmute' : 'Mute'}
|
||||
className={muted ? 'calling' : undefined}
|
||||
onClick={() => {
|
||||
const next = !muted
|
||||
setMuted(next)
|
||||
if (video.current) video.current.muted = next
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<IconButton
|
||||
name={captionsOn ? 'closed_caption' : 'closed_caption_disabled'}
|
||||
title={captions.length === 0 ? 'No captions for this stream' : captionsOn ? 'Captions on' : 'Captions off'}
|
||||
className={captionsOn ? 'active' : undefined}
|
||||
disabled={captions.length === 0}
|
||||
onClick={() => {
|
||||
if (captions.length === 1) setCaptionLanguage(captionsOn ? '' : captions[0].lang || captions[0].name)
|
||||
else setMenu(menu === 'captions' ? null : 'captions')
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
name="high_quality"
|
||||
title={quality > 0 ? `Resolution: ${quality}p` : 'Resolution: automatic'}
|
||||
disabled={choices.length === 0}
|
||||
onClick={() => setMenu(menu === 'quality' ? null : 'quality')}
|
||||
/>
|
||||
<IconButton
|
||||
name={muted ? 'volume_off' : 'volume_up'}
|
||||
title={muted ? 'Unmute' : 'Mute'}
|
||||
className={muted ? 'calling' : undefined}
|
||||
onClick={() => setMuted(!muted)}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
className="stream-volume"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={muted ? 0 : volume}
|
||||
aria-label="Volume"
|
||||
title="Volume"
|
||||
onChange={(e) => {
|
||||
const next = Number(e.target.value)
|
||||
setVolume(next)
|
||||
setMuted(next === 0)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={mode === 'window' ? 'video-stage in-window' : 'video-stage'} ref={frame}>
|
||||
<div className="video-tile" style={{ width: `${Math.floor(fit.tileWidth)}px` }}>
|
||||
<video ref={video} className="video-tile-picture" autoPlay playsInline />
|
||||
{ad !== null && (
|
||||
<div className="stream-ad small" role="status" title="Twitch plays ads in a stream for an account without Turbo or a subscription to the channel">
|
||||
<Icon name="campaign" size={14} />
|
||||
<span>Ad break · {ad} s</span>
|
||||
</div>
|
||||
)}
|
||||
{menu === 'quality' && (
|
||||
<StreamMenu
|
||||
title="Resolution"
|
||||
onClose={() => setMenu(null)}
|
||||
choices={[
|
||||
{ label: 'Auto', detail: quality === 0 && playing ? playing : undefined, checked: quality === 0 || !choices.some((c) => c.height === quality), onClick: () => setQuality(0) },
|
||||
...choices.map((c) => ({ label: c.label, checked: quality === c.height, onClick: () => setQuality(c.height) }))
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{menu === 'captions' && (
|
||||
<StreamMenu
|
||||
title="Captions"
|
||||
onClose={() => setMenu(null)}
|
||||
choices={[
|
||||
{ label: 'Off', checked: !captionsOn, onClick: () => setCaptionLanguage('') },
|
||||
...captions.map((t) => ({ label: t.name, checked: captionsOn && captionTrack(captions, captionLanguage, navigator.language) === t.id, onClick: () => setCaptionLanguage(t.lang || t.name) }))
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</VideoStage>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IconButton } from './Icon'
|
||||
import { Composer } from './Composer'
|
||||
import { useChat, usePref, useStore } from '../state/hooks'
|
||||
import type { ChatMessage } from '../state/store'
|
||||
import { applyRemoved, type RemovedMessages } from '../lib/moderation'
|
||||
|
||||
/**
|
||||
* A thread, beside the room it belongs to.
|
||||
@@ -26,6 +27,7 @@ export function ThreadPanel(): JSX.Element | null {
|
||||
// to ask for `ui.*`, which nothing writes, so it ignored both.
|
||||
const [comfy] = usePref<'classic' | 'comfy' | 'bubbles'>('display.messageMode', 'comfy')
|
||||
const [relativeTimestamps] = usePref<boolean>('display.relativeTimestamps', false)
|
||||
const [removedMode] = usePref<RemovedMessages>('kick.removedMessages', 'strike')
|
||||
const store = useStore()
|
||||
// Before the early return below, as every hook here has to be.
|
||||
const shared = useRowContext(thread?.bufferId ?? '')
|
||||
@@ -36,15 +38,15 @@ export function ThreadPanel(): JSX.Element | null {
|
||||
const messages = useMemo((): ChatMessage[] => {
|
||||
if (!thread) return []
|
||||
const live = (messagesByBuffer[thread.bufferId] || []).filter(
|
||||
(m) => m.id === thread.rootId || (m.replyTo?.thread && m.replyTo.id === thread.rootId)
|
||||
(m) => m.id === thread.rootId || (m.replyTo?.thread && m.replyTo.id === thread.rootId) || m.replyTo?.threadRoot === thread.rootId
|
||||
)
|
||||
// By id, so a message that is both fetched and live appears once. The
|
||||
// live copy wins: it is the one carrying edits and reactions that have
|
||||
// happened since the fetch.
|
||||
const byId = new Map<string, ChatMessage>()
|
||||
for (const m of [...thread.messages, ...live]) byId.set(m.id, m)
|
||||
return [...byId.values()].sort((a, b) => a.ts - b.ts)
|
||||
}, [thread, messagesByBuffer])
|
||||
return applyRemoved([...byId.values()].sort((a, b) => a.ts - b.ts), removedMode)
|
||||
}, [thread, messagesByBuffer, removedMode])
|
||||
|
||||
// Opened on, and following, the newest reply: a thread is read from its end.
|
||||
const count = messages.length
|
||||
@@ -53,6 +55,10 @@ export function ThreadPanel(): JSX.Element | null {
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}, [thread?.rootId, count])
|
||||
|
||||
// Not counting the message it grew from - which is not always here: a thread can outlive the scrollback
|
||||
// its first message was in, and then every message shown is a reply.
|
||||
const replyCount = messages.filter((m) => m.id !== thread?.rootId).length
|
||||
|
||||
if (!thread) return null
|
||||
|
||||
return (
|
||||
@@ -60,7 +66,7 @@ export function ThreadPanel(): JSX.Element | null {
|
||||
<div className="thread-header">
|
||||
<span className="thread-title ellipsis">Thread</span>
|
||||
<span className="small muted">
|
||||
{Math.max(0, messages.length - 1) === 1 ? '1 reply' : `${Math.max(0, messages.length - 1)} replies`}
|
||||
{replyCount === 1 ? '1 reply' : `${replyCount} replies`}
|
||||
</span>
|
||||
<IconButton name="close" title="Close thread" onClick={() => store.closeThreadPanel()} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { useChat } from '../state/hooks'
|
||||
import { RewardsPanel } from './RewardsPanel'
|
||||
|
||||
/**
|
||||
* A Twitch channel's points balance, and the way into the rewards panel (RewardsPanel.tsx) that Kick's plaque opens too.
|
||||
*
|
||||
* At the foot of the viewer list, where Kick's plaque is. Twitch's points are the person's own twitch.tv
|
||||
* session's to see and spend (see nobilis backend/twitch/points.rs), so it is drawn only for an account that has
|
||||
* that session, and the balance is read once a minute while the window is
|
||||
* on this channel. Moho does not claim the bonus chest or send the "watching" reports a points bot would to
|
||||
* earn faster.
|
||||
*/
|
||||
const REFRESH_MS = 60_000
|
||||
|
||||
export function TwitchPoints({ bufferId }: { bufferId: string }): JSX.Element | null {
|
||||
const pushed = useChat((s) => s.twitchPoints[bufferId])
|
||||
const [open, setOpen] = useState(false)
|
||||
const [unavailable, setUnavailable] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setUnavailable(false)
|
||||
const ask = (): void => {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
void window.moho
|
||||
.rpc('twitchPoints', { bufferId })
|
||||
.catch(() => !cancelled && setUnavailable(true))
|
||||
}
|
||||
ask()
|
||||
const timer = setInterval(ask, REFRESH_MS)
|
||||
document.addEventListener('visibilitychange', ask)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(timer)
|
||||
document.removeEventListener('visibilitychange', ask)
|
||||
}
|
||||
}, [bufferId])
|
||||
|
||||
// Closing the channel takes the panel with it.
|
||||
useEffect(() => setOpen(false), [bufferId])
|
||||
|
||||
// A channel with no points (or no way to read them) draws nothing rather than a plaque that says nothing.
|
||||
if (unavailable && !pushed) return null
|
||||
const points = pushed?.points
|
||||
const name = pushed?.name || 'points'
|
||||
return (
|
||||
<div className="kick-points twitch-points">
|
||||
<button type="button" className={`kick-points-plaque${open ? ' open' : ''}`} title={`Your channel ${name} here, and what they buy`} onClick={() => setOpen(true)}>
|
||||
<Icon name="toll" size={15} />
|
||||
<span className="ellipsis">{points === null || points === undefined ? 'Points' : `${points.toLocaleString()} ${name === 'points' ? '' : name}`.trim()}</span>
|
||||
<Icon name="redeem" size={15} />
|
||||
</button>
|
||||
{open && <RewardsPanel bufferId={bufferId} service="twitch" onClose={() => setOpen(false)} onBalance={() => undefined} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useStore } from '../state/hooks'
|
||||
|
||||
/**
|
||||
* Signing in to Twitch.
|
||||
*
|
||||
* One action. A window of twitch.tv opens; the person signs in there (typed
|
||||
* into Twitch's own page, never seen by moho) and then, in the same window,
|
||||
* approves what moho is asked to be allowed to do. That gives the account both
|
||||
* what Twitch's API takes (chat, follows, moderation) and the session its
|
||||
* player takes (video as the person, with their Turbo or subscriptions) -
|
||||
* see src/main/twitch-signin.ts and nobilis backend/twitch/login.rs. Waiting
|
||||
* can take minutes, so this listens for events while the window is open.
|
||||
*/
|
||||
interface Scope {
|
||||
scope: string
|
||||
why: string
|
||||
}
|
||||
|
||||
/** The scopes the sign-in asks for, said before it is started so nobody has to guess. */
|
||||
const ASKS: Scope[] = [
|
||||
{ scope: 'chat:read', why: 'read chat' },
|
||||
{ scope: 'chat:edit', why: 'send messages' },
|
||||
{ scope: 'user:write:chat', why: 'hear why Twitch turned a message down' },
|
||||
{ scope: 'user:read:follows', why: 'list the channels you follow' },
|
||||
{ scope: 'user:read:emotes', why: 'show the emotes you can use' }
|
||||
]
|
||||
|
||||
/** What moderation, whispers and the rest ask for, said before it is asked. */
|
||||
const MODERATION_ASKS: Scope[] = [
|
||||
{ scope: 'moderator:manage:banned_users', why: 'time out, ban and unban where you moderate' },
|
||||
{ scope: 'moderator:manage:chat_messages', why: 'remove messages where you moderate' },
|
||||
{ scope: 'moderator:manage:chat_settings', why: 'change slow, followers-only, subscribers-only and emote-only modes' },
|
||||
{ scope: 'moderator:manage:announcements', why: 'make announcements' },
|
||||
{ scope: 'user:manage:chat_color', why: 'change your name colour' },
|
||||
{ scope: 'user:manage:whispers', why: 'send whispers' },
|
||||
{ scope: 'whispers:read', why: 'read whispers sent to you' },
|
||||
{ scope: 'moderator:manage:shoutouts', why: 'give shoutouts where you moderate' },
|
||||
{ scope: 'user:manage:blocked_users', why: 'block and unblock people' }
|
||||
]
|
||||
|
||||
export function TwitchSignIn({
|
||||
useTor,
|
||||
onDone,
|
||||
label = 'Sign in with Twitch',
|
||||
children
|
||||
}: {
|
||||
useTor: boolean
|
||||
onDone: () => void
|
||||
label?: string
|
||||
/** Other things to offer beside the sign-in button, while nothing is waiting. */
|
||||
children?: React.ReactNode
|
||||
}): JSX.Element {
|
||||
const store = useStore()
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [detail, setDetail] = useState('')
|
||||
const [userCode, setUserCode] = useState('')
|
||||
const [scopes, setScopes] = useState<Scope[]>([...ASKS, ...MODERATION_ASKS])
|
||||
|
||||
useEffect(() => {
|
||||
const off = window.moho.onEvent((e) => {
|
||||
if (!busy || !e.event.startsWith('twitchLogin')) return
|
||||
const d = e.data as { detail?: string; scopes?: Scope[]; userCode?: string }
|
||||
if (e.event === 'twitchLoginCode') {
|
||||
if (d.scopes) setScopes(d.scopes)
|
||||
if (d.userCode) setUserCode(d.userCode)
|
||||
}
|
||||
else if (e.event === 'twitchLoginStatus') setDetail(d.detail || '')
|
||||
})
|
||||
return off
|
||||
}, [busy])
|
||||
|
||||
const begin = (): void => {
|
||||
setBusy(true)
|
||||
setDetail('')
|
||||
void window.moho
|
||||
.twitchSignIn({ useTor })
|
||||
.then((r) => {
|
||||
if (r.ok) {
|
||||
store.toast('info', 'Signed in to Twitch for chat and video')
|
||||
void store.refreshAccounts()
|
||||
onDone()
|
||||
} else if (r.error && r.error !== 'cancelled') {
|
||||
store.toast('error', r.error)
|
||||
}
|
||||
})
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
.finally(() => {
|
||||
setBusy(false)
|
||||
setDetail('')
|
||||
setUserCode('')
|
||||
void store.refreshAccounts()
|
||||
})
|
||||
}
|
||||
|
||||
if (busy) {
|
||||
return (
|
||||
<div className="add-form">
|
||||
<p>Sign in on twitch.tv in the window that opened. It then goes to Twitch's "Activate your device" page, where moho types this code in for you; then press Authorize.</p>
|
||||
{userCode && (
|
||||
<div className="field-row">
|
||||
<strong className="twitch-code" aria-label="Your sign-in code">
|
||||
{userCode}
|
||||
</strong>
|
||||
<button type="button" className="button subtle" onClick={() => void window.moho.copyText(userCode)}>
|
||||
Copy code
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="small muted">
|
||||
moho will be allowed to: {scopes.map((s) => s.why).join(', ')}. Twitch lists the same on its own page. You can end it any time at
|
||||
twitch.tv/settings/connections, and removing the account here ends it too. Closing the window cancels the sign-in.
|
||||
</p>
|
||||
{detail && <p className="small muted">{detail}…</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="small muted">
|
||||
One sign-in on twitch.tv, in a window that opens here: typed on Twitch's own page, never seen by moho. It signs you in for
|
||||
chat and for video together, so video plays as you. It asks to be allowed to: {scopes.map((s) => s.why).join(', ')}.
|
||||
</p>
|
||||
<div className="field-row">
|
||||
<button type="button" className="button primary" onClick={begin}>
|
||||
{label}
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -58,11 +58,11 @@ export function UserFooter({ account }: { account?: Account }): JSX.Element {
|
||||
const detail = details[account.id]
|
||||
// A signed-out Kick account is a reader: it has no presence to show, and
|
||||
// "Online" claimed one.
|
||||
const watching = account.service === 'kick' && !account.hasPassword && status !== 'connecting' && status !== 'offline'
|
||||
const watching = (account.service === 'kick' || account.service === 'twitch') && !account.hasPassword && status !== 'connecting' && status !== 'offline'
|
||||
// Mute and deafen are the whole client's, for whatever call is up - so
|
||||
// shown on a service that has calls, or anywhere while one is running,
|
||||
// and not beside an IRC or Kick account with nothing to apply them to.
|
||||
const showVoice = account.service === 'discord' || account.service === 'matrix' || inACall
|
||||
const showVoice = account.service === 'discord' || account.service === 'matrix' || account.service === 'steam' || inACall
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -16,7 +16,8 @@ import { useChat, useStore } from '../state/hooks'
|
||||
*/
|
||||
export function VerificationDialog(): JSX.Element | null {
|
||||
const active = useChat((s) => s.matrixVerification)
|
||||
if (!active) return null
|
||||
// Waiting on an answer, it is on the Invitations tile: this is for the check itself.
|
||||
if (!active || active.incoming) return null
|
||||
|
||||
// Keyed on the verification so its QR code is fetched afresh for a new one,
|
||||
// rather than a picture of the last session being shown against this one.
|
||||
|
||||
@@ -7,6 +7,12 @@ import { classes } from '../lib/util'
|
||||
const EDGE = 4
|
||||
/** The narrowest the corner panel may be made: its controls still fit. */
|
||||
const MIN_PIP_WIDTH = 240
|
||||
/** The tallest and shortest the picture above a conversation may be made. */
|
||||
const MIN_INLINE_HEIGHT = 140
|
||||
|
||||
/** Which edge or corner of the corner panel is held: top, bottom, left, right and their corners. */
|
||||
type Edge = 't' | 'b' | 'l' | 'r' | 'tl' | 'tr' | 'bl' | 'br'
|
||||
const EDGES: Edge[] = ['t', 'b', 'l', 'r', 'tl', 'tr', 'bl', 'br']
|
||||
|
||||
/**
|
||||
* The frame a moving picture lives in, whatever is feeding it.
|
||||
@@ -42,6 +48,8 @@ export function VideoStage({
|
||||
onPopOut,
|
||||
/** Back from that window into moho. */
|
||||
onBringBack,
|
||||
height,
|
||||
onHeight,
|
||||
children
|
||||
}: {
|
||||
/** Above the conversation, in a corner of the window, or a window of its own. */
|
||||
@@ -57,6 +65,9 @@ export function VideoStage({
|
||||
controls?: ReactNode
|
||||
onPopOut?: () => void
|
||||
onBringBack?: () => void
|
||||
/** Inline only: how tall the picture is now, and how to change it. `null` puts it back to what it was. */
|
||||
height?: number
|
||||
onHeight?: (height: number | null, commit: boolean) => void
|
||||
children: ReactNode
|
||||
}): JSX.Element {
|
||||
const panel = useRef<HTMLDivElement>(null)
|
||||
@@ -83,7 +94,8 @@ export function VideoStage({
|
||||
*/
|
||||
const [savedWidth, setSavedWidth] = usePref<number | null>('ui.pipWidth', null)
|
||||
const [width, setWidth] = useState(savedWidth)
|
||||
const sizing = useRef<{ x: number; w: number; h: number; right: number; bottom: number } | null>(null)
|
||||
const sizing = useRef<{ x: number; y: number; w: number; dir: Edge; left: number; top: number; right: number; bottom: number } | null>(null)
|
||||
const rise = useRef<{ y: number; h: number } | null>(null)
|
||||
|
||||
/**
|
||||
* Where the panel may be put, in the coordinates it is positioned in.
|
||||
@@ -167,15 +179,16 @@ export function VideoStage({
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizing from the top-left corner - the inside corner, for a panel that
|
||||
* starts in the bottom-right - so the edges against the window stay put and
|
||||
* the panel grows towards the middle of it.
|
||||
* Resizing from any edge or corner. The picture keeps its shape, so every handle changes the width and the
|
||||
* height follows. The side opposite the one held stays where it is: the edges against the window stay put when
|
||||
* the panel is grown from the inside, and the panel grows towards the middle of the window.
|
||||
*/
|
||||
const startResize = (e: PointerEvent<HTMLDivElement>): void => {
|
||||
const startResize = (e: PointerEvent<HTMLDivElement>, dir: Edge): void => {
|
||||
const box = panel.current?.getBoundingClientRect()
|
||||
if (!box) return
|
||||
const corner = local(box.right, box.bottom)
|
||||
sizing.current = { x: e.clientX, w: box.width, h: box.height, right: corner.x, bottom: corner.y }
|
||||
const from = local(box.left, box.top)
|
||||
const to = local(box.right, box.bottom)
|
||||
sizing.current = { x: e.clientX, y: e.clientY, w: box.width, dir, left: from.x, top: from.y, right: to.x, bottom: to.y }
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@@ -186,11 +199,22 @@ export function VideoStage({
|
||||
if (!from) return
|
||||
const room = (panel.current?.offsetParent as HTMLElement | null)?.getBoundingClientRect()
|
||||
const largest = Math.max(MIN_PIP_WIDTH, (room?.width ?? window.innerWidth) * 0.9)
|
||||
const w = Math.round(Math.min(Math.max(from.w + (from.x - e.clientX), MIN_PIP_WIDTH), largest))
|
||||
setWidth(w)
|
||||
const sideways = from.dir.includes('r') ? e.clientX - from.x : from.dir.includes('l') ? from.x - e.clientX : 0
|
||||
const upwards = (from.dir.includes('b') ? e.clientY - from.y : from.dir.includes('t') ? from.y - e.clientY : 0) * (16 / 9)
|
||||
// A corner follows whichever way it was pulled further.
|
||||
const grow = Math.abs(sideways) >= Math.abs(upwards) ? sideways : upwards
|
||||
setWidth(Math.round(Math.min(Math.max(from.w + grow, MIN_PIP_WIDTH), largest)))
|
||||
}
|
||||
|
||||
// The bottom-right corner stays where it was while the panel is resized.
|
||||
/** Where the panel's top-left goes for the size it came out at, with the far side held. */
|
||||
const anchored = (from: NonNullable<typeof sizing.current>, box: DOMRect): { x: number; y: number } =>
|
||||
clamp(
|
||||
// Held by its left when it was pulled from the right; otherwise by its right (the corner it starts in).
|
||||
from.dir.includes('r') ? from.left : from.right - box.width,
|
||||
from.dir.includes('b') ? from.top : from.bottom - box.height
|
||||
)
|
||||
|
||||
// The far corner stays where it was while the panel is resized.
|
||||
// Placed from the size the panel actually came out at, not a guess at it:
|
||||
// the title and controls do not grow with the picture, so scaling the whole
|
||||
// height drifted the bottom edge up the screen.
|
||||
@@ -198,7 +222,8 @@ export function VideoStage({
|
||||
const from = sizing.current
|
||||
const box = panel.current?.getBoundingClientRect()
|
||||
if (!from || !box) return
|
||||
setSpot(clamp(from.right - box.width, from.bottom - box.height))
|
||||
setSpot(anchored(from, box))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [width, clamp])
|
||||
|
||||
const endResize = (e: PointerEvent<HTMLDivElement>): void => {
|
||||
@@ -215,13 +240,33 @@ export function VideoStage({
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => {
|
||||
const box = panel.current?.getBoundingClientRect()
|
||||
const rest = box ? clamp(from.right - box.width, from.bottom - box.height) : null
|
||||
const rest = box ? anchored(from, box) : null
|
||||
if (rest) setSpot(rest)
|
||||
setSaved(rest)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Above a conversation, the picture is resized by its bottom edge.
|
||||
const startRise = (e: PointerEvent<HTMLDivElement>): void => {
|
||||
rise.current = { y: e.clientY, h: height ?? 0 }
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
e.preventDefault()
|
||||
}
|
||||
const onRise = (e: PointerEvent<HTMLDivElement>): void => {
|
||||
if (!rise.current || !onHeight) return
|
||||
const view = e.currentTarget.ownerDocument.defaultView ?? window
|
||||
onHeight(Math.round(Math.min(Math.max(rise.current.h + e.clientY - rise.current.y, MIN_INLINE_HEIGHT), view.innerHeight * 0.8)), false)
|
||||
}
|
||||
const endRise = (e: PointerEvent<HTMLDivElement>): void => {
|
||||
if (!rise.current || !onHeight) return
|
||||
const view = e.currentTarget.ownerDocument.defaultView ?? window
|
||||
const done = Math.round(Math.min(Math.max(rise.current.h + e.clientY - rise.current.y, MIN_INLINE_HEIGHT), view.innerHeight * 0.8))
|
||||
rise.current = null
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
onHeight(done, true)
|
||||
}
|
||||
|
||||
/** Where it sits: the corner it started in, or where it was put. */
|
||||
const placed =
|
||||
mode === 'pip'
|
||||
@@ -254,18 +299,19 @@ export function VideoStage({
|
||||
|
||||
return (
|
||||
<div className={classes('call-stage', mode === 'pip' && 'pip', mode === 'window' && 'in-window')} ref={panel} style={placed}>
|
||||
{mode === 'pip' && (
|
||||
// A grip in the inside corner, the way a window is resized from its
|
||||
// corner.
|
||||
<div
|
||||
className="call-stage-resize"
|
||||
title="Drag to resize"
|
||||
onPointerDown={startResize}
|
||||
onPointerMove={onResize}
|
||||
onPointerUp={endResize}
|
||||
onPointerCancel={endResize}
|
||||
/>
|
||||
)}
|
||||
{mode === 'pip' &&
|
||||
// Any edge or corner, the way a window is resized.
|
||||
EDGES.map((dir) => (
|
||||
<div
|
||||
key={dir}
|
||||
className={`call-stage-resize edge-${dir}`}
|
||||
title="Drag to resize"
|
||||
onPointerDown={(e) => startResize(e, dir)}
|
||||
onPointerMove={onResize}
|
||||
onPointerUp={endResize}
|
||||
onPointerCancel={endResize}
|
||||
/>
|
||||
))}
|
||||
{mode === 'window' && (
|
||||
// A window of its own has no frame, so this is its title bar: it moves
|
||||
// the window, and carries the two things a window is done with.
|
||||
@@ -300,6 +346,18 @@ export function VideoStage({
|
||||
|
||||
{children}
|
||||
|
||||
{mode === 'inline' && onHeight && (
|
||||
<div
|
||||
className="stage-resize"
|
||||
title="Drag to resize · double-click to reset"
|
||||
onPointerDown={startRise}
|
||||
onPointerMove={onRise}
|
||||
onPointerUp={endRise}
|
||||
onPointerCancel={endRise}
|
||||
onDoubleClick={() => onHeight(null, true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="call-stage-controls">
|
||||
{/* Which conversation this belongs to, because the picture no longer
|
||||
sits under it: it outlives looking at something else. */}
|
||||
|
||||
@@ -42,6 +42,12 @@ export function VoicePanel(): JSX.Element | null {
|
||||
setRtt(staged.rttMs)
|
||||
return
|
||||
}
|
||||
// A Steam call is held by this window, which is where its level is.
|
||||
if (session.accountId.startsWith('steam:')) {
|
||||
setLevel(store.steamVoiceLive.level)
|
||||
setRtt(store.steamVoiceLive.rttMs)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const levels = await window.moho.rpc<{ accountId: string; micPeak: number; rttMs?: number | null }[]>(
|
||||
'getVoiceLevels'
|
||||
@@ -60,13 +66,13 @@ export function VoicePanel(): JSX.Element | null {
|
||||
if (document.visibilityState === 'visible') void tick()
|
||||
}, 150)
|
||||
return () => clearInterval(timer)
|
||||
}, [session])
|
||||
}, [session, store])
|
||||
|
||||
if (!session) return null
|
||||
|
||||
// A one-to-one call has no guild to go back to; its home is the
|
||||
// conversation itself, which the DM page holds.
|
||||
const groupId = session.guildId ? `${session.accountId}|guild:${session.guildId}` : DM_GROUP_ID
|
||||
const groupId = session.railId ?? (session.guildId ? `${session.accountId}|guild:${session.guildId}` : DM_GROUP_ID)
|
||||
const guildName = session.isDirect ? '' : (groups.find((g) => g.id === groupId)?.name ?? '')
|
||||
const fill = levelFill(level)
|
||||
const quality = rtt === null ? null : rtt < 80 ? 'good' : rtt < 160 ? 'fair' : 'poor'
|
||||
|
||||
@@ -126,6 +126,7 @@ const CATEGORIES: { id: string; label: string; group: string; icon?: string; ser
|
||||
{ id: 'discord', label: 'Discord', group: 'Networks', service: 'discord' },
|
||||
{ id: 'matrix', label: 'Matrix', group: 'Networks', service: 'matrix' },
|
||||
{ id: 'kick', label: 'Kick', group: 'Networks', service: 'kick' },
|
||||
{ id: 'twitch', label: 'Twitch', group: 'Networks', service: 'twitch' },
|
||||
{ id: 'about', label: 'About', group: 'About' }
|
||||
]
|
||||
|
||||
@@ -244,6 +245,8 @@ function Page({ id }: { id: string }): JSX.Element | null {
|
||||
return <DiscordSettings />
|
||||
case 'kick':
|
||||
return <KickSettings />
|
||||
case 'twitch':
|
||||
return <TwitchSettings />
|
||||
case 'about':
|
||||
return <AboutSettings />
|
||||
default:
|
||||
@@ -1222,6 +1225,111 @@ function KickSettings(): JSX.Element {
|
||||
defaultValue={true}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Badges"
|
||||
description="Kick's level, founder and event badges, and a channel's own, drawn as pictures beside a name. Moderator, subscriber and the like stay as words."
|
||||
>
|
||||
<ToggleSetting
|
||||
settingKey="kick.showBadgePictures"
|
||||
label="Show badge pictures"
|
||||
description="Loaded from Kick's servers as they are shown"
|
||||
defaultValue={true}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Messages Kick removes"
|
||||
description="When a moderator or Kick's automatic moderation takes a message down. The text is kept, so you can still see what was said."
|
||||
>
|
||||
<SelectionSetting
|
||||
settingKey="kick.removedMessages"
|
||||
label="Removed messages"
|
||||
description="Struck through in red keeps what was said beside the fact that it is gone"
|
||||
defaultValue="strike"
|
||||
options={[
|
||||
{ label: 'Delete', value: 'remove' },
|
||||
{ label: 'Strikethrough', value: 'strike' },
|
||||
{ label: 'Replace with notice', value: 'replace' }
|
||||
]}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Twitch. What is here is what the plan (Gitea issue #55) has built so far:
|
||||
* where a channel's recent past comes from, and what is said about signing in.
|
||||
*/
|
||||
function TwitchSettings(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
title="Twitch"
|
||||
description="Channels are opened from the Join panel by name. Twitch's chat is public, so an account that never signs in still reads every channel; signing in adds talking and the channels you follow. Add an account from the Accounts pane."
|
||||
/>
|
||||
|
||||
<SettingsSection
|
||||
title="What to show besides chat"
|
||||
description="The same choices as Kick's: one setting covers a kind of event on both services."
|
||||
>
|
||||
<ToggleSetting settingKey="kick.showSubscriptions" label="Show subscriptions and gifted subs" defaultValue={true} />
|
||||
<ToggleSetting settingKey="kick.showRaids" label="Show raids" defaultValue={true} />
|
||||
<ToggleSetting
|
||||
settingKey="kick.showModeration"
|
||||
label="Show timeouts and bans"
|
||||
description="What a moderator did, which everybody in the channel can see happening anyway"
|
||||
defaultValue={true}
|
||||
/>
|
||||
<SelectionSetting
|
||||
settingKey="kick.removedMessages"
|
||||
label="Removed messages"
|
||||
description="When a moderator takes a message down. The text is kept, so you can still see what was said."
|
||||
defaultValue="strike"
|
||||
options={[
|
||||
{ label: 'Delete', value: 'remove' },
|
||||
{ label: 'Strikethrough', value: 'strike' },
|
||||
{ label: 'Replace with notice', value: 'replace' }
|
||||
]}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Badges"
|
||||
description="Twitch's own pictures beside a name: the channel's subscriber badge, moderator, VIP and the rest. With pictures off, the roles that have a short word are shown as that word."
|
||||
>
|
||||
<ToggleSetting
|
||||
settingKey="kick.showBadgePictures"
|
||||
label="Show badge pictures"
|
||||
description="Loaded from Twitch's servers as they are shown. The same switch as Kick's."
|
||||
defaultValue={true}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Watching"
|
||||
description="Twitch gives channel points for time spent watching, and counts it from a short report its own player sends once a minute. Moho plays the video itself, so while a Twitch stream is playing in the window (not paused, minimised or hidden) and you are signed in, it sends that same report for the channel, once a minute, and nothing when the channel is not live. It claims nothing and sends no more often than that."
|
||||
>
|
||||
<ToggleSetting settingKey="twitch.reportWatching" label="Report watching to Twitch" defaultValue={true} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Polls, predictions and raids"
|
||||
description="Shown as they happen, for every channel you have open, from the same live service twitch.tv's own pages use, which needs no account. You can vote and bet channel points from here, as your twitch.tv session; each only when you press its button. Moho does not claim the bonus chest and sends nothing to earn points faster."
|
||||
/>
|
||||
|
||||
<SettingsSection
|
||||
title="Recent messages"
|
||||
description="Twitch keeps no chat history, so a channel you open starts empty and fills as people speak."
|
||||
>
|
||||
<ToggleSetting
|
||||
settingKey="twitch.recentHistory"
|
||||
label="Fill a channel with its recent messages"
|
||||
description="Asks recent-messages.robotty.de, a service run by a third party that remembers each channel's last few hundred lines. It is told which channel you open, through the account's route. Off until you turn it on."
|
||||
defaultValue={false}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Member } from '../../../shared/wire'
|
||||
import type { BufferEntry } from '../state/store'
|
||||
import { activityIcon, activityLabel, dmActivity, profileActivity } from './activity'
|
||||
|
||||
const dm = { id: 'steam:me|Gabe', kind: 'dm', accountId: 'steam:me', name: 'Gabe' } as BufferEntry
|
||||
const member = (extra: Partial<Member>): Member => ({ nick: 'Gabe', status: 'online', ...extra }) as Member
|
||||
|
||||
describe('what somebody is doing, under their name', () => {
|
||||
it('has a gamepad for a game, a note for a track, and nothing for words they wrote', () => {
|
||||
expect(activityIcon({ kind: 'game', text: 'Resident Evil 4' })).toBe('sports_esports')
|
||||
expect(activityIcon({ kind: 'music', text: 'Ramones' })).toBe('music_note')
|
||||
expect(activityIcon({ kind: 'custom', text: 'ask questions' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads as a sentence where there is no room for a picture', () => {
|
||||
expect(activityLabel({ kind: 'game', text: 'Dota 2' })).toBe('Playing Dota 2')
|
||||
expect(activityLabel({ kind: 'music', text: 'Ramones' })).toBe('Listening to Ramones')
|
||||
expect(activityLabel({ kind: 'custom', text: 'ask questions' })).toBe('ask questions')
|
||||
})
|
||||
|
||||
it('is the one person of a direct message, and nobody else', () => {
|
||||
const playing = { kind: 'game', text: 'Dota 2' } as const
|
||||
expect(dmActivity(dm, { [dm.id]: [member({ activity: playing })] })).toEqual(playing)
|
||||
expect(dmActivity(dm, { [dm.id]: [member({ activity: null })] })).toBeUndefined()
|
||||
expect(dmActivity(dm, { [dm.id]: [member({})] })).toBeUndefined()
|
||||
expect(dmActivity(dm, {})).toBeUndefined()
|
||||
const room = { ...dm, kind: 'channel' } as BufferEntry
|
||||
expect(dmActivity(room, { [room.id]: [member({ activity: playing })] })).toBeUndefined()
|
||||
expect(dmActivity(dm, { [dm.id]: [member({ activity: playing }), member({ nick: 'x', activity: playing })] })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('what a profile card says somebody is doing', () => {
|
||||
const playing = { kind: 'game', text: 'Dota 2' } as const
|
||||
const buffers = [
|
||||
{ id: 'discord:1|#a', accountId: 'discord:1', kind: 'channel', name: '#a' },
|
||||
{ id: 'discord:2|#b', accountId: 'discord:2', kind: 'channel', name: '#b' }
|
||||
] as BufferEntry[]
|
||||
const who = { accountId: 'discord:1', id: '42', name: 'Gabe', handle: 'gabe#1' }
|
||||
|
||||
it('is what the lookup said, first', () => {
|
||||
expect(profileActivity({ ...who, activity: { kind: 'custom', text: 'brb' } }, {}, buffers)).toEqual({ kind: 'custom', text: 'brb' })
|
||||
})
|
||||
|
||||
it('is otherwise what the member lists already say, found by id or by name', () => {
|
||||
const byId = { 'discord:1|#a': [member({ nick: 'someone else', userId: '42', activity: playing })] }
|
||||
expect(profileActivity(who, byId, buffers)).toEqual(playing)
|
||||
const byName = { 'discord:1|#a': [member({ nick: 'GABE', activity: playing })] }
|
||||
expect(profileActivity(who, byName, buffers)).toEqual(playing)
|
||||
})
|
||||
|
||||
it('does not borrow it from another account, nor from somebody else', () => {
|
||||
expect(profileActivity(who, { 'discord:2|#b': [member({ nick: 'Gabe', userId: '42', activity: playing })] }, buffers)).toBeUndefined()
|
||||
expect(profileActivity(who, { 'discord:1|#a': [member({ nick: 'Other', userId: '7', activity: playing })] }, buffers)).toBeUndefined()
|
||||
expect(profileActivity(who, { 'discord:1|#a': [member({ nick: 'Gabe', userId: '42' })] }, buffers)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Activity, Member, Profile } from '../../../shared/wire'
|
||||
import type { BufferEntry } from '../state/store'
|
||||
|
||||
/** The picture before the line: a gamepad for a game, a note for a track, none for words. */
|
||||
export function activityIcon(activity: Activity): string | undefined {
|
||||
return activity.kind === 'game' ? 'sports_esports' : activity.kind === 'music' ? 'music_note' : undefined
|
||||
}
|
||||
|
||||
/** The same thing as a sentence, for a tooltip or a header where there is no picture. */
|
||||
export function activityLabel(activity: Activity): string {
|
||||
return activity.kind === 'game' ? `Playing ${activity.text}` : activity.kind === 'music' ? `Listening to ${activity.text}` : activity.text
|
||||
}
|
||||
|
||||
/**
|
||||
* What the one person in a direct message is doing, where the service says.
|
||||
* Nothing for a room, and nothing for a list of several: it is one person's.
|
||||
*/
|
||||
export function dmActivity(buffer: BufferEntry, presence: Record<string, Member[]>): Activity | undefined {
|
||||
if (buffer.kind !== 'dm') return undefined
|
||||
const own = presence[buffer.id]
|
||||
return (own?.length === 1 && own[0].activity) || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* What somebody is doing, for the profile card.
|
||||
*
|
||||
* What the service said in the lookup itself, and otherwise what their
|
||||
* presence already says wherever they are listed: the member lists carry it
|
||||
* for every service, so a card opened from a message needs no second question
|
||||
* to know the game somebody is in.
|
||||
*/
|
||||
export function profileActivity(
|
||||
profile: Pick<Profile, 'activity' | 'accountId' | 'id' | 'name' | 'handle'>,
|
||||
presence: Record<string, Member[]>,
|
||||
buffers: BufferEntry[]
|
||||
): Activity | undefined {
|
||||
if (profile.activity) return profile.activity
|
||||
const names = [profile.name, profile.handle].filter((n): n is string => !!n).map((n) => n.toLowerCase())
|
||||
for (const buffer of buffers) {
|
||||
if (buffer.accountId !== profile.accountId) continue
|
||||
const found = presence[buffer.id]?.find(
|
||||
(m) => m.activity && ((profile.id && m.userId === profile.id) || names.includes(m.nick.toLowerCase()))
|
||||
)
|
||||
if (found?.activity) return found.activity
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export function railConnecting(
|
||||
const accountOf = (id: string): Account | undefined => accounts.find((a) => a.id === id)
|
||||
const retrying = (b: Buffer): boolean => bufferLink(b, accountOf(b.accountId), details[b.accountId])?.retrying === true
|
||||
if (group.kind === 'dms') return buffers.some((b) => b.kind === 'dm' && retrying(b))
|
||||
if (group.kind === 'pinned' || group.kind === 'invite' || !group.accountId) return false
|
||||
if (group.kind === 'pinned' || group.kind === 'invitations' || !group.accountId) return false
|
||||
const account = accountOf(group.accountId)
|
||||
if (account && account.state === 'connecting') return true
|
||||
return buffers.some((b) => b.groupId === group.id && retrying(b))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferMenuEntries } from './buffermenu'
|
||||
import { bufferMenuEntries, leaveLabel } from './buffermenu'
|
||||
|
||||
const noop = (): void => {}
|
||||
|
||||
@@ -47,3 +47,19 @@ describe('a mute held on the account', () => {
|
||||
expect(entry?.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('what leaving is called', () => {
|
||||
const room = { id: 'b', accountId: 'steam:me', name: 'Raiders/#General', kind: 'channel' } as never
|
||||
const friend = { id: 'b', accountId: 'steam:me', name: 'Gabe', kind: 'dm' } as never
|
||||
|
||||
it('is leaving the group for a room of a Steam group, which cannot be left on its own', () => {
|
||||
expect(leaveLabel(room, 'steam')).toBe('Leave group…')
|
||||
})
|
||||
|
||||
it('is leaving the conversation for a friend, and the same words as before elsewhere', () => {
|
||||
expect(leaveLabel(friend, 'steam')).toBe('Leave conversation')
|
||||
expect(leaveLabel(room, 'matrix')).toBe('Leave room')
|
||||
expect(leaveLabel(room, 'irc')).toBe('Leave channel')
|
||||
expect(leaveLabel(room, 'discord')).toBe('Remove from list')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,6 +62,17 @@ export interface BufferMenuInput {
|
||||
markedUnread?: boolean
|
||||
/** Clears what is waiting here; offered only while something is. */
|
||||
onMarkRead?: () => void
|
||||
/**
|
||||
* What can be done about a Steam friend - what to call them, ending the
|
||||
* friendship, blocking them. Present only on a conversation with one.
|
||||
*/
|
||||
onSteamFriend?: (what: 'nickname' | 'remove' | 'block') => void
|
||||
/**
|
||||
* Opens the card for the person this conversation is with, the one a name
|
||||
* opens everywhere else. Steam's: a direct message there has no member list
|
||||
* to click a name in, so the way to it is the conversation's own menu.
|
||||
*/
|
||||
onProfile?: () => void
|
||||
}
|
||||
|
||||
/** What leaving is called, which depends on what is being left. */
|
||||
@@ -69,6 +80,8 @@ export function leaveLabel(buffer: BufferEntry, service?: string): string {
|
||||
if (buffer.kind === 'dm') return service === 'discord' ? 'Close conversation' : 'Leave conversation'
|
||||
if (service === 'matrix') return 'Leave room'
|
||||
if (service === 'irc') return 'Leave channel'
|
||||
// A room of a Steam group is left by leaving the group.
|
||||
if (service === 'steam') return 'Leave group…'
|
||||
if (service === 'discord') return 'Remove from list'
|
||||
return 'Close'
|
||||
}
|
||||
@@ -104,9 +117,16 @@ export function bufferMenuEntries({
|
||||
onDock,
|
||||
onMarkRead,
|
||||
onMarkUnread,
|
||||
markedUnread
|
||||
markedUnread,
|
||||
onSteamFriend,
|
||||
onProfile
|
||||
}: BufferMenuInput): MenuEntry[] {
|
||||
return [
|
||||
// First: it is the one that goes and looks at the person, where everything
|
||||
// below acts on the conversation.
|
||||
...(onProfile
|
||||
? ([{ label: 'Profile', icon: 'person', onClick: onProfile }, { separator: true }] as MenuEntry[])
|
||||
: []),
|
||||
...(canCall
|
||||
? ([
|
||||
inCall
|
||||
@@ -238,6 +258,15 @@ export function bufferMenuEntries({
|
||||
{ separator: true }
|
||||
] as MenuEntry[])
|
||||
: []),
|
||||
...(onSteamFriend
|
||||
? ([
|
||||
{ separator: true },
|
||||
{ label: 'Set nickname…', icon: 'edit', onClick: () => onSteamFriend('nickname') },
|
||||
{ label: 'Remove friend', icon: 'person_remove', danger: true, onClick: () => onSteamFriend('remove') },
|
||||
{ label: 'Block', icon: 'block', danger: true, onClick: () => onSteamFriend('block') },
|
||||
{ separator: true }
|
||||
] as MenuEntry[])
|
||||
: []),
|
||||
{ label: 'Hide', icon: 'visibility_off', onClick: onHide },
|
||||
...(buffer.kind === 'server'
|
||||
? []
|
||||
|
||||
@@ -24,7 +24,12 @@ const CAPABILITIES: Record<string, FormatKind[]> = {
|
||||
sneedchat: ['bold', 'italic', 'underline', 'strike', 'code', 'spoiler', 'quote', 'color'],
|
||||
// Quote is left out of IRC: a quote is a line of its own, and a newline
|
||||
// reaching IRC is a malformed message.
|
||||
irc: ['bold', 'italic', 'underline', 'strike', 'code', 'spoiler', 'color']
|
||||
irc: ['bold', 'italic', 'underline', 'strike', 'code', 'spoiler', 'color'],
|
||||
// Steam's friend chat takes no formatting: found against the real service,
|
||||
// which turns [b], [i], [u] and [strike] into literal text and drops [code],
|
||||
// [quote] and [spoiler] to their contents. So there is nothing to offer, and
|
||||
// what it does read - emoticons - is not a format.
|
||||
steam: []
|
||||
}
|
||||
|
||||
export function formatsFor(service: string | undefined): FormatKind[] {
|
||||
@@ -195,9 +200,15 @@ export function serialize(root: BoxNode, service: string | undefined): string {
|
||||
for (let i = 0; i < node.childNodes.length; i++) {
|
||||
const n = node.childNodes[i]
|
||||
if (n.nodeType === TEXT_NODE) {
|
||||
out += n.nodeValue ?? ''
|
||||
// Steam reads BBCode, so a bracket somebody typed is escaped: a "[b]"
|
||||
// they meant literally must not turn into bold.
|
||||
out += service === 'steam' ? (n.nodeValue ?? '').replace(/\[/g, '\\[') : (n.nodeValue ?? '')
|
||||
} else if (n.nodeName === 'IMG') {
|
||||
out += n.dataset?.token ?? ''
|
||||
const token = n.dataset?.token ?? ''
|
||||
// An emoticon leaves as the name between colons, which is how Steam
|
||||
// is told one was meant.
|
||||
const steam = service === 'steam' ? token.match(/^\[steam:emote:([A-Za-z0-9_-]+)\]$/) : null
|
||||
out += steam ? `:${steam[1]}:` : token
|
||||
} else if (n.nodeName === 'BR') {
|
||||
out += '\n'
|
||||
} else if (n.nodeType === ELEMENT_NODE) {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Account } from '../../../shared/wire'
|
||||
import type { BufferEntry } from '../state/store'
|
||||
import { dmCategoryId, dmCategoryName, planDmCategories, type DmCategoryState } from './dmcategories'
|
||||
|
||||
const account = (id: string, service: Account['service'], displayName = ''): Account => ({ id, service, displayName }) as Account
|
||||
const dm = (id: string, accountId: string): BufferEntry => ({ id, accountId, kind: 'dm', name: id }) as BufferEntry
|
||||
const channel = (id: string, accountId: string): BufferEntry => ({ id, accountId, kind: 'channel', name: id }) as BufferEntry
|
||||
|
||||
const empty: DmCategoryState = { categories: [], assignment: {}, filed: [], made: [] }
|
||||
const steam = account('steam:salastil', 'steam', 'salastil')
|
||||
const discord = account('discord:1', 'discord', 'Salastil')
|
||||
const kick = account('kick:me', 'kick', 'me')
|
||||
|
||||
describe('the heading for an account', () => {
|
||||
it('reads Protocol - Username', () => {
|
||||
expect(dmCategoryName(steam)).toBe('Steam - salastil')
|
||||
expect(dmCategoryName(discord)).toBe('Discord - Salastil')
|
||||
})
|
||||
|
||||
it('falls back to the id, without its prefix, when the account has no name yet', () => {
|
||||
expect(dmCategoryName(account('matrix:@me:poa.st', 'matrix'))).toBe('Matrix - @me:poa.st')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sorting direct messages under their account', () => {
|
||||
it('makes a heading for each account that has direct messages, once', () => {
|
||||
const plan = planDmCategories([steam, discord, kick], [], empty)!
|
||||
expect(plan.categories.map((c) => c.name)).toEqual(['Steam - salastil', 'Discord - Salastil'])
|
||||
expect(plan.made).toEqual(['steam:salastil', 'discord:1'])
|
||||
expect(plan.made).not.toContain('kick:me')
|
||||
})
|
||||
|
||||
it('files conversations that already exist, which is how an account added long ago gets sorted', () => {
|
||||
const plan = planDmCategories([steam, discord], [dm('a', 'steam:salastil'), dm('b', 'discord:1'), dm('c', 'steam:salastil')], empty)!
|
||||
expect(plan.assignment).toEqual({ a: dmCategoryId('steam:salastil'), b: dmCategoryId('discord:1'), c: dmCategoryId('steam:salastil') })
|
||||
expect(plan.filed.sort()).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('leaves channels alone', () => {
|
||||
const plan = planDmCategories([steam], [channel('#x', 'steam:salastil')], empty)!
|
||||
expect(plan.assignment).toEqual({})
|
||||
expect(plan.filed).toEqual([])
|
||||
})
|
||||
|
||||
it('does nothing once everything has been done, so nothing is written', () => {
|
||||
const first = planDmCategories([steam], [dm('a', 'steam:salastil')], empty)!
|
||||
expect(planDmCategories([steam], [dm('a', 'steam:salastil')], first)).toBeNull()
|
||||
})
|
||||
|
||||
it('files a new conversation when it arrives', () => {
|
||||
const first = planDmCategories([steam], [dm('a', 'steam:salastil')], empty)!
|
||||
const next = planDmCategories([steam], [dm('a', 'steam:salastil'), dm('b', 'steam:salastil')], first)!
|
||||
expect(next.assignment.b).toBe(dmCategoryId('steam:salastil'))
|
||||
})
|
||||
|
||||
it('does not put back a conversation that was moved out by hand', () => {
|
||||
const first = planDmCategories([steam], [dm('a', 'steam:salastil')], empty)!
|
||||
const moved = { ...first, assignment: {} }
|
||||
expect(planDmCategories([steam], [dm('a', 'steam:salastil')], moved)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not overwrite a heading somebody chose for it', () => {
|
||||
const plan = planDmCategories([steam], [dm('a', 'steam:salastil')], { ...empty, assignment: { a: 'mine' } })!
|
||||
expect(plan.assignment.a).toBe('mine')
|
||||
})
|
||||
|
||||
it('does not make a deleted heading again, and files nothing under it', () => {
|
||||
const first = planDmCategories([steam], [], empty)!
|
||||
const deleted = { ...first, categories: [] }
|
||||
expect(planDmCategories([steam], [], deleted)).toBeNull()
|
||||
const later = planDmCategories([steam], [dm('a', 'steam:salastil')], deleted)!
|
||||
expect(later.assignment).toEqual({})
|
||||
expect(later.filed).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('keeps a heading somebody renamed', () => {
|
||||
const first = planDmCategories([steam], [], empty)!
|
||||
const renamed = { ...first, categories: [{ id: dmCategoryId('steam:salastil'), name: 'Games' }] }
|
||||
expect(planDmCategories([steam], [dm('a', 'steam:salastil')], renamed)!.categories[0].name).toBe('Games')
|
||||
})
|
||||
|
||||
it('removes the heading of an account that has gone, and makes it afresh if it comes back', () => {
|
||||
const first = planDmCategories([steam, discord], [], empty)!
|
||||
const gone = planDmCategories([discord], [], first)!
|
||||
expect(gone.categories.map((c) => c.id)).toEqual([dmCategoryId('discord:1')])
|
||||
expect(gone.made).toEqual(['discord:1'])
|
||||
const back = planDmCategories([steam, discord], [], gone)!
|
||||
expect(back.categories.map((c) => c.id)).toContain(dmCategoryId('steam:salastil'))
|
||||
})
|
||||
|
||||
it('leaves the headings people made themselves alone', () => {
|
||||
const mine = { id: 'abc', name: 'Friends' }
|
||||
const plan = planDmCategories([steam], [], { ...empty, categories: [mine] })!
|
||||
expect(plan.categories[0]).toEqual(mine)
|
||||
})
|
||||
|
||||
it('waits for accounts before saying anything, and for an account before filing its conversations', () => {
|
||||
expect(planDmCategories([], [dm('a', 'steam:salastil')], empty)).toBeNull()
|
||||
const plan = planDmCategories([discord], [dm('a', 'steam:salastil')], empty)!
|
||||
expect(plan.filed).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Account } from '../../../shared/wire'
|
||||
import type { BufferEntry } from '../state/store'
|
||||
import type { CustomCategory } from './categories'
|
||||
import { serviceLabel } from './util'
|
||||
|
||||
/**
|
||||
* Direct messages, sorted under a heading per account.
|
||||
*
|
||||
* Every protocol added puts its conversations on the one direct-messages page,
|
||||
* which stops being readable after the second. Each account therefore gets a
|
||||
* heading of its own there - "Steam - salastil" - made the first time the
|
||||
* account is seen, and its conversations are filed under it as they arrive.
|
||||
*
|
||||
* The headings are the ordinary ones a person can make for themselves, so
|
||||
* everything about them still works: they can be renamed, reordered, collapsed
|
||||
* or deleted, and a conversation can be dragged to another. Because of that,
|
||||
* this only ever acts once for each thing. A heading that was deleted is not
|
||||
* made again, and a conversation that was moved out is not put back; what has
|
||||
* been done is remembered rather than re-derived on every change.
|
||||
*/
|
||||
|
||||
/** The services whose conversations land on the direct-messages page. */
|
||||
const DM_SERVICES = new Set(['irc', 'discord', 'matrix', 'steam'])
|
||||
|
||||
const PREFIX = 'dm:'
|
||||
|
||||
/** The id of an account's heading. Stable, so a rename does not lose it. */
|
||||
export function dmCategoryId(accountId: string): string {
|
||||
return `${PREFIX}${accountId}`
|
||||
}
|
||||
|
||||
/** "Protocol - Username", the way the heading reads. */
|
||||
export function dmCategoryName(account: Pick<Account, 'id' | 'service' | 'displayName'>): string {
|
||||
const user = account.displayName || account.id.replace(/^[a-z]+:/, '')
|
||||
return `${serviceLabel(account.service)} - ${user}`
|
||||
}
|
||||
|
||||
export interface DmCategoryState {
|
||||
/** The headings on the direct-messages page. */
|
||||
categories: CustomCategory[]
|
||||
/** Which heading each conversation is under, by conversation. */
|
||||
assignment: Record<string, string>
|
||||
/** The conversations already filed once, wherever they are now. */
|
||||
filed: string[]
|
||||
/** The accounts that have had a heading made, whether or not it is still there. */
|
||||
made: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* What to change so every account has its heading and every conversation that
|
||||
* has not been filed yet is under its account's. Null when there is nothing to
|
||||
* do, so the caller writes nothing on the great majority of calls.
|
||||
*/
|
||||
export function planDmCategories(
|
||||
accounts: Account[],
|
||||
buffers: BufferEntry[],
|
||||
current: DmCategoryState
|
||||
): DmCategoryState | null {
|
||||
// Before accounts are known there is nothing to say: an empty list is not
|
||||
// "every account was removed".
|
||||
if (accounts.length === 0) return null
|
||||
|
||||
let categories = current.categories
|
||||
let made = current.made
|
||||
const assignment = { ...current.assignment }
|
||||
const filed = new Set(current.filed)
|
||||
let changed = false
|
||||
|
||||
// A removed account's heading goes with it, and with it the memory of having
|
||||
// made it, so adding the account again starts it afresh.
|
||||
const live = new Set(accounts.map((a) => a.id))
|
||||
const orphan = (c: CustomCategory): boolean => c.id.startsWith(PREFIX) && !live.has(c.id.slice(PREFIX.length))
|
||||
if (categories.some(orphan)) {
|
||||
categories = categories.filter((c) => !orphan(c))
|
||||
changed = true
|
||||
}
|
||||
if (made.some((id) => !live.has(id))) {
|
||||
made = made.filter((id) => live.has(id))
|
||||
changed = true
|
||||
}
|
||||
|
||||
// The heading, the first time the account is seen.
|
||||
for (const account of accounts) {
|
||||
if (!DM_SERVICES.has(account.service) || made.includes(account.id)) continue
|
||||
if (!categories.some((c) => c.id === dmCategoryId(account.id))) {
|
||||
categories = [...categories, { id: dmCategoryId(account.id), name: dmCategoryName(account) }]
|
||||
}
|
||||
made = [...made, account.id]
|
||||
changed = true
|
||||
}
|
||||
|
||||
// The conversations, once each. One whose heading has been deleted, or that
|
||||
// was put somewhere by hand already, is marked filed and left alone.
|
||||
for (const buffer of buffers) {
|
||||
if (buffer.kind !== 'dm' || filed.has(buffer.id) || !live.has(buffer.accountId)) continue
|
||||
const heading = dmCategoryId(buffer.accountId)
|
||||
if (!assignment[buffer.id] && categories.some((c) => c.id === heading)) assignment[buffer.id] = heading
|
||||
filed.add(buffer.id)
|
||||
changed = true
|
||||
}
|
||||
|
||||
return changed ? { categories, assignment, filed: [...filed], made } : null
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildSmilieIndex,
|
||||
cheerUrl,
|
||||
discordEmojiUrl,
|
||||
embedColor,
|
||||
emojiPreview,
|
||||
@@ -14,6 +15,8 @@ import {
|
||||
hostnameOf,
|
||||
ircFormat,
|
||||
normalizeBBCode,
|
||||
sneedGreentext,
|
||||
sneedSameText,
|
||||
stripCodeBlocks,
|
||||
stripEmbeddedUrls,
|
||||
stripQuoteBlocks,
|
||||
@@ -307,3 +310,50 @@ describe('the forum\'s own files', () => {
|
||||
expect(extractMedia('https://i.ibb.co/JRkN8BFv/photo.avif', {}).map((m) => m.kind)).toEqual(['image'])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Twitch's emotes and cheers in a message", () => {
|
||||
it("draws Twitch's own, still and moving, and reads a stored third-party emote as its word", () => {
|
||||
const html = formatMessage('[twitch:emote:25:Kappa] [twitch:aemote:emotesv2_abc:Dance] [twitch:bttv:a1:OMEGALUL] [twitch:7tv:01AB:GAMBA]')
|
||||
expect(html).toContain('emoticons/v2/25/default/dark/2.0')
|
||||
expect(html).toContain('emoticons/v2/emotesv2_abc/animated/dark/2.0')
|
||||
expect(html).toContain('OMEGALUL GAMBA')
|
||||
expect(html).not.toContain('cdn.7tv.app')
|
||||
expect(html).not.toContain('[twitch:')
|
||||
expect(html).toContain('alt="Kappa"')
|
||||
})
|
||||
|
||||
it('draws a cheer as its picture and its amount, at the tier the amount reaches', () => {
|
||||
const html = formatMessage('[twitch:cheer:cheer:250] [twitch:cheer:kappa:5]')
|
||||
expect(html).toContain('actions/cheer/dark/animated/100/2.gif')
|
||||
expect(html).toContain('actions/kappa/dark/animated/1/2.gif')
|
||||
expect(html).toContain('<span class="cheer-amount">250</span>')
|
||||
expect(cheerUrl('Cheer', 10000)).toContain('/cheer/dark/animated/10000/')
|
||||
expect(cheerUrl('cheer', 999)).toContain('/animated/100/')
|
||||
})
|
||||
|
||||
it('shows what the composer would put for one, and nothing for what is not a token', () => {
|
||||
expect(emojiPreview('[twitch:emote:25:Kappa]')?.label).toBe('Kappa')
|
||||
expect(emojiPreview('[twitch:7tv:01AB:GAMBA]')).toBeNull()
|
||||
expect(emojiPreview('[twitch:nonsense:1:x]')).toBeNull()
|
||||
// An id with a slash in it is not an id, and cannot build another address.
|
||||
expect(formatMessage('[twitch:emote:../x:y]')).not.toContain('emoticons/v2/..')
|
||||
})
|
||||
})
|
||||
|
||||
describe("Sneedchat's greentext", () => {
|
||||
it('is a line that starts with ">", in 4chan\'s green, on that line only', () => {
|
||||
const html = formatMessage('plain\n>be me\nstill plain', { isSneedchat: true })
|
||||
expect(html).toBe('plain<br><span class="greentext">>be me</span><br>still plain')
|
||||
// Anywhere else it is just a character.
|
||||
expect(formatMessage('>not green')).not.toContain('greentext')
|
||||
})
|
||||
|
||||
it("is the same thing whether the site wrapped it in its own colour or not, and its echo matches what was sent", () => {
|
||||
expect(sneedGreentext('[color=#72ff72]>archlinuxwoman')).toBe('>archlinuxwoman')
|
||||
expect(sneedGreentext('[color=#72ff72]>a\nb')).toBe('>a\nb')
|
||||
expect(sneedGreentext('[color=#72ff72]>wrapped[/color]')).toBe('>wrapped')
|
||||
expect(sneedGreentext('[color=#ff0000]red stays')).toBe('[color=#ff0000]red stays')
|
||||
expect(sneedSameText('>archlinuxwoman', '[color=#72ff72]>archlinuxwoman')).toBe(true)
|
||||
expect(sneedSameText('hello', '[color=#72ff72]>hello')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,6 +34,19 @@ export interface MediaItem {
|
||||
* somebody opens it.
|
||||
*/
|
||||
page?: string
|
||||
/** Sent as a spoiler: hidden until it is clicked. */
|
||||
spoiler?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of these pictures sit inside a `||spoiler||`, and so are hidden the
|
||||
* way the words beside them are. Steam writes a spoilered picture that way;
|
||||
* Discord says it with the file's name instead.
|
||||
*/
|
||||
export function markSpoilered(items: MediaItem[], text: string): MediaItem[] {
|
||||
if (items.length === 0 || !text.includes('||')) return items
|
||||
const hidden = [...text.matchAll(/\|\|([\s\S]*?)\|\|/g)].map((m) => m[1])
|
||||
return items.map((item) => (hidden.some((h) => h.includes(item.url)) ? { ...item, spoiler: true } : item))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,6 +82,51 @@ export function kickEmoteUrl(id: string): string {
|
||||
return resolveMediaUrl(drawableEmoteUrl(`https://files.kick.com/emotes/${id}/fullsize`))
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture for one of Twitch's own emotes, by id.
|
||||
*
|
||||
* Public and built from the id, like Discord's and Kick's, so it draws for an
|
||||
* emote this account cannot use, which is the point of one somebody else sent.
|
||||
* The dark-background version, at twice the size: chat is drawn on a dark
|
||||
* surface and an emote is a row's height at most.
|
||||
*/
|
||||
export function twitchEmoteUrl(id: string, animated = false): string {
|
||||
return resolveMediaUrl(`https://static-cdn.jtvnw.net/emoticons/v2/${encodeURIComponent(id)}/${animated ? 'animated' : 'default'}/dark/2.0`)
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture for a Twitch cheermote: the prefix's animated picture of the tier the amount reaches. Twitch
|
||||
* has tiers at 1, 100, 1,000, 5,000 and 10,000 bits.
|
||||
*/
|
||||
export function cheerUrl(prefix: string, amount: number): string {
|
||||
const tier = [10000, 5000, 1000, 100, 1].find((t) => amount >= t) ?? 1
|
||||
return resolveMediaUrl(`https://d3aqoihi2n8ty8.cloudfront.net/actions/${encodeURIComponent(prefix.toLowerCase())}/dark/animated/${tier}/2.gif`)
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture for one of Steam's emoticons or stickers, by name.
|
||||
*
|
||||
* A name is all a message carries - `[steam:emote:steamhappy]` - and the
|
||||
* picture is a public one at an address built from it, the way Discord's is
|
||||
* built from an id. So it draws for an emoticon this account does not have,
|
||||
* which is the point of one somebody else sent.
|
||||
*/
|
||||
export function steamPictureUrl(kind: 'emote' | 'sticker', name: string): string {
|
||||
const base = 'https://community.fastly.steamstatic.com/economy/'
|
||||
return resolveMediaUrl(`${base}${kind === 'emote' ? 'emoticon' : 'sticker'}/${encodeURIComponent(name)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* What a Steam token names. An emoticon is one word; a sticker is Steam's own
|
||||
* name for it, which has spaces in it - `[steam:sticker:Nyan GLaDOS]`.
|
||||
*/
|
||||
export function steamTokenOf(text: string): { kind: 'emote' | 'sticker'; name: string } | null {
|
||||
const m = text.match(/^\[steam:(emote|sticker):([^\]\r\n"]{1,64})\]$/)
|
||||
if (!m) return null
|
||||
if (m[1] === 'emote' && !/^[A-Za-z0-9_-]+$/.test(m[2])) return null
|
||||
return { kind: m[1] as 'emote' | 'sticker', name: m[2] }
|
||||
}
|
||||
|
||||
/** What something picked out of the emoji list looks like, when it isn't text. */
|
||||
export interface EmojiPreview {
|
||||
/** Possibly a local path - run it through resolveMediaUrl before use. */
|
||||
@@ -98,11 +156,87 @@ export function emojiPreview(text: string, smilies: SmilieEntry[] = []): EmojiPr
|
||||
if (custom) return { src: discordEmojiUrl(custom[2]), label: `:${custom[1]}:` }
|
||||
const kick = text.match(/^\[emote:(\d+):([^\]]{0,64})\]$/)
|
||||
if (kick) return { src: kickEmoteUrl(kick[1]), label: `:${kick[2]}:` }
|
||||
const twitch = text.match(/^\[twitch:(a?emote):([A-Za-z0-9_]+):([^\]]{0,64})\]$/)
|
||||
if (twitch) return { src: twitchEmoteUrl(twitch[2], twitch[1] === 'aemote'), label: twitch[3] }
|
||||
const steam = steamTokenOf(text)
|
||||
if (steam) return { src: steamPictureUrl(steam.kind, steam.name), label: `:${steam.name}:` }
|
||||
const smilie = smilies.find((s) => s.aliases?.includes(text))
|
||||
if (smilie?.url) return { src: smilie.url, label: smilie.label }
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* What a cell in a service's own section sends back.
|
||||
*
|
||||
* Sneedchat's id is the shortcode itself, so the id is the text. Steam's is a
|
||||
* `steam:name` key, which is no message at all: it was inserted as the words
|
||||
* "steam:steambored". It leaves as the emoticon token, which the box draws.
|
||||
*/
|
||||
export function sourcePick(service: string, e: { id: string }): string {
|
||||
return service === 'steam' && e.id.startsWith('steam:') ? `[steam:emote:${e.id.slice(6)}]` : e.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Sneedchat's own greentext: the site turns a line that starts with ">" into the same line wrapped in a
|
||||
* green colour tag (`[color=#72ff72]>text`), so what comes back is never what was typed. Taken back to the
|
||||
* plain ">text", which is what the window draws green itself (4chan's, see `.greentext`) and what a sent
|
||||
* message is matched against.
|
||||
*/
|
||||
export function sneedGreentext(body: string): string {
|
||||
return body.replace(/^\[color=#72ff72\](>[^\n]*?)(?:\[\/color\])?$/gim, '$1')
|
||||
}
|
||||
|
||||
/** Whether a Sneedchat line that came back is the one that was sent, allowing for the site's greentext. */
|
||||
export function sneedSameText(sent: string, logged: string): boolean {
|
||||
return sent.trim() === sneedGreentext(logged).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether what was typed into a Steam conversation is what Steam logged.
|
||||
*
|
||||
* They are never spelled alike when a message holds an emoticon or a bracket:
|
||||
* the box sends `:steambored:` and Steam hands it back as a picture, which the
|
||||
* daemon writes as `[steam:emote:steambored]`; a bracket typed literally leaves
|
||||
* as `\[` and comes back as `[` with an invisible mark after it. Matched
|
||||
* strictly, the message sat grey until it timed out as failed, while its own
|
||||
* echo showed beside it.
|
||||
*/
|
||||
export function steamSameText(sent: string, logged: string): boolean {
|
||||
const sentForm = sent.replace(/\\\[/g, '[')
|
||||
const loggedForm = logged
|
||||
.replace(/\[steam:emote:([A-Za-z0-9_-]+)\]/g, ':$1:')
|
||||
// A link Steam made into a picture or a card comes back as the address, or as
|
||||
// words that link to it - which is what was typed.
|
||||
.replace(/\[steam:(?:image|video):(https?:\/\/[^\s\]]+)\]/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\(https?:\/\/[^\s)]+\)/g, '$1')
|
||||
.replace(/\u200b/g, '')
|
||||
.trim()
|
||||
return sentForm.trim() === loggedForm
|
||||
}
|
||||
|
||||
/**
|
||||
* A link that ends a clause is not a link ending in the clause's punctuation:
|
||||
* "see https://x.test/a, then" links `https://x.test/a`. A closing bracket goes
|
||||
* too, unless the address has the opening one - which is how a Wikipedia page
|
||||
* such as `.../Foo_(bar)` keeps its own. A semicolon stays, because the text is
|
||||
* escaped by now and `&` inside an address ends in one.
|
||||
*/
|
||||
export function splitTrailingPunctuation(found: string): [string, string] {
|
||||
let address = found
|
||||
let tail = ''
|
||||
for (;;) {
|
||||
const last = address.slice(-1)
|
||||
const opens = (address.match(/\(/g) ?? []).length
|
||||
const closes = (address.match(/\)/g) ?? []).length
|
||||
if (last && '.,:!?\'*'.includes(last) || (last === ')' && closes > opens)) {
|
||||
tail = last + tail
|
||||
address = address.slice(0, -1)
|
||||
} else {
|
||||
return [address, tail]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function escapeHtml(s: string): string {
|
||||
// Quotes as well as brackets: the link passes write a URL from the text
|
||||
// into an attribute, and a quote in it would end the attribute early.
|
||||
@@ -120,6 +254,13 @@ export function normalizeBBCode(text: string): string {
|
||||
if (!text) return text
|
||||
let out = text
|
||||
|
||||
// What Steam sends for a file, as the daemon first stored it, before it
|
||||
// wrote a marker: the tag with its address as an attribute, which was read
|
||||
// as `[img src=ADDRESS ...]ADDRESS` or as the address twice and a stray
|
||||
// `[/video]`. Read into the marker, so what was stored then draws as it does now.
|
||||
out = out.replace(/\[(img|video)\s+src="?(https?:\/\/[^\s"\]]+)"?[^\]]*\](?:\s*\2)?(?:\[\/\1\])?/gi, (_m, tag: string, url: string) => `\n[steam:${tag.toLowerCase() === 'img' ? 'image' : 'video'}:${url}]\n`)
|
||||
out = out.replace(/(https?:\/\/[^\s\]]+)\s*\n\s*\1\[\/video\]/gi, '\n[steam:video:$1]\n')
|
||||
|
||||
// [br] -> a real newline, so it flows through the same \n -> <br> step
|
||||
// below. Left alone, the catch-all "strip unrecognised BBCode" pass would
|
||||
// delete it with nothing in its place, gluing both sides together.
|
||||
@@ -385,6 +526,9 @@ export function ircFormat(text: string, mode: 'render' | 'strip'): string {
|
||||
|
||||
export function formatMessage(text: string, opts: FormatOptions = {}): string {
|
||||
if (!text) return text
|
||||
// A Steam picture or video named in a line that is not drawing its media - a
|
||||
// quoted reply, a preview - is the address it names.
|
||||
text = text.replace(STEAM_MEDIA_MARK_RE, '$2')
|
||||
const revealed = opts.revealedSpoilers || {}
|
||||
// First, because the codes are invisible characters sitting anywhere in the
|
||||
// line: a colour code inside a URL or between a pair of asterisks would
|
||||
@@ -450,6 +594,31 @@ export function formatMessage(text: string, opts: FormatOptions = {}): string {
|
||||
stow(`<img src="${kickEmoteUrl(id)}" class="custom-emoji" alt=":${name}:">`)
|
||||
)
|
||||
|
||||
// Twitch's own emotes, which arrive as `[twitch:emote:25:Kappa]`: the id for
|
||||
// the picture and the name for the words, so a screen reader and a copied
|
||||
// line both read "Kappa". Public and named by the token, like the others.
|
||||
out = out.replace(/\[twitch:(a?emote):([A-Za-z0-9_]+):([^\]]{0,64})\]/g, (_m, kind: string, id: string, name: string) =>
|
||||
stow(`<img src="${twitchEmoteUrl(id, kind === 'aemote')}" class="custom-emoji" alt="${name}" title="${name}">`)
|
||||
)
|
||||
// BetterTTV, FrankerFaceZ and 7TV are not drawn (Twitch does not carry them). A line stored while they were
|
||||
// is read as the words it stands for.
|
||||
out = out.replace(/\[twitch:(?:bttv|ffz|7tv):[A-Za-z0-9_]+:([^\]]{0,64})\]/g, '$1')
|
||||
// A cheer: its picture and the amount beside it, as Twitch's chat draws one.
|
||||
out = out.replace(/\[twitch:cheer:([a-z0-9]+):(\d{1,9})\]/g, (_m, prefix: string, amount: string) =>
|
||||
stow(`<img src="${cheerUrl(prefix, Number(amount))}" class="custom-emoji" alt="${prefix}" title="${prefix} ${amount}"><span class="cheer-amount">${amount}</span>`)
|
||||
)
|
||||
|
||||
// Steam's emoticons and stickers, which arrive as `[steam:emote:name]`. Like
|
||||
// the two above the picture is public and named by the token, so one that
|
||||
// somebody else sent draws whether or not this account has it. A sticker is
|
||||
// drawn larger, as Steam draws it.
|
||||
out = out.replace(/\[steam:(emote|sticker):([^\]\r\n]{1,80})\]/g, (m, kind: string, shown: string) => {
|
||||
// Escaped by now, and a name that was written with an ampersand in it is whole again.
|
||||
const name = shown.replace(/&/g, '&')
|
||||
if (!steamTokenOf(`[steam:${kind}:${name}]`)) return m
|
||||
return stow(`<img src="${steamPictureUrl(kind as 'emote' | 'sticker', name)}" class="${kind === 'sticker' ? 'custom-emoji steam-sticker' : 'custom-emoji'}" alt=":${shown}:">`)
|
||||
})
|
||||
|
||||
// Discord channel links. What arrives is `<#1393001234568164748>` and
|
||||
// nothing else - no name anywhere in the payload - so this is the only
|
||||
// chance to make it read as the "#general" the sender saw when they typed
|
||||
@@ -532,7 +701,10 @@ export function formatMessage(text: string, opts: FormatOptions = {}): string {
|
||||
// something to offer the reader to open (#247). And not into an escaped
|
||||
// bracket: `<https://x>` is how Discord suppresses an embed, and arrives
|
||||
// here as `<https://x>`.
|
||||
out = out.replace(/((?:https?|ircs?):\/\/(?:(?!>|<|")[^\s<])+)/g, '<a href="$1">$1</a>')
|
||||
out = out.replace(/((?:https?|ircs?):\/\/(?:(?!>|<|")[^\s<])+)/g, (_m, found: string) => {
|
||||
const [address, tail] = splitTrailingPunctuation(found)
|
||||
return `<a href="${address}">${address}</a>${tail}`
|
||||
})
|
||||
|
||||
// Anything else BBCode-shaped that wasn't recognised (e.g. [USER=..]
|
||||
// mentions, [sub]/[sup], a typo'd tag) - stripped rather than shown as
|
||||
@@ -544,6 +716,11 @@ export function formatMessage(text: string, opts: FormatOptions = {}): string {
|
||||
// A literal "\n" is just collapsible whitespace in HTML; a structured
|
||||
// multi-line message (a relay bot's author/link/stats/footer post) would
|
||||
// otherwise render as one run-together line.
|
||||
// Sneedchat's greentext: a line that starts with ">" is the green of 4chan's. The text is escaped by now, so
|
||||
// the mark is ">"; a line is closed inside itself, so the wrap cannot straddle another.
|
||||
if (opts.isSneedchat) {
|
||||
out = out.split('\n').map((line) => (line.startsWith('>') ? `<span class="greentext">${line}</span>` : line)).join('\n')
|
||||
}
|
||||
out = out.replace(/\n/g, '<br>')
|
||||
return out
|
||||
}
|
||||
@@ -580,6 +757,24 @@ export function hostnameOf(url: string): string {
|
||||
*/
|
||||
export const OPAQUE_IMAGE_HOSTS = ['i.ddos.lgbt']
|
||||
|
||||
/**
|
||||
* A picture Steam holds for somebody who sent it in chat. Its address has no
|
||||
* file extension anywhere - `.../ugc/<id>/<hash>/` - so it can only be
|
||||
* recognised by where it is kept.
|
||||
*/
|
||||
const STEAM_UPLOAD_RE = /^https:\/\/(?:images\.steamusercontent\.com|steamuserimages-a\.akamaihd\.net|images\.akamai\.steamstatic\.com)\/ugc\//i
|
||||
|
||||
/**
|
||||
* What the daemon writes for a file Steam holds for a sender: `[steam:image:URL]`
|
||||
* or `[steam:video:URL]`. Its address does not say which it is - there is no
|
||||
* extension anywhere in it - so the message does.
|
||||
*/
|
||||
const STEAM_MEDIA_MARK_RE = /\[steam:(image|video):(https?:\/\/[^\s\]]+)\]/g
|
||||
|
||||
export function isSteamUpload(url: string): boolean {
|
||||
return STEAM_UPLOAD_RE.test(url)
|
||||
}
|
||||
|
||||
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|avif)(\?\S*)?$/i
|
||||
const VIDEO_EXT_RE = /\.(mp4|webm|mov|mkv|ogv)(\?\S*)?$/i
|
||||
|
||||
@@ -679,6 +874,14 @@ export function extractMedia(
|
||||
// - two strings, one video - and keying on the string showed it twice.
|
||||
const seen = new Set<string>()
|
||||
|
||||
// Steam says what a file is, and these come first so the plain scan below
|
||||
// finds nothing left of them to guess at.
|
||||
for (const m of text.matchAll(STEAM_MEDIA_MARK_RE)) {
|
||||
if (seen.has(m[2])) continue
|
||||
seen.add(m[2])
|
||||
result.push({ url: m[2], kind: m[1] as 'image' | 'video' })
|
||||
}
|
||||
|
||||
// What the thumbnails were linked to. Those URLs are in the text as well -
|
||||
// inside the `[url=]` that wrapped the picture - and each is the same
|
||||
// picture as the thumbnail naming it, so drawing both shows the message
|
||||
@@ -686,7 +889,7 @@ export function extractMedia(
|
||||
const targets = new Set(Object.values(opts.thumbnails ?? {}))
|
||||
|
||||
for (const raw of urls) {
|
||||
const url = raw.replace(/[),.;!?]+$/, '')
|
||||
const url = raw.replace(/[),.;!?|]+$/, '')
|
||||
const yt = youtubeId(url)
|
||||
const identity = yt ? `yt:${yt}` : url
|
||||
if (seen.has(identity) || targets.has(url) || isForumUpload(url)) continue
|
||||
@@ -703,6 +906,9 @@ export function extractMedia(
|
||||
} else if (OPAQUE_IMAGE_HOSTS.includes(hostnameOf(url))) {
|
||||
seen.add(identity)
|
||||
result.push({ url, kind: 'image', ...biggerCopy(url, opts.thumbnails) })
|
||||
} else if (isSteamUpload(url)) {
|
||||
seen.add(identity)
|
||||
result.push({ url, kind: 'image', ...biggerCopy(url, opts.thumbnails) })
|
||||
} else if (isGifPage(url)) {
|
||||
// A page, not a file: see GIF_PAGE_RE. Left as the link it is.
|
||||
continue
|
||||
@@ -734,6 +940,8 @@ export function stripEmbeddedUrls(text: string, mediaItems: MediaItem[]): string
|
||||
if (!text || mediaItems.length === 0) return text
|
||||
let out = text
|
||||
for (const item of mediaItems) out = out.split(item.url).join('')
|
||||
// What is left of a Steam marker once its address has been lifted out.
|
||||
out = out.replace(/\[steam:(?:image|video):\]/g, '')
|
||||
|
||||
// A link left holding nothing, which is what the wrapper round a thumbnail
|
||||
// becomes once the picture it was wrapping has been lifted out and drawn as
|
||||
@@ -755,6 +963,8 @@ export function stripEmbeddedUrls(text: string, mediaItems: MediaItem[]): string
|
||||
if (id && shown.has(id)) out = out.split(raw).join('')
|
||||
}
|
||||
}
|
||||
// A spoiler that held only a picture is empty now it has been lifted out.
|
||||
out = out.replace(/\|\|\s*\|\|/g, '')
|
||||
return out.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MatrixInvite, BufferGroup } from '../../../shared/wire'
|
||||
import type { BufferGroup } from '../../../shared/wire'
|
||||
import type { BufferEntry } from '../state/store'
|
||||
|
||||
/**
|
||||
@@ -8,44 +8,29 @@ import type { BufferEntry } from '../state/store'
|
||||
* actually sends.
|
||||
*/
|
||||
export type RailGroup = Omit<BufferGroup, 'kind'> & {
|
||||
kind: BufferGroup['kind'] | 'pinned' | 'invite'
|
||||
kind: BufferGroup['kind'] | 'pinned' | 'invitations'
|
||||
}
|
||||
|
||||
/**
|
||||
* The rail entry for a room somebody has invited you to.
|
||||
* The page gathering every invitation waiting on an answer, from every service.
|
||||
*
|
||||
* A tile of its own rather than a mark on the account's, because that is what
|
||||
* it behaves like: it arrives on its own, it is answered once, and then it is
|
||||
* gone. A badge on an account tile would still be there after the answer,
|
||||
* saying nothing, until something else redrew it.
|
||||
* One tile rather than one per invitation, because what they have in common
|
||||
* is that they are waiting on you and expire once answered, not which service
|
||||
* sent them. Like the pinned page it belongs to no account and exists only
|
||||
* while there is something on it.
|
||||
*/
|
||||
export const INVITE_PREFIX = 'invite:'
|
||||
export const INVITATIONS_GROUP_ID = '~invitations'
|
||||
|
||||
export function inviteGroupId(accountId: string, roomId: string): string {
|
||||
return `${INVITE_PREFIX}${accountId}|${roomId}`
|
||||
}
|
||||
|
||||
/** The account and room an invite entry stands for, or null. */
|
||||
export function readInviteGroupId(groupId: string): { accountId: string; roomId: string } | null {
|
||||
if (!groupId.startsWith(INVITE_PREFIX)) return null
|
||||
const rest = groupId.slice(INVITE_PREFIX.length)
|
||||
const split = rest.indexOf('|')
|
||||
if (split < 0) return null
|
||||
return { accountId: rest.slice(0, split), roomId: rest.slice(split + 1) }
|
||||
}
|
||||
|
||||
export function inviteGroup(accountId: string, invite: MatrixInvite): RailGroup {
|
||||
export function invitationsGroup(count: number): RailGroup {
|
||||
return {
|
||||
id: inviteGroupId(accountId, invite.roomId),
|
||||
accountId,
|
||||
service: 'matrix',
|
||||
kind: 'invite',
|
||||
name: invite.name,
|
||||
iconUrl: invite.avatarUrl ?? undefined,
|
||||
// Ahead of everything, including direct messages: it is the only entry
|
||||
// in the rail that expires if nobody answers it.
|
||||
id: INVITATIONS_GROUP_ID,
|
||||
accountId: '',
|
||||
service: '',
|
||||
kind: 'invitations',
|
||||
name: count > 1 ? `Invitations (${count})` : 'Invitations',
|
||||
// Ahead of everything: it is the only entry that expires if nobody answers.
|
||||
position: -2000
|
||||
} as RailGroup
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,7 +144,7 @@ export function visibleGroups(groups: BufferGroup[], buffers: BufferEntry[]): Bu
|
||||
|
||||
/** Entries the user cannot drag, and which always lead the rail. */
|
||||
export function isFixedEntry(group: RailGroup): boolean {
|
||||
return group.kind === 'dms' || group.kind === 'pinned' || group.kind === 'invite'
|
||||
return group.kind === 'dms' || group.kind === 'pinned' || group.kind === 'invitations'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,10 +158,10 @@ export function isFixedEntry(group: RailGroup): boolean {
|
||||
*/
|
||||
export function orderedGroups(groups: RailGroup[], order: string[]): RailGroup[] {
|
||||
const rank = new Map(order.map((id, i) => [id, i]))
|
||||
// Direct messages, then pinned. Stated as a rule rather than left to the
|
||||
// Invitations, then direct messages, then pinned. Stated as a rule rather than left to the
|
||||
// position numbers, which are the backend's business and were never meant
|
||||
// to encode this.
|
||||
const leadRank = (g: RailGroup): number => (g.kind === 'dms' ? 0 : 1)
|
||||
const leadRank = (g: RailGroup): number => (g.kind === 'invitations' ? -1 : g.kind === 'dms' ? 0 : 1)
|
||||
const lead = groups
|
||||
.filter(isFixedEntry)
|
||||
.sort((a, b) => leadRank(a) - leadRank(b) || a.position - b.position)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user