Compare commits
@@ -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,7 +1,7 @@
|
||||
pkgname=moho-git
|
||||
pkgver=r1.268751e
|
||||
pkgrel=1
|
||||
pkgdesc="Unified desktop chat client for IRC, Discord, Sneedchat and Matrix"
|
||||
pkgdesc="Unified desktop chat client for IRC, Discord, Matrix, Kick, Sneedchat and Steam"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/Moho-Chat/moho"
|
||||
license=('GPL3')
|
||||
|
||||
@@ -1,48 +1,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...289b5db54a
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,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,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,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()
|
||||
}
|
||||
+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 />
|
||||
|
||||
+206
-1
@@ -2546,6 +2546,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 +2725,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 +5635,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 +6922,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 +7730,35 @@ video.lightbox-media {
|
||||
|
||||
/* Label and value on one line, labels aligned, so the card is read down its
|
||||
left edge rather than hunted through. */
|
||||
.profile-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xxs);
|
||||
padding: var(--spacing-s);
|
||||
margin-bottom: var(--spacing-s);
|
||||
border-radius: var(--corner-radius-small);
|
||||
background: var(--surface-container-high);
|
||||
}
|
||||
|
||||
.profile-section-title {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: var(--font-size-xs, 11px);
|
||||
}
|
||||
|
||||
.profile-status-presence {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.profile-status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.profile-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -9366,6 +9458,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 +10851,65 @@ 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;
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -1,8 +1,9 @@
|
||||
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 { IgnoreList } from './IgnoreList'
|
||||
import { TunnelAllSwitch } from './settings/Tunnel'
|
||||
import { useChat, useConnectionDetail, useMapPref, usePref, useStore } from '../state/hooks'
|
||||
@@ -23,7 +24,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'] as const
|
||||
type AddableService = (typeof FORMS)[number]
|
||||
|
||||
/** What `listProtocols` answers with. */
|
||||
@@ -134,6 +135,7 @@ export function AccountsPanel(): JSX.Element {
|
||||
{adding === 'sneedchat' && <SneedChatForm />}
|
||||
{adding === 'matrix' && <MatrixForm />}
|
||||
{adding === 'kick' && <KickForm onDone={() => setAdding(null)} />}
|
||||
{adding === 'steam' && <SteamForm onDone={() => setAdding(null)} />}
|
||||
{adding && (
|
||||
<button type="button" className="button subtle small" onClick={() => setAdding(null)}>
|
||||
Cancel
|
||||
@@ -456,6 +458,8 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
|
||||
|
||||
{account.service === 'matrix' && <MatrixAccountTools account={account} />}
|
||||
|
||||
{account.service === 'steam' && <SteamAccountTools account={account} />}
|
||||
|
||||
{account.service === 'sneedchat' && (
|
||||
<>
|
||||
<RouteLevel account={account} onChange={(level) => call('setAccountRoute', { accountId: account.id, level })} />
|
||||
@@ -1192,6 +1196,282 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
@@ -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,
|
||||
@@ -84,11 +76,13 @@ export function JoinPanel(): JSX.Element {
|
||||
return <KickJoin 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'].includes(a.service))
|
||||
|
||||
return (
|
||||
<div className="join-frame">
|
||||
@@ -367,6 +361,274 @@ function KickJoin({ account }: { account: Account }): JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 +727,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 +933,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 +994,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 +1004,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>
|
||||
))}
|
||||
|
||||
@@ -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,6 +7,7 @@ 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'
|
||||
@@ -778,6 +779,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,6 +55,7 @@ import {
|
||||
hasDirectMessages,
|
||||
isChatKind,
|
||||
isNotice,
|
||||
isWarning,
|
||||
isReward,
|
||||
isWhisper,
|
||||
nickColor
|
||||
@@ -384,7 +389,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 +473,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 +570,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 +661,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,6 +812,7 @@ function MessageRowBody({
|
||||
message.isHighlight && 'highlight',
|
||||
whispered && 'whisper',
|
||||
notice && highlightNotices && 'notice',
|
||||
isWarning(message.kind, message.id) && 'warning',
|
||||
message.pending && 'pending',
|
||||
message.failed && 'failed',
|
||||
isSystem && 'system',
|
||||
@@ -1368,7 +1395,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 +1421,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 +1461,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 +1609,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
|
||||
@@ -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)
|
||||
@@ -174,8 +179,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)
|
||||
@@ -263,6 +288,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 +379,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 +484,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 +537,7 @@ interface MemberRowProps {
|
||||
|
||||
function MemberRow({
|
||||
member,
|
||||
comfy,
|
||||
blockKey,
|
||||
blocked,
|
||||
canOpenDm,
|
||||
@@ -638,24 +689,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 +708,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() }
|
||||
}
|
||||
@@ -62,7 +62,7 @@ export function UserFooter({ account }: { account?: Account }): JSX.Element {
|
||||
// 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'
|
||||
|
||||
@@ -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,30 @@ export function kickEmoteUrl(id: string): string {
|
||||
return resolveMediaUrl(drawableEmoteUrl(`https://files.kick.com/emotes/${id}/fullsize`))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +135,70 @@ 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 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 +216,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 +488,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 +556,17 @@ export function formatMessage(text: string, opts: FormatOptions = {}): string {
|
||||
stow(`<img src="${kickEmoteUrl(id)}" class="custom-emoji" alt=":${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 +649,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 +700,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 +817,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 +832,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 +849,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 +883,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 +906,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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import discordSvg from '../assets/discord.svg'
|
||||
import sneedchatMark from '../assets/sneedchat.png'
|
||||
import matrixSvg from '../assets/matrix-mark.svg'
|
||||
import kickSvg from '../assets/kick.svg'
|
||||
import steamSvg from '../assets/steam-mark.svg'
|
||||
|
||||
/**
|
||||
* Discord channel buffers are named "GuildName/#channel" internally - that's
|
||||
@@ -69,6 +70,8 @@ export function serviceIcon(service: string): ServiceIcon {
|
||||
return { mark: discordSvg }
|
||||
case 'matrix':
|
||||
return { mark: matrixSvg }
|
||||
case 'steam':
|
||||
return { mark: steamSvg }
|
||||
case 'irc':
|
||||
return { glyph: 'tag' }
|
||||
case 'slack':
|
||||
@@ -100,6 +103,8 @@ export function serviceLabel(service: string): string {
|
||||
return 'Sneedchat'
|
||||
case 'kick':
|
||||
return 'Kick'
|
||||
case 'steam':
|
||||
return 'Steam'
|
||||
case 'slack':
|
||||
return 'Slack'
|
||||
case 'jabber':
|
||||
@@ -137,6 +142,16 @@ export function isNotice(kind: string | undefined): boolean {
|
||||
return kind === 'notice'
|
||||
}
|
||||
|
||||
/**
|
||||
* A line the service wrote to warn about the conversation itself - Steam's
|
||||
* notice that somebody may not be who they appear to be - drawn in red so it is
|
||||
* not scrolled past. Its own kind. A warning stored before it had one is a
|
||||
* system line with the id Steam's notices are kept under.
|
||||
*/
|
||||
export function isWarning(kind: string | undefined, id?: string): boolean {
|
||||
return kind === 'warning' || (kind === 'system' && !!id?.startsWith('notice:'))
|
||||
}
|
||||
|
||||
/**
|
||||
* A message sent privately to you rather than to the room it arrived through.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
||||
import { store, type ChatState } from './store'
|
||||
import { listInvitations, type Invitation } from '../lib/invitations'
|
||||
|
||||
/**
|
||||
* Slice subscription. The selector must return something referentially stable
|
||||
@@ -17,6 +18,29 @@ export function useStore(): typeof store {
|
||||
return store
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the log is drawn in the comfy family (comfy, and bubbles with it) as
|
||||
* against classic. The lists beside it follow: comfy has faces and a line of
|
||||
* what somebody is doing, classic is names, as on IRC.
|
||||
*/
|
||||
export function useComfy(): boolean {
|
||||
const [mode] = usePref<'classic' | 'comfy' | 'bubbles'>('display.messageMode', 'comfy')
|
||||
return mode !== 'classic'
|
||||
}
|
||||
|
||||
/** Everything waiting on a yes or a no, from every service. */
|
||||
export function useInvitations(): Invitation[] {
|
||||
const rooms = useChat((s) => s.matrixInvites)
|
||||
const friends = useChat((s) => s.friendRequests)
|
||||
const discordFriends = useChat((s) => s.discordFriends)
|
||||
const verification = useChat((s) => s.matrixVerification)
|
||||
const accounts = useChat((s) => s.accounts)
|
||||
return useMemo(
|
||||
() => listInvitations({ rooms, friends, discordFriends, verification }, accounts),
|
||||
[rooms, friends, discordFriends, verification, accounts]
|
||||
)
|
||||
}
|
||||
|
||||
const identity = (s: ChatState): ChatState => s
|
||||
|
||||
export function useChatState(): ChatState {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ChatStore } from './store'
|
||||
|
||||
/** The store, with a conversation already in its list, and the daemon's update for it applied. */
|
||||
@@ -29,3 +29,30 @@ describe('an update to a conversation the window already has', () => {
|
||||
expect([b.unread, b.mentions, b.highlight]).toEqual([4, 2, true])
|
||||
})
|
||||
})
|
||||
|
||||
describe('scrolling to the top of a conversation', () => {
|
||||
function withHistory(rows: unknown[][]): { store: ChatStore; rpc: ReturnType<typeof vi.fn> } {
|
||||
const rpc = vi.fn()
|
||||
for (const answer of rows) rpc.mockResolvedValueOnce(answer)
|
||||
;(globalThis as unknown as { window: unknown }).window = { moho: { rpc } }
|
||||
const store = new ChatStore() as unknown as { state: Record<string, unknown> }
|
||||
store.state = { ...store.state, messagesByBuffer: { b: [{ id: 'm1', ts: 100, bufferId: 'b', body: 'hello' }] }, loadingMore: {} }
|
||||
return { store: store as unknown as ChatStore, rpc }
|
||||
}
|
||||
|
||||
it('asks for what is older once and, told there is none, does not ask again at the next scroll', async () => {
|
||||
const { store, rpc } = withHistory([[], []])
|
||||
await store.loadMoreHistory('b')
|
||||
await store.loadMoreHistory('b')
|
||||
await store.loadMoreHistory('b')
|
||||
expect(rpc).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('asks again once older messages have arrived, since what is oldest has changed', async () => {
|
||||
const { store, rpc } = withHistory([[{ id: 'm0', ts: 50, bufferId: 'b', body: 'before' }], []])
|
||||
await store.loadMoreHistory('b')
|
||||
await store.loadMoreHistory('b')
|
||||
await store.loadMoreHistory('b')
|
||||
expect(rpc).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
+499
-18
@@ -8,6 +8,7 @@ import type {
|
||||
DccTransfer,
|
||||
DiscordFriend,
|
||||
MatrixInvite,
|
||||
FriendRequest,
|
||||
MatrixVerification,
|
||||
Member,
|
||||
Message,
|
||||
@@ -23,11 +24,13 @@ import type {
|
||||
} from '../../../shared/wire'
|
||||
import { addToast, type ToastAction, type ToastItem } from '../lib/toasts'
|
||||
import { ALL_STATUSES, statusesFor, supportsStatus } from '../lib/status'
|
||||
import { buildSmilieIndex, type SmilieEntry, type SmilieIndex } from '../lib/format'
|
||||
import type { SteamGroup, GroupCall } from '../lib/steamgroups'
|
||||
import { buildSmilieIndex, steamSameText, type SmilieEntry, type SmilieIndex } from '../lib/format'
|
||||
import { bufferDisplayName, isChatKind, isImageFile, resolveMediaUrl } from '../lib/util'
|
||||
import { runExport } from '../lib/exporter'
|
||||
import type { ForumPost } from '../lib/forum'
|
||||
import { DM_GROUP_ID, isDirectMessage } from '../lib/groups'
|
||||
import { DM_GROUP_ID, INVITATIONS_GROUP_ID, isDirectMessage } from '../lib/groups'
|
||||
import { listInvitations, type Invitation } from '../lib/invitations'
|
||||
import { ircNetworkFor } from '../lib/networks'
|
||||
import { parseDeepLink, type IrcLink } from '../../../shared/deeplink'
|
||||
import { MatrixCalls, type CallMember, type MatrixCallEvent, type RingingCall } from '../lib/matrixcall'
|
||||
@@ -38,6 +41,8 @@ import { cameraKey, closeFeeds, feedFrame, streamKey as feedStreamKey } from '..
|
||||
import { DEFAULT_QUALITY, encodeSettings, sendCamera, shareScreen, type VideoQuality } from '../lib/discordscreen'
|
||||
import type { PopoutState } from '../../../shared/ipc'
|
||||
import { isNewerVersion } from '../lib/version'
|
||||
import { steamRoomIds, type SteamChatGroup } from '../lib/steamchat'
|
||||
import { SteamVoice, type SteamRing, type SteamVoicePhase, type SteamVoiceTarget } from '../lib/steamvoice'
|
||||
|
||||
/**
|
||||
* The whole client-side model, held in one immutable object that is replaced
|
||||
@@ -373,6 +378,14 @@ export interface PeekTarget {
|
||||
joining?: boolean
|
||||
}
|
||||
|
||||
/** A Steam group's room that has voice, and who is in it. */
|
||||
export interface SteamVoiceRoom {
|
||||
roomId: string
|
||||
bufferId?: string | null
|
||||
name: string
|
||||
members: { userId: string; nick: string; avatarUrl?: string | null; isSelf?: boolean }[]
|
||||
}
|
||||
|
||||
export interface ChatState {
|
||||
/** Who is composing, per buffer, with when to stop believing it. */
|
||||
typingByBuffer: Record<string, { nicks: string[]; until: number }>
|
||||
@@ -438,6 +451,14 @@ export interface ChatState {
|
||||
voiceSessions: VoiceSession[]
|
||||
/** Conversations ringing right now, newest last. */
|
||||
incomingCalls: IncomingCall[]
|
||||
/** The voice rooms of each Steam group and who is in each, keyed by `account|group`. */
|
||||
steamVoiceRooms: Record<string, SteamVoiceRoom[]>
|
||||
/** Steam calls waiting to be answered. */
|
||||
steamRings: SteamRing[]
|
||||
/** The Steam voice this window holds, if any: where, and how far it has got. */
|
||||
steamVoiceNow: { accountId: string; target: SteamVoiceTarget; phase: SteamVoicePhase } | null
|
||||
/** A group somebody has asked to leave, waiting on the confirmation. */
|
||||
leaveRequest: { id: string; name: string; kind: string } | null
|
||||
/**
|
||||
* A link waiting for an account to be made for it.
|
||||
*
|
||||
@@ -676,6 +697,12 @@ export interface ChatState {
|
||||
matrixWidgets: Record<string, unknown[]>
|
||||
/** Rooms each Matrix account has been invited to and not answered. */
|
||||
matrixInvites: Record<string, MatrixInvite[]>
|
||||
/** Who has asked each account to be their friend, by account. */
|
||||
friendRequests: Record<string, FriendRequest[]>
|
||||
/** Which invitation the invitations page is showing. */
|
||||
activeInvitationKey: string
|
||||
/** A question asked about a Steam friend, waiting on an answer in a dialog. */
|
||||
steamDialog: { what: 'nickname' | 'remove' | 'block'; bufferId: string } | null
|
||||
/**
|
||||
* The profile being shown, or null.
|
||||
*
|
||||
@@ -684,6 +711,8 @@ export interface ChatState {
|
||||
* whatever draws it cannot be the thing that asked.
|
||||
*/
|
||||
profile: Profile | null
|
||||
/** Each Steam account's friend groups as Steam last said them; absent until it has. */
|
||||
steamGroups: Record<string, SteamGroup[]>
|
||||
replyingTo: { id: string; from: string; body: string } | null
|
||||
/** Whether answering pings the author (Discord's "@ ON"): on for every new reply. */
|
||||
replyPing: boolean
|
||||
@@ -708,6 +737,17 @@ export interface ChatState {
|
||||
matrixLoginStatus: string
|
||||
}
|
||||
|
||||
/** A person as the daemon reports one in a Steam friend request. */
|
||||
interface SteamPerson {
|
||||
steamid: string
|
||||
name: string | null
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
function personToRequest(p: SteamPerson): FriendRequest {
|
||||
return { id: p.steamid, name: p.name, avatarUrl: p.avatarUrl }
|
||||
}
|
||||
|
||||
export type Toast = ToastItem
|
||||
|
||||
const INITIAL: ChatState = {
|
||||
@@ -726,6 +766,10 @@ const INITIAL: ChatState = {
|
||||
voiceChannels: [],
|
||||
voiceGuildId: '',
|
||||
voiceSessions: [],
|
||||
steamVoiceRooms: {},
|
||||
steamRings: [],
|
||||
steamVoiceNow: null,
|
||||
leaveRequest: null,
|
||||
incomingCalls: [],
|
||||
transfers: [],
|
||||
minimisedTransfers: [],
|
||||
@@ -783,7 +827,11 @@ const INITIAL: ChatState = {
|
||||
ignoredByAccount: {},
|
||||
matrixWidgets: {},
|
||||
matrixInvites: {},
|
||||
friendRequests: {},
|
||||
activeInvitationKey: '',
|
||||
steamDialog: null,
|
||||
profile: null,
|
||||
steamGroups: {},
|
||||
replyingTo: null,
|
||||
replyPing: true,
|
||||
editingId: '',
|
||||
@@ -826,6 +874,7 @@ const INITIAL: ChatState = {
|
||||
*/
|
||||
function echoMatches(echo: ChatMessage, real: Message): boolean {
|
||||
if (echo.pendingBody === real.body) return true
|
||||
if (real.bufferId.startsWith('steam:') && echo.pendingBody !== undefined && steamSameText(echo.pendingBody, real.body)) return true
|
||||
// A file is posted as markup the daemon builds - a BBCode image tag on
|
||||
// Sneedchat, a bare link on IRC - so what comes back is never what was
|
||||
// typed, and usually nothing was typed at all. Matching on the whole body
|
||||
@@ -1003,6 +1052,8 @@ const MAX_MESSAGES_IDLE = 200
|
||||
/** As many mentions as the inbox will hold; the daemon's own limit matches. */
|
||||
const MAX_MENTIONS = 100
|
||||
const BACKLOG_PAGE = 200
|
||||
/** How long "nothing older" is believed before the top is allowed to ask again. */
|
||||
const HISTORY_END_MS = 10 * 60 * 1000
|
||||
const SEND_TIMEOUT_MS = 10000
|
||||
|
||||
export class ChatStore {
|
||||
@@ -1414,6 +1465,7 @@ export class ChatStore {
|
||||
try {
|
||||
this.set({ accounts: await window.moho.rpc<Account[]>('listAccounts') })
|
||||
void this.refreshInvites()
|
||||
void this.refreshFriendRequests()
|
||||
} catch (e) {
|
||||
this.toast('error', `Couldn't list accounts: ${(e as Error).message}`)
|
||||
}
|
||||
@@ -1428,9 +1480,10 @@ export class ChatStore {
|
||||
* the join panel.
|
||||
*/
|
||||
async refreshInvites(): Promise<void> {
|
||||
for (const account of this.state.accounts.filter((a) => a.service === 'matrix')) {
|
||||
for (const account of this.state.accounts.filter((a) => a.service === 'matrix' || a.service === 'steam')) {
|
||||
try {
|
||||
const invites = await window.moho.rpc<MatrixInvite[]>('listMatrixInvites', {
|
||||
// A Steam account's are the groups its friends have invited it to: the same shape, a room.
|
||||
const invites = await window.moho.rpc<MatrixInvite[]>(account.service === 'steam' ? 'steamGroupInvites' : 'listMatrixInvites', {
|
||||
accountId: account.id
|
||||
})
|
||||
this.set({ matrixInvites: { ...this.state.matrixInvites, [account.id]: invites } })
|
||||
@@ -1440,13 +1493,208 @@ export class ChatStore {
|
||||
}
|
||||
}
|
||||
|
||||
/** Accepts an invitation, or turns it down. */
|
||||
answerMatrixInvite(accountId: string, roomId: string, accept: boolean): void {
|
||||
void window.moho
|
||||
.rpc(accept ? 'acceptMatrixInvite' : 'declineMatrixInvite', { accountId, roomId })
|
||||
// Not removed here: the next sync stops listing it, which is what
|
||||
// actually confirms the server agreed.
|
||||
.catch((e: Error) => this.toast('error', e.message))
|
||||
/**
|
||||
* Who has asked each account to be their friend. Steam is the service with
|
||||
* requests today; the state is every service's so the next one only has to
|
||||
* say who asked.
|
||||
*/
|
||||
async refreshFriendRequests(): Promise<void> {
|
||||
const next: Record<string, FriendRequest[]> = {}
|
||||
for (const account of this.state.accounts.filter((a) => a.service === 'steam')) {
|
||||
try {
|
||||
const r = await window.moho.rpc<{ incoming: SteamPerson[] }>('steamFriendRequests', { accountId: account.id })
|
||||
next[account.id] = r.incoming.map(personToRequest)
|
||||
} catch {
|
||||
// Not connected yet, or an older daemon: nothing to answer.
|
||||
}
|
||||
}
|
||||
this.set({ friendRequests: next })
|
||||
// Discord keeps its requests among its friends; the daemon pushes changes,
|
||||
// and this is the first reading, so one that arrived while moho was
|
||||
// closed is waiting on the Invitations tile when it opens.
|
||||
for (const account of this.state.accounts.filter((a) => a.service === 'discord')) {
|
||||
try {
|
||||
const list = await window.moho.rpc<DiscordFriend[]>('listDiscordFriends', { accountId: account.id })
|
||||
this.set({ discordFriends: { ...this.state.discordFriends, [account.id]: list } })
|
||||
} catch {
|
||||
// Not connected yet: the gateway's own push fills it in.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything waiting on a yes or a no, as the invitations tile lists it. */
|
||||
pendingInvitations(): Invitation[] {
|
||||
const s = this.state
|
||||
return listInvitations(
|
||||
{ rooms: s.matrixInvites, friends: s.friendRequests, discordFriends: s.discordFriends, verification: s.matrixVerification },
|
||||
s.accounts
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers an invitation, from any service.
|
||||
*
|
||||
* It leaves the list the moment the service has agreed, rather than waiting
|
||||
* for the next sync or friends list to stop mentioning it: a request that
|
||||
* stays after being answered looks like it did not work.
|
||||
*
|
||||
* Accepting then opens what was accepted. A friend request has become a
|
||||
* conversation with that person, and a room invitation the room, so the pane
|
||||
* that was offering it turns into that conversation. It is not there for a
|
||||
* moment - the service has to tell moho about it - so this waits for it.
|
||||
*/
|
||||
async answerInvitation(invitation: Invitation, accept: boolean): Promise<void> {
|
||||
const { accountId, id } = invitation
|
||||
let steamGroupJoined = ''
|
||||
try {
|
||||
if (invitation.kind === 'verification') {
|
||||
await window.moho.rpc(accept ? 'respondMatrixVerification' : 'cancelMatrixVerification', {
|
||||
accountId,
|
||||
verificationId: id,
|
||||
...(accept ? { accept: true } : {})
|
||||
})
|
||||
} else if (invitation.kind === 'friend') {
|
||||
if (invitation.service === 'discord') {
|
||||
await window.moho.rpc('answerDiscordFriendRequest', { accountId, userId: id, accept })
|
||||
} else {
|
||||
await window.moho.rpc('steamAnswerFriendRequest', { accountId, steamid: id, accept })
|
||||
}
|
||||
} else if (invitation.service === 'steam') {
|
||||
const r = await window.moho.rpc<{ groupId?: string }>('steamChatGroupAction', { accountId, groupId: id, action: accept ? 'acceptInvite' : 'dismissInvite' })
|
||||
if (accept && r.groupId) steamGroupJoined = r.groupId
|
||||
} else {
|
||||
await window.moho.rpc(accept ? 'acceptMatrixInvite' : 'declineMatrixInvite', { accountId, roomId: id })
|
||||
}
|
||||
} catch (e) {
|
||||
this.toast('error', (e as Error).message)
|
||||
return
|
||||
}
|
||||
this.set(this.withoutInvitation(invitation))
|
||||
if (!accept && invitation.kind === 'verification') this.set({ matrixVerification: null })
|
||||
// A Steam group is opened at its first room, which is not found by who it is with.
|
||||
if (steamGroupJoined && (await this.openSteamGroup(steamGroupJoined))) return
|
||||
// A verification continues in its own dialog once it is accepted, and that
|
||||
// is the whole of what accepting opens.
|
||||
if (accept && invitation.kind !== 'verification' && !steamGroupJoined && (await this.openAccepted(invitation))) return
|
||||
// Nothing to open: stay on what is still waiting, or leave the page.
|
||||
if (this.pendingInvitations().length === 0 && this.state.activeGroupId === INVITATIONS_GROUP_ID) {
|
||||
this.selectGroup(`account:${accountId}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** The state with one invitation taken out of every place it is kept. */
|
||||
private withoutInvitation(invitation: Invitation): Partial<ChatState> {
|
||||
const { accountId, id } = invitation
|
||||
const v = this.state.matrixVerification
|
||||
return {
|
||||
matrixInvites: {
|
||||
...this.state.matrixInvites,
|
||||
[accountId]: (this.state.matrixInvites[accountId] ?? []).filter((i) => !(invitation.kind === 'room' && i.roomId === id))
|
||||
},
|
||||
friendRequests: {
|
||||
...this.state.friendRequests,
|
||||
[accountId]: (this.state.friendRequests[accountId] ?? []).filter((r) => !(invitation.kind === 'friend' && r.id === id))
|
||||
},
|
||||
discordFriends: {
|
||||
...this.state.discordFriends,
|
||||
[accountId]: (this.state.discordFriends[accountId] ?? []).filter(
|
||||
(f) => !(invitation.kind === 'friend' && f.userId === id && f.kind === 'incoming')
|
||||
)
|
||||
},
|
||||
// Accepted, the check carries on in its dialog; declined, it is over.
|
||||
matrixVerification: invitation.kind === 'verification' && v?.verificationId === id ? (v.state === 'requested' ? { ...v, incoming: false } : v) : v
|
||||
}
|
||||
}
|
||||
|
||||
/** Waits for a Steam group just joined to have its rooms, and opens the first. Whether it did. */
|
||||
private async openSteamGroup(railId: string): Promise<boolean> {
|
||||
for (let waited = 0; waited < 8000; waited += 200) {
|
||||
const room = this.state.buffers.find((b) => b.groupId === railId && b.kind === 'channel')
|
||||
if (room) {
|
||||
this.selectGroup(railId)
|
||||
await this.selectBuffer(room.id)
|
||||
return true
|
||||
}
|
||||
if (waited % 1000 === 0) await this.refreshBuffers()
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Waits for what an accepted invitation became, and opens it. Whether it did. */
|
||||
private async openAccepted(invitation: Invitation): Promise<boolean> {
|
||||
// Discord's friends are not conversations until one is opened: asking for
|
||||
// it is what makes the direct message exist, so it does not have to be
|
||||
// waited for.
|
||||
if (invitation.service === 'discord' && invitation.kind === 'friend') {
|
||||
try {
|
||||
const r = await window.moho.rpc<{ bufferId: string }>('openDiscordDm', { accountId: invitation.accountId, userId: invitation.id })
|
||||
await this.refreshBuffers()
|
||||
await this.selectBuffer(r.bufferId)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const found = (): BufferEntry | undefined =>
|
||||
this.state.buffers.find(
|
||||
(b) => b.accountId === invitation.accountId && b.remoteId === invitation.id && (invitation.kind === 'room' || b.kind === 'dm')
|
||||
)
|
||||
for (let waited = 0; waited < 8000; waited += 200) {
|
||||
const buffer = found()
|
||||
if (buffer) {
|
||||
await this.selectBuffer(buffer.id)
|
||||
return true
|
||||
}
|
||||
if (waited % 1000 === 0) await this.refreshBuffers()
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Asks what to do about a Steam friend, in a dialog. */
|
||||
askSteamFriend(what: 'nickname' | 'remove' | 'block', bufferId: string): void {
|
||||
this.set({ steamDialog: { what, bufferId } })
|
||||
}
|
||||
|
||||
closeSteamDialog(): void {
|
||||
this.set({ steamDialog: null })
|
||||
}
|
||||
|
||||
/** Does it: what to call them, ending the friendship, or blocking them. */
|
||||
async doSteamFriend(what: 'nickname' | 'remove' | 'block', bufferId: string, nickname = ''): Promise<void> {
|
||||
const buffer = this.state.buffers.find((b) => b.id === bufferId)
|
||||
this.set({ steamDialog: null })
|
||||
if (!buffer) return
|
||||
try {
|
||||
if (what === 'nickname') {
|
||||
await window.moho.rpc('steamSetNickname', { bufferId, nickname })
|
||||
} else if (what === 'remove') {
|
||||
await window.moho.rpc('steamRemoveFriend', { accountId: buffer.accountId, steamid: buffer.remoteId })
|
||||
this.toast('info', `Removed ${buffer.name}`)
|
||||
} else {
|
||||
await window.moho.rpc('steamBlockFriend', { bufferId, block: true })
|
||||
this.toast('info', `Blocked ${buffer.name}`)
|
||||
}
|
||||
} catch (e) {
|
||||
this.toast('error', (e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismisses the warning over a conversation: it goes now, and the service is told. */
|
||||
async dismissNotice(bufferId: string): Promise<void> {
|
||||
this.set({ buffers: this.state.buffers.map((b) => (b.id === bufferId ? { ...b, notice: undefined } : b)) })
|
||||
try {
|
||||
await window.moho.rpc('steamDismissNotice', { bufferId })
|
||||
} catch (e) {
|
||||
this.toast('error', (e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
/** Looks at one invitation, on the page that gathers them. */
|
||||
selectInvitation(key: string): void {
|
||||
this.set({ activeInvitationKey: key })
|
||||
this.selectGroup(INVITATIONS_GROUP_ID)
|
||||
}
|
||||
|
||||
private groupsTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -1552,6 +1800,8 @@ export class ChatStore {
|
||||
// Applied optimistically: a mute button that waits for a round trip feels
|
||||
// broken, and the daemon's reply corrects it either way.
|
||||
this.set({ voicePrefs: { ...this.state.voicePrefs, ...next } })
|
||||
// And onto the Steam voice this window holds, which obeys the same buttons.
|
||||
this.steamVoice.applyMute()
|
||||
try {
|
||||
this.set({ voicePrefs: await window.moho.rpc<VoicePrefs>('setVoiceMuted', next) })
|
||||
} catch (e) {
|
||||
@@ -1689,10 +1939,94 @@ export class ChatStore {
|
||||
}
|
||||
|
||||
async refreshVoiceSessions(): Promise<void> {
|
||||
// Steam's voice is held in this window and not by the daemon, so it is
|
||||
// added to what the daemon says rather than lost when that is asked for.
|
||||
const steam = this.steamVoiceSession()
|
||||
try {
|
||||
this.set({ voiceSessions: await window.moho.rpc<VoiceSession[]>('getVoiceSession') })
|
||||
this.set({ voiceSessions: [...(await window.moho.rpc<VoiceSession[]>('getVoiceSession')), ...steam] })
|
||||
} catch {
|
||||
this.set({ voiceSessions: [] })
|
||||
this.set({ voiceSessions: steam })
|
||||
}
|
||||
}
|
||||
|
||||
/** The Steam voice of this window, as the one kind of session every voice surface lists. */
|
||||
private steamVoiceSession(): VoiceSession[] {
|
||||
const v = this.steamVoice
|
||||
const t = v.target
|
||||
if (!v.active || !t) return []
|
||||
return t.kind === 'room'
|
||||
? [{ accountId: v.accountId, channelId: t.roomId, channelName: t.name, isDirect: false, bufferId: t.bufferId, railId: t.railId }]
|
||||
: [{ accountId: v.accountId, channelId: t.steamid, channelName: t.name, isDirect: true, bufferId: t.bufferId }]
|
||||
}
|
||||
|
||||
/** What Steam's voice is doing changed: show it. */
|
||||
private syncSteamVoice(): void {
|
||||
const v = this.steamVoice
|
||||
const others = this.state.voiceSessions.filter((s) => !s.accountId.startsWith('steam:'))
|
||||
this.set({
|
||||
voiceSessions: [...others, ...this.steamVoiceSession()],
|
||||
steamRings: [...v.rings],
|
||||
steamVoiceNow: v.active && v.target ? { accountId: v.accountId, target: v.target, phase: v.phase } : null
|
||||
})
|
||||
}
|
||||
|
||||
/** The window's own Steam voice, for what changes too fast to keep in state: the level and who is speaking. */
|
||||
get steamVoiceLive(): SteamVoice {
|
||||
return this.steamVoice
|
||||
}
|
||||
|
||||
private steamVoice: SteamVoice = new SteamVoice({
|
||||
rpc: (params) => window.moho.rpc('steamVoice', params),
|
||||
onChange: () => this.syncSteamVoice(),
|
||||
toast: (kind, message) => this.toast(kind, message),
|
||||
muted: () => ({ micMuted: this.state.voicePrefs.micMuted, deafened: this.state.voicePrefs.deafened }),
|
||||
nameOf: (accountId, steamid) => this.state.buffers.find((b) => b.accountId === accountId && b.remoteId === steamid)?.name ?? steamid,
|
||||
mayRing: (accountId, steamid) => {
|
||||
const account = this.state.accounts.find((a) => a.id === accountId)
|
||||
const friend = this.state.buffers.some((b) => b.accountId === accountId && b.kind === 'dm' && b.remoteId === steamid)
|
||||
return friend && account?.status !== 'invisible' && account?.status !== 'dnd'
|
||||
}
|
||||
})
|
||||
|
||||
/** Joins the voice of a room of a Steam group. */
|
||||
async joinSteamVoice(accountId: string, groupId: string, roomId: string): Promise<void> {
|
||||
const buffer = this.state.buffers.find((b) => b.accountId === accountId && b.remoteId === `${groupId}:${roomId}`)
|
||||
const group = this.state.groups.find((g) => g.id === `${accountId}|steamgroup:${groupId}`)
|
||||
if (!buffer || !group) return
|
||||
const room = (this.state.steamVoiceRooms[`${accountId}|${groupId}`] ?? []).find((r) => r.roomId === roomId)
|
||||
await this.steamVoice.start(accountId, { kind: 'room', groupId, roomId, bufferId: buffer.id, name: room?.name ?? bufferDisplayName(buffer.name), groupName: group.name, railId: group.id })
|
||||
}
|
||||
|
||||
/** Calls a Steam friend, from their conversation. */
|
||||
async callSteamFriend(bufferId: string): Promise<void> {
|
||||
const buffer = this.state.buffers.find((b) => b.id === bufferId)
|
||||
if (!buffer?.remoteId) return
|
||||
// They are already calling: answer that, rather than starting a second call to them.
|
||||
const ringing = this.steamVoice.ringFrom(buffer.accountId, buffer.remoteId)
|
||||
if (ringing) {
|
||||
await this.answerSteamCall(ringing)
|
||||
return
|
||||
}
|
||||
await this.steamVoice.start(buffer.accountId, { kind: 'friend', steamid: buffer.remoteId, bufferId, name: bufferDisplayName(buffer.name) })
|
||||
}
|
||||
|
||||
async answerSteamCall(ring: SteamRing): Promise<void> {
|
||||
const buffer = this.state.buffers.find((b) => b.accountId === ring.accountId && b.remoteId === ring.steamid)
|
||||
await this.steamVoice.answer(ring, buffer?.id ?? '', buffer ? bufferDisplayName(buffer.name) : ring.steamid)
|
||||
if (buffer) this.selectBuffer(buffer.id)
|
||||
}
|
||||
|
||||
async declineSteamCall(ring: SteamRing): Promise<void> {
|
||||
await this.steamVoice.decline(ring)
|
||||
}
|
||||
|
||||
/** Reads who is in a Steam group's voice rooms, when its channels are shown. */
|
||||
async refreshSteamVoiceRooms(accountId: string, groupId: string): Promise<void> {
|
||||
try {
|
||||
const rooms = await window.moho.rpc<SteamVoiceRoom[]>('steamVoiceRooms', { accountId, groupId })
|
||||
this.set({ steamVoiceRooms: { ...this.state.steamVoiceRooms, [`${accountId}|${groupId}`]: rooms } })
|
||||
} catch {
|
||||
/* none shown */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1722,14 +2056,24 @@ export class ChatStore {
|
||||
* nothing is removed here on optimism - leaving is the one action where
|
||||
* showing it as done before it is would be worse than a moment's delay.
|
||||
*/
|
||||
async leaveGroup(groupId: string): Promise<void> {
|
||||
async leaveGroup(groupId: string): Promise<boolean> {
|
||||
// A voice room of a Steam group is left with the group: the call would be in a place that is gone.
|
||||
const voice = this.state.steamVoiceNow
|
||||
if (voice?.target.kind === 'room' && groupId.endsWith(`|steamgroup:${voice.target.groupId}`)) await this.steamVoice.stop()
|
||||
try {
|
||||
await window.moho.rpc('leaveGroup', { groupId })
|
||||
return true
|
||||
} catch (e) {
|
||||
this.toast('error', `Couldn't leave: ${(e as Error).message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Asks to leave a group, which is confirmed first: it cannot be undone without a new invitation. */
|
||||
requestLeave(group: { id: string; name: string; kind: string } | null): void {
|
||||
this.set({ leaveRequest: group })
|
||||
}
|
||||
|
||||
/** Asks the log to scroll to a message, and to stop following the tail. */
|
||||
setJumpTarget(messageId: string): void {
|
||||
this.set({ jumpTarget: messageId })
|
||||
@@ -1787,6 +2131,10 @@ export class ChatStore {
|
||||
* resolves it to the channel the call happens in.
|
||||
*/
|
||||
async callBuffer(bufferId: string): Promise<void> {
|
||||
if (this.state.buffers.find((b) => b.id === bufferId)?.accountId.startsWith('steam:')) {
|
||||
await this.callSteamFriend(bufferId)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await window.moho.rpc('startDiscordCall', { bufferId })
|
||||
await this.refreshVoiceSessions()
|
||||
@@ -2101,6 +2449,10 @@ export class ChatStore {
|
||||
}
|
||||
|
||||
async leaveVoice(accountId: string): Promise<void> {
|
||||
if (accountId.startsWith('steam:')) {
|
||||
await this.steamVoice.stop()
|
||||
return
|
||||
}
|
||||
// What this end was sending goes with it. Left running, the camera light
|
||||
// would stay on and the encoder would go on handing frames to a call
|
||||
// that has ended.
|
||||
@@ -2313,6 +2665,58 @@ export class ChatStore {
|
||||
this.set({ profile: null })
|
||||
}
|
||||
|
||||
/** Asks what groups a Steam account has, for when it has just connected. */
|
||||
loadSteamGroups(accountId: string): void {
|
||||
void window.moho
|
||||
.rpc<SteamGroup[]>('steamFriendGroups', { accountId })
|
||||
.then((groups) => this.set({ steamGroups: { ...this.state.steamGroups, [accountId]: groups } }))
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a friend's conversation to another of the account's Steam folders, which is a change to the
|
||||
* account on Steam: out of the folder they are filed under, and into the one chosen - or out of
|
||||
* them all when none is. What comes back says where they now are, so nothing is moved here until
|
||||
* Steam has agreed.
|
||||
*/
|
||||
moveSteamFriend(bufferId: string, toGroup: number | null): void {
|
||||
const buffer = this.state.buffers.find((b) => b.id === bufferId)
|
||||
if (!buffer || !buffer.remoteId) return
|
||||
const friend = buffer.remoteId
|
||||
const groups = [...(this.state.steamGroups[buffer.accountId] ?? [])].sort((a, b) => a.id - b.id)
|
||||
// The one they are drawn under, which is the lowest-numbered of those they are in.
|
||||
const from = groups.find((g) => g.members.includes(friend))
|
||||
const to = toGroup === null ? undefined : groups.find((g) => g.id === toGroup)
|
||||
if (toGroup !== null && !to) return
|
||||
const calls: GroupCall[] = []
|
||||
if (from && from.id !== to?.id) calls.push({ accountId: buffer.accountId, action: 'remove', groupId: from.id, steamid: friend })
|
||||
if (to && !to.members.includes(friend)) calls.push({ accountId: buffer.accountId, action: 'add', groupId: to.id, steamid: friend })
|
||||
if (calls.length > 0) this.runSteamGroupCalls(calls)
|
||||
}
|
||||
|
||||
/** Makes a folder on a Steam account, which Steam then tells every client of. */
|
||||
createSteamFolder(accountId: string, name: string): void {
|
||||
this.runSteamGroupCalls([{ accountId, action: 'create', groupId: 0, name }])
|
||||
}
|
||||
|
||||
renameSteamFolder(accountId: string, groupId: number, name: string): void {
|
||||
this.runSteamGroupCalls([{ accountId, action: 'rename', groupId, name }])
|
||||
}
|
||||
|
||||
/** Deletes a folder: the friends in it stay friends, filed under nothing. */
|
||||
deleteSteamFolder(accountId: string, groupId: number): void {
|
||||
this.runSteamGroupCalls([{ accountId, action: 'delete', groupId }])
|
||||
}
|
||||
|
||||
/** Makes the changes to a Steam account's groups that moho's own headings asked for. */
|
||||
runSteamGroupCalls(calls: GroupCall[]): void {
|
||||
for (const call of calls) {
|
||||
void window.moho
|
||||
.rpc('steamGroupChange', { accountId: call.accountId, action: call.action, groupId: call.groupId, steamid: call.steamid, name: call.name })
|
||||
.catch((e: Error) => this.toast('error', `Steam did not change the group: ${e.message}`))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an IRC account rejoins this channel when it connects.
|
||||
*
|
||||
@@ -3119,6 +3523,19 @@ export class ChatStore {
|
||||
this.matrixCalls.handle(data as unknown as MatrixCallEvent)
|
||||
break
|
||||
|
||||
// Steam's voice: the server telling this window's WebRTC session what is
|
||||
// happening. The media is the window's; see lib/steamvoice.ts.
|
||||
case 'steamVoice':
|
||||
this.steamVoice.handle(data as never)
|
||||
break
|
||||
// Removed from a Steam group by somebody else.
|
||||
case 'steamGroupRemoved':
|
||||
this.toast('error', `You were ${data.how} ${data.name}`)
|
||||
break
|
||||
case 'steamVoiceRooms':
|
||||
this.set({ steamVoiceRooms: { ...this.state.steamVoiceRooms, [`${data.accountId}|${data.groupId}`]: data.rooms as SteamVoiceRoom[] } })
|
||||
break
|
||||
|
||||
// Friends, and requests either way, as they change. Discord announces
|
||||
// these rather than expecting a client to poll - which is what makes a
|
||||
// request that arrives while the panel is open appear in it.
|
||||
@@ -3418,6 +3835,12 @@ export class ChatStore {
|
||||
//
|
||||
// Ignored when it is not about whoever is on screen - two lookups in
|
||||
// quick succession would otherwise have the slower one win.
|
||||
// Steam says what groups an account has sorted its friends into, or that one changed.
|
||||
case 'steamFriendGroups': {
|
||||
this.set({ steamGroups: { ...this.state.steamGroups, [data.accountId as string]: (data.groups ?? []) as SteamGroup[] } })
|
||||
break
|
||||
}
|
||||
|
||||
case 'profile': {
|
||||
const answer = data as unknown as Profile
|
||||
const open = this.state.profile
|
||||
@@ -3426,6 +3849,12 @@ export class ChatStore {
|
||||
break
|
||||
}
|
||||
|
||||
// A Steam account's friends have invited it to a group, or an invitation has been answered. They are
|
||||
// rooms to be offered, kept with the rest of them.
|
||||
case 'steamGroupInvites':
|
||||
this.set({ matrixInvites: { ...this.state.matrixInvites, [data.accountId as string]: (data.invites as MatrixInvite[]) || [] } })
|
||||
break
|
||||
|
||||
case 'matrixInvites':
|
||||
this.set({
|
||||
matrixInvites: {
|
||||
@@ -3581,6 +4010,7 @@ export class ChatStore {
|
||||
accountId: data.accountId,
|
||||
verificationId: data.verificationId,
|
||||
state: 'requested',
|
||||
incoming: true,
|
||||
otherUser: data.fromUser,
|
||||
otherDevice: data.fromDevice,
|
||||
isSelf: data.isSelf
|
||||
@@ -3622,6 +4052,19 @@ export class ChatStore {
|
||||
void this.refreshAccounts()
|
||||
break
|
||||
|
||||
case 'steamFriendRequests': {
|
||||
const incoming = ((data.requests as { incoming?: SteamPerson[] } | undefined)?.incoming ?? []).map(personToRequest)
|
||||
this.set({ friendRequests: { ...this.state.friendRequests, [data.accountId as string]: incoming } })
|
||||
break
|
||||
}
|
||||
case 'steamFriendRequest': {
|
||||
// Said once, by the daemon, when somebody new asks. Answering is on
|
||||
// the request's own tile in the rail, so that is where the words point.
|
||||
const who = (data.request as { name?: string | null; steamid?: string } | undefined) ?? {}
|
||||
this.toast('info', `${who.name || who.steamid || 'Somebody'} wants to be your friend on Steam. It is waiting in the server list.`)
|
||||
break
|
||||
}
|
||||
|
||||
case 'matrixLoginStatus':
|
||||
this.set({ matrixLoginStatus: data.detail || '' })
|
||||
// An SSO sign-in arrives with the address of the server's own login
|
||||
@@ -3752,6 +4195,8 @@ export class ChatStore {
|
||||
...b,
|
||||
...data,
|
||||
link: data.link,
|
||||
// As sent, for the same reason: a dismissed warning is left out.
|
||||
notice: data.notice,
|
||||
syncing: data.syncing,
|
||||
topic: data.topic,
|
||||
serverMuted: data.serverMuted ?? false,
|
||||
@@ -4378,11 +4823,21 @@ export class ChatStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where scrolling to the top found nothing older, by conversation: the message that was the oldest
|
||||
* and when. The top is scrolled to over and over, and without this each time asked the service
|
||||
* the same question and was told the same thing. Asked again once it has been a while, since the
|
||||
* answer may only have been "nothing, just now".
|
||||
*/
|
||||
private historyEnd = new Map<string, { id: string; at: number }>()
|
||||
|
||||
async loadMoreHistory(bufferId: string): Promise<void> {
|
||||
if (this.state.loadingMore[bufferId]) return
|
||||
const list = this.state.messagesByBuffer[bufferId] || []
|
||||
const oldest = list.find((m) => !m.pending)
|
||||
if (!oldest) return
|
||||
const end = this.historyEnd.get(bufferId)
|
||||
if (end && end.id === oldest.id && Date.now() - end.at < HISTORY_END_MS) return
|
||||
|
||||
this.set({ loadingMore: { ...this.state.loadingMore, [bufferId]: true } })
|
||||
try {
|
||||
@@ -4391,11 +4846,15 @@ export class ChatStore {
|
||||
before: oldest.ts,
|
||||
limit: BACKLOG_PAGE
|
||||
})
|
||||
if (rows.length) {
|
||||
const current = this.state.messagesByBuffer[bufferId] || []
|
||||
const seen = new Set(current.map((m) => m.id))
|
||||
const older = rows.filter((r) => !seen.has(r.id)) as ChatMessage[]
|
||||
const current = this.state.messagesByBuffer[bufferId] || []
|
||||
const seen = new Set(current.map((m) => m.id))
|
||||
const older = rows.filter((r) => !seen.has(r.id)) as ChatMessage[]
|
||||
if (older.length) {
|
||||
this.historyEnd.delete(bufferId)
|
||||
this.setMessages(bufferId, [...older, ...current])
|
||||
} else {
|
||||
// Nothing before what is here: not asked again at the next scroll.
|
||||
this.historyEnd.set(bufferId, { id: oldest.id, at: Date.now() })
|
||||
}
|
||||
} catch (e) {
|
||||
this.toast('error', `Couldn't load older messages: ${(e as Error).message}`)
|
||||
@@ -4872,6 +5331,19 @@ export class ChatStore {
|
||||
private async refreshMatrixPermissions(bufferId: string): Promise<void> {
|
||||
if (this.state.matrixPermissions[bufferId]) return
|
||||
const account = this.accountFor(bufferId)
|
||||
// A room of a Steam chat group says what the account's roles allow there.
|
||||
if (account?.service === 'steam') {
|
||||
const ids = steamRoomIds(this.state.buffers.find((b) => b.id === bufferId))
|
||||
if (!ids) return
|
||||
try {
|
||||
const groups = await window.moho.rpc<SteamChatGroup[]>('steamChatGroups', { accountId: account.id, groupId: ids.group })
|
||||
// Not kept when the group is not known yet: asked again at the next opening.
|
||||
if (groups[0]) this.set({ matrixPermissions: { ...this.state.matrixPermissions, [bufferId]: { canRedactOthers: groups[0].mayModerate } } })
|
||||
} catch {
|
||||
/* advisory only */
|
||||
}
|
||||
return
|
||||
}
|
||||
if (account?.service !== 'matrix') return
|
||||
try {
|
||||
const perms = await window.moho.rpc<RoomPermissions>('getMatrixRoomPermissions', {
|
||||
@@ -4885,6 +5357,15 @@ export class ChatStore {
|
||||
}
|
||||
|
||||
async closeBuffer(bufferId: string): Promise<void> {
|
||||
// A room of a Steam group cannot be left on its own: being in the group is what puts you in
|
||||
// its rooms. Closing one would only hide it until Steam next mentioned it, so what is offered
|
||||
// is leaving the group, asked about first.
|
||||
const room = this.state.buffers.find((b) => b.id === bufferId)
|
||||
if (room && room.kind === 'channel' && room.groupId?.includes('|steamgroup:')) {
|
||||
const group = this.state.groups.find((g) => g.id === room.groupId)
|
||||
if (group) this.requestLeave({ id: group.id, name: group.name, kind: group.kind })
|
||||
return
|
||||
}
|
||||
// Asked before the buffer goes, since afterwards there is nothing to ask.
|
||||
const sneedchat = this.accountFor(bufferId)?.service === 'sneedchat'
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useChat, usePref, usePrefsReady } from './hooks'
|
||||
import { DM_GROUP_ID } from '../lib/groups'
|
||||
import type { CustomCategory } from '../lib/categories'
|
||||
import { planDmCategories } from '../lib/dmcategories'
|
||||
import { seedSteamFolders } from '../lib/steamgroups'
|
||||
import { useStore } from './hooks'
|
||||
|
||||
// Held at module level: a fresh array as a fallback each render reads as a
|
||||
// change every time.
|
||||
const NO_CATEGORIES: Record<string, CustomCategory[]> = {}
|
||||
const NO_ASSIGNMENT: Record<string, string> = {}
|
||||
const NO_IDS: string[] = []
|
||||
|
||||
/**
|
||||
* Keeps the direct-messages page sorted by account. See lib/dmcategories.ts
|
||||
* for what it does and, as much, what it leaves alone.
|
||||
*
|
||||
* Mounted once, in the main window: it writes the same preferences the page
|
||||
* reads, and a second window doing it too would be two writers.
|
||||
*/
|
||||
export function useDmCategories(): void {
|
||||
const ready = usePrefsReady()
|
||||
const store = useStore()
|
||||
const accounts = useChat((s) => s.accounts)
|
||||
const buffers = useChat((s) => s.buffers)
|
||||
const steamGroups = useChat((s) => s.steamGroups)
|
||||
const [categories, setCategories] = usePref<Record<string, CustomCategory[]>>('customCategories', NO_CATEGORIES)
|
||||
const [assignment, setAssignment] = usePref<Record<string, string>>('channelCategory', NO_ASSIGNMENT)
|
||||
const [filed, setFiled] = usePref<string[]>('dmAutoFiled', NO_IDS)
|
||||
const [made, setMade] = usePref<string[]>('dmHeadingsMade', NO_IDS)
|
||||
const [seeded, setSeeded] = usePref<string[]>('steamFoldersSeeded', NO_IDS)
|
||||
|
||||
// A Steam account's groups, asked for when it connects: it also says them on
|
||||
// its own, but not necessarily after this window is listening.
|
||||
const connectedSteam = accounts.filter((a) => a.service === 'steam' && a.state === 'connected').map((a) => a.id).join(',')
|
||||
useEffect(() => {
|
||||
for (const id of connectedSteam.split(',').filter(Boolean)) store.loadSteamGroups(id)
|
||||
}, [connectedSteam, store])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return
|
||||
// The account headings first, then the Steam groups on top of what they
|
||||
// made: one write for both, so neither is lost to the other.
|
||||
const dm = planDmCategories(accounts, buffers, {
|
||||
categories: categories[DM_GROUP_ID] ?? [],
|
||||
assignment,
|
||||
filed,
|
||||
made
|
||||
})
|
||||
const steam = seedSteamFolders(accounts, buffers, steamGroups, {
|
||||
categories: dm?.categories ?? categories[DM_GROUP_ID] ?? [],
|
||||
assignment: dm?.assignment ?? assignment,
|
||||
seeded
|
||||
})
|
||||
if (!dm && !steam) return
|
||||
const next = steam ?? dm!
|
||||
setCategories({ ...categories, [DM_GROUP_ID]: next.categories })
|
||||
setAssignment(next.assignment)
|
||||
if (dm) {
|
||||
setFiled(dm.filed)
|
||||
setMade(dm.made)
|
||||
}
|
||||
if (steam) setSeeded(steam.seeded)
|
||||
}, [ready, accounts, buffers, steamGroups, categories, assignment, filed, made, seeded, setCategories, setAssignment, setFiled, setMade, setSeeded])
|
||||
}
|
||||
+53
-1
@@ -3,7 +3,7 @@
|
||||
|
||||
export interface Account {
|
||||
id: string
|
||||
service: 'irc' | 'discord' | 'sneedchat' | 'matrix' | 'kick' | 'jabber' | 'slack'
|
||||
service: 'irc' | 'discord' | 'sneedchat' | 'matrix' | 'kick' | 'steam' | 'jabber' | 'slack'
|
||||
displayName: string
|
||||
/**
|
||||
* How the user is presenting: "online" | "idle". Distinct from
|
||||
@@ -182,6 +182,16 @@ export interface Buffer {
|
||||
/** tor | site | chat | refused */
|
||||
cause?: string
|
||||
}
|
||||
/**
|
||||
* A warning the service has put on this conversation, drawn over it until it
|
||||
* is dismissed - Steam's "this person may not be who they appear to be".
|
||||
* Gone when dismissed, and back if the service raises it again.
|
||||
*/
|
||||
notice?: {
|
||||
/** suspicious */
|
||||
kind: string
|
||||
text: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -384,6 +394,8 @@ export interface VoiceSession {
|
||||
bufferId?: string
|
||||
/** A stage channel: speakers and an audience. */
|
||||
stage?: boolean
|
||||
/** The rail entry this belongs to, where its id is not a Discord guild's: a Steam group. */
|
||||
railId?: string
|
||||
}
|
||||
|
||||
export interface ReplyPreview {
|
||||
@@ -625,14 +637,26 @@ export interface Profile {
|
||||
away?: string
|
||||
/** Presence word - "online", "idle", "dnd", "offline". */
|
||||
status?: string
|
||||
/** What they are doing, where the service says: a game, a track, or words they wrote. */
|
||||
activity?: Activity | null
|
||||
/** Rooms or channels shared with them, where the service will say. */
|
||||
channels?: string[]
|
||||
/** Anything else worth showing, already phrased for a person to read. */
|
||||
extra?: { label: string; value: string }[]
|
||||
/** The names they have had, newest first, where the service keeps them; `when` is the service's own words for the moment. */
|
||||
nameHistory?: { name: string; when: string }[]
|
||||
/** Set while the service is still being asked; the card fills in. */
|
||||
pending?: boolean
|
||||
}
|
||||
|
||||
/** What somebody is doing, as a line under their name. */
|
||||
export interface Activity {
|
||||
/** Decides the little picture before it: a gamepad, a note, or none. */
|
||||
kind: 'game' | 'music' | 'custom'
|
||||
/** The game, the track, or the words. */
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
nick: string
|
||||
prefix?: string
|
||||
@@ -666,8 +690,19 @@ export interface Member {
|
||||
* split into online and offline.
|
||||
*/
|
||||
status?: string
|
||||
/**
|
||||
* What they are doing, said under their name: a game, a track, or a status
|
||||
* they wrote themselves. Only where the service says; absent when the dot is
|
||||
* the whole answer.
|
||||
*/
|
||||
activity?: Activity | null
|
||||
/** Their picture, where the service has one in its member list. */
|
||||
avatarUrl?: string
|
||||
/** Steam: which kind of client they are on, and when they were last seen (unix seconds). */
|
||||
client?: string
|
||||
lastSeen?: number
|
||||
/** Steam: what the game they are in says about what they are doing. */
|
||||
rich?: string
|
||||
}
|
||||
|
||||
export interface Protocol {
|
||||
@@ -754,6 +789,18 @@ export interface MatrixInvite {
|
||||
isDirect: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Somebody who has asked to be this account's friend and is waiting to be
|
||||
* answered. Offered in the rail beside a Matrix room's invitation, because it
|
||||
* is the same kind of thing: a request that expires when it is answered.
|
||||
*/
|
||||
export interface FriendRequest {
|
||||
/** The service's own id for the person. */
|
||||
id: string
|
||||
name: string | null
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
export interface MatrixVerification {
|
||||
accountId: string
|
||||
verificationId: string
|
||||
@@ -771,6 +818,11 @@ export interface MatrixVerification {
|
||||
isSelf?: boolean
|
||||
/** Set on an incoming request that has not been answered yet. */
|
||||
fromUser?: string
|
||||
/**
|
||||
* Somebody else asked, and it has not been answered: it is waiting on the
|
||||
* Invitations tile rather than showing as a dialog. Cleared once it is.
|
||||
*/
|
||||
incoming?: boolean
|
||||
fromDevice?: string
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user