Compare commits
83
Commits
@@ -1,23 +0,0 @@
|
||||
# On a merge, .gitmodules keeps the answer this branch already has.
|
||||
#
|
||||
# master names the daemon at github.com because master is what a stranger
|
||||
# clones, and development names it at the Gitea instance where the work
|
||||
# happens. Without this, a merge that touched .gitmodules on both sides would
|
||||
# stop and ask which host is right - and the answer is always "the one the
|
||||
# branch being merged into already said".
|
||||
#
|
||||
# `merge=ours` needs a driver by that name, which is one line per clone and
|
||||
# not something a repository can carry for you:
|
||||
#
|
||||
# git config merge.ours.driver true
|
||||
#
|
||||
# Without it git falls back to an ordinary merge, which conflicts rather than
|
||||
# doing the wrong thing quietly - so a clone that has not run it is no worse
|
||||
# off than before this file existed.
|
||||
#
|
||||
# The edge, because it is a real one: this keeps the *whole file*, not just the
|
||||
# URL. If development ever adds a second submodule, master takes none of that
|
||||
# entry while still taking the gitlink the merge brings with it - a submodule
|
||||
# with nothing in .gitmodules to say where it comes from. Adding a submodule is
|
||||
# therefore a thing to re-do by hand on master, and the only such thing.
|
||||
/.gitmodules merge=ours
|
||||
@@ -17,6 +17,29 @@ Each section has two parts:
|
||||
that made it, including dependency updates
|
||||
(`node scripts/changelog-deps.mjs <previous tag>` lists those).
|
||||
|
||||
## [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,3 +1,4 @@
|
||||
# Maintainer: Salastil <[email protected]>
|
||||
pkgname=moho-git
|
||||
pkgver=r1.268751e
|
||||
pkgrel=1
|
||||
|
||||
@@ -1,48 +1,43 @@
|
||||
# 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, 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, 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.
|
||||
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 |
|
||||
| **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 +45,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...cd8a44cbf9
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "moho",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "moho",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.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.1.0",
|
||||
"description": "Unified desktop chat client for IRC, Discord, Sneedchat and Matrix",
|
||||
"author": "Salastil <[email protected]>",
|
||||
"license": "GPL-3.0-or-later",
|
||||
|
||||
@@ -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,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,61 @@
|
||||
#!/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))],
|
||||
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,45 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Opens the Accounts pane in a scratch moho, starts a Twitch sign-in and photographs the code it shows,
|
||||
* then cancels it. Nothing is approved, so no account is signed in and nothing is saved.
|
||||
*
|
||||
* 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 } = 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'))
|
||||
for (let i = 0; i < 20 && !(await ev(`!!document.querySelector('.twitch-code')`)); i++) await sleep(500)
|
||||
say('code shown', await ev(`JSON.stringify({ code: document.querySelector('.twitch-code')?.textContent, buttons: [...document.querySelectorAll('.add-form button')].map((b) => b.textContent.trim()) })`))
|
||||
await shot('twitch-signin-code.png')
|
||||
say('cancel', await clickText('button', 'Cancel sign-in'))
|
||||
await sleep(500)
|
||||
say('back to the form', await ev(`!document.querySelector('.twitch-code') && !!([...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,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()
|
||||
}
|
||||
+11
-2
@@ -902,10 +902,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
|
||||
|
||||
@@ -156,7 +156,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,14 @@ 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 { 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 +35,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 +55,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)
|
||||
@@ -320,6 +324,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 +415,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 />
|
||||
|
||||
+288
-1
@@ -1115,6 +1115,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 +2575,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 +2754,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 +5664,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 +6951,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;
|
||||
}
|
||||
|
||||
@@ -7667,6 +7759,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;
|
||||
@@ -9366,6 +9487,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 +10880,118 @@ 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;
|
||||
}
|
||||
|
||||
/* The code Twitch gives to be approved on twitch.tv: read off one screen and
|
||||
typed on another, so it is large and spaced. */
|
||||
.twitch-code {
|
||||
align-self: flex-start;
|
||||
padding: 10px 18px;
|
||||
border: 1px solid var(--surface-variant-text);
|
||||
border-radius: var(--radius-xs);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 28px;
|
||||
font-weight: var(--weight-bold);
|
||||
letter-spacing: 0.28em;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
@@ -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,345 @@ 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: an account that
|
||||
* only reads can sign in, and one whose sign-in Twitch has stopped accepting
|
||||
* can sign in again. 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.
|
||||
*/
|
||||
function TwitchAccountTools({ account }: { account: Account }): JSX.Element | null {
|
||||
const refused = account.state === 'auth_failed'
|
||||
if (account.hasPassword && !refused) return null
|
||||
return (
|
||||
<div className="account-tools">
|
||||
<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'} />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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')}
|
||||
@@ -1273,7 +1304,7 @@ 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') && (
|
||||
{!thread && service !== 'steam' && 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
|
||||
@@ -166,6 +171,9 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
|
||||
/>
|
||||
) : 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>}
|
||||
</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
|
||||
@@ -949,7 +950,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}`}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { createContext, useContext, useEffect, useLayoutEffect, useMemo,
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { Icon } from './Icon'
|
||||
import { discordEmojiUrl, emojiPreview, type SmilieEntry } from '../lib/format'
|
||||
import { discordEmojiUrl, emojiPreview, sourcePick, type SmilieEntry } from '../lib/format'
|
||||
import { drawableEmoteUrl, onLocalEmotes } from '../lib/emotecache'
|
||||
import { LottieSticker } from './LottieSticker'
|
||||
import type { CustomEmoji } from '../../../shared/wire'
|
||||
@@ -72,6 +72,8 @@ 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)}]`
|
||||
if (e.url) return `[emote:${e.id}:${e.name}]`
|
||||
return `<${e.animated ? 'a' : ''}:${e.name}:${e.id}>`
|
||||
}
|
||||
@@ -205,6 +207,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 +225,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,7 +257,7 @@ 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.
|
||||
@@ -350,8 +359,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),
|
||||
@@ -802,7 +811,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,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { useStore } from '../state/hooks'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import { useEscapeLayer } from '../lib/layers'
|
||||
|
||||
/**
|
||||
@@ -43,6 +43,13 @@ export function KickPoints({ bufferId }: { bufferId: string }): JSX.Element | nu
|
||||
|
||||
const [points, setPoints] = useState<number | null>(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])
|
||||
|
||||
const refresh = (): void => {
|
||||
void window.moho
|
||||
.rpc<Answer>('listKickRewards', { bufferId })
|
||||
|
||||
@@ -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'
|
||||
@@ -8,7 +9,7 @@ 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 { COMMON_EMOJI, EmojiPicker, readRecent, type StickerEntry } from './EmojiPicker'
|
||||
import { reactionLabel, reactorsSentence } from '../lib/reactions'
|
||||
import { UploadMeter } from './UploadMeter'
|
||||
import { RichText } from '../lib/richtext'
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
embedColor,
|
||||
extractCodeBlocks,
|
||||
extractMedia,
|
||||
markSpoilered,
|
||||
extractQuoteBlocks,
|
||||
formatMessage,
|
||||
normalizeBBCode,
|
||||
@@ -40,6 +42,8 @@ import {
|
||||
stripEmbeddedUrls,
|
||||
stripQuoteBlocks,
|
||||
discordEmojiUrl,
|
||||
steamPictureUrl,
|
||||
steamTokenOf,
|
||||
type ChannelIndex
|
||||
} from '../lib/format'
|
||||
import {
|
||||
@@ -51,11 +55,13 @@ import {
|
||||
hasDirectMessages,
|
||||
isChatKind,
|
||||
isNotice,
|
||||
isWarning,
|
||||
isReward,
|
||||
isWhisper,
|
||||
nickColor
|
||||
} from '../lib/util'
|
||||
import { useEscapeLayer } from '../lib/layers'
|
||||
import { removedReason } from '../lib/moderation'
|
||||
|
||||
/**
|
||||
* A badge, short enough to sit beside a name.
|
||||
@@ -79,7 +85,11 @@ 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'
|
||||
}
|
||||
return short[badge.type] ?? badge.text.slice(0, 3).toUpperCase()
|
||||
}
|
||||
@@ -345,6 +355,10 @@ 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 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 +398,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),
|
||||
@@ -459,14 +482,14 @@ function MessageRowBody({
|
||||
// this function again and neither of which is re-stored.
|
||||
const raw = 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))
|
||||
|
||||
@@ -556,7 +579,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 +670,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
|
||||
? ([
|
||||
{
|
||||
@@ -786,8 +821,10 @@ 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',
|
||||
@@ -901,15 +938,31 @@ 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 ? (
|
||||
// Kick's own pictures, unless they are turned off. The tooltip
|
||||
// and the alt text say what it is for anybody who cannot see it.
|
||||
showBadgePictures && (
|
||||
<img
|
||||
key={badge.type}
|
||||
className="sender-badge-image"
|
||||
src={media(badge.image)}
|
||||
alt={badge.text}
|
||||
title={badge.text}
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<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 +1003,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"
|
||||
@@ -997,6 +1051,21 @@ function MessageRowBody({
|
||||
{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>
|
||||
))}
|
||||
|
||||
@@ -1067,7 +1136,7 @@ function MessageRowBody({
|
||||
<KickEventCard embed={kickEvent} />
|
||||
) : (
|
||||
parts.html && (
|
||||
<span className="message-body selectable">
|
||||
<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
|
||||
@@ -1368,7 +1437,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 +1463,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 +1503,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,6 +1651,9 @@ 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]} />
|
||||
|
||||
@@ -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,15 @@
|
||||
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 { 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 +51,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 +88,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 +130,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 +140,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 +180,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 +264,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 +289,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 +380,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
|
||||
@@ -440,6 +485,12 @@ export function NickList(): JSX.Element {
|
||||
|
||||
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 +538,7 @@ interface MemberRowProps {
|
||||
|
||||
function MemberRow({
|
||||
member,
|
||||
comfy,
|
||||
blockKey,
|
||||
blocked,
|
||||
canOpenDm,
|
||||
@@ -638,24 +690,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 +709,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>
|
||||
|
||||
@@ -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,9 +1,12 @@
|
||||
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'
|
||||
|
||||
@@ -24,8 +27,68 @@ 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
|
||||
|
||||
/** 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 +99,30 @@ 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)
|
||||
/** What the stream offers, once its playlist has been read. */
|
||||
const [choices, setChoices] = useState<Choice[]>([])
|
||||
const [captions, setCaptions] = useState<CaptionTrack[]>([])
|
||||
/** 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: 0 is automatic, and an empty language is captions off.
|
||||
const [quality, setQuality] = usePref<number>('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.
|
||||
@@ -78,49 +154,61 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
|
||||
}
|
||||
}, [mode, minimized, watching])
|
||||
|
||||
// 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
|
||||
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)
|
||||
}
|
||||
})
|
||||
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) => {
|
||||
setCaptions(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
|
||||
@@ -136,8 +224,34 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
|
||||
return () => {
|
||||
player.destroy()
|
||||
hls.current = null
|
||||
setChoices([])
|
||||
setCaptions([])
|
||||
setPlaying('')
|
||||
}
|
||||
}, [url, minimized, store, watching?.bufferId])
|
||||
}, [hasUrl, picture, minimized, store])
|
||||
|
||||
// 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)
|
||||
player.subtitleDisplay = track >= 0
|
||||
player.subtitleTrack = track
|
||||
}, [captionLanguage, captions])
|
||||
|
||||
// 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 +266,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)}` : ''
|
||||
@@ -171,21 +286,59 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
|
||||
onPopOut={mode === 'window' ? undefined : () => store.setStreamPoppedOut(true)}
|
||||
onBringBack={() => store.setStreamPoppedOut(false)}
|
||||
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={() => {
|
||||
const next = !muted
|
||||
setMuted(next)
|
||||
if (video.current) video.current.muted = next
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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 />
|
||||
{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,182 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useStore } from '../state/hooks'
|
||||
|
||||
/**
|
||||
* Signing in to Twitch.
|
||||
*
|
||||
* Nothing is typed here, and no password is ever seen. Twitch gives a short
|
||||
* code, this shows it, and the person approves it on twitch.tv in their own
|
||||
* browser, signed in however they sign in (see nobilis backend/twitch/login.rs).
|
||||
* Waiting can take minutes, so it is a conversation of events rather than one
|
||||
* request: the code, what it is doing, and the result.
|
||||
*/
|
||||
interface Scope {
|
||||
scope: string
|
||||
why: string
|
||||
}
|
||||
|
||||
interface Code {
|
||||
userCode: string
|
||||
verificationUri: string
|
||||
expiresIn: number
|
||||
scopes: Scope[]
|
||||
at: number
|
||||
}
|
||||
|
||||
/** 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' }
|
||||
]
|
||||
|
||||
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 [code, setCode] = useState<Code | null>(null)
|
||||
const [left, setLeft] = useState(0)
|
||||
|
||||
// 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 to Twitch from here, so an
|
||||
// event arriving before the id is known can only be this one's.
|
||||
const current = useRef('')
|
||||
|
||||
useEffect(() => {
|
||||
const off = window.moho.onEvent((e) => {
|
||||
if (!e.event.startsWith('twitchLogin')) return
|
||||
const d = e.data as {
|
||||
loginId?: string
|
||||
detail?: string
|
||||
userCode?: string
|
||||
verificationUri?: string
|
||||
expiresIn?: number
|
||||
scopes?: Scope[]
|
||||
success?: boolean
|
||||
error?: string
|
||||
}
|
||||
if (current.current && d.loginId !== current.current) return
|
||||
if (e.event === 'twitchLoginCode' && d.userCode && d.verificationUri) {
|
||||
setCode({ userCode: d.userCode, verificationUri: d.verificationUri, expiresIn: d.expiresIn ?? 1800, scopes: d.scopes ?? ASKS, at: Date.now() })
|
||||
} else if (e.event === 'twitchLoginStatus') {
|
||||
setDetail(d.detail || '')
|
||||
} else if (e.event === 'twitchLoginResult') {
|
||||
current.current = ''
|
||||
setBusy(false)
|
||||
setCode(null)
|
||||
setDetail('')
|
||||
if (d.success) {
|
||||
void store.refreshAccounts()
|
||||
onDone()
|
||||
} else {
|
||||
store.toast('error', d.error || 'Twitch 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 Twitch and frees what it was holding.
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (current.current) void window.moho.rpc('cancelTwitchLogin', { loginId: current.current }).catch(() => {})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// How long the code has left, counted down so nobody types one that has run out.
|
||||
useEffect(() => {
|
||||
if (!code) return
|
||||
const tick = (): void => setLeft(Math.max(0, code.expiresIn - Math.floor((Date.now() - code.at) / 1000)))
|
||||
tick()
|
||||
const timer = setInterval(tick, 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [code])
|
||||
|
||||
const begin = (): void => {
|
||||
setBusy(true)
|
||||
setDetail('')
|
||||
current.current = ''
|
||||
void window.moho
|
||||
.rpc<{ loginId: string }>('startTwitchLogin', { useTor })
|
||||
.then((r) => {
|
||||
current.current = r.loginId
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
setBusy(false)
|
||||
store.toast('error', e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const cancel = (): void => {
|
||||
if (current.current) void window.moho.rpc('cancelTwitchLogin', { loginId: current.current }).catch(() => {})
|
||||
current.current = ''
|
||||
setBusy(false)
|
||||
setCode(null)
|
||||
setDetail('')
|
||||
}
|
||||
|
||||
if (code) {
|
||||
const minutes = Math.floor(left / 60)
|
||||
const seconds = String(left % 60).padStart(2, '0')
|
||||
return (
|
||||
<div className="add-form">
|
||||
<p>Approve this on twitch.tv to finish signing in.</p>
|
||||
<div className="twitch-code" aria-label="Your sign-in code">
|
||||
{code.userCode}
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<button type="button" className="button primary" onClick={() => void window.moho.openExternal(code.verificationUri)}>
|
||||
Open twitch.tv
|
||||
</button>
|
||||
<button type="button" className="button subtle" onClick={() => void window.moho.copyText(code.userCode)}>
|
||||
Copy code
|
||||
</button>
|
||||
<button type="button" className="button" onClick={cancel}>
|
||||
Cancel sign-in
|
||||
</button>
|
||||
</div>
|
||||
<p className="small muted">
|
||||
The page opens with the code already filled in. If it asks for it, type the code above. It is good for{' '}
|
||||
{left > 0 ? `${minutes}:${seconds}` : 'no time at all - start again'}.
|
||||
</p>
|
||||
<p className="small muted">
|
||||
moho will be allowed to: {code.scopes.map((s) => s.why).join(', ')}. You can end it any time at
|
||||
twitch.tv/settings/connections, and removing the account here ends it too.
|
||||
</p>
|
||||
{detail && <p className="small muted">{detail}…</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="small muted">
|
||||
You sign in on twitch.tv, in your own browser - moho never sees your password. It asks to be allowed to:{' '}
|
||||
{ASKS.map((s) => s.why).join(', ')}.
|
||||
</p>
|
||||
<div className="field-row">
|
||||
<button type="button" className="button primary" disabled={busy} onClick={begin}>
|
||||
{busy ? 'Asking Twitch…' : label}
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
{busy && detail && <p className="small muted">{detail}…</p>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,87 @@ 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="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
|
||||
}
|
||||
@@ -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,42 @@ 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): string {
|
||||
return resolveMediaUrl(`https://static-cdn.jtvnw.net/emoticons/v2/${encodeURIComponent(id)}/default/dark/2.0`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +147,72 @@ 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:emote:([A-Za-z0-9_]+):([^\]]{0,64})\]$/)
|
||||
if (twitch) return { src: twitchEmoteUrl(twitch[1]), label: twitch[2] }
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +230,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 +502,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 +570,24 @@ 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:emote:([A-Za-z0-9_]+):([^\]]{0,64})\]/g, (_m, id: string, name: string) =>
|
||||
stow(`<img src="${twitchEmoteUrl(id)}" class="custom-emoji" alt="${name}" title="${name}">`)
|
||||
)
|
||||
|
||||
// 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 +670,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
|
||||
@@ -580,6 +721,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 +838,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 +853,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 +870,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 +904,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 +927,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)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Account } from '../../../shared/wire'
|
||||
import { INVITATIONS_GROUP_ID, invitationsGroup, isFixedEntry, orderedGroups, type RailGroup } from './groups'
|
||||
import { currentInvitation, listInvitations, type InvitationSources } from './invitations'
|
||||
|
||||
const account = (id: string, service: Account['service']): Account => ({ id, service }) as Account
|
||||
|
||||
const accounts = [account('matrix:@me:x', 'matrix'), account('steam:me', 'steam'), account('discord:1', 'discord')]
|
||||
|
||||
const sources = (part: Partial<InvitationSources>): InvitationSources => ({ rooms: {}, friends: {}, discordFriends: {}, verification: null, ...part })
|
||||
const room = { roomId: '!room:x', name: 'Room', inviter: 'alice', avatarUrl: null, isDirect: false }
|
||||
const dm = { roomId: '!dm:x', name: 'Bob', inviter: 'bob', avatarUrl: 'mxc://x/b', isDirect: true }
|
||||
const friend = { id: '76561197960287930', name: 'Some One', avatarUrl: 'https://example.test/a.jpg' }
|
||||
|
||||
describe('the invitations from every service', () => {
|
||||
const list = listInvitations(sources({ rooms: { 'matrix:@me:x': [room, dm] }, friends: { 'steam:me': [friend] } }), accounts)
|
||||
|
||||
it('are one list, in the order the accounts are listed', () => {
|
||||
expect(list.map((i) => [i.service, i.kind, i.name])).toEqual([
|
||||
['matrix', 'room', 'Room'],
|
||||
['matrix', 'room', 'Bob'],
|
||||
['steam', 'friend', 'Some One']
|
||||
])
|
||||
})
|
||||
|
||||
it('say what accepting makes: a conversation with a person, or a room', () => {
|
||||
expect(list.map((i) => i.direct)).toEqual([false, true, true])
|
||||
})
|
||||
|
||||
it('say who is asking and for what', () => {
|
||||
expect(list[0].detail).toBe('alice invited you to this room')
|
||||
expect(list[1].detail).toBe('bob invited you to a direct message')
|
||||
expect(list[2].detail).toBe('wants to be your friend')
|
||||
})
|
||||
|
||||
it('include the groups a Steam friend has invited the account to, as rooms', () => {
|
||||
const steam = listInvitations(sources({ rooms: { 'steam:me': [{ roomId: '78995476', name: 'Raiders', inviter: 'Gabe', avatarUrl: null, isDirect: false }] } }), accounts)
|
||||
expect(steam.map((i) => [i.service, i.kind, i.name, i.detail])).toEqual([['steam', 'room', 'Raiders', 'Gabe invited you to a group chat']])
|
||||
})
|
||||
|
||||
it('are told apart by account and kind, so two with the same id are two', () => {
|
||||
const twin = listInvitations(sources({ rooms: { 'matrix:@me:x': [{ ...room, roomId: '7' }] }, friends: { 'steam:me': [{ id: '7', name: null }] } }), accounts)
|
||||
expect(new Set(twin.map((i) => i.key)).size).toBe(2)
|
||||
})
|
||||
|
||||
it('call a person by their id until the service says who they are', () => {
|
||||
expect(listInvitations(sources({ friends: { 'steam:me': [{ id: '42', name: null }] } }), accounts)[0].name).toBe('42')
|
||||
})
|
||||
|
||||
it('leave out an account that has gone', () => {
|
||||
expect(listInvitations(sources({ rooms: { 'matrix:@gone:x': [room] }, friends: { 'steam:gone': [friend] } }), accounts)).toEqual([])
|
||||
})
|
||||
|
||||
it('take in the requests Discord keeps among its friends, and only the ones sent to us', () => {
|
||||
const found = listInvitations(
|
||||
sources({
|
||||
discordFriends: {
|
||||
'discord:1': [
|
||||
{ userId: '1', username: 'pal', kind: 'friend' },
|
||||
{ userId: '2', username: 'asker', globalName: 'Asker', kind: 'incoming' },
|
||||
{ userId: '3', username: 'asked', kind: 'outgoing' },
|
||||
{ userId: '4', username: 'plain' }
|
||||
]
|
||||
}
|
||||
}),
|
||||
accounts
|
||||
)
|
||||
expect(found.map((i) => [i.service, i.kind, i.id, i.name])).toEqual([['discord', 'friend', '2', 'Asker']])
|
||||
})
|
||||
|
||||
it('take in a Matrix verification only while it is waiting on an answer', () => {
|
||||
const verification = { accountId: 'matrix:@me:x', verificationId: 'v1', state: 'requested', otherUser: '@carol:x', isSelf: false, incoming: true }
|
||||
const waiting = listInvitations(sources({ verification }), accounts)
|
||||
expect(waiting.map((i) => [i.kind, i.name, i.detail])).toEqual([['verification', '@carol:x', 'wants to verify with you']])
|
||||
expect(listInvitations(sources({ verification: { ...verification, incoming: false } }), accounts)).toEqual([])
|
||||
// One we started is not an invitation to anybody.
|
||||
expect(listInvitations(sources({ verification: { ...verification, incoming: undefined } }), accounts)).toEqual([])
|
||||
// One of our own sessions is named as that, with the device.
|
||||
const mine = listInvitations(sources({ verification: { ...verification, isSelf: true, otherUser: undefined, otherDevice: 'Phone' } }), accounts)
|
||||
expect([mine[0].name, mine[0].detail]).toEqual(['Another of your sessions', 'wants to verify (Phone)'])
|
||||
})
|
||||
|
||||
it('show the one chosen, or the first when none is or it has been answered', () => {
|
||||
expect(currentInvitation(list, list[2].key)?.name).toBe('Some One')
|
||||
expect(currentInvitation(list, 'gone')?.name).toBe('Room')
|
||||
expect(currentInvitation([], 'x')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the invitations tile', () => {
|
||||
it('is one fixed tile that leads the rail, ahead of direct messages', () => {
|
||||
const tile = invitationsGroup(3)
|
||||
expect(tile.id).toBe(INVITATIONS_GROUP_ID)
|
||||
expect(tile.name).toBe('Invitations (3)')
|
||||
expect(invitationsGroup(1).name).toBe('Invitations')
|
||||
expect(isFixedEntry(tile)).toBe(true)
|
||||
const others = [
|
||||
{ id: 'dm', kind: 'dms', position: -1000, name: 'Direct Messages' },
|
||||
{ id: 'g', kind: 'guild', position: 1, name: 'g' }
|
||||
] as RailGroup[]
|
||||
expect(orderedGroups([...others, tile], []).map((g) => g.id)).toEqual([INVITATIONS_GROUP_ID, 'dm', 'g'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Account, DiscordFriend, FriendRequest, MatrixInvite, MatrixVerification } from '../../../shared/wire'
|
||||
|
||||
/**
|
||||
* Something waiting on a yes or a no from you: a room somebody invited you to,
|
||||
* somebody asking to be your friend, a session asking to be verified. Every
|
||||
* service's are one list, because what they share is that they are offered,
|
||||
* answered once, and then gone - not which protocol they came by.
|
||||
*
|
||||
* Anything a service asks that has exactly those two answers belongs here, and
|
||||
* not on a page of its own for that service.
|
||||
*/
|
||||
export interface Invitation {
|
||||
/** Unique across accounts and kinds; what selection is by. */
|
||||
key: string
|
||||
accountId: string
|
||||
service: string
|
||||
kind: InvitationKind
|
||||
/** The service's own id: a room id, or the person's id. */
|
||||
id: string
|
||||
name: string
|
||||
avatarUrl: string | null
|
||||
/** Who is asking, and for what, in a few words. */
|
||||
detail: string
|
||||
/** Whether accepting makes a conversation with one person. */
|
||||
direct: boolean
|
||||
}
|
||||
|
||||
export type InvitationKind = 'room' | 'friend' | 'verification'
|
||||
|
||||
export function invitationKey(accountId: string, kind: InvitationKind, id: string): string {
|
||||
return `${kind}|${accountId}|${id}`
|
||||
}
|
||||
|
||||
/** What is waiting, by where each service keeps it. */
|
||||
export interface InvitationSources {
|
||||
/** Matrix rooms and direct messages somebody invited the account to. */
|
||||
rooms: Record<string, MatrixInvite[]>
|
||||
/** Steam's requests, by account. */
|
||||
friends: Record<string, FriendRequest[]>
|
||||
/** Discord's relationships, by account; the ones somebody sent are requests. */
|
||||
discordFriends: Record<string, DiscordFriend[]>
|
||||
/** A Matrix verification, if somebody has asked and it is not answered. */
|
||||
verification: MatrixVerification | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every pending invitation, across accounts, in the order the accounts are
|
||||
* listed and each account's own order within. Requests for an account that is
|
||||
* no longer there are dropped rather than shown with nobody to answer as.
|
||||
*/
|
||||
export function listInvitations(sources: InvitationSources, accounts: Account[]): Invitation[] {
|
||||
const out: Invitation[] = []
|
||||
for (const account of accounts) {
|
||||
for (const invite of sources.rooms[account.id] ?? []) {
|
||||
out.push({
|
||||
key: invitationKey(account.id, 'room', invite.roomId),
|
||||
accountId: account.id,
|
||||
service: account.service,
|
||||
kind: 'room',
|
||||
id: invite.roomId,
|
||||
name: invite.name,
|
||||
avatarUrl: invite.avatarUrl,
|
||||
detail: `${invite.inviter ? `${invite.inviter} invited you` : 'You have been invited'}${invite.isDirect ? ' to a direct message' : account.service === 'steam' ? ' to a group chat' : ' to this room'}`,
|
||||
direct: invite.isDirect
|
||||
})
|
||||
}
|
||||
const people: { id: string; name: string; avatarUrl: string | null }[] = [
|
||||
...(sources.friends[account.id] ?? []).map((r) => ({ id: r.id, name: r.name || r.id, avatarUrl: r.avatarUrl ?? null })),
|
||||
...(sources.discordFriends[account.id] ?? [])
|
||||
.filter((f) => f.kind === 'incoming')
|
||||
.map((f) => ({ id: f.userId, name: f.globalName || f.username, avatarUrl: f.avatarUrl ?? null }))
|
||||
]
|
||||
for (const person of people) {
|
||||
out.push({
|
||||
key: invitationKey(account.id, 'friend', person.id),
|
||||
accountId: account.id,
|
||||
service: account.service,
|
||||
kind: 'friend',
|
||||
...person,
|
||||
detail: 'wants to be your friend',
|
||||
direct: true
|
||||
})
|
||||
}
|
||||
const v = sources.verification
|
||||
if (v && v.incoming && v.accountId === account.id) {
|
||||
const who = v.otherUser || v.fromUser || ''
|
||||
const mine = v.isSelf !== false
|
||||
out.push({
|
||||
key: invitationKey(account.id, 'verification', v.verificationId),
|
||||
accountId: account.id,
|
||||
service: account.service,
|
||||
kind: 'verification',
|
||||
id: v.verificationId,
|
||||
name: mine ? 'Another of your sessions' : who || 'Somebody',
|
||||
avatarUrl: null,
|
||||
detail: mine ? `wants to verify${v.otherDevice ? ` (${v.otherDevice})` : ''}` : 'wants to verify with you',
|
||||
direct: false
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The one being looked at: the one chosen, or the first when none is or it has gone. */
|
||||
export function currentInvitation(list: Invitation[], key: string): Invitation | undefined {
|
||||
return list.find((i) => i.key === key) ?? list[0]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { withPrefetch } from './ivsplaylist'
|
||||
|
||||
/** A rendition's playlist as Kick's stream serves one: two-second segments, six-second target, two prefetch lines. */
|
||||
const playlist = [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-VERSION:3',
|
||||
'#EXT-X-TARGETDURATION:6',
|
||||
'#EXT-X-MEDIA-SEQUENCE:2939',
|
||||
'#EXT-X-PROGRAM-DATE-TIME:2026-10-13T22:58:41.442Z',
|
||||
'#EXTINF:2.000,live',
|
||||
'https://cdn.example/v1/segment/a',
|
||||
'#EXT-X-PROGRAM-DATE-TIME:2026-10-13T22:58:43.442Z',
|
||||
'#EXTINF:2.000,live',
|
||||
'https://cdn.example/v1/segment/b',
|
||||
'#EXT-X-PREFETCH:https://cdn.example/v1/segment/c',
|
||||
'#EXT-X-PREFETCH:https://cdn.example/v1/segment/d',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
describe('a playlist that lists what is being made', () => {
|
||||
it('has each prefetch line become a segment as long as the last complete one, after the complete ones', () => {
|
||||
const lines = withPrefetch(playlist).split('\n').filter(Boolean)
|
||||
expect(lines.slice(-4)).toEqual(['#EXTINF:2.000,prefetch', 'https://cdn.example/v1/segment/c', '#EXTINF:2.000,prefetch', 'https://cdn.example/v1/segment/d'])
|
||||
expect(lines.some((l) => l.startsWith('#EXT-X-PREFETCH'))).toBe(false)
|
||||
expect(lines.filter((l) => l.startsWith('https://')).length).toBe(4)
|
||||
})
|
||||
|
||||
it('can take only some of them, or none', () => {
|
||||
expect(withPrefetch(playlist, 1).split('\n').filter((l) => l.startsWith('https://'))).toHaveLength(3)
|
||||
expect(withPrefetch(playlist, 0)).toBe(playlist)
|
||||
})
|
||||
|
||||
it('is left as it was when it has none, when it is of a stream that has ended, or when nothing is complete to measure by', () => {
|
||||
const plain = playlist.replace(/#EXT-X-PREFETCH:\S+\n/g, '')
|
||||
expect(withPrefetch(plain)).toBe(plain)
|
||||
const ended = `${playlist}#EXT-X-ENDLIST\n`
|
||||
expect(withPrefetch(ended)).toBe(ended)
|
||||
const none = ['#EXTM3U', '#EXT-X-TARGETDURATION:6', '#EXT-X-PREFETCH:https://cdn.example/v1/segment/c', ''].join('\n')
|
||||
expect(withPrefetch(none)).toBe(none)
|
||||
})
|
||||
|
||||
it('keeps everything else about the playlist, in the order it had it', () => {
|
||||
const out = withPrefetch(playlist).split('\n')
|
||||
expect(out.slice(0, 5)).toEqual(playlist.split('\n').slice(0, 5))
|
||||
expect(out).toContain('#EXT-X-PROGRAM-DATE-TIME:2026-10-13T22:58:43.442Z')
|
||||
})
|
||||
})
|
||||
|
||||
describe('withPrefetch on segments of uneven length', () => {
|
||||
it('leaves the playlist as it is when the last segment is no guide to the next', () => {
|
||||
const playlist = ['#EXTM3U', '#EXTINF:3.200,live', 'a.ts', '#EXTINF:5.166,live', 'b.ts', '#EXT-X-PREFETCH:c.ts', ''].join('\n')
|
||||
expect(withPrefetch(playlist)).toBe(playlist)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import Hls from 'hls.js'
|
||||
|
||||
/**
|
||||
* Amazon IVS's low-latency playlists, which Kick's streams are, list what is already complete and then
|
||||
* what is being made: `#EXT-X-PREFETCH:<address>`, one per segment of the next few seconds. The address
|
||||
* answers as soon as it is asked and sends the segment as it is produced, so asking early is the way to
|
||||
* be seconds nearer the broadcaster. IVS's own player does that. hls.js does not know the tag and
|
||||
* leaves it out, which is why a stream played with it runs several seconds behind one played with
|
||||
* IVS's.
|
||||
*
|
||||
* Here each of those lines becomes a segment of the playlist like any other, the same length as the
|
||||
* last complete one, so hls.js asks for it and plays it as it arrives.
|
||||
*/
|
||||
/**
|
||||
* The addresses that were listed as prefetch, which hls.js must not judge the connection by: one of
|
||||
* them is sent as it is made, so it takes as long to arrive as it takes to broadcast (two seconds of
|
||||
* picture in two seconds), and bandwidth worked out from that is the stream's own bitrate. Taken for
|
||||
* the connection's, it moved the picture down a rendition at every other segment.
|
||||
*/
|
||||
const ahead = new Set<string>()
|
||||
|
||||
function remember(address: string): void {
|
||||
ahead.add(address)
|
||||
// Only the last few matter; a stream that runs for hours must not grow this for ever.
|
||||
if (ahead.size > 64) ahead.delete(ahead.values().next().value as string)
|
||||
}
|
||||
|
||||
/** How far apart, in seconds, the longest and shortest segment may be for the next to be taken as their length. */
|
||||
const UNIFORM_WITHIN = 0.25
|
||||
|
||||
export function withPrefetch(playlist: string, take = 2): string {
|
||||
if (take <= 0 || !playlist.includes('#EXT-X-PREFETCH:') || playlist.includes('#EXT-X-ENDLIST')) return playlist
|
||||
const lines = playlist.split('\n')
|
||||
const kept: string[] = []
|
||||
const ahead_: string[] = []
|
||||
let length = ''
|
||||
const lengths: number[] = []
|
||||
for (const line of lines) {
|
||||
const prefetch = /^#EXT-X-PREFETCH:(\S+)/.exec(line.trim())
|
||||
if (prefetch) {
|
||||
ahead_.push(prefetch[1])
|
||||
continue
|
||||
}
|
||||
const segment = /^#EXTINF:([0-9.]+)/.exec(line)
|
||||
if (segment) {
|
||||
length = segment[1]
|
||||
lengths.push(Number(segment[1]))
|
||||
}
|
||||
kept.push(line)
|
||||
}
|
||||
// Nothing to say how long they are if no segment is complete yet.
|
||||
if (!length || ahead_.length === 0) return playlist
|
||||
// How long a segment still being made will turn out to be is a guess from the ones before it, and only a
|
||||
// good one where they are all alike. A broadcaster that is not re-encoded by IVS (one that sends its own
|
||||
// segments) has them of 3 to 5 seconds, and a guess of 3 for a segment of 5 leaves a gap the picture then
|
||||
// stops in, every few seconds.
|
||||
if (Math.max(...lengths) - Math.min(...lengths) > UNIFORM_WITHIN) return playlist
|
||||
while (kept.length > 0 && kept[kept.length - 1].trim() === '') kept.pop()
|
||||
for (const address of ahead_.slice(0, take)) {
|
||||
remember(address)
|
||||
kept.push(`#EXTINF:${length},prefetch`, address)
|
||||
}
|
||||
return `${kept.join('\n')}\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* hls.js's own judge of the connection, told to leave the prefetch segments out of it. See `ahead`.
|
||||
*/
|
||||
export function ivsAbr(): typeof Hls.DefaultConfig.abrController {
|
||||
const Base = Hls.DefaultConfig.abrController as unknown as new (hls: unknown) => Record<string, unknown>
|
||||
class Abr extends Base {
|
||||
// Private in hls.js's types and called by each of its three handlers that measure a segment.
|
||||
ignoreFragment(frag: { url?: string }): boolean {
|
||||
const parent = (Base.prototype as { ignoreFragment?: (f: unknown) => boolean }).ignoreFragment
|
||||
return ahead.has(frag.url ?? '') || (parent ? parent.call(this, frag) : false)
|
||||
}
|
||||
}
|
||||
return Abr as unknown as typeof Hls.DefaultConfig.abrController
|
||||
}
|
||||
|
||||
/**
|
||||
* hls.js's own loader, with the playlist of a rendition read through `withPrefetch`. Everything else it
|
||||
* loads - the master playlist, the segments, the captions - is as it was.
|
||||
*/
|
||||
export function ivsLoader(take = 2): typeof Hls.DefaultConfig.loader {
|
||||
const Base = Hls.DefaultConfig.loader as unknown as new (config: unknown) => {
|
||||
load(context: Record<string, unknown>, config: unknown, callbacks: Record<string, (...args: unknown[]) => void>): void
|
||||
}
|
||||
class Loader extends Base {
|
||||
load(context: Record<string, unknown>, config: unknown, callbacks: Record<string, (...args: unknown[]) => void>): void {
|
||||
if (context.type !== 'level') return super.load(context, config, callbacks)
|
||||
super.load(context, config, {
|
||||
...callbacks,
|
||||
onSuccess: (response, stats, ctx, details) => {
|
||||
const r = response as { data?: unknown }
|
||||
if (typeof r.data === 'string') response = { ...r, data: withPrefetch(r.data, take) }
|
||||
callbacks.onSuccess(response, stats, ctx, details)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
return Loader as unknown as typeof Hls.DefaultConfig.loader
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ChatMessage } from '../state/store'
|
||||
import { applyRemoved, removedReason, REMOVED_NOTICE } from './moderation'
|
||||
|
||||
const said = (id: string, extra: Partial<ChatMessage> = {}): ChatMessage =>
|
||||
({ id, bufferId: 'b', from: 'x', body: `text ${id}`, ts: 1, isAction: false, isHighlight: false, kind: 'chat', ...extra }) as ChatMessage
|
||||
|
||||
const log = [said('a'), said('b', { moderated: { ai: true, rules: ['hate'] }, html: '<b>x</b>', embeds: [{ kind: 'gif' } as never] }), said('c')]
|
||||
|
||||
describe('removed messages', () => {
|
||||
it('are left out when that is chosen', () => {
|
||||
expect(applyRemoved(log, 'remove').map((m) => m.id)).toEqual(['a', 'c'])
|
||||
})
|
||||
it('keep their words when struck through is chosen, and the list is the same one', () => {
|
||||
expect(applyRemoved(log, 'strike')).toBe(log)
|
||||
})
|
||||
it('have a notice put where their words were when that is chosen', () => {
|
||||
const [, removed] = applyRemoved(log, 'replace')
|
||||
expect(removed.body).toBe(REMOVED_NOTICE)
|
||||
expect(removed.html).toBeUndefined()
|
||||
expect(removed.embeds).toEqual([])
|
||||
expect(removed.moderated).toEqual({ ai: true, rules: ['hate'], replaced: true })
|
||||
// And only that one.
|
||||
expect(applyRemoved(log, 'replace')[0].body).toBe('text a')
|
||||
})
|
||||
it('change nothing in a log where nothing was removed', () => {
|
||||
const clean = [said('a')]
|
||||
expect(applyRemoved(clean, 'remove')).toBe(clean)
|
||||
expect(applyRemoved(clean, 'replace')).toBe(clean)
|
||||
})
|
||||
it('say why they were removed when they know', () => {
|
||||
expect(removedReason(log[1])).toBe("Removed by Kick's automatic moderation: hate")
|
||||
expect(removedReason(said('d', { moderated: {} }))).toBe('Removed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ChatMessage } from '../state/store'
|
||||
|
||||
/**
|
||||
* What to do with a message the service has taken down. The daemon keeps the
|
||||
* text and says it is gone; this is the window's say in how that looks.
|
||||
*/
|
||||
export type RemovedMessages = 'remove' | 'strike' | 'replace'
|
||||
|
||||
export const REMOVED_NOTICE = 'This message was removed by moderation'
|
||||
|
||||
/** What a removed message says it was removed for, for a tooltip. */
|
||||
export function removedReason(m: ChatMessage): string {
|
||||
const rules = m.moderated?.rules ?? []
|
||||
const by = m.moderated?.ai ? "Removed by Kick's automatic moderation" : 'Removed'
|
||||
return rules.length > 0 ? `${by}: ${rules.join(', ')}` : by
|
||||
}
|
||||
|
||||
/**
|
||||
* The messages to draw, with those that were removed handled as chosen:
|
||||
* left out, kept as they were (the row draws them struck through), or with
|
||||
* what they said swapped for a notice. A list with none removed comes back
|
||||
* as it was, so nothing downstream re-renders for a setting it does not use.
|
||||
*/
|
||||
export function applyRemoved(messages: ChatMessage[], mode: RemovedMessages): ChatMessage[] {
|
||||
if (mode === 'strike' || !messages.some((m) => m.moderated)) return messages
|
||||
if (mode === 'remove') return messages.filter((m) => !m.moderated)
|
||||
return messages.map((m) =>
|
||||
m.moderated
|
||||
? { ...m, body: REMOVED_NOTICE, html: undefined, embeds: [], attachments: [], components: [], moderated: { ...m.moderated, replaced: true } }
|
||||
: m
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
* What to call a reaction, and who to say gave it.
|
||||
*/
|
||||
|
||||
import { steamTokenOf } from './format'
|
||||
|
||||
/**
|
||||
* The name to show for a reaction's emoji. A custom one carries its own, in
|
||||
* the token it is stored as (`<:name:id>`, or `<a:name:id>` when it moves); a
|
||||
@@ -10,6 +12,8 @@
|
||||
export function reactionLabel(emoji: string, unicodeNames: ReadonlyMap<string, string> = new Map()): string {
|
||||
const custom = emoji.match(/^<a?:([A-Za-z0-9_~]{2,32}):\d+>$/)
|
||||
if (custom) return `:${custom[1]}:`
|
||||
const steam = steamTokenOf(emoji)
|
||||
if (steam) return `:${steam.name}:`
|
||||
const name = unicodeNames.get(emoji)
|
||||
return name ? `:${name}:` : emoji
|
||||
}
|
||||
|
||||
@@ -65,7 +65,8 @@ const ALLOWED_CLASSES = new Set([
|
||||
'smilie',
|
||||
'channel-mention',
|
||||
'unknown',
|
||||
'custom-emoji'
|
||||
'custom-emoji',
|
||||
'steam-sticker'
|
||||
])
|
||||
|
||||
/** Hex or a plain CSS colour keyword - nothing that could carry a url() or expression. */
|
||||
|
||||
@@ -14,6 +14,10 @@ describe('the statuses a service has', () => {
|
||||
expect(statusesFor('matrix')).toEqual(['online', 'dnd', 'invisible'])
|
||||
})
|
||||
|
||||
it('gives Steam online, away and invisible, and moho\'s own do not disturb', () => {
|
||||
expect(statusesFor('steam')).toEqual(['online', 'idle', 'dnd', 'invisible'])
|
||||
})
|
||||
|
||||
it('gives Kick and Sneedchat only the one that is moho\'s own', () => {
|
||||
expect(statusesFor('kick')).toEqual(['online', 'dnd'])
|
||||
expect(statusesFor('sneedchat')).toEqual(['online', 'dnd'])
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* daemon, which also refuses what a service has no state for.
|
||||
*
|
||||
* Offered by what each can actually express rather than by what could be faked
|
||||
* from something nearby: Discord has all four; IRC has away and back - idle and
|
||||
* from something nearby: Discord has all four; Steam has online, away and
|
||||
* invisible, with do not disturb moho's own as everywhere; IRC has away and back - idle and
|
||||
* online; Matrix has online and offline presence, so invisible is real and idle
|
||||
* is not; Kick and Sneedchat have none. Do not disturb is online with moho's own
|
||||
* desktop notifications silenced, which any service can have, so it is on all of
|
||||
@@ -20,9 +21,9 @@ export function supportsStatus(service: string, status: Choosable): boolean {
|
||||
case 'dnd':
|
||||
return true
|
||||
case 'idle':
|
||||
return service === 'discord' || service === 'irc'
|
||||
return service === 'discord' || service === 'irc' || service === 'steam'
|
||||
case 'invisible':
|
||||
return service === 'discord' || service === 'matrix'
|
||||
return service === 'discord' || service === 'matrix' || service === 'steam'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { steamGroupInviteCode, steamGroupOfRail, steamRoomIds } from './steamchat'
|
||||
|
||||
describe('Steam chat groups', () => {
|
||||
it('tells a group on the rail from every other entry', () => {
|
||||
expect(steamGroupOfRail('steam:me|steamgroup:78989756')).toBe('78989756')
|
||||
expect(steamGroupOfRail('steam:me|guild:5')).toBeNull()
|
||||
expect(steamGroupOfRail('steam:me|steamgroup:')).toBeNull()
|
||||
})
|
||||
|
||||
it('finds the group and room a channel is, and nothing in a friend conversation', () => {
|
||||
expect(steamRoomIds({ kind: 'channel', remoteId: '78989756:316198645' })).toEqual({ group: '78989756', room: '316198645' })
|
||||
expect(steamRoomIds({ kind: 'dm', remoteId: '76561197960265729' })).toBeNull()
|
||||
expect(steamRoomIds({ kind: 'channel', remoteId: 'x:y' })).toBeNull()
|
||||
expect(steamRoomIds(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('reads the invitation to a group out of a link, and refuses what is not one', () => {
|
||||
expect(steamGroupInviteCode('https://s.team/chat/Ga3kODnC')).toBe('Ga3kODnC')
|
||||
expect(steamGroupInviteCode(' s.team/chat/Ga3kODnC ')).toBe('Ga3kODnC')
|
||||
expect(steamGroupInviteCode('https://steamcommunity.com/chat/invite/Ga3kODnC?x=1')).toBe('Ga3kODnC')
|
||||
expect(steamGroupInviteCode('https://s.team/p/abc-def/XYZ')).toBeNull()
|
||||
expect(steamGroupInviteCode('someone')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Buffer } from '../../../shared/wire'
|
||||
|
||||
/**
|
||||
* Steam chat groups, as the window tells them from everything else Steam is.
|
||||
*
|
||||
* A group is a server on the rail and its rooms are channels; a friend is a
|
||||
* direct message. Both are Steam buffers, so what separates them is the shape
|
||||
* of the id the daemon files them under, which is kept in one place so no
|
||||
* component has to know it.
|
||||
*/
|
||||
|
||||
/** The group a rail entry is, when it is a Steam chat group: its number as the daemon writes it. */
|
||||
export function steamGroupOfRail(railId: string): string | null {
|
||||
const at = railId.indexOf('|steamgroup:')
|
||||
return at < 0 ? null : railId.slice(at + '|steamgroup:'.length) || null
|
||||
}
|
||||
|
||||
/** The group and the room a Steam channel is, from the id the daemon keeps on it. */
|
||||
export function steamRoomIds(buffer: Pick<Buffer, 'remoteId' | 'kind'> | null | undefined): { group: string; room: string } | null {
|
||||
if (!buffer || buffer.kind !== 'channel' || !buffer.remoteId) return null
|
||||
const [group, room] = buffer.remoteId.split(':')
|
||||
return group && room && /^\d+$/.test(group) && /^\d+$/.test(room) ? { group, room } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The code in an invitation to a group, from a link or the bare code; null
|
||||
* for anything else - a friend's invite link, a name - so one box can take
|
||||
* either and the right thing is done with each.
|
||||
*/
|
||||
export function steamGroupInviteCode(text: string): string | null {
|
||||
const t = text.trim()
|
||||
const link = /(?:^|\/\/)(?:s\.team\/chat\/|steamcommunity\.com\/chat\/invite\/)([A-Za-z0-9]{4,32})(?:[/?#]|$)/.exec(t)
|
||||
return link ? link[1] : null
|
||||
}
|
||||
|
||||
/** What the daemon says about one of the account's groups. */
|
||||
export interface SteamChatGroup {
|
||||
id: string
|
||||
railId: string
|
||||
name: string
|
||||
tagline: string
|
||||
owner: string
|
||||
myRank: number
|
||||
myRankWord: string
|
||||
mayModerate: boolean
|
||||
mayManage: boolean
|
||||
mayInvite: boolean
|
||||
mayChangeRoles: boolean
|
||||
/** Giving roles to people and taking them away: a different permission, and not in a community group's chat. */
|
||||
mayAssignRoles: boolean
|
||||
mayMentionAll: boolean
|
||||
defaultRole: string
|
||||
clan: boolean
|
||||
muted: boolean
|
||||
notify: number
|
||||
permissions: { key: string; label: string }[]
|
||||
rooms: { id: string; name: string; voice: boolean }[]
|
||||
roles: { id: string; name: string }[]
|
||||
members: { steamid: string; name: string; rank: number; rankWord: string; roles: string[] }[]
|
||||
kicked: { steamid: string; name: string }[]
|
||||
}
|
||||
|
||||
/** Notification levels as Steam numbers them, in the order the menu lists them. */
|
||||
/** How long a removal keeps somebody out, as the Steam client offers it. */
|
||||
export const STEAM_KICK_LENGTHS: { label: string; seconds: number }[] = [
|
||||
{ label: 'An hour', seconds: 3600 },
|
||||
{ label: 'A day', seconds: 86400 },
|
||||
{ label: 'A week', seconds: 604800 }
|
||||
]
|
||||
|
||||
export const STEAM_NOTIFY_LEVELS: { value: number; label: string }[] = [
|
||||
{ value: 4, label: 'All messages' },
|
||||
{ value: 3, label: '@everyone and me' },
|
||||
{ value: 2, label: '@me only' },
|
||||
{ value: 1, label: 'Nothing' }
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { STEAM_MAX_BYTES, steamFileRefusal } from './steamfiles'
|
||||
|
||||
describe('the files Steam takes in a chat', () => {
|
||||
it('takes the types its own client takes, by extension in any case', () => {
|
||||
for (const name of ['a.jpg', 'a.JPEG', 'a.png', 'a.gif', 'a.webm', 'a.mpg', 'a.mp4', 'a.mpeg', 'a.ogv', 'a.webp', 'a.avif']) {
|
||||
expect(steamFileRefusal(name, 10), name).toBeNull()
|
||||
}
|
||||
})
|
||||
it('refuses the rest in words that name what it does take', () => {
|
||||
expect(steamFileRefusal('paper.pdf', 10)).toContain('.pdf')
|
||||
expect(steamFileRefusal('paper.pdf', 10)).toContain('webm')
|
||||
expect(steamFileRefusal('noextension')).toContain('that kind of')
|
||||
})
|
||||
it('refuses a file over thirty megabytes, and an empty one', () => {
|
||||
expect(steamFileRefusal('a.png', STEAM_MAX_BYTES)).toBeNull()
|
||||
expect(steamFileRefusal('a.png', STEAM_MAX_BYTES + 1)).toContain('30 MB')
|
||||
expect(steamFileRefusal('a.png', 0)).toContain('empty')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* What Steam takes as a file in a chat: the types and the size its own client
|
||||
* checks before it will start an upload. The daemon checks the same before it
|
||||
* sends; this is here so a file that will be refused says so at once, beside
|
||||
* the box, instead of after the message has been written and sent.
|
||||
*/
|
||||
export const STEAM_FILE_TYPES = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'mp4', 'webm', 'mpg', 'mpeg', 'ogv']
|
||||
|
||||
/** Thirty megabytes, the client's own limit. */
|
||||
export const STEAM_MAX_BYTES = 30 * 1024 * 1024
|
||||
|
||||
/** Why Steam will not take this file, or null if it will. */
|
||||
export function steamFileRefusal(name: string, size?: number): string | null {
|
||||
const dot = name.lastIndexOf('.')
|
||||
const extension = dot < 0 ? '' : name.slice(dot + 1).toLowerCase()
|
||||
if (!STEAM_FILE_TYPES.includes(extension)) {
|
||||
return `Steam does not take ${extension ? `.${extension}` : 'that kind of'} files in chat - it takes ${STEAM_FILE_TYPES.join(', ')}`
|
||||
}
|
||||
if (size === 0) return `${name} is empty`
|
||||
if (size !== undefined && size > STEAM_MAX_BYTES) return `${name} is over the ${STEAM_MAX_BYTES / 1024 / 1024} MB Steam allows in chat`
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { emojiPreview, extractMedia, formatMessage, splitTrailingPunctuation, normalizeBBCode, isSteamUpload, markSpoilered, stripEmbeddedUrls, sourcePick, steamSameText } from './format'
|
||||
import { formatsFor, serialize, wrap, type BoxNode } from './composeFormat'
|
||||
import { reactionLabel } from './reactions'
|
||||
import { isWarning } from './util'
|
||||
|
||||
const text = (value: string): BoxNode => ({ nodeType: 3, nodeValue: value, nodeName: '#text', childNodes: [] })
|
||||
const el = (nodeName: string, children: BoxNode[], dataset?: Record<string, string>): BoxNode => ({ nodeType: 1, nodeValue: null, nodeName, childNodes: children, dataset })
|
||||
const emoticon = (name: string): BoxNode => el('IMG', [], { token: `[steam:emote:${name}]` })
|
||||
|
||||
describe('what Steam sends is drawn', () => {
|
||||
it('draws an emoticon by its name, as a picture, and a sticker larger', () => {
|
||||
const html = formatMessage('hi [steam:emote:steamhappy] and [steam:sticker:CatSurprise]')
|
||||
expect(html).toContain('economy/emoticon/steamhappy')
|
||||
expect(html).toContain('class="custom-emoji"')
|
||||
expect(html).toContain('economy/sticker/CatSurprise')
|
||||
expect(html).toContain('custom-emoji steam-sticker')
|
||||
expect(html).not.toContain('[steam:')
|
||||
})
|
||||
|
||||
it('does not take other square brackets for pictures', () => {
|
||||
const html = formatMessage('[steam:emote:bad name] [steam:other:x] [steam:emote:]')
|
||||
expect(html).not.toContain('<img')
|
||||
})
|
||||
|
||||
it('draws the formatting the daemon read into marks', () => {
|
||||
const html = formatMessage('**bold** and *italic* and [u]under[/u] and ~~struck~~ and ||secret|| and [the site](https://example.com)')
|
||||
expect(html).toContain('<b>bold</b>')
|
||||
expect(html).toContain('<i>italic</i>')
|
||||
expect(html).toMatch(/<u>under<\/u>/)
|
||||
expect(html).toMatch(/<(s|del|strike)>struck<\/(s|del|strike)>/)
|
||||
expect(html).toContain('spoiler')
|
||||
expect(html).toContain('href="https://example.com"')
|
||||
})
|
||||
|
||||
it('previews an emoticon picked out of the picker', () => {
|
||||
expect(emojiPreview('[steam:emote:steamhappy]')?.label).toBe(':steamhappy:')
|
||||
expect(emojiPreview('[steam:emote:steamhappy]')?.src).toContain('economy/emoticon/steamhappy')
|
||||
expect(emojiPreview('[steam:emote:a b]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('what is written for Steam', () => {
|
||||
it('offers no formatting, because Steam takes none', () => {
|
||||
expect(formatsFor('steam')).toEqual([])
|
||||
expect(wrap('steam', 'bold', 'x')).toBe('x')
|
||||
})
|
||||
|
||||
it('escapes a bracket somebody typed, so it stays a bracket', () => {
|
||||
expect(serialize(el('DIV', [text('a [b]literal[/b] tag')]), 'steam')).toBe('a \\[b]literal\\[/b] tag')
|
||||
// Other services are not touched.
|
||||
expect(serialize(el('DIV', [text('a [b]x[/b]')]), 'discord')).toBe('a [b]x[/b]')
|
||||
})
|
||||
|
||||
it('sends bold typed in the box as plain text, not as tags Steam would show literally', () => {
|
||||
expect(serialize(el('DIV', [text('hi '), el('B', [text('there [x]')])]), 'steam')).toBe('hi there \\[x]')
|
||||
})
|
||||
|
||||
it('sends an emoticon as its name between colons, and only for Steam', () => {
|
||||
expect(serialize(el('DIV', [text('hello '), emoticon('steamhappy')]), 'steam')).toBe('hello :steamhappy:')
|
||||
expect(serialize(el('DIV', [emoticon('steamhappy')]), 'discord')).toBe('[steam:emote:steamhappy]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a reaction with a Steam emoticon', () => {
|
||||
it('is named by the emoticon', () => {
|
||||
expect(reactionLabel('[steam:emote:steamhappy]')).toBe(':steamhappy:')
|
||||
expect(reactionLabel('[steam:sticker:CatSurprise]')).toBe(':CatSurprise:')
|
||||
expect(reactionLabel('[steam:emote:a b]')).toBe('[steam:emote:a b]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('picking a Steam emoticon from the picker', () => {
|
||||
it('sends its token, which the box draws and sends as the colon form', () => {
|
||||
const entry = { id: 'steam:steambored', name: 'steambored', url: 'https://community.fastly.steamstatic.com/economy/emoticon/steambored', animated: false, locked: false }
|
||||
const token = sourcePick('steam', entry)
|
||||
expect(token).toBe('[steam:emote:steambored]')
|
||||
expect(emojiPreview(token)?.src).toContain('economy/emoticon/steambored')
|
||||
expect(serialize(el('DIV', [text('hi '), el('IMG', [], { token })]), 'steam')).toBe('hi :steambored:')
|
||||
// Sneedchat's id is its shortcode, and stays that.
|
||||
expect(sourcePick('sneedchat', { ...entry, id: ':lol:' })).toBe(':lol:')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a Steam message is the one just sent', () => {
|
||||
it('matches what was typed to what Steam logged, emoticons and brackets included', () => {
|
||||
expect(steamSameText('hi', 'hi')).toBe(true)
|
||||
expect(steamSameText(':steambored:', '[steam:emote:steambored]')).toBe(true)
|
||||
expect(steamSameText('a :steamhappy: b :steamsad:', 'a [steam:emote:steamhappy] b [steam:emote:steamsad]')).toBe(true)
|
||||
expect(steamSameText('not \\[b]bold', 'not [\u200bb]bold')).toBe(true)
|
||||
expect(steamSameText(':steambored:', '[steam:emote:steamhappy]')).toBe(false)
|
||||
expect(steamSameText('hello', 'hello there')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a picture sent through Steam', () => {
|
||||
const url = 'https://images.steamusercontent.com/ugc/1269778906054997137/522C3130A7C6AE1FC52CB49820609316A34ECD01/'
|
||||
it('is known by where Steam keeps it, since its address has no extension', () => {
|
||||
expect(isSteamUpload(url)).toBe(true)
|
||||
expect(isSteamUpload('https://example.com/ugc/1/2/')).toBe(false)
|
||||
expect(isSteamUpload('https://images.steamusercontent.com/other/1')).toBe(false)
|
||||
})
|
||||
it('is drawn as a picture', () => {
|
||||
expect(extractMedia(`\n${url}\n`).map((m) => [m.url, m.kind])).toEqual([[url, 'image']])
|
||||
})
|
||||
it('is drawn as what the message says it is, a video having no extension to say so', () => {
|
||||
const video = 'https://cdn.steamusercontent.com/ugc/9279387166147417773/0642C6E9889E1C2419DD7F85743538C67DD8CA36/'
|
||||
const items = extractMedia(`\n[steam:video:${video}]\n`)
|
||||
expect(items.map((m) => [m.url, m.kind])).toEqual([[video, 'video']])
|
||||
expect(stripEmbeddedUrls(`hi\n[steam:video:${video}]\n`, items)).toBe('hi')
|
||||
expect(formatMessage(`[steam:video:${video}]`)).toContain('cdn.steamusercontent.com')
|
||||
expect(formatMessage(`[steam:video:${video}]`)).not.toContain('steam:video')
|
||||
})
|
||||
it('is read from how the daemon first stored a video, too', () => {
|
||||
const video = 'https://cdn.steamusercontent.com/ugc/9279387166147417773/0642C6E9889E1C2419DD7F85743538C67DD8CA36/'
|
||||
const stored = `||\n${video}\n${video}[/video]||`
|
||||
const items = markSpoilered(extractMedia(normalizeBBCode(stored)), normalizeBBCode(stored))
|
||||
expect(items.map((m) => [m.kind, m.spoiler])).toEqual([['video', true]])
|
||||
})
|
||||
it('is hidden when Steam sent it as a spoiler, and leaves no empty bars behind', () => {
|
||||
const body = `||\n[steam:image:${url}]\n||`
|
||||
const items = markSpoilered(extractMedia(body), body)
|
||||
expect(items.map((m) => m.spoiler)).toEqual([true])
|
||||
expect(stripEmbeddedUrls(body, items)).toBe('')
|
||||
const plain = `${url}`
|
||||
expect(markSpoilered(extractMedia(plain), plain).map((m) => m.spoiler)).toEqual([undefined])
|
||||
})
|
||||
it('is read from the tag as it was stored before the daemon read it', () => {
|
||||
const stored = `[img src=${url} thumbnail_src=${url} srcset="" width=479 height=450]${url}`
|
||||
expect(normalizeBBCode(stored).trim()).toBe(`[steam:image:${url}]`)
|
||||
expect(extractMedia(normalizeBBCode(stored)).map((m) => [m.url, m.kind])).toEqual([[url, 'image']])
|
||||
})
|
||||
it('hides a picture a Sneedchat or Discord message spoilers, by the same rule', () => {
|
||||
const png = 'https://example.com/a.png'
|
||||
const sneed = normalizeBBCode(`[spoiler][img]${png}[/img][/spoiler]`)
|
||||
expect(markSpoilered(extractMedia(sneed), sneed).map((m) => m.spoiler)).toEqual([true])
|
||||
const discord = `look ||${png}||`
|
||||
expect(markSpoilered(extractMedia(discord), discord).map((m) => m.spoiler)).toEqual([true])
|
||||
const open = `look ${png}`
|
||||
expect(markSpoilered(extractMedia(open), open).map((m) => m.spoiler)).toEqual([undefined])
|
||||
})
|
||||
})
|
||||
|
||||
describe('a Steam sticker is named by words', () => {
|
||||
it('draws one whose name has spaces in it, larger, from an address that takes them', () => {
|
||||
const html = formatMessage('[steam:sticker:Nyan GLaDOS]')
|
||||
expect(html).toContain('economy/sticker/Nyan%20GLaDOS')
|
||||
expect(html).toContain('steam-sticker')
|
||||
expect(html).not.toContain('[steam:')
|
||||
expect(emojiPreview('[steam:sticker:Keyboard Headcrab]')?.label).toBe(':Keyboard Headcrab:')
|
||||
})
|
||||
it('keeps an emoticon one word', () => {
|
||||
expect(emojiPreview('[steam:emote:a b]')).toBeNull()
|
||||
expect(formatMessage('[steam:emote:a b]')).not.toContain('<img')
|
||||
})
|
||||
it('is held as a reaction under that name', () => {
|
||||
expect(reactionLabel('[steam:sticker:Nyan GLaDOS]')).toBe(':Nyan GLaDOS:')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a link in a sentence', () => {
|
||||
it('does not take the sentence\'s punctuation', () => {
|
||||
expect(splitTrailingPunctuation('https://x.test/a,')).toEqual(['https://x.test/a', ','])
|
||||
expect(splitTrailingPunctuation('https://x.test/a.')).toEqual(['https://x.test/a', '.'])
|
||||
expect(splitTrailingPunctuation('https://x.test/a?!')).toEqual(['https://x.test/a', '?!'])
|
||||
expect(splitTrailingPunctuation('https://x.test/a)')).toEqual(['https://x.test/a', ')'])
|
||||
expect(splitTrailingPunctuation('https://x.test/Foo_(bar)')).toEqual(['https://x.test/Foo_(bar)', ''])
|
||||
expect(splitTrailingPunctuation('https://x.test/?a=1&b=2')).toEqual(['https://x.test/?a=1&b=2', ''])
|
||||
expect(splitTrailingPunctuation('https://x.test/a?b=1')).toEqual(['https://x.test/a?b=1', ''])
|
||||
})
|
||||
it('is drawn with the punctuation outside it', () => {
|
||||
const html = formatMessage('read https://x.test/a, then https://x.test/b.')
|
||||
expect(html).toContain('<a href="https://x.test/a">https://x.test/a</a>,')
|
||||
expect(html).toContain('<a href="https://x.test/b">https://x.test/b</a>.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the message just sent, when Steam made something of its link', () => {
|
||||
it('is the one that came back as a picture, a video or a card', () => {
|
||||
const png = 'https://upload.wikimedia.org/a/b.png'
|
||||
expect(steamSameText(png, `[steam:image:${png}]`)).toBe(true)
|
||||
expect(steamSameText('https://x.test/v', '[steam:video:https://x.test/v]')).toBe(true)
|
||||
expect(steamSameText('x.salastil.com', '[x.salastil.com](http://x.salastil.com)')).toBe(true)
|
||||
expect(steamSameText('https://x.test/p', 'https://x.test/p')).toBe(true)
|
||||
expect(steamSameText(png, '[steam:image:https://other.test/c.png]')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('a warning line in a conversation', () => {
|
||||
it('is red whether it is written as a warning or was stored as Steam\'s notice before there was a kind for it', () => {
|
||||
expect(isWarning('warning')).toBe(true)
|
||||
expect(isWarning('system', 'notice:100:1')).toBe(true)
|
||||
expect(isWarning('system', '1790000000:1')).toBe(false)
|
||||
expect(isWarning('chat', 'notice:100:1')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Account } from '../../../shared/wire'
|
||||
import type { BufferEntry } from '../state/store'
|
||||
import { bufferMenuEntries, type BufferMenuInput } from './buffermenu'
|
||||
|
||||
const buffer = { id: 'steam:me|Gabe', accountId: 'steam:me', kind: 'dm', name: 'Gabe', remoteId: '76561197960265829' } as BufferEntry
|
||||
const account = { id: 'steam:me', service: 'steam' } as Account
|
||||
const noop = (): void => {}
|
||||
|
||||
function menu(extra: Partial<BufferMenuInput> = {}): string[] {
|
||||
const entries = bufferMenuEntries({
|
||||
buffer, account, muted: false, pinned: false, filed: false, inCall: false, poppedOut: false, canCall: false,
|
||||
live: false, watched: false, onTogglePin: noop, onToggleMute: noop, onHide: noop, onClose: noop, onCall: noop,
|
||||
onHangUp: noop, onFile: noop, onPopOut: noop, onDock: noop, ...extra
|
||||
})
|
||||
return entries.flatMap((e) => ('label' in e && e.label ? [e.label] : []))
|
||||
}
|
||||
|
||||
describe('the menu of a conversation with a Steam friend', () => {
|
||||
it('offers what to call them, ending the friendship and blocking them, when it is told they are one', () => {
|
||||
const asked: string[] = []
|
||||
const labels = menu({ onSteamFriend: (what) => asked.push(what) })
|
||||
expect(labels).toEqual(expect.arrayContaining(['Set nickname…', 'Remove friend', 'Block']))
|
||||
const entries = bufferMenuEntries({
|
||||
buffer, account, muted: false, pinned: false, filed: false, inCall: false, poppedOut: false, canCall: false,
|
||||
live: false, watched: false, onTogglePin: noop, onToggleMute: noop, onHide: noop, onClose: noop, onCall: noop,
|
||||
onHangUp: noop, onFile: noop, onPopOut: noop, onDock: noop, onSteamFriend: (what) => asked.push(what)
|
||||
})
|
||||
for (const label of ['Set nickname…', 'Remove friend', 'Block']) {
|
||||
const entry = entries.find((e) => 'label' in e && e.label === label)
|
||||
if (entry && 'onClick' in entry) entry.onClick?.()
|
||||
}
|
||||
expect(asked).toEqual(['nickname', 'remove', 'block'])
|
||||
})
|
||||
|
||||
it('does not, anywhere else', () => {
|
||||
const labels = menu()
|
||||
expect(labels).not.toContain('Remove friend')
|
||||
expect(labels).not.toContain('Block')
|
||||
expect(labels).not.toContain('Set nickname…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the way to a Steam friend\'s profile', () => {
|
||||
it('is the first thing on the menu of a conversation with one', () => {
|
||||
let opened = 0
|
||||
const entries = bufferMenuEntries({
|
||||
buffer, account, muted: false, pinned: false, filed: false, inCall: false, poppedOut: false, canCall: false,
|
||||
live: false, watched: false, onTogglePin: noop, onToggleMute: noop, onHide: noop, onClose: noop, onCall: noop,
|
||||
onHangUp: noop, onFile: noop, onPopOut: noop, onDock: noop, onProfile: () => opened++
|
||||
})
|
||||
expect('label' in entries[0] && entries[0].label).toBe('Profile')
|
||||
const first = entries[0]
|
||||
if ('onClick' in first) first.onClick?.()
|
||||
expect(opened).toBe(1)
|
||||
})
|
||||
|
||||
it('is not offered where there is no such column', () => {
|
||||
expect(menu()).not.toContain('Profile')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Account } from '../../../shared/wire'
|
||||
import type { BufferEntry } from '../state/store'
|
||||
import { dmCategoryId } from './dmcategories'
|
||||
import { folderName, headingName, parseSteamCategory, seedSteamFolders, steamCategoryId, type SteamGroup } from './steamgroups'
|
||||
|
||||
const account = { id: 'steam:me', service: 'steam', displayName: 'me' } as Account
|
||||
const dm = (name: string, remoteId: string): BufferEntry => ({ id: `steam:me|${name}`, accountId: 'steam:me', kind: 'dm', name, remoteId }) as BufferEntry
|
||||
const GABE = '76561197960265729'
|
||||
const ALYX = '76561197960287930'
|
||||
const heading = dmCategoryId('steam:me')
|
||||
|
||||
type Current = { categories: { id: string; name: string }[]; assignment: Record<string, string>; seeded: string[] }
|
||||
const start = (over: Partial<Current> = {}): Current => ({ categories: [{ id: heading, name: 'Steam - me' }], assignment: {}, seeded: [], ...over })
|
||||
const groups = (list: SteamGroup[]): Record<string, SteamGroup[]> => ({ 'steam:me': list })
|
||||
const buffers = [dm('Gabe', GABE), dm('Alyx', ALYX)]
|
||||
const filed = { 'steam:me|Gabe': heading, 'steam:me|Alyx': heading }
|
||||
|
||||
describe('a Steam folder is named for its id and read back from it', () => {
|
||||
it('has a stable heading id', () => {
|
||||
expect(steamCategoryId('steam:me', 7)).toBe('sg:steam:me:7')
|
||||
expect(parseSteamCategory('sg:steam:me:7')).toEqual({ accountId: 'steam:me', groupId: 7 })
|
||||
expect(parseSteamCategory(heading)).toBeNull()
|
||||
expect(parseSteamCategory('sg:nonsense')).toBeNull()
|
||||
})
|
||||
|
||||
it('is shown without the "Steam - " some of them carry, and a heading from it is called protocol, then folder', () => {
|
||||
expect(folderName('Steam - Goons')).toBe('Goons')
|
||||
expect(folderName('Friends')).toBe('Friends')
|
||||
expect(headingName(account, { id: 1, name: 'Steam - Goons', members: [] })).toBe('Steam - Goons')
|
||||
expect(headingName(account, { id: 3, name: 'Friends', members: [] })).toBe('Steam - Friends')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the direct-messages page starts from an account\'s folders, once', () => {
|
||||
it('makes a heading for each, with their friends under it, the first time they are heard', () => {
|
||||
const plan = seedSteamFolders([account], buffers, groups([{ id: 7, name: 'Steam - Raiders', members: [GABE] }]), start({ assignment: filed }))!
|
||||
expect(plan.categories.find((c) => c.id === 'sg:steam:me:7')?.name).toBe('Steam - Raiders')
|
||||
expect(plan.assignment['steam:me|Gabe']).toBe('sg:steam:me:7')
|
||||
expect(plan.assignment['steam:me|Alyx']).toBe(heading)
|
||||
expect(plan.seeded).toEqual(['steam:me'])
|
||||
})
|
||||
|
||||
it('leaves a conversation a person has put somewhere themselves where it is', () => {
|
||||
const plan = seedSteamFolders([account], buffers, groups([{ id: 7, name: 'Raiders', members: [GABE] }]), start({ assignment: { ...filed, 'steam:me|Gabe': 'cat-mine' } }))!
|
||||
expect(plan.assignment['steam:me|Gabe']).toBe('cat-mine')
|
||||
})
|
||||
|
||||
it('does nothing before Steam has spoken, and nothing for an account with no folders but marks it', () => {
|
||||
expect(seedSteamFolders([account], buffers, {}, start())).toBeNull()
|
||||
const none = seedSteamFolders([account], buffers, groups([]), start())!
|
||||
expect(none.categories).toEqual(start().categories)
|
||||
expect(none.seeded).toEqual(['steam:me'])
|
||||
})
|
||||
|
||||
it('never does it again: a folder made on Steam later is not made here, and one deleted here is not made again', () => {
|
||||
const first = seedSteamFolders([account], buffers, groups([{ id: 7, name: 'Raiders', members: [GABE] }]), start({ assignment: filed }))!
|
||||
const later = groups([{ id: 7, name: 'Raiders', members: [GABE] }, { id: 8, name: 'Work', members: [ALYX] }])
|
||||
expect(seedSteamFolders([account], buffers, later, { categories: first.categories, assignment: first.assignment, seeded: first.seeded })).toBeNull()
|
||||
const deleted = first.categories.filter((c) => c.id !== 'sg:steam:me:7')
|
||||
expect(seedSteamFolders([account], buffers, later, { categories: deleted, assignment: first.assignment, seeded: first.seeded })).toBeNull()
|
||||
})
|
||||
|
||||
it('follows nothing: a rename or a move on Steam does not reach the page', () => {
|
||||
const first = seedSteamFolders([account], buffers, groups([{ id: 7, name: 'Raiders', members: [GABE] }]), start({ assignment: filed }))!
|
||||
const moved = groups([{ id: 7, name: 'Renamed', members: [ALYX] }])
|
||||
expect(seedSteamFolders([account], buffers, moved, { categories: first.categories, assignment: first.assignment, seeded: first.seeded })).toBeNull()
|
||||
})
|
||||
|
||||
it('only marks an account whose page already has headings from its folders, and leaves them as they are', () => {
|
||||
const had = [{ id: heading, name: 'Steam - me' }, { id: 'sg:steam:me:7', name: 'Steam - Raiders' }, { id: 'sg:steam:me:8', name: 'Mine now' }]
|
||||
const plan = seedSteamFolders([account], buffers, groups([{ id: 7, name: 'Steam - Raiders', members: [GABE] }]), { categories: had, assignment: { 'steam:me|Gabe': 'sg:steam:me:7' }, seeded: [] })!
|
||||
expect(plan.categories.map((c) => c.name)).toEqual(['Steam - me', 'Steam - Raiders', 'Mine now'])
|
||||
expect(plan.assignment['steam:me|Gabe']).toBe('sg:steam:me:7')
|
||||
expect(plan.seeded).toEqual(['steam:me'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Account } from '../../../shared/wire'
|
||||
import type { BufferEntry } from '../state/store'
|
||||
import type { CustomCategory } from './categories'
|
||||
import { dmCategoryId } from './dmcategories'
|
||||
import { serviceLabel } from './util'
|
||||
|
||||
/**
|
||||
* Steam's friend folders, and the headings on the direct-messages page that start from them.
|
||||
*
|
||||
* A Steam account keeps folders for its friends - "Raiders", "Work" - and a friend can be in
|
||||
* several. The direct-messages page has headings that a person makes, renames, fills by dragging
|
||||
* and deletes, and those are this window's own: nothing done to them is done on Steam, and nothing
|
||||
* Steam does reaches them. The folders are mirrored both ways only on the Steam account's own page,
|
||||
* where they are the page's headings and not copies of anything.
|
||||
*
|
||||
* What this does is start the direct-messages page from them, once: the first time an account's
|
||||
* folders are heard, if it has any, there is a heading for each, and each friend's conversation is
|
||||
* under the folder they are in. After that the headings are the person's, so a folder made on Steam
|
||||
* later is not made here, one deleted here is not made again, and none of it is sent to Steam.
|
||||
*/
|
||||
|
||||
export interface SteamGroup {
|
||||
id: number
|
||||
name: string
|
||||
/** Friends in it, by 64-bit id as text. */
|
||||
members: string[]
|
||||
}
|
||||
|
||||
const PREFIX = 'sg:'
|
||||
|
||||
/** The heading that stands for one of an account's groups. Stable across renames. */
|
||||
export function steamCategoryId(accountId: string, groupId: number): string {
|
||||
return `${PREFIX}${accountId}:${groupId}`
|
||||
}
|
||||
|
||||
/** Which account and group a heading stands for, if it stands for one. */
|
||||
export function parseSteamCategory(id: string): { accountId: string; groupId: number } | null {
|
||||
if (!id.startsWith(PREFIX)) return null
|
||||
const rest = id.slice(PREFIX.length)
|
||||
const colon = rest.lastIndexOf(':')
|
||||
const groupId = Number(rest.slice(colon + 1))
|
||||
return colon > 0 && Number.isInteger(groupId) ? { accountId: rest.slice(0, colon), groupId } : null
|
||||
}
|
||||
|
||||
/** A change to make on Steam. */
|
||||
export interface GroupCall {
|
||||
accountId: string
|
||||
action: 'add' | 'remove' | 'rename' | 'delete' | 'create'
|
||||
groupId: number
|
||||
steamid?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
/** A folder's name as it is shown: Steam's own list holds some with "Steam - " in front, which its own client does not show. */
|
||||
export function folderName(name: string): string {
|
||||
return name.replace(/^Steam - /, '').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* What a heading made from a folder is called on the direct-messages page: the protocol, then the
|
||||
* folder, as that page's headings for an account are ("Steam - Goons"), so a folder is not taken
|
||||
* for another protocol's heading of the same name.
|
||||
*/
|
||||
export function headingName(account: Pick<Account, 'service'>, group: SteamGroup): string {
|
||||
const name = folderName(group.name) || group.name
|
||||
return `${serviceLabel(account.service)} - ${name}`
|
||||
}
|
||||
|
||||
export interface SteamSeed {
|
||||
categories: CustomCategory[]
|
||||
assignment: Record<string, string>
|
||||
/** The accounts whose folders have been looked at, whether or not there was anything to make. */
|
||||
seeded: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the direct-messages page from an account's folders, once. Null when there is nothing to do,
|
||||
* so the caller writes nothing on the great majority of calls. `groups` has an entry only for an
|
||||
* account whose folders have been heard: one that has not said yet is not one with none.
|
||||
*
|
||||
* An account whose page already has headings from its folders - from before this was done once, when
|
||||
* they were followed - is only marked, and its headings are left as they are.
|
||||
*/
|
||||
export function seedSteamFolders(
|
||||
accounts: Account[],
|
||||
buffers: BufferEntry[],
|
||||
groups: Record<string, SteamGroup[] | undefined>,
|
||||
current: { categories: CustomCategory[]; assignment: Record<string, string>; seeded: string[] }
|
||||
): SteamSeed | null {
|
||||
let categories = current.categories.map((c) => ({ ...c }))
|
||||
const assignment = { ...current.assignment }
|
||||
const seeded = [...current.seeded]
|
||||
let changed = false
|
||||
|
||||
for (const account of accounts) {
|
||||
if (account.service !== 'steam' || seeded.includes(account.id)) continue
|
||||
const heard = groups[account.id]
|
||||
if (!heard) continue
|
||||
const own = `${PREFIX}${account.id}:`
|
||||
// Headings from its folders already on the page - from before this was done once - are left as
|
||||
// they are: they are the person's.
|
||||
if (!categories.some((c) => c.id.startsWith(own))) {
|
||||
const heading = dmCategoryId(account.id)
|
||||
const sorted = [...heard].sort((a, b) => a.id - b.id)
|
||||
for (const g of sorted) categories.push({ id: steamCategoryId(account.id, g.id), name: headingName(account, g) })
|
||||
for (const b of buffers) {
|
||||
if (b.accountId !== account.id || b.kind !== 'dm' || !b.remoteId) continue
|
||||
const home = sorted.find((g) => g.members.includes(b.remoteId!))
|
||||
// Only a conversation still where the account's heading put it: one a person has put somewhere is theirs.
|
||||
if (home && (!assignment[b.id] || assignment[b.id] === heading)) assignment[b.id] = steamCategoryId(account.id, home.id)
|
||||
}
|
||||
}
|
||||
seeded.push(account.id)
|
||||
changed = true
|
||||
}
|
||||
return changed ? { categories, assignment, seeded } : null
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AGAIN, SteamVoice, browserOf, plainFailure, shapeOffer, speakersOf } from './steamvoice'
|
||||
|
||||
const hooks = (rpc: (p: Record<string, unknown>) => Promise<unknown> = async () => ({})) => ({
|
||||
rpc: rpc as never,
|
||||
onChange: () => {},
|
||||
toast: () => {},
|
||||
muted: () => ({ micMuted: false, deafened: false }),
|
||||
nameOf: (_a: string, s: string) => s,
|
||||
mayRing: () => true
|
||||
})
|
||||
|
||||
describe('Steam voice', () => {
|
||||
it('asks for discontinuous transmission on the opus line and leaves the rest of the offer alone', () => {
|
||||
const offer = ['v=0', 'm=audio 9 UDP/TLS/RTP/SAVPF 111 63', 'a=rtpmap:111 opus/48000/2', 'a=fmtp:111 minptime=10;useinbandfec=1', 'a=rtpmap:63 red/48000/2', 'a=fmtp:63 111/111', ''].join('\r\n')
|
||||
expect(shapeOffer(offer).split('\r\n')).toEqual(['v=0', 'm=audio 9 UDP/TLS/RTP/SAVPF 111 63', 'a=rtpmap:111 opus/48000/2', 'a=fmtp:111 minptime=10;useinbandfec=1;usedtx=1', 'a=rtpmap:63 red/48000/2', 'a=fmtp:63 111/111', ''])
|
||||
expect(shapeOffer('v=0\r\n')).toBe('v=0\r\n')
|
||||
})
|
||||
|
||||
it('says it is Chrome, which is what Electron is, with the version it has', () => {
|
||||
expect(browserOf('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) moho/1.0 Chrome/152.0.7977.130 Electron/44.4.4 Safari/537.36')).toEqual({ name: 'Chrome', version: '152.0.7977.130' })
|
||||
expect(browserOf('nothing')).toEqual({ name: 'Chrome', version: '0' })
|
||||
})
|
||||
|
||||
it('finds who is speaking from what each stream carries, ignoring streams nobody owns and quiet ones', () => {
|
||||
const owners = new Map([[1, '7656'], [2, '7657'], [3, '7658']])
|
||||
const sources = [{ source: 1, audioLevel: 0.4 }, { source: 2, audioLevel: 0.001 }, { source: 9, audioLevel: 0.9 }, { source: 3 }]
|
||||
expect([...speakersOf(sources, owners)]).toEqual(['7656'])
|
||||
})
|
||||
|
||||
it('shows a call as ringing until it is answered or ended, one ring a person', () => {
|
||||
const v = new SteamVoice(hooks())
|
||||
v.handle({ accountId: 'steam:me', kind: 'callRequested', data: { steamid: '7656', voiceChatId: '10' } })
|
||||
v.handle({ accountId: 'steam:me', kind: 'callRequested', data: { steamid: '7657', voiceChatId: '11' } })
|
||||
v.handle({ accountId: 'steam:me', kind: 'callRequested', data: { steamid: '7656', voiceChatId: '12' } })
|
||||
expect(v.rings.map((r) => [r.steamid, r.voiceChatId])).toEqual([['7657', '11'], ['7656', '12']])
|
||||
v.handle({ accountId: 'steam:me', kind: 'ended', data: { voiceChatId: '11' } })
|
||||
expect(v.rings.map((r) => r.steamid)).toEqual(['7656'])
|
||||
})
|
||||
|
||||
it('does not ring for somebody who may not ring: not a friend, or while the account is busy', () => {
|
||||
const v = new SteamVoice({ ...hooks(), mayRing: (_a, s) => s === '7656' })
|
||||
v.handle({ accountId: 'steam:me', kind: 'callRequested', data: { steamid: '7657', voiceChatId: '11' } })
|
||||
expect(v.rings).toEqual([])
|
||||
v.handle({ accountId: 'steam:me', kind: 'callRequested', data: { steamid: '7656', voiceChatId: '12' } })
|
||||
expect(v.rings.map((r) => r.steamid)).toEqual(['7656'])
|
||||
expect(v.ringFrom('steam:me', '7656')?.voiceChatId).toBe('12')
|
||||
})
|
||||
|
||||
it('declining a call says no to Steam and takes the ring away', async () => {
|
||||
const sent: Record<string, unknown>[] = []
|
||||
const v = new SteamVoice(hooks(async (p) => (sent.push(p), {})))
|
||||
v.handle({ accountId: 'steam:me', kind: 'callRequested', data: { steamid: '7656', voiceChatId: '10' } })
|
||||
await v.decline(v.rings[0])
|
||||
expect(v.rings).toEqual([])
|
||||
expect(sent).toEqual([{ op: 'answerCall', accountId: 'steam:me', steamid: '7656', voiceChatId: '10', accept: false }])
|
||||
})
|
||||
|
||||
it('takes the mark off a failure that may pass before it is said', () => {
|
||||
expect(plainFailure(`${AGAIN}Steam is not answering just now`)).toBe('Steam is not answering just now')
|
||||
expect(plainFailure('this room has no voice')).toBe('this room has no voice')
|
||||
})
|
||||
|
||||
it('does nothing about a call it is not in', () => {
|
||||
const v = new SteamVoice(hooks())
|
||||
v.handle({ accountId: 'steam:me', kind: 'sessionConnected', data: { ssrc: 1 } })
|
||||
v.handle({ accountId: 'steam:me', kind: 'remoteDescription', data: { sdp: '{}' } })
|
||||
expect(v.phase).toBe('idle')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* Steam voice: one call, or one room's voice chat, held in the window.
|
||||
*
|
||||
* Steam's voice is WebRTC with Valve's own server as the only peer. The window
|
||||
* offers, the server answers with a description of a relay it holds for the
|
||||
* session, and from there audio goes to the relay and comes back mixed, with
|
||||
* the server renegotiating as people come and go and saying who is who by stream
|
||||
* number. Chromium does all the media; the daemon carries the signalling on the
|
||||
* account's connection (backend/steam/voice.rs, which says where each call was
|
||||
* read from), and everything it hears comes back as `steamVoice` events.
|
||||
*
|
||||
* The order is the Steam client's own: open the microphone, make the offer,
|
||||
* `initiate`; the server says the session is `sessionConnected`; once the media
|
||||
* path is up either `joinRoom` or `requestCall`; then `data`, which names the
|
||||
* stream that is ours and is what makes the chat count this connection as being
|
||||
* in it. Everything before that is plumbing and nobody is told of it.
|
||||
*/
|
||||
|
||||
/** What a call is doing, in the words a person would use. */
|
||||
export type SteamVoicePhase = 'idle' | 'starting' | 'connecting' | 'ringing' | 'connected'
|
||||
|
||||
/** Where the voice is. */
|
||||
export type SteamVoiceTarget =
|
||||
| { kind: 'room'; groupId: string; roomId: string; bufferId: string; name: string; groupName: string; railId: string }
|
||||
| { kind: 'friend'; steamid: string; bufferId: string; name: string; voiceChatId?: string }
|
||||
|
||||
/** How somebody in the call is showing, as they last said. */
|
||||
export interface SteamVoiceStatus {
|
||||
micMuted: boolean
|
||||
outputMuted: boolean
|
||||
noMic: boolean
|
||||
}
|
||||
|
||||
/** What the daemon sends up. */
|
||||
export interface SteamVoiceEvent {
|
||||
accountId: string
|
||||
kind: string
|
||||
data: Record<string, any>
|
||||
}
|
||||
|
||||
/** Somebody calling this account. */
|
||||
export interface SteamRing {
|
||||
accountId: string
|
||||
steamid: string
|
||||
voiceChatId: string
|
||||
}
|
||||
|
||||
export interface SteamVoiceHooks {
|
||||
/** One call to the daemon's `steamVoice`. */
|
||||
rpc: (params: Record<string, unknown>) => Promise<any>
|
||||
/** Something about the call that a window shows changed. */
|
||||
onChange: () => void
|
||||
toast: (kind: 'info' | 'error', message: string) => void
|
||||
/** Whether the microphone is muted, and the output, as the footer buttons say. */
|
||||
muted: () => { micMuted: boolean; deafened: boolean }
|
||||
/** Who a Steam id is, for the name on a ring. */
|
||||
nameOf: (accountId: string, steamid: string) => string
|
||||
/** Whether a call from this person may ring: a friend, while the account is not invisible or busy. */
|
||||
mayRing: (accountId: string, steamid: string) => boolean
|
||||
}
|
||||
|
||||
/** How long a call may take to connect before it is given up on. */
|
||||
const CONNECT_TIMEOUT_MS = 60_000
|
||||
/**
|
||||
* What the daemon puts at the front of a failure that may be gone in a moment, as the Steam client
|
||||
* reads them: it tries again, after a little longer each time, for up to a minute.
|
||||
*/
|
||||
export const AGAIN = '[again] '
|
||||
|
||||
/** How long to wait before each new try: the Steam client's own, quick at first and then slower. */
|
||||
const RETRY_DELAYS_MS = [250, 1000, 1000, 5000, 5000, 10000, 10000, 10000, 10000, 10000, 30000]
|
||||
|
||||
/** The words of a failure, without the mark that says it may pass. */
|
||||
export function plainFailure(message: string): string {
|
||||
return message.startsWith(AGAIN) ? message.slice(AGAIN.length) : message
|
||||
}
|
||||
|
||||
/** How long somebody has to answer. */
|
||||
const RING_TIMEOUT_MS = 300_000
|
||||
|
||||
/**
|
||||
* The offer as the server wants it: the opus line says what the Steam client's own
|
||||
* says - discontinuous transmission on, which is what keeps a silent microphone
|
||||
* from filling the relay with packets of nothing.
|
||||
*/
|
||||
export function shapeOffer(sdp: string): string {
|
||||
const lines = sdp.split('\r\n')
|
||||
const pt = lines.map((l) => /^a=rtpmap:(\d+)\sopus/.exec(l)?.[1]).find(Boolean)
|
||||
if (!pt) return sdp
|
||||
return lines.map((l) => (l.startsWith(`a=fmtp:${pt} `) ? `a=fmtp:${pt} minptime=10;useinbandfec=1;usedtx=1` : l)).join('\r\n')
|
||||
}
|
||||
|
||||
/** The browser as the server is told it: its name and its version. */
|
||||
export function browserOf(userAgent: string): { name: string; version: string } {
|
||||
const chrome = /Chrome\/([\d.]+)/.exec(userAgent)
|
||||
return { name: 'Chrome', version: chrome?.[1] ?? '0' }
|
||||
}
|
||||
|
||||
/** Who is speaking, from what each stream is carrying and who each stream belongs to. */
|
||||
export function speakersOf(
|
||||
sources: { source: number; audioLevel?: number }[],
|
||||
owners: Map<number, string>,
|
||||
threshold = 0.02
|
||||
): Set<string> {
|
||||
const out = new Set<string>()
|
||||
for (const s of sources) {
|
||||
const who = owners.get(s.source)
|
||||
if (who && (s.audioLevel ?? 0) > threshold) out.add(who)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export class SteamVoice {
|
||||
accountId = ''
|
||||
target: SteamVoiceTarget | null = null
|
||||
phase: SteamVoicePhase = 'idle'
|
||||
/** The loudest the microphone has been in the last moment, 0 to 1. */
|
||||
level = 0
|
||||
rttMs: number | null = null
|
||||
/** Steam ids of whoever is audible. */
|
||||
speakers = new Set<string>()
|
||||
/** What the others say about themselves. */
|
||||
statuses = new Map<string, SteamVoiceStatus>()
|
||||
rings: SteamRing[] = []
|
||||
|
||||
private hooks: SteamVoiceHooks
|
||||
private pc: RTCPeerConnection | null = null
|
||||
private mic: MediaStream | null = null
|
||||
private audio: AudioContext | null = null
|
||||
private analyser: AnalyserNode | null = null
|
||||
private sinks = new Map<string, HTMLAudioElement>()
|
||||
private voiceChatId = ''
|
||||
private session: { ssrc: number; clientIp: number; clientPort: number; serverIp: number; serverPort: number } | null = null
|
||||
private iceUp = false
|
||||
private joining = false
|
||||
private accepted = false
|
||||
private owners = new Map<number, string>()
|
||||
private updating = false
|
||||
private pending: Record<string, any> | null = null
|
||||
private meters: ReturnType<typeof setInterval> | null = null
|
||||
private stats: ReturnType<typeof setInterval> | null = null
|
||||
private deadline: ReturnType<typeof setTimeout> | null = null
|
||||
/** Bumped on every start and stop, so what comes back late for a call that is gone is dropped. */
|
||||
private epoch = 0
|
||||
/** How many times this call has been tried again, and when it first began. */
|
||||
private attempt = 0
|
||||
private began = 0
|
||||
|
||||
constructor(hooks: SteamVoiceHooks) {
|
||||
this.hooks = hooks
|
||||
}
|
||||
|
||||
get active(): boolean {
|
||||
return this.phase !== 'idle'
|
||||
}
|
||||
|
||||
/** Begins voice in a room, or a call to a friend, on an account. */
|
||||
async start(accountId: string, target: SteamVoiceTarget, retrying = false): Promise<void> {
|
||||
if (this.active) await this.stop()
|
||||
if (!retrying) {
|
||||
this.attempt = 0
|
||||
this.began = Date.now()
|
||||
}
|
||||
const epoch = ++this.epoch
|
||||
this.accountId = accountId
|
||||
this.target = target
|
||||
this.phase = 'starting'
|
||||
this.voiceChatId = target.kind === 'friend' ? (target.voiceChatId ?? '') : ''
|
||||
this.session = null
|
||||
this.iceUp = false
|
||||
this.joining = false
|
||||
this.accepted = target.kind === 'friend' && !!target.voiceChatId
|
||||
this.hooks.onChange()
|
||||
this.deadline = setTimeout(() => this.fail('Voice did not connect'), CONNECT_TIMEOUT_MS)
|
||||
try {
|
||||
await this.openMic()
|
||||
if (epoch !== this.epoch) return
|
||||
await this.offer(epoch)
|
||||
} catch (e) {
|
||||
this.failed(epoch, e as Error, "Couldn't start voice")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A call went wrong: if it may pass, and it is not yet a minute since it began, start it again after
|
||||
* the next of the waits the Steam client uses; otherwise say so. What is late for a call that is gone
|
||||
* is dropped.
|
||||
*/
|
||||
private failed(epoch: number, error: Error, what: string): void {
|
||||
if (epoch !== this.epoch) return
|
||||
const target = this.target
|
||||
if (error.message.startsWith(AGAIN) && target && Date.now() - this.began < CONNECT_TIMEOUT_MS) {
|
||||
const account = this.accountId
|
||||
const delay = RETRY_DELAYS_MS[Math.min(this.attempt++, RETRY_DELAYS_MS.length - 1)]
|
||||
this.epoch++
|
||||
this.teardown()
|
||||
setTimeout(() => void this.start(account, target, true), delay)
|
||||
return
|
||||
}
|
||||
this.fail(`${what}: ${plainFailure(error.message)}`)
|
||||
}
|
||||
|
||||
/** Leaves: a room is left, and a call is hung up on, which ends it for both. */
|
||||
async stop(): Promise<void> {
|
||||
const { target, accountId, voiceChatId } = this
|
||||
const was = this.phase
|
||||
this.epoch++
|
||||
this.teardown()
|
||||
if (!target || was === 'idle') return
|
||||
try {
|
||||
if (target.kind === 'room') {
|
||||
if (was === 'connected' || voiceChatId) await this.hooks.rpc({ op: 'leaveRoom', accountId, groupId: target.groupId, roomId: target.roomId })
|
||||
} else if (voiceChatId) {
|
||||
await this.hooks.rpc({ op: 'endCall', accountId, steamid: target.steamid })
|
||||
}
|
||||
} catch {
|
||||
// Already gone: Steam ends it on its side when the connection drops.
|
||||
}
|
||||
}
|
||||
|
||||
private fail(message: string): void {
|
||||
this.hooks.toast('error', message)
|
||||
void this.stop()
|
||||
}
|
||||
|
||||
private teardown(): void {
|
||||
for (const t of [this.deadline]) if (t) clearTimeout(t)
|
||||
this.deadline = null
|
||||
if (this.meters) clearInterval(this.meters)
|
||||
if (this.stats) clearInterval(this.stats)
|
||||
this.meters = this.stats = null
|
||||
this.pc?.close()
|
||||
this.pc = null
|
||||
this.mic?.getTracks().forEach((t) => t.stop())
|
||||
this.mic = null
|
||||
void this.audio?.close().catch(() => {})
|
||||
this.audio = null
|
||||
this.analyser = null
|
||||
for (const el of this.sinks.values()) {
|
||||
el.pause()
|
||||
el.srcObject = null
|
||||
el.remove()
|
||||
}
|
||||
this.sinks.clear()
|
||||
this.phase = 'idle'
|
||||
this.target = null
|
||||
this.level = 0
|
||||
this.rttMs = null
|
||||
this.speakers = new Set()
|
||||
this.statuses.clear()
|
||||
this.owners.clear()
|
||||
this.pending = null
|
||||
this.updating = false
|
||||
this.voiceChatId = ''
|
||||
this.session = null
|
||||
this.hooks.onChange()
|
||||
}
|
||||
|
||||
private async openMic(): Promise<void> {
|
||||
try {
|
||||
this.mic = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } })
|
||||
} catch {
|
||||
// Listening without a microphone is still a call; the others are told there is none.
|
||||
this.mic = null
|
||||
}
|
||||
this.applyMute()
|
||||
}
|
||||
|
||||
/** Follows the footer's microphone and deafen buttons. */
|
||||
applyMute(): void {
|
||||
const { micMuted, deafened } = this.hooks.muted()
|
||||
this.mic?.getAudioTracks().forEach((t) => (t.enabled = !(micMuted || deafened)))
|
||||
for (const el of this.sinks.values()) el.muted = deafened
|
||||
if (this.phase === 'connected') void this.sendStatus()
|
||||
}
|
||||
|
||||
private async sendStatus(): Promise<void> {
|
||||
if (!this.voiceChatId) return
|
||||
const { micMuted, deafened } = this.hooks.muted()
|
||||
await this.hooks
|
||||
.rpc({ op: 'status', accountId: this.accountId, voiceChatId: this.voiceChatId, micMuted: micMuted || deafened, outputMuted: deafened, noMic: !this.mic?.getAudioTracks().length, sampleRate: this.audio?.sampleRate ?? 48000 })
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
private async offer(epoch: number): Promise<void> {
|
||||
const pc = new RTCPeerConnection()
|
||||
this.pc = pc
|
||||
this.mic?.getAudioTracks().forEach((t) => pc.addTrack(t, this.mic as MediaStream))
|
||||
pc.ontrack = (e) => this.hear(e.track, e.streams[0])
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
if (pc !== this.pc) return
|
||||
if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') {
|
||||
this.iceUp = true
|
||||
void this.tryJoin(epoch)
|
||||
} else if (pc.iceConnectionState === 'failed') {
|
||||
this.fail('The connection to Steam voice failed')
|
||||
}
|
||||
}
|
||||
this.startMeters()
|
||||
const made = await pc.createOffer({ offerToReceiveAudio: true, voiceActivityDetection: true } as RTCOfferOptions)
|
||||
if (epoch !== this.epoch) return
|
||||
const sdp = shapeOffer(made.sdp ?? '')
|
||||
await pc.setLocalDescription({ type: 'offer', sdp })
|
||||
this.phase = 'connecting'
|
||||
this.hooks.onChange()
|
||||
const browser = browserOf(navigator.userAgent)
|
||||
const answer = await this.hooks.rpc({ op: 'initiate', accountId: this.accountId, sdp: JSON.stringify({ type: 'offer', sdp }), browserName: browser.name, browserVersion: browser.version })
|
||||
if (epoch !== this.epoch || pc !== this.pc) return
|
||||
const remote = JSON.parse(answer.remoteDescription as string) as RTCSessionDescriptionInit
|
||||
await pc.setRemoteDescription(remote)
|
||||
}
|
||||
|
||||
/** The media path is up and Steam has said the session is connected: join the chat. */
|
||||
private async tryJoin(epoch: number): Promise<void> {
|
||||
if (epoch !== this.epoch || this.joining || !this.iceUp || !this.session || !this.target) return
|
||||
this.joining = true
|
||||
const target = this.target
|
||||
try {
|
||||
if (target.kind === 'room') {
|
||||
const joined = await this.hooks.rpc({ op: 'joinRoom', accountId: this.accountId, groupId: target.groupId, roomId: target.roomId })
|
||||
if (epoch !== this.epoch) return
|
||||
this.voiceChatId = joined.voiceChatId ?? ''
|
||||
await this.connectData(epoch)
|
||||
} else if (this.voiceChatId) {
|
||||
// Somebody called, and has been answered.
|
||||
await this.connectData(epoch)
|
||||
} else {
|
||||
const asked = await this.hooks.rpc({ op: 'requestCall', accountId: this.accountId, steamid: target.steamid })
|
||||
if (epoch !== this.epoch) return
|
||||
this.voiceChatId = asked.voiceChatId ?? ''
|
||||
this.phase = 'ringing'
|
||||
if (this.deadline) clearTimeout(this.deadline)
|
||||
this.deadline = setTimeout(() => this.fail(`${target.name} did not answer`), RING_TIMEOUT_MS)
|
||||
this.hooks.onChange()
|
||||
if (this.accepted) await this.connectData(epoch)
|
||||
}
|
||||
} catch (e) {
|
||||
this.failed(epoch, e as Error, "Couldn't join voice")
|
||||
}
|
||||
}
|
||||
|
||||
/** Tells the chat which stream is ours, which is what makes this connection part of it. */
|
||||
private async connectData(epoch: number): Promise<void> {
|
||||
const s = this.session
|
||||
if (!s || !this.voiceChatId) return
|
||||
await this.hooks.rpc({ op: 'data', accountId: this.accountId, voiceChatId: this.voiceChatId, ssrc: s.ssrc, clientIp: s.clientIp, clientPort: s.clientPort, serverIp: s.serverIp, serverPort: s.serverPort, userAgent: navigator.userAgent, worklets: true, dataChannel: false })
|
||||
if (epoch !== this.epoch) return
|
||||
if (this.deadline) clearTimeout(this.deadline)
|
||||
this.deadline = null
|
||||
this.phase = 'connected'
|
||||
this.hooks.onChange()
|
||||
void this.sendStatus()
|
||||
}
|
||||
|
||||
/** What the server says: the session connecting, a new offer, people in the call, a call arriving or ending. */
|
||||
handle(event: SteamVoiceEvent): void {
|
||||
// Calls arriving and ending concern a window that is not in one too.
|
||||
if (event.kind !== 'callRequested' && event.kind !== 'ended' && event.accountId !== this.accountId) return
|
||||
const d = event.data
|
||||
switch (event.kind) {
|
||||
case 'sessionConnected':
|
||||
if (!this.active) return
|
||||
this.session = { ssrc: d.ssrc, clientIp: d.clientIp, clientPort: d.clientPort, serverIp: d.serverIp, serverPort: d.serverPort }
|
||||
void this.tryJoin(this.epoch)
|
||||
break
|
||||
case 'remoteDescription':
|
||||
if (!this.active || !this.pc) return
|
||||
if (this.updating) this.pending = d
|
||||
else void this.renegotiate(d)
|
||||
break
|
||||
case 'status': {
|
||||
if (d.voiceChatId !== this.voiceChatId) return
|
||||
this.statuses.set(d.steamid, { micMuted: !!d.micMuted, outputMuted: !!d.outputMuted, noMic: !!d.noMic })
|
||||
this.hooks.onChange()
|
||||
break
|
||||
}
|
||||
case 'allStatus':
|
||||
if (d.voiceChatId !== this.voiceChatId) return
|
||||
for (const u of d.users ?? []) this.statuses.set(u.steamid, { micMuted: !!u.micMuted, outputMuted: !!u.outputMuted, noMic: !!u.noMic })
|
||||
this.hooks.onChange()
|
||||
break
|
||||
case 'ended':
|
||||
if (this.active && d.voiceChatId === this.voiceChatId) {
|
||||
this.hooks.toast('info', this.target?.kind === 'friend' ? 'The call ended' : 'Voice ended')
|
||||
this.epoch++
|
||||
this.teardown()
|
||||
}
|
||||
this.rings = this.rings.filter((r) => r.voiceChatId !== d.voiceChatId)
|
||||
this.hooks.onChange()
|
||||
break
|
||||
case 'shouldRejoin':
|
||||
// Steam moved the room's voice to another server: start again where we were.
|
||||
if (this.target?.kind === 'room' && this.target.groupId === d.groupId && this.target.roomId === d.roomId) {
|
||||
const [account, target] = [this.accountId, this.target]
|
||||
this.epoch++
|
||||
this.teardown()
|
||||
setTimeout(() => void this.start(account, target), 250)
|
||||
}
|
||||
break
|
||||
case 'callRequested':
|
||||
this.ring(event.accountId, d.steamid, d.voiceChatId)
|
||||
break
|
||||
case 'callResponse':
|
||||
if (d.voiceChatId !== this.voiceChatId || this.target?.kind !== 'friend') return
|
||||
if (d.accepted) {
|
||||
this.accepted = true
|
||||
if (this.phase === 'ringing') void this.connectData(this.epoch).catch((e: Error) => this.failed(this.epoch, e, "Couldn't join the call"))
|
||||
} else {
|
||||
this.hooks.toast('info', `${this.target.name} declined`)
|
||||
void this.stop()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private ring(accountId: string, steamid: string, voiceChatId: string): void {
|
||||
// The Steam client ignores a call from somebody who is not a friend, or while it is invisible or busy.
|
||||
if (!this.hooks.mayRing(accountId, steamid)) return
|
||||
// Already talking to them: it is them calling back to be let in again.
|
||||
if (this.target?.kind === 'friend' && this.target.steamid === steamid && this.active) {
|
||||
void this.hooks.rpc({ op: 'answerCall', accountId, steamid, voiceChatId, accept: true }).catch(() => {})
|
||||
return
|
||||
}
|
||||
this.rings = [...this.rings.filter((r) => r.steamid !== steamid), { accountId, steamid, voiceChatId }]
|
||||
this.hooks.onChange()
|
||||
}
|
||||
|
||||
/** The ring from this person, if there is one: somebody calling who is then called back is answered instead. */
|
||||
ringFrom(accountId: string, steamid: string): SteamRing | undefined {
|
||||
return this.rings.find((r) => r.accountId === accountId && r.steamid === steamid)
|
||||
}
|
||||
|
||||
/** Says yes to a call, and joins it. */
|
||||
async answer(ring: SteamRing, bufferId: string, name: string): Promise<void> {
|
||||
this.rings = this.rings.filter((r) => r !== ring)
|
||||
this.hooks.onChange()
|
||||
try {
|
||||
await this.hooks.rpc({ op: 'answerCall', accountId: ring.accountId, steamid: ring.steamid, voiceChatId: ring.voiceChatId, accept: true })
|
||||
} catch (e) {
|
||||
this.hooks.toast('error', `Couldn't answer: ${plainFailure((e as Error).message)}`)
|
||||
return
|
||||
}
|
||||
await this.start(ring.accountId, { kind: 'friend', steamid: ring.steamid, bufferId, name, voiceChatId: ring.voiceChatId })
|
||||
}
|
||||
|
||||
async decline(ring: SteamRing): Promise<void> {
|
||||
this.rings = this.rings.filter((r) => r !== ring)
|
||||
this.hooks.onChange()
|
||||
await this.hooks.rpc({ op: 'answerCall', accountId: ring.accountId, steamid: ring.steamid, voiceChatId: ring.voiceChatId, accept: false }).catch(() => {})
|
||||
}
|
||||
|
||||
/** The server's new description of the session, which is an offer: take it and answer. */
|
||||
private async renegotiate(d: Record<string, any>): Promise<void> {
|
||||
const pc = this.pc
|
||||
if (!pc) return
|
||||
this.updating = true
|
||||
this.owners = new Map((d.ssrcs ?? []).map((m: { ssrc: number; steamid: string }) => [m.ssrc, m.steamid]))
|
||||
try {
|
||||
const remote = JSON.parse(d.sdp as string) as RTCSessionDescriptionInit
|
||||
await pc.setRemoteDescription(remote)
|
||||
if (remote.type === 'offer') {
|
||||
const answer = await pc.createAnswer()
|
||||
await pc.setLocalDescription(answer)
|
||||
const s = this.session
|
||||
// The Steam client sends this and does not look at what comes back, and Steam may well refuse one that
|
||||
// reaches it before the session has joined the chat. The offer is answered either way, so a refusal is
|
||||
// not the end of the call.
|
||||
await this.hooks
|
||||
.rpc({ op: 'acknowledge', accountId: this.accountId, version: d.version, serverIp: s?.serverIp, serverPort: s?.serverPort, clientIp: s?.clientIp, clientPort: s?.clientPort })
|
||||
.catch(() => {})
|
||||
}
|
||||
} catch (e) {
|
||||
this.fail(`Voice renegotiation failed: ${plainFailure((e as Error).message)}`)
|
||||
return
|
||||
}
|
||||
this.updating = false
|
||||
const next = this.pending
|
||||
this.pending = null
|
||||
if (next) await this.renegotiate(next)
|
||||
}
|
||||
|
||||
/** Plays what the server sends, one element per stream so each can be silenced with the rest. */
|
||||
private hear(track: MediaStreamTrack, stream: MediaStream | undefined): void {
|
||||
if (track.kind !== 'audio') return
|
||||
const owned = stream ?? new MediaStream([track])
|
||||
const el = this.sinks.get(owned.id) ?? document.createElement('audio')
|
||||
el.autoplay = true
|
||||
el.srcObject = owned
|
||||
el.muted = this.hooks.muted().deafened
|
||||
el.style.display = 'none'
|
||||
document.body.appendChild(el)
|
||||
void el.play().catch(() => {})
|
||||
this.sinks.set(owned.id, el)
|
||||
}
|
||||
|
||||
/** The microphone's level and who else is audible, often; the round trip, less so. */
|
||||
private startMeters(): void {
|
||||
if (this.mic?.getAudioTracks().length) {
|
||||
try {
|
||||
this.audio = new AudioContext()
|
||||
const source = this.audio.createMediaStreamSource(this.mic)
|
||||
this.analyser = this.audio.createAnalyser()
|
||||
this.analyser.fftSize = 512
|
||||
source.connect(this.analyser)
|
||||
} catch {
|
||||
this.analyser = null
|
||||
}
|
||||
}
|
||||
const buf = new Uint8Array(512)
|
||||
this.meters = setInterval(() => {
|
||||
let level = 0
|
||||
if (this.analyser) {
|
||||
this.analyser.getByteTimeDomainData(buf)
|
||||
let peak = 0
|
||||
for (const b of buf) peak = Math.max(peak, Math.abs(b - 128) / 128)
|
||||
// The mic reads on a log-ish scale to a person; speech sits well under full.
|
||||
level = this.mic?.getAudioTracks()[0]?.enabled ? Math.min(1, peak * 2) : 0
|
||||
}
|
||||
const sources: { source: number; audioLevel?: number }[] = []
|
||||
for (const r of this.pc?.getReceivers() ?? []) {
|
||||
for (const s of r.getSynchronizationSources?.() ?? []) sources.push(s)
|
||||
}
|
||||
const speakers = speakersOf(sources, this.owners)
|
||||
const same = speakers.size === this.speakers.size && [...speakers].every((s) => this.speakers.has(s))
|
||||
this.level = level
|
||||
if (!same) {
|
||||
this.speakers = speakers
|
||||
this.hooks.onChange()
|
||||
}
|
||||
}, 120)
|
||||
this.stats = setInterval(() => {
|
||||
void this.pc?.getStats().then((report) => {
|
||||
report.forEach((s) => {
|
||||
if (s.type === 'candidate-pair' && s.nominated && typeof s.currentRoundTripTime === 'number') this.rttMs = Math.round(s.currentRoundTripTime * 1000)
|
||||
})
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { autoCeiling, captionTrack, menuOrder, renditionLabel, renditionOf } from './streamplayer'
|
||||
|
||||
const ladder = [
|
||||
{ height: 160, frameRate: 30, bitrate: 230000 },
|
||||
{ height: 1080, frameRate: 60, bitrate: 8519450 },
|
||||
{ height: 720, frameRate: 60, bitrate: 3422999 },
|
||||
{ height: 480, frameRate: 30, bitrate: 1427999 },
|
||||
{ height: 360, frameRate: 30, bitrate: 630000 }
|
||||
]
|
||||
|
||||
describe('the renditions a stream offers', () => {
|
||||
it('are named as Kick names them: the height, and the frame rate where it is above thirty', () => {
|
||||
expect(renditionLabel({ height: 720, frameRate: 60 })).toBe('720p60')
|
||||
expect(renditionLabel({ height: 480, frameRate: 30 })).toBe('480p')
|
||||
expect(renditionLabel({ height: 360 })).toBe('360p')
|
||||
expect(renditionLabel({ height: 1080, frameRate: 59.94 })).toBe('1080p60')
|
||||
})
|
||||
|
||||
it('are listed tallest first, with where each is in the player\'s own list, and one to a height', () => {
|
||||
expect(menuOrder(ladder).map((r) => [r.label, r.index])).toEqual([['1080p60', 1], ['720p60', 2], ['480p', 3], ['360p', 4], ['160p', 0]])
|
||||
expect(menuOrder([...ladder, { height: 720, frameRate: 30, bitrate: 1 }]).filter((r) => r.height === 720)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('are found by the height somebody wants, or by nothing when it is not offered', () => {
|
||||
expect(renditionOf(ladder, 480)).toBe(3)
|
||||
expect(renditionOf(ladder, 900)).toBe(-1)
|
||||
})
|
||||
|
||||
it('have a ceiling for automatic choice: the tallest that fits, the shortest when none does, none when there is no ceiling', () => {
|
||||
expect(autoCeiling(ladder, 720)).toBe(2)
|
||||
expect(autoCeiling(ladder, 1440)).toBe(1)
|
||||
expect(autoCeiling(ladder, 100)).toBe(0)
|
||||
expect(autoCeiling(ladder, 0)).toBe(-1)
|
||||
expect(autoCeiling([], 720)).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the captions a stream offers', () => {
|
||||
const tracks = [{ id: 0, name: 'English', lang: 'en' }, { id: 1, name: 'Portuguese', lang: 'pt' }, { id: 2, name: 'Spanish', lang: 'es' }]
|
||||
|
||||
it('are off when nothing is wanted or there is nothing to show', () => {
|
||||
expect(captionTrack(tracks, '')).toBe(-1)
|
||||
expect(captionTrack([], 'en')).toBe(-1)
|
||||
})
|
||||
|
||||
it('are the language that was chosen, however it was written', () => {
|
||||
expect(captionTrack(tracks, 'pt')).toBe(1)
|
||||
expect(captionTrack(tracks, 'PT-BR')).toBe(1)
|
||||
})
|
||||
|
||||
it('fall back to the person\'s own language, and then to the first, when the one chosen is not offered', () => {
|
||||
expect(captionTrack(tracks, 'ja', 'es')).toBe(2)
|
||||
expect(captionTrack(tracks, 'ja', 'xx')).toBe(0)
|
||||
expect(captionTrack(tracks, 'ja')).toBe(0)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user