Author SHA1 Message Date
SalastilandClaude Sonnet 5.5 335ac8b59d Merge development into master: 1.1.0, Steam
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 15:49:35 -04:00
36 changed files with 114 additions and 1766 deletions
+23
View File
@@ -0,0 +1,23 @@
# On a merge, .gitmodules keeps the answer this branch already has.
#
# master names the daemon at github.com because master is what a stranger
# clones, and development names it at the Gitea instance where the work
# happens. Without this, a merge that touched .gitmodules on both sides would
# stop and ask which host is right - and the answer is always "the one the
# branch being merged into already said".
#
# `merge=ours` needs a driver by that name, which is one line per clone and
# not something a repository can carry for you:
#
# git config merge.ours.driver true
#
# Without it git falls back to an ordinary merge, which conflicts rather than
# doing the wrong thing quietly - so a clone that has not run it is no worse
# off than before this file existed.
#
# The edge, because it is a real one: this keeps the *whole file*, not just the
# URL. If development ever adds a second submodule, master takes none of that
# entry while still taking the gitlink the merge brings with it - a submodule
# with nothing in .gitmodules to say where it comes from. Adding a submodule is
# therefore a thing to re-do by hand on master, and the only such thing.
/.gitmodules merge=ours
+1 -2
View File
@@ -1,8 +1,7 @@
# Maintainer: Salastil <[email protected]>
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 -1
Submodule nobilis updated: cd8a44cbf9...289b5db54a
-99
View File
@@ -1,99 +0,0 @@
// The page the Kick video probe plays in: one hls.js player per configuration, measured while it plays.
import Hls from 'hls.js'
import { ivsAbr, ivsLoader } from '../../src/renderer/src/lib/ivsplaylist'
interface Config {
name: string
url: string
seconds: number
prefetch: number
hls: Record<string, unknown>
cap?: number
}
declare global {
interface Window {
runProbe: (config: Config) => Promise<Record<string, unknown>>
}
}
window.runProbe = async (cfg) => {
// How far this clock is from the server's, from the time the playlist says it was made at.
const sent = Date.now()
const text = await (await fetch(cfg.url)).text()
const received = Date.now()
const server = Number(/SERVER-TIME",VALUE="([0-9.]+)"/.exec(text)?.[1] ?? NaN)
const offset = server * 1000 - (sent + received) / 2
return new Promise((resolve) => {
const video = document.getElementById('v') as HTMLVideoElement
const hls = new Hls({ ...cfg.hls, ...(cfg.prefetch > 0 ? { loader: ivsLoader(cfg.prefetch), abrController: ivsAbr() } : {}) } as never)
const latency: number[] = []
const levels = new Set<string>()
const fatal: string[] = []
let stalls = 0
let waits = 0
let switches = 0
let waitStart = 0
let waited = 0
let firstFrame = 0
const began = Date.now()
video.muted = true
video.onwaiting = (): void => {
waits++
if (!waitStart && firstFrame) waitStart = Date.now()
}
video.onplaying = (): void => {
if (!firstFrame) firstFrame = Date.now() - began
if (waitStart) {
waited += Date.now() - waitStart
waitStart = 0
}
}
hls.on(Hls.Events.ERROR, (_e, d) => {
if (d.details === 'bufferStalledError') stalls++
if (d.fatal) fatal.push(d.details)
})
hls.on(Hls.Events.LEVEL_SWITCHED, (_e, d) => {
switches++
levels.add(`${hls.levels[d.level]?.height}p`)
})
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (cfg.cap) {
const best = hls.levels.reduce((b, l, i) => (l.height <= (cfg.cap ?? 720) && l.height >= (hls.levels[b]?.height ?? 0) ? i : b), -1)
if (best >= 0) hls.autoLevelCapping = best
}
void video.play().catch(() => {})
})
hls.attachMedia(video)
hls.loadSource(cfg.url)
const timer = setInterval(() => {
const at = hls.playingDate
if (at && !video.paused && firstFrame) latency.push((Date.now() + offset - at.getTime()) / 1000)
if (Date.now() - began < cfg.seconds * 1000) return
clearInterval(timer)
const q = video.getVideoPlaybackQuality()
const sorted = [...latency].sort((a, b) => a - b)
const pick = (p: number): number => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] ?? NaN
const settled = sorted.slice(Math.floor(sorted.length * 0.2))
const mean = settled.length ? settled.reduce((a, b) => a + b, 0) / settled.length : NaN
hls.destroy()
resolve({
name: cfg.name,
startedInMs: firstFrame,
samples: latency.length,
latencyMin: +Math.min(...latency).toFixed(2),
latencyMax: +Math.max(...latency).toFixed(2),
latencyMedian: +pick(0.5).toFixed(2),
latencyP95: +pick(0.95).toFixed(2),
latencyMeanAfterSettling: +mean.toFixed(2),
rebufferSeconds: +(waited / 1000).toFixed(2),
waits,
stallErrors: stalls,
dropped: `${q.droppedVideoFrames}/${q.totalVideoFrames}`,
levelSwitches: switches,
levels: [...levels].join(','),
fatal: fatal.join(',')
})
}, 1000)
})
}
-64
View File
@@ -1,64 +0,0 @@
#!/usr/bin/env node
/**
* Plays a live Kick stream with several hls.js configurations, one after the other, in the app's own
* Chromium, and says for each how far behind the broadcaster it ran, how often it stopped to wait,
* and how many frames it dropped. Nothing is signed in: the playlist is public.
*
* node scripts/probe-kick-video.mjs [channel] [seconds] (defaults: spreen, 75)
*/
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const channel = process.argv[2] ?? 'spreen'
const seconds = Number(process.argv[3] ?? 75)
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..')
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'kick-video-'))
execFileSync(path.join(root, 'node_modules/.bin/esbuild'), [path.join(root, 'scripts/lib/hlsprobe.ts'), '--bundle', '--format=iife', `--outfile=${path.join(work, 'probe.js')}`, '--log-level=error'])
fs.writeFileSync(path.join(work, 'probe.html'), '<!doctype html><body style="margin:0"><video id="v" width="1280" height="720" autoplay muted playsinline></video><script src="probe.js"></script></body>')
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
const playlist = async () => {
const r = await fetch(`https://kick.com/api/v2/channels/${channel}/playback-url`, { headers: { 'User-Agent': UA, Accept: 'application/json' } })
return (await r.json()).data
}
const base = { capLevelToPlayerSize: true }
const configs = [
{ name: 'as it is now (3 target durations, no prefetch)', prefetch: 0, hls: { ...base, liveSyncDurationCount: 3 } },
{ name: 'seconds: sync 4s, no prefetch', prefetch: 0, hls: { ...base, liveSyncDuration: 4, liveMaxLatencyDuration: 10 } },
{ name: 'prefetch 2, sync 4s', prefetch: 2, hls: { ...base, liveSyncDuration: 4, liveMaxLatencyDuration: 10 } },
{ name: 'prefetch 2, sync 3s, catch-up 1.1', prefetch: 2, hls: { ...base, liveSyncDuration: 3, liveMaxLatencyDuration: 8, maxLiveSyncPlaybackRate: 1.1 } },
{ name: 'prefetch 1, sync 3s, catch-up 1.1', prefetch: 1, hls: { ...base, liveSyncDuration: 3, liveMaxLatencyDuration: 8, maxLiveSyncPlaybackRate: 1.1 } },
{ name: 'prefetch 2, sync 5s, catch-up 1.1', prefetch: 2, hls: { ...base, liveSyncDuration: 5, liveMaxLatencyDuration: 10, maxLiveSyncPlaybackRate: 1.1 } }
]
if (process.env.LIST === 'steady') {
configs.length = 0
configs.push(
{ name: 'prefetch 2, sync 3s', prefetch: 2, hls: { ...base, liveSyncDuration: 3, liveMaxLatencyDuration: 10 } },
{ name: 'prefetch 2, sync 4s', prefetch: 2, hls: { ...base, liveSyncDuration: 4, liveMaxLatencyDuration: 10 } },
{ name: 'prefetch 2, sync 5s', prefetch: 2, hls: { ...base, liveSyncDuration: 5, liveMaxLatencyDuration: 10 } }
)
}
const only = process.env.ONLY ? process.env.ONLY.split(',').map(Number) : null
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
try {
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
await sleep(2500)
await page.call('Page.navigate', { url: `file://${path.join(work, 'probe.html')}` })
await sleep(1500)
for (const [i, cfg] of configs.entries()) {
if (only && !only.includes(i)) continue
const url = await playlist()
const out = await page.evaluate(`window.runProbe(${JSON.stringify({ ...cfg, url, seconds, cap: 720 })})`)
console.log(JSON.stringify(out))
}
} finally {
await stop()
}
-19
View File
@@ -1,19 +0,0 @@
import fs from 'node:fs'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (c) => page.evaluate(c)
try {
try { const cl = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString()); for (const c of cl.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`]) } catch {}
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
const a = await rpc('addKickAccount', {})
await rpc('joinBuffer', { accountId: a.accountId, name: 'spreen' })
const id = `${a.accountId}|spreen`
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
await sleep(20000)
console.log(await ev(`JSON.stringify({ images: document.querySelectorAll('.sender-badge-image').length, loaded: [...document.querySelectorAll('.sender-badge-image')].filter((i) => i.complete && i.naturalWidth > 0).length, words: [...document.querySelectorAll('.sender-badge')].map((b) => b.textContent).slice(0, 8), titles: [...new Set([...document.querySelectorAll('.sender-badge-image')].map((i) => i.title))].slice(0, 8) })`))
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(process.env.HOME + '/.local/share/moho-dev/shots/kick-badges.png', Buffer.from(data, 'base64'))
} finally { await stop() }
-91
View File
@@ -1,91 +0,0 @@
#!/usr/bin/env node
/**
* Watches a real live Kick stream in a scratch moho with no account signed in, and checks what the
* player does: that it plays, how far behind it runs, that the resolution menu changes the picture, that
* the CC button shows the captions, and that the player is built once and not again as the address is
* renewed. Photographs the stream, each menu and the captions.
*
* node scripts/shot-kick-stream.mjs <output directory> [channel] [idle seconds]
*/
import fs from 'node:fs'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
const channel = process.argv[3] ?? 'spreen'
const idle = Number(process.argv[4] ?? 60)
fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
const ev = (code) => page.evaluate(code)
const shot = async (name) => {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
}
const click = (selector) => ev(`(() => { const b = document.querySelector(${JSON.stringify(selector)}); if (!b || b.disabled) return false; b.click(); return true })()`)
const video = (expr) => ev(`(() => { const v = document.querySelector('video.video-tile-picture'); return v ? (${expr}) : null })()`)
try {
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
const added = await rpc('addKickAccount', {})
say('account', JSON.stringify(added))
say('join', JSON.stringify(await rpc('joinBuffer', { accountId: added.accountId, name: channel })))
const id = `${added.accountId}|${channel}`
let live = null
for (let i = 0; i < 40 && !live?.playbackUrl; i++) {
await sleep(1000)
live = await ev(`window.__mohoShots.state().kickStreams[${JSON.stringify(id)}] || null`)
}
say('stream', JSON.stringify({ live: live?.live, hasUrl: !!live?.playbackUrl, expires: live?.playbackExpires, livestreamId: live?.livestreamId }))
await ev(`(async () => { await window.__mohoShots.store.selectBuffer(${JSON.stringify(id)}); window.__mohoShots.store.watchStream(${JSON.stringify(id)}) })()`)
for (let i = 0; i < 40; i++) {
if ((await video('v.readyState >= 3 && v.currentTime > 1')) === true) break
await sleep(500)
}
say('playing', await video('({ t: v.currentTime, w: v.videoWidth, h: v.videoHeight, paused: v.paused })').then(JSON.stringify))
await sleep(4000)
await shot('stream-playing.png')
// Resolution.
say('quality menu', await click('button[title^="Resolution"]'))
await sleep(400)
say('quality choices', JSON.stringify(await ev(`[...document.querySelectorAll('.stream-menu .context-menu-item')].map((b) => b.textContent.trim())`)))
await shot('stream-quality-menu.png')
for (const want of ['480p', 'Auto']) {
await ev(`(() => { const b = [...document.querySelectorAll('.stream-menu .context-menu-item')].find((x) => x.textContent.trim().startsWith(${JSON.stringify(want)})); b && b.click() })()`)
await sleep(8000)
say(`after choosing ${want}`, JSON.stringify(await video('({ h: v.videoHeight, t: +v.currentTime.toFixed(1) })')), JSON.stringify(await ev(`document.querySelector('button[title^="Resolution"]')?.title`)))
if (want === '480p') await click('button[title^="Resolution"]')
}
// Captions.
say('captions button', await ev(`JSON.stringify({ title: document.querySelector('button[title*="aptions"]')?.title, disabled: document.querySelector('button[title*="aptions"]')?.disabled })`))
say('captions menu', await click('button[title*="aptions"]'))
await sleep(400)
say('caption choices', JSON.stringify(await ev(`[...document.querySelectorAll('.stream-menu .context-menu-item')].map((b) => b.textContent.trim())`)))
await shot('stream-captions-menu.png')
await ev(`(() => { const b = [...document.querySelectorAll('.stream-menu .context-menu-item')].find((x) => x.textContent.trim() === 'English'); b && b.click() })()`)
await sleep(15000)
say('text tracks', await video(`JSON.stringify([...v.textTracks].map((t) => ({ lang: t.language, mode: t.mode, cues: t.cues ? t.cues.length : 0 })))`))
await shot('stream-captions-on.png')
say('captions button now', await ev(`document.querySelector('button[title*="aptions"]')?.title`))
// Built once.
const sources = new Set()
const began = Date.now()
while (Date.now() - began < idle * 1000) {
sources.add(await video('v.src'))
await sleep(1000)
}
const later = await ev(`window.__mohoShots.state().kickStreams[${JSON.stringify(id)}]`)
say(`over ${idle}s: ${sources.size} player source(s); address expires`, later?.playbackExpires, 'changed:', later?.playbackUrl !== live?.playbackUrl)
say('quality', JSON.stringify(await video('(() => { const q = v.getVideoPlaybackQuality(); return { dropped: q.droppedVideoFrames, total: q.totalVideoFrames } })()')))
} finally {
await stop()
}
-88
View File
@@ -1,88 +0,0 @@
#!/usr/bin/env node
/**
* Reads a busy live Kick channel in a scratch moho and checks what replies and removals look like: that a
* reply carries its thread, that the thread opens from it, and that a removed message is struck through in
* red, left out or replaced by a notice according to Settings -> Kick. Removal is the one thing it cannot
* wait for, so one is injected through the store the way the daemon's event would arrive.
*
* node scripts/shot-kick-threads.mjs <output directory> [channel]
*/
import fs from 'node:fs'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
const channel = process.argv[3] ?? 'xqc'
fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
const ev = (code) => page.evaluate(code)
const shot = async (name) => {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
}
try {
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
const added = await rpc('addKickAccount', {})
say('join', JSON.stringify(await rpc('joinBuffer', { accountId: added.accountId, name: channel })))
const id = `${added.accountId}|${channel}`
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
// Replies, as the daemon read them: each should name the thread it belongs to.
let replies = []
for (let i = 0; i < 120 && replies.length < 2; i++) {
await sleep(1000)
replies = JSON.parse(await ev(`JSON.stringify((window.__mohoShots.state().messagesByBuffer[${JSON.stringify(id)}] || []).filter((m) => m.replyTo).map((m) => ({ id: m.id, to: m.replyTo.id, root: m.replyTo.threadRoot, thread: m.replyTo.thread })))`))
}
say('replies seen', replies.length, JSON.stringify(replies.slice(0, 4)))
say('every reply has a thread root', replies.every((r) => r.root))
await sleep(1500)
await shot('kick-replies.png')
await ev(`document.querySelector('.reply-preview')?.scrollIntoView({ block: 'center' })`)
await sleep(500)
await shot('kick-reply-row.png')
// Open a thread from a reply.
say('thread button', await ev(`(() => { const b = document.querySelector('.reply-thread-open'); if (!b) return false; b.click(); return true })()`))
await sleep(1200)
say('thread panel', await ev(`JSON.stringify({ open: !!document.querySelector('.thread-pane'), rows: document.querySelectorAll('.thread-pane .message-row').length, title: document.querySelector('.thread-header')?.textContent })`))
await shot('kick-thread.png')
await ev(`window.__mohoShots.store.closeThreadPanel()`)
await sleep(300)
// A removal, as the daemon would announce it, in each of the three ways.
const victim = await ev(`(() => { const ms = window.__mohoShots.state().messagesByBuffer[${JSON.stringify(id)}] || []; const m = [...ms].reverse().find((x) => x.kind === 'chat' && x.body && !x.replyTo); return m && m.id })()`)
await ev(`window.__mohoShots.store.handleEvent({ event: 'messageModerated', data: { bufferId: ${JSON.stringify(id)}, id: ${JSON.stringify(victim)}, moderated: { ai: true, rules: ['hate'] } } })`)
await sleep(500)
await ev(`document.querySelector('.message-row.removed')?.scrollIntoView({ block: 'center' })`)
await sleep(500)
say('removed row (default: strike)', await ev(`(() => { const r = document.querySelector('.message-row.removed .message-body'); if (!r) return null; const s = getComputedStyle(r); return JSON.stringify({ text: r.textContent.slice(0, 40), color: s.color, line: s.textDecorationLine, title: r.title }) })()`))
await shot('kick-removed-strike.png')
for (const [label, name] of [['Replace with notice', 'replace'], ['Delete', 'remove'], ['Strikethrough', 'strike']]) {
await ev(`window.__mohoShots.store.setActivePanel('settings')`)
await sleep(500)
await ev(`[...document.querySelectorAll('.settings-rail-item, .settings-rail button')].find((b) => b.textContent.trim().startsWith('Kick'))?.click()`)
await sleep(500)
say('setting', label, await ev(`(() => { const b = [...document.querySelectorAll('.setting-segmented button')].find((x) => x.textContent.trim() === ${JSON.stringify(label)}); if (!b) return false; b.click(); return true })()`))
await sleep(300)
if (name === 'replace') await shot('kick-settings.png')
await ev(`window.__mohoShots.store.setActivePanel('')`)
await sleep(500)
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
await sleep(700)
say(name, await ev(`JSON.stringify({ removedRows: document.querySelectorAll('.message-row.removed').length, text: document.querySelector('.message-row.removed .message-body')?.textContent.slice(0, 50) })`))
await ev(`document.querySelector('.message-row.removed')?.scrollIntoView({ block: 'center' })`)
await sleep(300)
if (name !== 'strike') await shot(`kick-removed-${name}.png`)
}
} finally {
await stop()
}
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env node
/**
* Reads a busy live Twitch channel in a scratch moho with no sign-in, and checks what the window shows:
* that the channel joins, messages arrive with their colours, badges and emote pictures, replies carry
* their quote, the room's modes are in the header and the chatters are counted.
*
* node scripts/shot-twitch-chat.mjs <output directory> [channel] [seconds]
*/
import fs from 'node:fs'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
const channel = process.argv[3] ?? 'xqc'
const seconds = Number(process.argv[4] ?? 40)
fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
const ev = (code) => page.evaluate(code)
try {
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
// HISTORY=1 turns on the third-party recent messages, which is off until a person says so.
if (process.env.HISTORY) await ev(`window.moho.prefs.set('twitch.recentHistory', true)`)
const added = await rpc('addTwitchAccount', {})
say('account', JSON.stringify(added))
await sleep(2500)
say('accounts', await ev(`JSON.stringify(window.__mohoShots.state().accounts.map((a) => ({ id: a.id, service: a.service, state: a.state, name: a.displayName, hasPassword: a.hasPassword })))`))
say('join', JSON.stringify(await rpc('joinBuffer', { accountId: added.accountId, name: channel })))
const id = `${added.accountId}|${channel}`
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
say('a channel that does not exist', JSON.stringify(await rpc('joinBuffer', { accountId: added.accountId, name: 'zzzz_no_such_channel_zzzz' })))
if (process.env.HISTORY) {
await sleep(6000)
say('rows six seconds after opening, with the past', await ev(`(window.__mohoShots.state().messagesByBuffer[${JSON.stringify(id)}] || []).length`))
}
await sleep(seconds * 1000)
say('buffers', await ev(`JSON.stringify(window.__mohoShots.state().buffers.filter((b) => b.accountId === ${JSON.stringify(added.accountId)}).map((b) => ({ id: b.id, modes: b.channelModes })))`))
say('page', await ev(`JSON.stringify({
rows: document.querySelectorAll('.message-row').length,
emotes: document.querySelectorAll('.message-row img.custom-emoji').length,
emotesLoaded: [...document.querySelectorAll('.message-row img.custom-emoji')].filter((i) => i.complete && i.naturalWidth > 0).length,
badges: [...new Set([...document.querySelectorAll('.message-row .sender-badge')].map((b) => b.textContent))],
replies: document.querySelectorAll('.message-row .reply-preview').length,
header: document.querySelector('.nicklist-header span')?.textContent,
coloured: document.querySelectorAll('.message-row .message-from[style*="color"]').length
})`))
await shot('twitch-chat.png')
async function shot(name) {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
}
} finally {
await stop()
}
-45
View File
@@ -1,45 +0,0 @@
#!/usr/bin/env node
/**
* Opens the Accounts pane in a scratch moho, starts a Twitch sign-in and photographs the code it shows,
* then cancels it. Nothing is approved, so no account is signed in and nothing is saved.
*
* node scripts/shot-twitch-signin.mjs <output directory>
*/
import fs from 'node:fs'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
const ev = (code) => page.evaluate(code)
const shot = async (name) => {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
}
const clickText = (selector, text) => ev(`(() => { const b = [...document.querySelectorAll(${JSON.stringify(selector)})].find((x) => x.textContent.trim().startsWith(${JSON.stringify(text)})); if (!b) return false; b.click(); return true })()`)
try {
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1100, height: 800, deviceScaleFactor: 1, mobile: false })
await ev(`window.__mohoShots.store.setActivePanel('accounts')`)
await sleep(600)
say('twitch chip', await clickText('.service-chip', 'Twitch'))
await sleep(500)
await shot('twitch-signin-form.png')
say('sign in', await clickText('button', 'Sign in with Twitch'))
for (let i = 0; i < 20 && !(await ev(`!!document.querySelector('.twitch-code')`)); i++) await sleep(500)
say('code shown', await ev(`JSON.stringify({ code: document.querySelector('.twitch-code')?.textContent, buttons: [...document.querySelectorAll('.add-form button')].map((b) => b.textContent.trim()) })`))
await shot('twitch-signin-code.png')
say('cancel', await clickText('button', 'Cancel sign-in'))
await sleep(500)
say('back to the form', await ev(`!document.querySelector('.twitch-code') && !!([...document.querySelectorAll('button')].find((b) => b.textContent.trim() === 'Sign in with Twitch'))`))
say('accounts saved', await ev(`window.__mohoShots.state().accounts.filter((a) => a.service === 'twitch').length`))
} finally {
await stop()
}
-34
View File
@@ -1,34 +0,0 @@
#!/usr/bin/env node
/**
* Reads a busy live Kick channel in a scratch moho and reports how the chatters count grows against the
* names listed, and whether drawing a long list holds the window up.
*
* node scripts/watch-kick-roster.mjs [channel] [seconds]
*/
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const channel = process.argv[2] ?? 'spreen'
const seconds = Number(process.argv[3] ?? 150)
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
try {
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
await sleep(2500)
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
const added = await rpc('addKickAccount', {})
await rpc('joinBuffer', { accountId: added.accountId, name: channel })
const id = `${added.accountId}|${channel}`
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
await ev(`(() => { window.__long = 0; window.__longMs = 0; new PerformanceObserver((l) => { for (const e of l.getEntries()) { window.__long++; window.__longMs += e.duration } }).observe({ entryTypes: ['longtask'] }) })()`)
const began = Date.now()
while (Date.now() - began < seconds * 1000) {
await sleep(15000)
console.log(await ev(`JSON.stringify({ header: document.querySelector('.nicklist-header span')?.textContent, rows: document.querySelectorAll('.nicklist .member-row, .nicklist-scroll > div > div:not(.nicklist-group)').length, state: (() => { const s = window.__mohoShots.state(); return { listed: (s.presenceByBuffer[${JSON.stringify(id)}] || []).length, total: s.presenceTotalByBuffer[${JSON.stringify(id)}] } })(), longTasks: window.__long, longMs: Math.round(window.__longMs) })`))
}
} finally {
await stop()
}
-53
View File
@@ -1,53 +0,0 @@
#!/usr/bin/env node
/**
* Watches a live Kick stream in a scratch moho and reports every time the picture waits for data: when,
* for how long, and how much was buffered ahead of the playhead just before.
*
* node scripts/watch-kick-stalls.mjs [channel] [seconds]
*/
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const channel = process.argv[2] ?? 'spreen'
const seconds = Number(process.argv[3] ?? 90)
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
try {
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
await sleep(2500)
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
const added = await rpc('addKickAccount', {})
await rpc('joinBuffer', { accountId: added.accountId, name: channel })
const id = `${added.accountId}|${channel}`
for (let i = 0; i < 40; i++) {
await sleep(1000)
if (await ev(`!!window.__mohoShots.state().kickStreams[${JSON.stringify(id)}]?.playbackUrl`)) break
}
await ev(`(async () => { await window.__mohoShots.store.selectBuffer(${JSON.stringify(id)}); window.__mohoShots.store.watchStream(${JSON.stringify(id)}) })()`)
for (let i = 0; i < 40; i++) {
if (await ev(`!!document.querySelector('video.video-tile-picture')`)) break
await sleep(500)
}
await ev(`(() => {
const v = document.querySelector('video.video-tile-picture')
const log = (window.__stalls = { events: [], samples: [], t0: performance.now() })
const at = () => +((performance.now() - log.t0) / 1000).toFixed(1)
const ahead = () => { const b = v.buffered; return b.length ? +(b.end(b.length - 1) - v.currentTime).toFixed(2) : 0 }
for (const n of ['waiting', 'stalled', 'playing', 'seeking', 'seeked', 'ratechange']) v.addEventListener(n, () => log.events.push({ t: at(), n, ahead: ahead(), rate: v.playbackRate, ct: +v.currentTime.toFixed(2) }))
setInterval(() => log.samples.push({ t: at(), ahead: ahead(), ct: +v.currentTime.toFixed(2), rate: v.playbackRate }), 500)
})()`)
await sleep(seconds * 1000)
const r = JSON.parse(await ev('JSON.stringify(window.__stalls)'))
const waits = r.events.filter((e) => e.n === 'waiting')
console.log(`${waits.length} waiting events in ${seconds}s`)
for (const e of r.events) console.log(JSON.stringify(e))
const a = r.samples.map((s) => s.ahead)
console.log('buffer ahead: min', Math.min(...a), 'max', Math.max(...a), 'mean', +(a.reduce((x, y) => x + y, 0) / a.length).toFixed(2))
console.log('rates seen', [...new Set(r.samples.map((s) => s.rate))])
console.log('quality', await ev(`JSON.stringify((() => { const q = document.querySelector('video.video-tile-picture').getVideoPlaybackQuality(); return { dropped: q.droppedVideoFrames, total: q.totalVideoFrames } })())`))
} finally {
await stop()
}
-82
View File
@@ -1115,35 +1115,6 @@ button.video-tile {
object-fit: cover;
}
/* The live captions of a stream, drawn by the video element itself. */
.video-tile-picture::cue {
background: rgba(0, 0, 0, 0.72);
color: #fff;
font-size: clamp(13px, 2.2vw, 20px);
line-height: 1.3;
}
/* A list over the picture, in place of a menu in the window: a stream popped out is another document. */
.stream-menu-scrim {
position: absolute;
inset: 0;
z-index: 2;
}
.context-menu.stream-menu {
position: absolute;
top: var(--spacing-s);
right: var(--spacing-s);
z-index: 3;
max-height: calc(100% - 2 * var(--spacing-s));
overflow-y: auto;
transform-origin: top right;
}
.stream-menu-title {
padding: 2px var(--spacing-s) var(--spacing-xs);
}
/* Nothing to look at: the name and a face, which is what a call without
cameras actually is. */
.video-tile-picture.audio-only {
@@ -10942,56 +10913,3 @@ html[data-reduce-motion] *::after {
color: inherit;
cursor: pointer;
}
/* A message the service took down, kept so that what it said can still be read. */
.message-row.removed .message-body,
.message-row.removed .message-body * {
color: var(--danger-text);
text-decoration: line-through;
}
/* The notice that stands where the words were: nothing to strike through. */
.message-row.removed.replaced .message-body,
.message-row.removed.replaced .message-body * {
color: var(--text-muted);
font-style: italic;
text-decoration: none;
}
/* The way into a thread from a reply, at the end of the line it quotes. */
.reply-preview .reply-thread-open {
flex: none;
margin-left: auto;
padding: 0 4px;
background: none;
border: 0;
color: inherit;
cursor: pointer;
border-radius: var(--radius-xs);
}
.reply-preview .reply-thread-open:hover {
color: var(--surface-variant-text);
background: var(--surface-hover);
}
/* A badge Kick sends as a picture, at the height of the line's small text. */
.sender-badge-image {
display: inline-block;
width: 16px;
height: 16px;
margin-left: var(--spacing-xxs);
object-fit: contain;
vertical-align: middle;
}
/* The code Twitch gives to be approved on twitch.tv: read off one screen and
typed on another, so it is large and spaced. */
.twitch-code {
align-self: flex-start;
padding: 10px 18px;
border: 1px solid var(--surface-variant-text);
border-radius: var(--radius-xs);
font-family: var(--font-mono);
font-size: 28px;
font-weight: var(--weight-bold);
letter-spacing: 0.28em;
user-select: all;
}
-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<!-- Twitch's mark as a plain silhouette: the speech bubble with its two bars. -->
<path fill="#fff" d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0 1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143-3.428 3.428h-3.43l-3 3v-3H6.857V1.714h13.714z"/>
</svg>

Before

Width:  |  Height:  |  Size: 383 B

+1 -67
View File
@@ -4,7 +4,6 @@ import { useEffect, useRef, useState } from 'react'
import { Icon, IconButton, ServiceMark } from './Icon'
import { MatrixAccountTools } from './MatrixAccountTools'
import { SteamAccountTools } from './SteamAccountTools'
import { TwitchSignIn } from './TwitchSignIn'
import { IgnoreList } from './IgnoreList'
import { TunnelAllSwitch } from './settings/Tunnel'
import { useChat, useConnectionDetail, useMapPref, usePref, useStore } from '../state/hooks'
@@ -25,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', 'steam', 'twitch'] as const
const FORMS = ['irc', 'discord', 'sneedchat', 'matrix', 'kick', 'steam'] as const
type AddableService = (typeof FORMS)[number]
/** What `listProtocols` answers with. */
@@ -137,7 +136,6 @@ export function AccountsPanel(): JSX.Element {
{adding === 'matrix' && <MatrixForm />}
{adding === 'kick' && <KickForm onDone={() => setAdding(null)} />}
{adding === 'steam' && <SteamForm onDone={() => setAdding(null)} />}
{adding === 'twitch' && <TwitchForm onDone={() => setAdding(null)} />}
{adding && (
<button type="button" className="button subtle small" onClick={() => setAdding(null)}>
Cancel
@@ -461,7 +459,6 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
{account.service === 'matrix' && <MatrixAccountTools account={account} />}
{account.service === 'steam' && <SteamAccountTools account={account} />}
{account.service === 'twitch' && <TwitchAccountTools account={account} />}
{account.service === 'sneedchat' && (
<>
@@ -1475,69 +1472,6 @@ function SteamForm({ onDone }: { onDone: () => void }): JSX.Element {
)
}
/**
* Adding a Twitch account: sign in, or only read.
*
* Twitch's chat is public, so an account that never signs in still reads every
* channel it is pointed at - the same choice Kick's form offers. Signing in is
* what buys sending, the channels you follow and the emotes you can use.
*/
function TwitchForm({ onDone }: { onDone: () => void }): JSX.Element {
const store = useStore()
const [useTor, setUseTor] = useState(false)
const [busy, setBusy] = useState(false)
const readOnly = (): void => {
setBusy(true)
void window.moho
.rpc('addTwitchAccount', { useTor })
.then(() => {
void store.refreshAccounts()
onDone()
})
.catch((e: Error) => store.toast('error', e.message))
.finally(() => setBusy(false))
}
return (
<div className="add-form">
<TorSwitch service="twitch" on={useTor} onChange={setUseTor} />
<TwitchSignIn useTor={useTor} onDone={onDone}>
<button
type="button"
className="button subtle"
disabled={busy}
title="Read channels without an account. Twitch's chat is public; sending needs a sign-in."
onClick={readOnly}
>
Read without signing in
</button>
</TwitchSignIn>
</div>
)
}
/**
* What a Twitch account can be asked to do from its own card: an account that
* only reads can sign in, and one whose sign-in Twitch has stopped accepting
* can sign in again. The new sign-in is a Twitch account of its own, under the
* login that approved it, so an anonymous reader is left where it was.
*/
function TwitchAccountTools({ account }: { account: Account }): JSX.Element | null {
const refused = account.state === 'auth_failed'
if (account.hasPassword && !refused) return null
return (
<div className="account-tools">
<p className="small muted">
{refused
? 'Twitch no longer accepts this sign-in. Signing in again keeps its channels and settings.'
: 'This account only reads. Sign in to send messages and to see the channels you follow.'}
</p>
<TwitchSignIn useTor={account.useTor} onDone={() => undefined} label={refused ? 'Sign in again' : 'Sign in'} />
</div>
)
}
/**
* Whether an account's connections go through Tor or a SOCKS5 proxy.
*
+2 -2
View File
@@ -864,13 +864,13 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
// A Kick account with no sign-in watches; it cannot talk. Offering the
// box anyway meant typing a message the service was always going to refuse.
if (account && (account.service === 'kick' || account.service === 'twitch') && !account.hasPassword) {
if (account && account.service === 'kick' && !account.hasPassword) {
return (
<div className="composer">
<div className="divider-h" />
<div className="composer-watching small muted">
<Icon name="visibility" size={16} />
<span>Watching signed out. Sign in to {account.service === 'twitch' ? 'Twitch' : 'Kick'} to talk here.</span>
<span>Watching signed out. Sign in to Kick to talk here.</span>
<button type="button" className="button" onClick={() => store.setActivePanel('accounts')}>
Sign in
</button>
+1 -20
View File
@@ -74,8 +74,6 @@ export function JoinPanel(): JSX.Element {
return <DiscordJoin account={account} />
case 'kick':
return <KickJoin account={account} />
case 'twitch':
return <TwitchJoin account={account} />
case 'sneedchat':
return <SneedchatRooms account={account} />
case 'steam':
@@ -84,7 +82,7 @@ export function JoinPanel(): JSX.Element {
return <div className="panel muted">Joining isn&apos;t supported for this service yet.</div>
}
})()
const joinable = all.filter((a) => ['irc', 'matrix', 'discord', 'kick', 'sneedchat', 'steam', 'twitch'].includes(a.service))
const joinable = all.filter((a) => ['irc', 'matrix', 'discord', 'kick', 'sneedchat', 'steam'].includes(a.service))
return (
<div className="join-frame">
@@ -363,23 +361,6 @@ function KickJoin({ account }: { account: Account }): JSX.Element {
)
}
function TwitchJoin({ account }: { account: Account }): JSX.Element {
const store = useStore()
return (
<div className="panel join-panel">
<SubmitField
label="Open a streamer's chat"
placeholder="channel name, or a twitch.tv link"
onSubmit={(name) => void store.joinAndOpen(account.id, name)}
/>
<p className="small muted">
Twitch chat is public, so this works without signing in. Signing in adds talking and the
channels you follow. A channel that does not exist is said so and not kept.
</p>
</div>
)
}
/** One person in a friend request. */
interface SteamPerson {
steamid: string
+1 -8
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { Icon } from './Icon'
import { useChat, useStore } from '../state/hooks'
import { useStore } from '../state/hooks'
import { useEscapeLayer } from '../lib/layers'
/**
@@ -43,13 +43,6 @@ export function KickPoints({ bufferId }: { bufferId: string }): JSX.Element | nu
const [points, setPoints] = useState<number | null>(null)
// Told by Kick the moment it changes, where the daemon is following it; the
// timer below is for a channel that is not live, which is not followed.
const pushed = useChat((s) => s.kickPointsByBuffer[bufferId])
useEffect(() => {
if (pushed !== undefined) setPoints(pushed)
}, [pushed])
const refresh = (): void => {
void window.moho
.rpc<Answer>('listKickRewards', { bufferId })
+8 -15
View File
@@ -12,7 +12,6 @@ import { RouteAccount, useStrictRoute } from '../lib/route'
import { useChat, usePref, useStore } from '../state/hooks'
import { bufferDisplayName, dayLabel, isChatKind, startsNewDay } from '../lib/util'
import type { ChannelIndex } from '../lib/format'
import { applyRemoved, type RemovedMessages } from '../lib/moderation'
import type { ChatMessage } from '../state/store'
/**
@@ -235,8 +234,6 @@ export function MessageList(): JSX.Element {
const [showPolls] = usePref<boolean>('kick.showPolls', true)
const [showStream] = usePref<boolean>('kick.showStreamEvents', true)
const [showKickMod] = usePref<boolean>('kick.showModeration', true)
// What Kick took down: left out, struck through, or replaced by a notice.
const [removedMode] = usePref<RemovedMessages>('kick.removedMessages', 'strike')
const [showReaders] = usePref<boolean>('matrix.showReadReceipts', true)
const [blockedNicks] = usePref<string[]>('blockedNicks', [])
const readers = useChat((s) => s.readersByBuffer)[bufferId]
@@ -259,8 +256,7 @@ export function MessageList(): JSX.Element {
const threadReplies = useMemo(() => {
const counts: Record<string, number> = {}
for (const m of all) {
const root = m.replyTo?.thread ? m.replyTo.id : m.replyTo?.threadRoot
if (root) counts[root] = (counts[root] ?? 0) + 1
if (m.replyTo?.thread) counts[m.replyTo.id] = (counts[m.replyTo.id] ?? 0) + 1
}
return counts
}, [all])
@@ -283,17 +279,14 @@ export function MessageList(): JSX.Element {
stream: showStream,
moderation: showKickMod
}
return applyRemoved(
all.filter((m) => {
if (accountId && blockedNicks.includes(`${accountId}|${m.from}`)) return false
const kind = m.kind || 'chat'
if (isChatKind(kind) || kind === 'system') return true
return allowed[kind] !== false
}),
removedMode
)
return all.filter((m) => {
if (accountId && blockedNicks.includes(`${accountId}|${m.from}`)) return false
const kind = m.kind || 'chat'
if (isChatKind(kind) || kind === 'system') return true
return allowed[kind] !== false
})
}, [
all, accountId, blockedNicks, removedMode,
all, accountId, blockedNicks,
showJoin, showPart, showNick, showTopic, showMode,
showMxJoin, showMxInvite, showMxKick, showMxQuit,
showRedemptions, showSubs, showRaids, showPolls, showStream, showKickMod
+11 -53
View File
@@ -61,7 +61,6 @@ import {
nickColor
} from '../lib/util'
import { useEscapeLayer } from '../lib/layers'
import { removedReason } from '../lib/moderation'
/**
* A badge, short enough to sit beside a name.
@@ -85,11 +84,7 @@ function badgeLabel(badge: MessageBadge): string {
// Not a title like the rest of these - a warning that whoever sent this
// was using a device nobody has vouched for. Without its own short form
// the fallback below would abbreviate the explanation into "SEN".
unverified: '⚠',
// Twitch's: somebody's first message in a channel, and a message mirrored
// from another channel of a shared chat.
'first-message': 'NEW',
'shared-chat': 'SHARED'
unverified: '⚠'
}
return short[badge.type] ?? badge.text.slice(0, 3).toUpperCase()
}
@@ -355,10 +350,6 @@ function MessageRowBody({
// preview. The object itself, so the row only re-renders when that one
// message changes.
const replyId = message.replyTo && !message.replyTo.thread && !message.replyTo.forwarded ? message.replyTo.id : ''
// The thread open beside the room, if any: a reply to its first message has
// nothing to say that the panel's own heading does not.
const openRoot = useChat((s) => s.openThread?.rootId)
const [showBadgePictures] = usePref<boolean>('kick.showBadgePictures', true)
const original = useChat((s) => (replyId ? s.messagesByBuffer[bufferId]?.find((m) => m.id === replyId) : undefined))
// Which message is open for editing is the window's, not each row's, so
// Up arrow in the message box can open the last one.
@@ -824,7 +815,6 @@ function MessageRowBody({
isWarning(message.kind, message.id) && 'warning',
message.pending && 'pending',
message.failed && 'failed',
message.moderated && (message.moderated.replaced ? 'removed replaced' : 'removed'),
isSystem && 'system',
reward && 'reward',
kickEvent && 'has-kick-event',
@@ -938,31 +928,15 @@ function MessageRowBody({
{timeLabel}
</span>
)}
{message.badges?.map((badge) =>
badge.image ? (
// Kick's own pictures, unless they are turned off. The tooltip
// and the alt text say what it is for anybody who cannot see it.
showBadgePictures && (
<img
key={badge.type}
className="sender-badge-image"
src={media(badge.image)}
alt={badge.text}
title={badge.text}
loading="lazy"
draggable={false}
/>
)
) : (
<span
key={badge.type}
className={`sender-badge small ${badge.type}`}
title={badge.count ? `${badge.text} — ${badge.count} months` : badge.text}
>
{badgeLabel(badge)}
</span>
)
)}
{message.badges?.map((badge) => (
<span
key={badge.type}
className={`sender-badge small ${badge.type}`}
title={badge.count ? `${badge.text} — ${badge.count} months` : badge.text}
>
{badgeLabel(badge)}
</span>
))}
</span>
)}
@@ -1003,7 +977,6 @@ function MessageRowBody({
the panel's title already says. */}
{message.replyTo &&
!(inThread && message.replyTo.thread) &&
!(inThread && message.replyTo.id === openRoot) &&
(message.replyTo.thread && !inThread ? (
<button
type="button"
@@ -1051,21 +1024,6 @@ function MessageRowBody({
{message.replyTo.from}
</span>
<span className="ellipsis">{message.replyTo.body}</span>
{/* Kick groups a chain of replies under the message it began
with; a reply is a way into that conversation. */}
{!inThread && message.replyTo.threadRoot && (
<button
type="button"
className="reply-thread-open small"
title="Open the thread"
onClick={(e) => {
e.stopPropagation()
void store.openThreadPanel(bufferId, message.replyTo!.threadRoot!)
}}
>
<Icon name="forum" size={13} />
</button>
)}
</div>
))}
@@ -1136,7 +1094,7 @@ function MessageRowBody({
<KickEventCard embed={kickEvent} />
) : (
parts.html && (
<span className="message-body selectable" title={message.moderated && !message.moderated.replaced ? removedReason(message) : undefined}>
<span className="message-body selectable">
<RichText
html={parts.html}
// Ours unless the sender sent their own formatting, which
+6 -7
View File
@@ -88,10 +88,10 @@ function kickPermissions(members: Member[], me: string): Record<string, boolean>
/**
* Why the list says what it says, for the one service where it needs saying.
*/
function rosterNote(service: string | undefined, total: number, listed: number): string | undefined {
if (service !== 'kick' && service !== 'twitch') return undefined
const base = `${service === 'twitch' ? 'Twitch' : 'Kick'} has no viewer list — this counts everybody who has spoken here since you opened it`
return total > listed ? `${base}. The ${listed.toLocaleString()} most recent are listed.` : base
function rosterNote(service: string | undefined): string | undefined {
return service === 'kick'
? 'Kick has no viewer list — this is everybody who has spoken here recently'
: undefined
}
/**
@@ -140,7 +140,6 @@ export function NickList(): JSX.Element {
const [query, setQuery] = useState('')
const members = (buffer && presenceByBuffer[buffer.id]) || []
const total = useChat((s) => (buffer ? s.presenceTotalByBuffer[buffer.id] : undefined)) ?? members.length
const account = buffer && accounts.find((a) => a.id === buffer.accountId)
// Any protocol that reports presence gets the online/offline split.
const hasPresence = members.some((m) => m.status !== undefined)
@@ -264,8 +263,8 @@ export function NickList(): JSX.Element {
// Named for what the list actually is. Kick has no viewer list to
// ask for - it is built from who has spoken - and calling that
// "members" would claim to know something nobody here can know.
<span className="small muted ellipsis" title={rosterNote(account?.service, total, members.length)}>
{total.toLocaleString()} {account?.service === 'kick' || account?.service === 'twitch' ? 'recently talking' : 'members'}
<span className="small muted ellipsis" title={rosterNote(account?.service)}>
{members.length} {account?.service === 'kick' ? 'recently talking' : 'members'}
</span>
)}
<IconButton
+42 -195
View File
@@ -1,12 +1,9 @@
import { useEffect, useRef, useState } from 'react'
import Hls from 'hls.js'
import { Icon, IconButton } from './Icon'
import { ivsAbr, ivsLoader } from '../lib/ivsplaylist'
import { autoCeiling, captionTrack, menuOrder, renditionLabel, renditionOf, type CaptionTrack, type Rendition } from '../lib/streamplayer'
import { useEscapeLayer } from '../lib/layers'
import { IconButton } from './Icon'
import { VideoStage } from './VideoStage'
import { fitTiles } from './CallStage'
import { useChat, usePref, useStore } from '../state/hooks'
import { useChat, useStore } from '../state/hooks'
import { bufferDisplayName } from '../lib/util'
import { StageWindow, STREAM_WINDOW_NAME } from './stage/StageWindow'
@@ -27,68 +24,8 @@ import { StageWindow, STREAM_WINDOW_NAME } from './stage/StageWindow'
* stream has one picture, and the grid a call draws would be a grid of one.
*/
/** What automatic choice of rendition may go up to: a picture this size beside a chat has no use for more. */
const AUTO_MAX_HEIGHT = 720
/**
* How far behind the newest segment the player settles, in seconds.
*
* In seconds because hls.js counts the other way in multiples of the playlist's target duration, and Kick's
* playlists say six seconds for segments that are two: "three segments" was eighteen seconds behind, which is
* where most of the delay between the broadcaster and the picture came from. With the prefetched segments
* (see lib/ivsplaylist.ts) this is a few seconds behind the broadcaster, and no catch-up speed: playing a little
* fast to close the gap was measured to stall.
*/
const LIVE_SYNC_SECONDS = 4
const LIVE_MAX_LATENCY_SECONDS = 10
/** The renditions of a player's playlist, as the choices are made from them. */
function renditionsOf(player: Hls): Rendition[] {
return player.levels.map((l) => ({ height: l.height, frameRate: Number(l.attrs?.['FRAME-RATE']) || undefined, bitrate: l.bitrate }))
}
interface MenuChoice {
label: string
detail?: string
checked: boolean
onClick: () => void
}
/** A short list drawn over the picture, since a popped-out window is a document of its own and a menu in this one would open in the wrong place. */
function StreamMenu({ title, choices, onClose }: { title: string; choices: MenuChoice[]; onClose: () => void }): JSX.Element {
useEscapeLayer(onClose)
return (
<>
<div className="stream-menu-scrim" onClick={onClose} />
<div className="context-menu stream-menu" role="menu" aria-label={title}>
<div className="stream-menu-title small muted">{title}</div>
{choices.map((c) => (
<button
key={c.label}
type="button"
role="menuitemradio"
aria-checked={c.checked}
className="context-menu-item"
onClick={() => {
c.onClick()
onClose()
}}
>
<span className="context-menu-label">{c.label}</span>
{c.detail && <span className="small muted">{c.detail}</span>}
{c.checked && <Icon name="check" size={16} className="context-menu-check" />}
</button>
))}
</div>
</>
)
}
interface Choice {
index: number
label: string
height: number
}
/** The rendition ceiling. See the comment where it is used. */
const MAX_HEIGHT = 720
export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'window' }): JSX.Element | null {
const store = useStore()
@@ -99,30 +36,17 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
const video = useRef<HTMLVideoElement>(null)
const frame = useRef<HTMLDivElement>(null)
const hls = useRef<Hls | null>(null)
/** The newest playback URL, for a player that has to begin again to reach without being rebuilt every time
* one arrives. */
/** The newest playback URL, for the error path to reach without being
* rebuilt every time one arrives. */
const latest = useRef('')
const [room, setRoom] = useState({ width: 640, height: 260 })
const [muted, setMuted] = useState(false)
/** What the stream offers, once its playlist has been read. */
const [choices, setChoices] = useState<Choice[]>([])
const [captions, setCaptions] = useState<CaptionTrack[]>([])
/** What automatic choice is playing now, for the menu to say. */
const [playing, setPlaying] = useState('')
const [menu, setMenu] = useState<'quality' | 'captions' | null>(null)
// Remembered between streams: 0 is automatic, and an empty language is captions off.
const [quality, setQuality] = usePref<number>('stream.quality', 0)
const [captionLanguage, setCaptionLanguage] = usePref<string>('stream.captions', '')
const stream = watching ? streams[watching.bufferId] : undefined
// The newest address rather than the one this began with: the signature in it expires, and the daemon asks for
// a new one before it does. The player is not rebuilt for it - it is only where a player that has to start
// over, after being refused, starts from.
// The current one rather than the one this started with: the signature in a
// playback URL expires, and the refresh already running is what replaces it.
const url = stream?.playbackUrl ?? ''
latest.current = url
// A different picture is a different stream or a different broadcast; the same one with a new address is not.
const picture = `${watching?.bufferId ?? ''}|${stream?.livestreamId ?? ''}`
const hasUrl = url !== ''
// The same measuring the call stage does, for the same reason: this is a
// different shape in a narrow window, a full-screen one, and the corner.
@@ -154,61 +78,49 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
}
}, [mode, minimized, watching])
// The player itself, torn down and rebuilt only when the picture changes - not when the viewer count ticks over,
// and not when the address is renewed, which it is every few minutes.
// The player itself, torn down and rebuilt only when the picture changes -
// not when the viewer count ticks over, which it does every minute.
useEffect(() => {
const el = video.current
if (!el || !hasUrl || minimized) return
if (!el || !url || minimized) return
if (!Hls.isSupported()) {
store.toast('error', 'This build cannot play a stream')
return
}
const player = new Hls({
// Kick's playlists, which list what is being made as well as what is made.
loader: ivsLoader(2),
abrController: ivsAbr(),
// How far behind to play, in seconds: see LIVE_SYNC_SECONDS.
liveSyncDuration: LIVE_SYNC_SECONDS,
liveMaxLatencyDuration: LIVE_MAX_LATENCY_SECONDS,
// Never fetch a rendition larger than the box it is drawn in, when the choice is automatic.
capLevelToPlayerSize: true,
// A stream that runs for hours must not keep them all.
backBufferLength: 20
// Enough buffer to ride out a hiccup, not so much that "live" means two
// minutes ago: this is a chat window beside the stream, and people talk
// about what just happened.
liveSyncDurationCount: 3,
// Never fetch a rendition larger than the box it is drawn in.
capLevelToPlayerSize: true
})
hls.current = player
player.attachMedia(el)
player.loadSource(latest.current)
player.loadSource(url)
player.on(Hls.Events.MANIFEST_PARSED, () => {
setChoices(menuOrder(renditionsOf(player)))
// 1080p60 is 5.8 Mbps and a real amount of CPU to decode beside a chat
// client, and the ladder below it is cheaper for a picture this size.
// A ceiling rather than a fixed choice: the bitrate ladder underneath
// still adapts to the connection.
const ceiling = player.levels.reduce(
(best, level, i) => (level.height <= MAX_HEIGHT && level.height >= (player.levels[best]?.height ?? 0) ? i : best),
-1
)
if (ceiling >= 0) player.autoLevelCapping = ceiling
void el.play().catch(() => {
/* A picture that will not start is not worth an error dialog. */
})
})
player.on(Hls.Events.LEVEL_LOADED, (_e, data) => {
// Behind by a segment and a half at the least: where they are 5 seconds long, 4 seconds is less than one.
const longest = Math.max(0, ...data.details.fragments.map((f) => f.duration))
const behind = Math.max(LIVE_SYNC_SECONDS, longest * 1.5)
if (player.config.liveSyncDuration !== behind) {
player.config.liveSyncDuration = behind
player.config.liveMaxLatencyDuration = Math.max(LIVE_MAX_LATENCY_SECONDS, behind * 2.5)
}
})
player.on(Hls.Events.LEVEL_SWITCHED, (_e, data) => {
const level = player.levels[data.level]
if (level) setPlaying(renditionLabel({ height: level.height, frameRate: Number(level.attrs?.['FRAME-RATE']) || undefined }))
})
player.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_e, data) => {
setCaptions(data.subtitleTracks.map((t) => ({ id: t.id, name: t.name, lang: t.lang })))
})
player.on(Hls.Events.ERROR, (_e, data) => {
if (!data.fatal) return
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
// A refused segment is an expired token, not a dead stream: the daemon
// renews the address before it expires, so the next one is already
// here and this only has to begin from it. Reloading the source picks
// up whichever one is current.
// A refused segment is an expired token, not a dead stream: the
// daemon re-asks Kick about the channel every minute, so the next
// playback URL is already on its way and this only has to wait for
// it. Reloading the source picks up whichever one is current.
if (latest.current) player.loadSource(latest.current)
player.startLoad()
return
@@ -224,34 +136,8 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
return () => {
player.destroy()
hls.current = null
setChoices([])
setCaptions([])
setPlaying('')
}
}, [hasUrl, picture, minimized, store])
// Which rendition: automatic, or the height that was chosen when this stream offers it.
useEffect(() => {
const player = hls.current
if (!player || choices.length === 0) return
const wanted = quality > 0 ? renditionOf(renditionsOf(player), quality) : -1
if (wanted >= 0) {
player.autoLevelCapping = -1
player.currentLevel = wanted
} else {
player.currentLevel = -1
player.autoLevelCapping = autoCeiling(renditionsOf(player), AUTO_MAX_HEIGHT)
}
}, [quality, choices])
// Which captions: none, or the language that was chosen when this stream has captions in it.
useEffect(() => {
const player = hls.current
if (!player) return
const track = captionTrack(captions, captionLanguage, navigator.language)
player.subtitleDisplay = track >= 0
player.subtitleTrack = track
}, [captionLanguage, captions])
}, [url, minimized, store, watching?.bufferId])
// A stream that has ended is not a picture any more, and the surface should
// not sit there showing the last frame of it.
@@ -266,7 +152,6 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
const where = buffers.find((b) => b.id === watching.bufferId)?.name ?? ''
const fit = fitTiles(1, room.width, room.height, Infinity)
const captionsOn = captionTrack(captions, captionLanguage, navigator.language) >= 0
const watchers = stream?.viewers
const subtitle = `Live${watchers ? ` · ${watchers.toLocaleString()} watching` : ''}${
where ? ` · ${bufferDisplayName(where)}` : ''
@@ -286,59 +171,21 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
onPopOut={mode === 'window' ? undefined : () => store.setStreamPoppedOut(true)}
onBringBack={() => store.setStreamPoppedOut(false)}
controls={
<>
<IconButton
name={captionsOn ? 'closed_caption' : 'closed_caption_disabled'}
title={captions.length === 0 ? 'No captions for this stream' : captionsOn ? 'Captions on' : 'Captions off'}
className={captionsOn ? 'active' : undefined}
disabled={captions.length === 0}
onClick={() => {
if (captions.length === 1) setCaptionLanguage(captionsOn ? '' : captions[0].lang || captions[0].name)
else setMenu(menu === 'captions' ? null : 'captions')
}}
/>
<IconButton
name="high_quality"
title={quality > 0 ? `Resolution: ${quality}p` : 'Resolution: automatic'}
disabled={choices.length === 0}
onClick={() => setMenu(menu === 'quality' ? null : 'quality')}
/>
<IconButton
name={muted ? 'volume_off' : 'volume_up'}
title={muted ? 'Unmute' : 'Mute'}
className={muted ? 'calling' : undefined}
onClick={() => {
const next = !muted
setMuted(next)
if (video.current) video.current.muted = next
}}
/>
</>
<IconButton
name={muted ? 'volume_off' : 'volume_up'}
title={muted ? 'Unmute' : 'Mute'}
className={muted ? 'calling' : undefined}
onClick={() => {
const next = !muted
setMuted(next)
if (video.current) video.current.muted = next
}}
/>
}
>
<div className={mode === 'window' ? 'video-stage in-window' : 'video-stage'} ref={frame}>
<div className="video-tile" style={{ width: `${Math.floor(fit.tileWidth)}px` }}>
<video ref={video} className="video-tile-picture" autoPlay playsInline />
{menu === 'quality' && (
<StreamMenu
title="Resolution"
onClose={() => setMenu(null)}
choices={[
{ label: 'Auto', detail: quality === 0 && playing ? playing : undefined, checked: quality === 0 || !choices.some((c) => c.height === quality), onClick: () => setQuality(0) },
...choices.map((c) => ({ label: c.label, checked: quality === c.height, onClick: () => setQuality(c.height) }))
]}
/>
)}
{menu === 'captions' && (
<StreamMenu
title="Captions"
onClose={() => setMenu(null)}
choices={[
{ label: 'Off', checked: !captionsOn, onClick: () => setCaptionLanguage('') },
...captions.map((t) => ({ label: t.name, checked: captionsOn && captionTrack(captions, captionLanguage, navigator.language) === t.id, onClick: () => setCaptionLanguage(t.lang || t.name) }))
]}
/>
)}
</div>
</div>
</VideoStage>
+4 -10
View File
@@ -5,7 +5,6 @@ import { IconButton } from './Icon'
import { Composer } from './Composer'
import { useChat, usePref, useStore } from '../state/hooks'
import type { ChatMessage } from '../state/store'
import { applyRemoved, type RemovedMessages } from '../lib/moderation'
/**
* A thread, beside the room it belongs to.
@@ -27,7 +26,6 @@ export function ThreadPanel(): JSX.Element | null {
// to ask for `ui.*`, which nothing writes, so it ignored both.
const [comfy] = usePref<'classic' | 'comfy' | 'bubbles'>('display.messageMode', 'comfy')
const [relativeTimestamps] = usePref<boolean>('display.relativeTimestamps', false)
const [removedMode] = usePref<RemovedMessages>('kick.removedMessages', 'strike')
const store = useStore()
// Before the early return below, as every hook here has to be.
const shared = useRowContext(thread?.bufferId ?? '')
@@ -38,15 +36,15 @@ export function ThreadPanel(): JSX.Element | null {
const messages = useMemo((): ChatMessage[] => {
if (!thread) return []
const live = (messagesByBuffer[thread.bufferId] || []).filter(
(m) => m.id === thread.rootId || (m.replyTo?.thread && m.replyTo.id === thread.rootId) || m.replyTo?.threadRoot === thread.rootId
(m) => m.id === thread.rootId || (m.replyTo?.thread && m.replyTo.id === thread.rootId)
)
// By id, so a message that is both fetched and live appears once. The
// live copy wins: it is the one carrying edits and reactions that have
// happened since the fetch.
const byId = new Map<string, ChatMessage>()
for (const m of [...thread.messages, ...live]) byId.set(m.id, m)
return applyRemoved([...byId.values()].sort((a, b) => a.ts - b.ts), removedMode)
}, [thread, messagesByBuffer, removedMode])
return [...byId.values()].sort((a, b) => a.ts - b.ts)
}, [thread, messagesByBuffer])
// Opened on, and following, the newest reply: a thread is read from its end.
const count = messages.length
@@ -55,10 +53,6 @@ export function ThreadPanel(): JSX.Element | null {
if (el) el.scrollTop = el.scrollHeight
}, [thread?.rootId, count])
// Not counting the message it grew from - which is not always here: a thread can outlive the scrollback
// its first message was in, and then every message shown is a reply.
const replyCount = messages.filter((m) => m.id !== thread?.rootId).length
if (!thread) return null
return (
@@ -66,7 +60,7 @@ export function ThreadPanel(): JSX.Element | null {
<div className="thread-header">
<span className="thread-title ellipsis">Thread</span>
<span className="small muted">
{replyCount === 1 ? '1 reply' : `${replyCount} replies`}
{Math.max(0, messages.length - 1) === 1 ? '1 reply' : `${Math.max(0, messages.length - 1)} replies`}
</span>
<IconButton name="close" title="Close thread" onClick={() => store.closeThreadPanel()} />
</div>
@@ -1,182 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { useStore } from '../state/hooks'
/**
* Signing in to Twitch.
*
* Nothing is typed here, and no password is ever seen. Twitch gives a short
* code, this shows it, and the person approves it on twitch.tv in their own
* browser, signed in however they sign in (see nobilis backend/twitch/login.rs).
* Waiting can take minutes, so it is a conversation of events rather than one
* request: the code, what it is doing, and the result.
*/
interface Scope {
scope: string
why: string
}
interface Code {
userCode: string
verificationUri: string
expiresIn: number
scopes: Scope[]
at: number
}
/** The scopes the sign-in asks for, said before it is started so nobody has to guess. */
const ASKS: Scope[] = [
{ scope: 'chat:read', why: 'read chat' },
{ scope: 'chat:edit', why: 'send messages' },
{ scope: 'user:write:chat', why: 'hear why Twitch turned a message down' },
{ scope: 'user:read:follows', why: 'list the channels you follow' },
{ scope: 'user:read:emotes', why: 'show the emotes you can use' }
]
export function TwitchSignIn({
useTor,
onDone,
label = 'Sign in with Twitch',
children
}: {
useTor: boolean
onDone: () => void
label?: string
/** Other things to offer beside the sign-in button, while nothing is waiting. */
children?: React.ReactNode
}): JSX.Element {
const store = useStore()
const [busy, setBusy] = useState(false)
const [detail, setDetail] = useState('')
const [code, setCode] = useState<Code | null>(null)
const [left, setLeft] = useState(0)
// The id lives in a ref as well as state, because the first event can be
// handled before the render that follows the answer to the request that
// started the sign-in. Nothing else is signing in to Twitch from here, so an
// event arriving before the id is known can only be this one's.
const current = useRef('')
useEffect(() => {
const off = window.moho.onEvent((e) => {
if (!e.event.startsWith('twitchLogin')) return
const d = e.data as {
loginId?: string
detail?: string
userCode?: string
verificationUri?: string
expiresIn?: number
scopes?: Scope[]
success?: boolean
error?: string
}
if (current.current && d.loginId !== current.current) return
if (e.event === 'twitchLoginCode' && d.userCode && d.verificationUri) {
setCode({ userCode: d.userCode, verificationUri: d.verificationUri, expiresIn: d.expiresIn ?? 1800, scopes: d.scopes ?? ASKS, at: Date.now() })
} else if (e.event === 'twitchLoginStatus') {
setDetail(d.detail || '')
} else if (e.event === 'twitchLoginResult') {
current.current = ''
setBusy(false)
setCode(null)
setDetail('')
if (d.success) {
void store.refreshAccounts()
onDone()
} else {
store.toast('error', d.error || 'Twitch sign-in failed')
}
}
})
return off
}, [onDone, store])
// Closing the form on a sign-in that is still waiting tells the daemon
// nobody is, so it stops asking Twitch and frees what it was holding.
useEffect(
() => () => {
if (current.current) void window.moho.rpc('cancelTwitchLogin', { loginId: current.current }).catch(() => {})
},
[]
)
// How long the code has left, counted down so nobody types one that has run out.
useEffect(() => {
if (!code) return
const tick = (): void => setLeft(Math.max(0, code.expiresIn - Math.floor((Date.now() - code.at) / 1000)))
tick()
const timer = setInterval(tick, 1000)
return () => clearInterval(timer)
}, [code])
const begin = (): void => {
setBusy(true)
setDetail('')
current.current = ''
void window.moho
.rpc<{ loginId: string }>('startTwitchLogin', { useTor })
.then((r) => {
current.current = r.loginId
})
.catch((e: Error) => {
setBusy(false)
store.toast('error', e.message)
})
}
const cancel = (): void => {
if (current.current) void window.moho.rpc('cancelTwitchLogin', { loginId: current.current }).catch(() => {})
current.current = ''
setBusy(false)
setCode(null)
setDetail('')
}
if (code) {
const minutes = Math.floor(left / 60)
const seconds = String(left % 60).padStart(2, '0')
return (
<div className="add-form">
<p>Approve this on twitch.tv to finish signing in.</p>
<div className="twitch-code" aria-label="Your sign-in code">
{code.userCode}
</div>
<div className="field-row">
<button type="button" className="button primary" onClick={() => void window.moho.openExternal(code.verificationUri)}>
Open twitch.tv
</button>
<button type="button" className="button subtle" onClick={() => void window.moho.copyText(code.userCode)}>
Copy code
</button>
<button type="button" className="button" onClick={cancel}>
Cancel sign-in
</button>
</div>
<p className="small muted">
The page opens with the code already filled in. If it asks for it, type the code above. It is good for{' '}
{left > 0 ? `${minutes}:${seconds}` : 'no time at all - start again'}.
</p>
<p className="small muted">
moho will be allowed to: {code.scopes.map((s) => s.why).join(', ')}. You can end it any time at
twitch.tv/settings/connections, and removing the account here ends it too.
</p>
{detail && <p className="small muted">{detail}…</p>}
</div>
)
}
return (
<>
<p className="small muted">
You sign in on twitch.tv, in your own browser - moho never sees your password. It asks to be allowed to:{' '}
{ASKS.map((s) => s.why).join(', ')}.
</p>
<div className="field-row">
<button type="button" className="button primary" disabled={busy} onClick={begin}>
{busy ? 'Asking Twitch…' : label}
</button>
{children}
</div>
{busy && detail && <p className="small muted">{detail}…</p>}
</>
)
}
+1 -1
View File
@@ -58,7 +58,7 @@ export function UserFooter({ account }: { account?: Account }): JSX.Element {
const detail = details[account.id]
// A signed-out Kick account is a reader: it has no presence to show, and
// "Online" claimed one.
const watching = (account.service === 'kick' || account.service === 'twitch') && !account.hasPassword && status !== 'connecting' && status !== 'offline'
const watching = account.service === 'kick' && !account.hasPassword && status !== 'connecting' && status !== 'offline'
// Mute and deafen are the whole client's, for whatever call is up - so
// shown on a service that has calls, or anywhere while one is running,
// and not beside an IRC or Kick account with nothing to apply them to.
@@ -126,7 +126,6 @@ const CATEGORIES: { id: string; label: string; group: string; icon?: string; ser
{ id: 'discord', label: 'Discord', group: 'Networks', service: 'discord' },
{ id: 'matrix', label: 'Matrix', group: 'Networks', service: 'matrix' },
{ id: 'kick', label: 'Kick', group: 'Networks', service: 'kick' },
{ id: 'twitch', label: 'Twitch', group: 'Networks', service: 'twitch' },
{ id: 'about', label: 'About', group: 'About' }
]
@@ -245,8 +244,6 @@ function Page({ id }: { id: string }): JSX.Element | null {
return <DiscordSettings />
case 'kick':
return <KickSettings />
case 'twitch':
return <TwitchSettings />
case 'about':
return <AboutSettings />
default:
@@ -1225,87 +1222,6 @@ function KickSettings(): JSX.Element {
defaultValue={true}
/>
</SettingsSection>
<SettingsSection
title="Badges"
description="Kick's level, founder and event badges, and a channel's own, drawn as pictures beside a name. Moderator, subscriber and the like stay as words."
>
<ToggleSetting
settingKey="kick.showBadgePictures"
label="Show badge pictures"
description="Loaded from Kick's servers as they are shown"
defaultValue={true}
/>
</SettingsSection>
<SettingsSection
title="Messages Kick removes"
description="When a moderator or Kick's automatic moderation takes a message down. The text is kept, so you can still see what was said."
>
<SelectionSetting
settingKey="kick.removedMessages"
label="Removed messages"
description="Struck through in red keeps what was said beside the fact that it is gone"
defaultValue="strike"
options={[
{ label: 'Delete', value: 'remove' },
{ label: 'Strikethrough', value: 'strike' },
{ label: 'Replace with notice', value: 'replace' }
]}
/>
</SettingsSection>
</>
)
}
/**
* Twitch. What is here is what the plan (Gitea issue #55) has built so far:
* where a channel's recent past comes from, and what is said about signing in.
*/
function TwitchSettings(): JSX.Element {
return (
<>
<SettingsSection
title="Twitch"
description="Channels are opened from the Join panel by name. Twitch's chat is public, so an account that never signs in still reads every channel; signing in adds talking and the channels you follow. Add an account from the Accounts pane."
/>
<SettingsSection
title="What to show besides chat"
description="The same choices as Kick's: one setting covers a kind of event on both services."
>
<ToggleSetting settingKey="kick.showSubscriptions" label="Show subscriptions and gifted subs" defaultValue={true} />
<ToggleSetting settingKey="kick.showRaids" label="Show raids" defaultValue={true} />
<ToggleSetting
settingKey="kick.showModeration"
label="Show timeouts and bans"
description="What a moderator did, which everybody in the channel can see happening anyway"
defaultValue={true}
/>
<SelectionSetting
settingKey="kick.removedMessages"
label="Removed messages"
description="When a moderator takes a message down. The text is kept, so you can still see what was said."
defaultValue="strike"
options={[
{ label: 'Delete', value: 'remove' },
{ label: 'Strikethrough', value: 'strike' },
{ label: 'Replace with notice', value: 'replace' }
]}
/>
</SettingsSection>
<SettingsSection
title="Recent messages"
description="Twitch keeps no chat history, so a channel you open starts empty and fills as people speak."
>
<ToggleSetting
settingKey="twitch.recentHistory"
label="Fill a channel with its recent messages"
description="Asks recent-messages.robotty.de, a service run by a third party that remembers each channel's last few hundred lines. It is told which channel you open, through the account's route. Off until you turn it on."
defaultValue={false}
/>
</SettingsSection>
</>
)
}
-21
View File
@@ -82,18 +82,6 @@ export function kickEmoteUrl(id: string): string {
return resolveMediaUrl(drawableEmoteUrl(`https://files.kick.com/emotes/${id}/fullsize`))
}
/**
* The picture for one of Twitch's own emotes, by id.
*
* Public and built from the id, like Discord's and Kick's, so it draws for an
* emote this account cannot use, which is the point of one somebody else sent.
* The dark-background version, at twice the size: chat is drawn on a dark
* surface and an emote is a row's height at most.
*/
export function twitchEmoteUrl(id: string): string {
return resolveMediaUrl(`https://static-cdn.jtvnw.net/emoticons/v2/${encodeURIComponent(id)}/default/dark/2.0`)
}
/**
* The picture for one of Steam's emoticons or stickers, by name.
*
@@ -147,8 +135,6 @@ export function emojiPreview(text: string, smilies: SmilieEntry[] = []): EmojiPr
if (custom) return { src: discordEmojiUrl(custom[2]), label: `:${custom[1]}:` }
const kick = text.match(/^\[emote:(\d+):([^\]]{0,64})\]$/)
if (kick) return { src: kickEmoteUrl(kick[1]), label: `:${kick[2]}:` }
const twitch = text.match(/^\[twitch:emote:([A-Za-z0-9_]+):([^\]]{0,64})\]$/)
if (twitch) return { src: twitchEmoteUrl(twitch[1]), label: twitch[2] }
const steam = steamTokenOf(text)
if (steam) return { src: steamPictureUrl(steam.kind, steam.name), label: `:${steam.name}:` }
const smilie = smilies.find((s) => s.aliases?.includes(text))
@@ -570,13 +556,6 @@ export function formatMessage(text: string, opts: FormatOptions = {}): string {
stow(`<img src="${kickEmoteUrl(id)}" class="custom-emoji" alt=":${name}:">`)
)
// Twitch's own emotes, which arrive as `[twitch:emote:25:Kappa]`: the id for
// the picture and the name for the words, so a screen reader and a copied
// line both read "Kappa". Public and named by the token, like the others.
out = out.replace(/\[twitch:emote:([A-Za-z0-9_]+):([^\]]{0,64})\]/g, (_m, id: string, name: string) =>
stow(`<img src="${twitchEmoteUrl(id)}" class="custom-emoji" alt="${name}" title="${name}">`)
)
// Steam's emoticons and stickers, which arrive as `[steam:emote:name]`. Like
// the two above the picture is public and named by the token, so one that
// somebody else sent draws whether or not this account has it. A sticker is
-55
View File
@@ -1,55 +0,0 @@
import { describe, expect, it } from 'vitest'
import { withPrefetch } from './ivsplaylist'
/** A rendition's playlist as Kick's stream serves one: two-second segments, six-second target, two prefetch lines. */
const playlist = [
'#EXTM3U',
'#EXT-X-VERSION:3',
'#EXT-X-TARGETDURATION:6',
'#EXT-X-MEDIA-SEQUENCE:2939',
'#EXT-X-PROGRAM-DATE-TIME:2026-10-13T22:58:41.442Z',
'#EXTINF:2.000,live',
'https://cdn.example/v1/segment/a',
'#EXT-X-PROGRAM-DATE-TIME:2026-10-13T22:58:43.442Z',
'#EXTINF:2.000,live',
'https://cdn.example/v1/segment/b',
'#EXT-X-PREFETCH:https://cdn.example/v1/segment/c',
'#EXT-X-PREFETCH:https://cdn.example/v1/segment/d',
''
].join('\n')
describe('a playlist that lists what is being made', () => {
it('has each prefetch line become a segment as long as the last complete one, after the complete ones', () => {
const lines = withPrefetch(playlist).split('\n').filter(Boolean)
expect(lines.slice(-4)).toEqual(['#EXTINF:2.000,prefetch', 'https://cdn.example/v1/segment/c', '#EXTINF:2.000,prefetch', 'https://cdn.example/v1/segment/d'])
expect(lines.some((l) => l.startsWith('#EXT-X-PREFETCH'))).toBe(false)
expect(lines.filter((l) => l.startsWith('https://')).length).toBe(4)
})
it('can take only some of them, or none', () => {
expect(withPrefetch(playlist, 1).split('\n').filter((l) => l.startsWith('https://'))).toHaveLength(3)
expect(withPrefetch(playlist, 0)).toBe(playlist)
})
it('is left as it was when it has none, when it is of a stream that has ended, or when nothing is complete to measure by', () => {
const plain = playlist.replace(/#EXT-X-PREFETCH:\S+\n/g, '')
expect(withPrefetch(plain)).toBe(plain)
const ended = `${playlist}#EXT-X-ENDLIST\n`
expect(withPrefetch(ended)).toBe(ended)
const none = ['#EXTM3U', '#EXT-X-TARGETDURATION:6', '#EXT-X-PREFETCH:https://cdn.example/v1/segment/c', ''].join('\n')
expect(withPrefetch(none)).toBe(none)
})
it('keeps everything else about the playlist, in the order it had it', () => {
const out = withPrefetch(playlist).split('\n')
expect(out.slice(0, 5)).toEqual(playlist.split('\n').slice(0, 5))
expect(out).toContain('#EXT-X-PROGRAM-DATE-TIME:2026-10-13T22:58:43.442Z')
})
})
describe('withPrefetch on segments of uneven length', () => {
it('leaves the playlist as it is when the last segment is no guide to the next', () => {
const playlist = ['#EXTM3U', '#EXTINF:3.200,live', 'a.ts', '#EXTINF:5.166,live', 'b.ts', '#EXT-X-PREFETCH:c.ts', ''].join('\n')
expect(withPrefetch(playlist)).toBe(playlist)
})
})
-103
View File
@@ -1,103 +0,0 @@
import Hls from 'hls.js'
/**
* Amazon IVS's low-latency playlists, which Kick's streams are, list what is already complete and then
* what is being made: `#EXT-X-PREFETCH:<address>`, one per segment of the next few seconds. The address
* answers as soon as it is asked and sends the segment as it is produced, so asking early is the way to
* be seconds nearer the broadcaster. IVS's own player does that. hls.js does not know the tag and
* leaves it out, which is why a stream played with it runs several seconds behind one played with
* IVS's.
*
* Here each of those lines becomes a segment of the playlist like any other, the same length as the
* last complete one, so hls.js asks for it and plays it as it arrives.
*/
/**
* The addresses that were listed as prefetch, which hls.js must not judge the connection by: one of
* them is sent as it is made, so it takes as long to arrive as it takes to broadcast (two seconds of
* picture in two seconds), and bandwidth worked out from that is the stream's own bitrate. Taken for
* the connection's, it moved the picture down a rendition at every other segment.
*/
const ahead = new Set<string>()
function remember(address: string): void {
ahead.add(address)
// Only the last few matter; a stream that runs for hours must not grow this for ever.
if (ahead.size > 64) ahead.delete(ahead.values().next().value as string)
}
/** How far apart, in seconds, the longest and shortest segment may be for the next to be taken as their length. */
const UNIFORM_WITHIN = 0.25
export function withPrefetch(playlist: string, take = 2): string {
if (take <= 0 || !playlist.includes('#EXT-X-PREFETCH:') || playlist.includes('#EXT-X-ENDLIST')) return playlist
const lines = playlist.split('\n')
const kept: string[] = []
const ahead_: string[] = []
let length = ''
const lengths: number[] = []
for (const line of lines) {
const prefetch = /^#EXT-X-PREFETCH:(\S+)/.exec(line.trim())
if (prefetch) {
ahead_.push(prefetch[1])
continue
}
const segment = /^#EXTINF:([0-9.]+)/.exec(line)
if (segment) {
length = segment[1]
lengths.push(Number(segment[1]))
}
kept.push(line)
}
// Nothing to say how long they are if no segment is complete yet.
if (!length || ahead_.length === 0) return playlist
// How long a segment still being made will turn out to be is a guess from the ones before it, and only a
// good one where they are all alike. A broadcaster that is not re-encoded by IVS (one that sends its own
// segments) has them of 3 to 5 seconds, and a guess of 3 for a segment of 5 leaves a gap the picture then
// stops in, every few seconds.
if (Math.max(...lengths) - Math.min(...lengths) > UNIFORM_WITHIN) return playlist
while (kept.length > 0 && kept[kept.length - 1].trim() === '') kept.pop()
for (const address of ahead_.slice(0, take)) {
remember(address)
kept.push(`#EXTINF:${length},prefetch`, address)
}
return `${kept.join('\n')}\n`
}
/**
* hls.js's own judge of the connection, told to leave the prefetch segments out of it. See `ahead`.
*/
export function ivsAbr(): typeof Hls.DefaultConfig.abrController {
const Base = Hls.DefaultConfig.abrController as unknown as new (hls: unknown) => Record<string, unknown>
class Abr extends Base {
// Private in hls.js's types and called by each of its three handlers that measure a segment.
ignoreFragment(frag: { url?: string }): boolean {
const parent = (Base.prototype as { ignoreFragment?: (f: unknown) => boolean }).ignoreFragment
return ahead.has(frag.url ?? '') || (parent ? parent.call(this, frag) : false)
}
}
return Abr as unknown as typeof Hls.DefaultConfig.abrController
}
/**
* hls.js's own loader, with the playlist of a rendition read through `withPrefetch`. Everything else it
* loads - the master playlist, the segments, the captions - is as it was.
*/
export function ivsLoader(take = 2): typeof Hls.DefaultConfig.loader {
const Base = Hls.DefaultConfig.loader as unknown as new (config: unknown) => {
load(context: Record<string, unknown>, config: unknown, callbacks: Record<string, (...args: unknown[]) => void>): void
}
class Loader extends Base {
load(context: Record<string, unknown>, config: unknown, callbacks: Record<string, (...args: unknown[]) => void>): void {
if (context.type !== 'level') return super.load(context, config, callbacks)
super.load(context, config, {
...callbacks,
onSuccess: (response, stats, ctx, details) => {
const r = response as { data?: unknown }
if (typeof r.data === 'string') response = { ...r, data: withPrefetch(r.data, take) }
callbacks.onSuccess(response, stats, ctx, details)
}
})
}
}
return Loader as unknown as typeof Hls.DefaultConfig.loader
}
-35
View File
@@ -1,35 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { ChatMessage } from '../state/store'
import { applyRemoved, removedReason, REMOVED_NOTICE } from './moderation'
const said = (id: string, extra: Partial<ChatMessage> = {}): ChatMessage =>
({ id, bufferId: 'b', from: 'x', body: `text ${id}`, ts: 1, isAction: false, isHighlight: false, kind: 'chat', ...extra }) as ChatMessage
const log = [said('a'), said('b', { moderated: { ai: true, rules: ['hate'] }, html: '<b>x</b>', embeds: [{ kind: 'gif' } as never] }), said('c')]
describe('removed messages', () => {
it('are left out when that is chosen', () => {
expect(applyRemoved(log, 'remove').map((m) => m.id)).toEqual(['a', 'c'])
})
it('keep their words when struck through is chosen, and the list is the same one', () => {
expect(applyRemoved(log, 'strike')).toBe(log)
})
it('have a notice put where their words were when that is chosen', () => {
const [, removed] = applyRemoved(log, 'replace')
expect(removed.body).toBe(REMOVED_NOTICE)
expect(removed.html).toBeUndefined()
expect(removed.embeds).toEqual([])
expect(removed.moderated).toEqual({ ai: true, rules: ['hate'], replaced: true })
// And only that one.
expect(applyRemoved(log, 'replace')[0].body).toBe('text a')
})
it('change nothing in a log where nothing was removed', () => {
const clean = [said('a')]
expect(applyRemoved(clean, 'remove')).toBe(clean)
expect(applyRemoved(clean, 'replace')).toBe(clean)
})
it('say why they were removed when they know', () => {
expect(removedReason(log[1])).toBe("Removed by Kick's automatic moderation: hate")
expect(removedReason(said('d', { moderated: {} }))).toBe('Removed')
})
})
-32
View File
@@ -1,32 +0,0 @@
import type { ChatMessage } from '../state/store'
/**
* What to do with a message the service has taken down. The daemon keeps the
* text and says it is gone; this is the window's say in how that looks.
*/
export type RemovedMessages = 'remove' | 'strike' | 'replace'
export const REMOVED_NOTICE = 'This message was removed by moderation'
/** What a removed message says it was removed for, for a tooltip. */
export function removedReason(m: ChatMessage): string {
const rules = m.moderated?.rules ?? []
const by = m.moderated?.ai ? "Removed by Kick's automatic moderation" : 'Removed'
return rules.length > 0 ? `${by}: ${rules.join(', ')}` : by
}
/**
* The messages to draw, with those that were removed handled as chosen:
* left out, kept as they were (the row draws them struck through), or with
* what they said swapped for a notice. A list with none removed comes back
* as it was, so nothing downstream re-renders for a setting it does not use.
*/
export function applyRemoved(messages: ChatMessage[], mode: RemovedMessages): ChatMessage[] {
if (mode === 'strike' || !messages.some((m) => m.moderated)) return messages
if (mode === 'remove') return messages.filter((m) => !m.moderated)
return messages.map((m) =>
m.moderated
? { ...m, body: REMOVED_NOTICE, html: undefined, embeds: [], attachments: [], components: [], moderated: { ...m.moderated, replaced: true } }
: m
)
}
-57
View File
@@ -1,57 +0,0 @@
import { describe, expect, it } from 'vitest'
import { autoCeiling, captionTrack, menuOrder, renditionLabel, renditionOf } from './streamplayer'
const ladder = [
{ height: 160, frameRate: 30, bitrate: 230000 },
{ height: 1080, frameRate: 60, bitrate: 8519450 },
{ height: 720, frameRate: 60, bitrate: 3422999 },
{ height: 480, frameRate: 30, bitrate: 1427999 },
{ height: 360, frameRate: 30, bitrate: 630000 }
]
describe('the renditions a stream offers', () => {
it('are named as Kick names them: the height, and the frame rate where it is above thirty', () => {
expect(renditionLabel({ height: 720, frameRate: 60 })).toBe('720p60')
expect(renditionLabel({ height: 480, frameRate: 30 })).toBe('480p')
expect(renditionLabel({ height: 360 })).toBe('360p')
expect(renditionLabel({ height: 1080, frameRate: 59.94 })).toBe('1080p60')
})
it('are listed tallest first, with where each is in the player\'s own list, and one to a height', () => {
expect(menuOrder(ladder).map((r) => [r.label, r.index])).toEqual([['1080p60', 1], ['720p60', 2], ['480p', 3], ['360p', 4], ['160p', 0]])
expect(menuOrder([...ladder, { height: 720, frameRate: 30, bitrate: 1 }]).filter((r) => r.height === 720)).toHaveLength(1)
})
it('are found by the height somebody wants, or by nothing when it is not offered', () => {
expect(renditionOf(ladder, 480)).toBe(3)
expect(renditionOf(ladder, 900)).toBe(-1)
})
it('have a ceiling for automatic choice: the tallest that fits, the shortest when none does, none when there is no ceiling', () => {
expect(autoCeiling(ladder, 720)).toBe(2)
expect(autoCeiling(ladder, 1440)).toBe(1)
expect(autoCeiling(ladder, 100)).toBe(0)
expect(autoCeiling(ladder, 0)).toBe(-1)
expect(autoCeiling([], 720)).toBe(-1)
})
})
describe('the captions a stream offers', () => {
const tracks = [{ id: 0, name: 'English', lang: 'en' }, { id: 1, name: 'Portuguese', lang: 'pt' }, { id: 2, name: 'Spanish', lang: 'es' }]
it('are off when nothing is wanted or there is nothing to show', () => {
expect(captionTrack(tracks, '')).toBe(-1)
expect(captionTrack([], 'en')).toBe(-1)
})
it('are the language that was chosen, however it was written', () => {
expect(captionTrack(tracks, 'pt')).toBe(1)
expect(captionTrack(tracks, 'PT-BR')).toBe(1)
})
it('fall back to the person\'s own language, and then to the first, when the one chosen is not offered', () => {
expect(captionTrack(tracks, 'ja', 'es')).toBe(2)
expect(captionTrack(tracks, 'ja', 'xx')).toBe(0)
expect(captionTrack(tracks, 'ja')).toBe(0)
})
})
-74
View File
@@ -1,74 +0,0 @@
/**
* The choices a stream's player offers - which rendition, which captions - kept apart from the player so
* that what they mean can be said and tested without one.
*/
/** A rendition as the playlist describes it. */
export interface Rendition {
height: number
frameRate?: number
bitrate?: number
}
/** "720p", and "720p60" for one that moves at 60 frames a second, as Kick's own player calls them. */
export function renditionLabel(r: Rendition): string {
const fps = r.frameRate && r.frameRate > 30 ? Math.round(r.frameRate) : 0
return `${r.height}p${fps || ''}`
}
/**
* The highest rendition that automatic choice may pick: the tallest that is no taller than `ceiling`, or
* the shortest there is when all are taller, since a ceiling below everything would leave nothing to play.
* -1 for no ceiling at all.
*/
export function autoCeiling(renditions: Rendition[], ceiling: number): number {
if (renditions.length === 0 || ceiling <= 0) return -1
let best = -1
let lowest = 0
renditions.forEach((r, i) => {
if (r.height < renditions[lowest].height) lowest = i
if (r.height <= ceiling && (best < 0 || r.height > renditions[best].height || (r.height === renditions[best].height && (r.bitrate ?? 0) > (renditions[best].bitrate ?? 0)))) best = i
})
return best >= 0 ? best : lowest
}
/** The rendition of a wanted height: the one with the most bits if there are two, -1 if there is none. */
export function renditionOf(renditions: Rendition[], height: number): number {
let found = -1
renditions.forEach((r, i) => {
if (r.height === height && (found < 0 || (r.bitrate ?? 0) > (renditions[found].bitrate ?? 0))) found = i
})
return found
}
/** The renditions as a menu lists them, tallest first, with each one's place in the player's own list. */
export function menuOrder(renditions: Rendition[]): { index: number; label: string; height: number }[] {
const seen = new Set<number>()
return renditions
.map((r, index) => ({ index, label: renditionLabel(r), height: r.height, bitrate: r.bitrate ?? 0 }))
.sort((a, b) => b.height - a.height || b.bitrate - a.bitrate)
// Two of one height are one choice.
.filter((r) => (seen.has(r.height) ? false : (seen.add(r.height), true)))
.map(({ index, label, height }) => ({ index, label, height }))
}
/** A caption track as the master playlist lists it. */
export interface CaptionTrack {
id: number
name: string
lang?: string
}
/**
* Which caption track a remembered choice means: its language where there is one, the first track when
* what was chosen is not offered here, and none when captions are off.
*/
export function captionTrack(tracks: CaptionTrack[], wanted: string, language = ''): number {
if (!wanted || tracks.length === 0) return -1
const same = (lang: string): number => tracks.findIndex((t) => (t.lang ?? '').toLowerCase().split('-')[0] === lang.toLowerCase().split('-')[0])
const chosen = same(wanted)
if (chosen >= 0) return tracks[chosen].id
// On, but in a language this stream has none of: the person's own language if it is there, else the first.
const own = language ? same(language) : -1
return tracks[own >= 0 ? own : 0].id
}
-5
View File
@@ -3,7 +3,6 @@ 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'
import twitchSvg from '../assets/twitch-mark.svg'
/**
* Discord channel buffers are named "GuildName/#channel" internally - that's
@@ -73,8 +72,6 @@ export function serviceIcon(service: string): ServiceIcon {
return { mark: matrixSvg }
case 'steam':
return { mark: steamSvg }
case 'twitch':
return { mark: twitchSvg }
case 'irc':
return { glyph: 'tag' }
case 'slack':
@@ -108,8 +105,6 @@ export function serviceLabel(service: string): string {
return 'Kick'
case 'steam':
return 'Steam'
case 'twitch':
return 'Twitch'
case 'slack':
return 'Slack'
case 'jabber':
+11 -66
View File
@@ -333,15 +333,11 @@ export interface KickStream {
* Where the video is: a signed Amazon IVS playlist, good for watching in
* this window rather than in a browser tab beside it.
*
* Replaced shortly before the signature in it expires, which is about every
* seven minutes - so whoever is playing it reads the current one rather
* than the one they started with, and does not start over for a new one.
* Replaced on every refresh, because the signature in it expires - so
* whoever is playing it reads the current one rather than the one they
* started with.
*/
playbackUrl?: string | null
/** Unix seconds the address stops being accepted, where it says. */
playbackExpires?: number | null
/** The broadcast's own id: a different one is a different stream, even in the same channel. */
livestreamId?: number | null
}
/** One box on a form a Discord bot sent. */
@@ -539,10 +535,6 @@ export interface ChatState {
joinPanelAccountId: string
messagesByBuffer: Record<string, ChatMessage[]>
presenceByBuffer: Record<string, Member[]>
/** Points the signed-in account has in each Kick channel, as Kick last pushed them. */
kickPointsByBuffer: Record<string, number>
/** How many people a roster stands for, where the service says it is more than it lists (Kick). */
presenceTotalByBuffer: Record<string, number>
/** Buffers whose initial backlog fetch has completed. */
loadedBuffers: Record<string, true>
loadingMore: Record<string, true>
@@ -794,8 +786,6 @@ const INITIAL: ChatState = {
joinPanelAccountId: '',
messagesByBuffer: {},
presenceByBuffer: {},
presenceTotalByBuffer: {},
kickPointsByBuffer: {},
loadedBuffers: {},
loadingMore: {},
loadingNewer: {},
@@ -2613,7 +2603,7 @@ export class ChatStore {
*/
async openThreadPanel(bufferId: string, rootId: string): Promise<void> {
const known = (this.state.messagesByBuffer[bufferId] || []).filter(
(m) => m.id === rootId || (m.replyTo?.thread && m.replyTo.id === rootId) || m.replyTo?.threadRoot === rootId
(m) => m.id === rootId || (m.replyTo?.thread && m.replyTo.id === rootId)
)
this.set({ openThread: { bufferId, rootId, messages: known, loading: true } })
try {
@@ -2642,22 +2632,15 @@ export class ChatStore {
const open = this.state.openThread
if (!open || (!body.trim() && !attachment)) return
const root = open.messages.find((m) => m.id === open.rootId)
// Kick answers a message, not a thread, and the daemon quotes whatever it
// is told to answer from what it has stored. A thread whose first message
// is older than the scrollback still has replies on screen, and answering
// the first of those keeps the new one in the same thread.
const earliest = !root && this.accountFor(open.bufferId)?.service === 'kick' ? open.messages[0] : undefined
await this.dispatchSend(
open.bufferId,
body,
earliest
? { id: earliest.id, from: earliest.from, body: earliest.body }
: {
id: open.rootId,
from: root?.from ?? '',
body: root?.body ?? '',
thread: true
},
{
id: open.rootId,
from: root?.from ?? '',
body: root?.body ?? '',
thread: true
},
attachment,
spoilers
)
@@ -3609,17 +3592,6 @@ export class ChatStore {
)
break
// Kept rather than removed: the daemon says the service took it down and
// leaves the words, and what that looks like is the window's to decide.
// Kick says the balance changed: the five-minute earn, a bet, a reward.
case 'kickPoints':
this.set({ kickPointsByBuffer: { ...this.state.kickPointsByBuffer, [data.bufferId]: data.points } })
break
case 'messageModerated':
this.mapMessage(data.bufferId, data.id, (m) => ({ ...m, moderated: data.moderated ?? {} }))
break
case 'reactionsChanged':
this.mapMessage(data.bufferId, data.id, (m) => ({ ...m, reactions: data.reactions }))
break
@@ -3979,8 +3951,7 @@ export class ChatStore {
case 'presenceChange':
this.set({
presenceByBuffer: { ...this.state.presenceByBuffer, [data.bufferId]: data.members },
presenceTotalByBuffer: { ...this.state.presenceTotalByBuffer, [data.bufferId]: data.total ?? data.members.length }
presenceByBuffer: { ...this.state.presenceByBuffer, [data.bufferId]: data.members }
})
break
@@ -4519,7 +4490,6 @@ export class ChatStore {
void this.refreshMatrixPermissions(bufferId)
const service = this.accountFor(bufferId)?.service
if (service === 'twitch') void this.loadTwitchRecent(bufferId)
if (service === 'sneedchat') void this.fetchSmilies()
// Both services answer the same question about the open conversation -
// what can go in a message here that isn't text - so both ask it the same
@@ -4696,31 +4666,6 @@ export class ChatStore {
this.set({ lastReadTs })
}
/** The Twitch channels whose recent past has been asked for this session. */
private twitchRecentAsked = new Set<string>()
/**
* A Twitch channel's recent past, once per channel per session, if the person
* has said a third-party service may be asked for it (Settings, Twitch).
* Twitch keeps no history of its own, so without this a channel opens empty
* and fills as people speak. Off by default: it tells somebody else's server
* which channel is being opened.
*/
private async loadTwitchRecent(bufferId: string): Promise<void> {
if (this.twitchRecentAsked.has(bufferId)) return
const prefs = await window.moho.prefs.getAll()
if (prefs['twitch.recentHistory'] !== true) return
this.twitchRecentAsked.add(bufferId)
try {
const r = await window.moho.rpc<{ read: number }>('twitchRecentMessages', { bufferId })
if (r.read > 0) await this.loadBacklog(bufferId)
} catch (e) {
// Not worth interrupting anybody over: the channel works, it just starts empty.
this.twitchRecentAsked.delete(bufferId)
console.debug('twitch recent messages:', (e as Error).message)
}
}
private async loadBacklog(bufferId: string): Promise<void> {
try {
const rows = await window.moho.rpc<Message[]>('getBacklog', { bufferId, before: 0, limit: BACKLOG_PAGE })
+1 -31
View File
@@ -3,7 +3,7 @@
export interface Account {
id: string
service: 'irc' | 'discord' | 'sneedchat' | 'matrix' | 'kick' | 'steam' | 'twitch' | 'jabber' | 'slack'
service: 'irc' | 'discord' | 'sneedchat' | 'matrix' | 'kick' | 'steam' | 'jabber' | 'slack'
displayName: string
/**
* How the user is presenting: "online" | "idle". Distinct from
@@ -419,29 +419,6 @@ export interface ReplyPreview {
* a forward is somebody else's message arriving.
*/
forwarded?: boolean
/**
* The message a whole chain of replies grew from, where the service groups
* them into a thread (Kick does).
*
* Unlike `thread`, `id` here is still the message that was answered - this
* says which thread that message is part of, and a reply can be both.
*/
threadRoot?: string
}
/**
* A message the service took down and the daemon kept.
*
* Whether to show it, strike it through or put a notice in its place is the
* window's choice (`kick.removedMessages`), so the text is still here.
*/
export interface Moderated {
/** Whether the service's automatic moderation did it, rather than a person. */
ai?: boolean
/** The rules it was said to break. */
rules?: string[]
/** Set by the window, never the daemon: this message's text has been swapped for a notice. */
replaced?: boolean
}
/**
@@ -611,8 +588,6 @@ export interface Message {
* button below it.
*/
components?: MessageComponent[]
/** Present once the service has removed the message. */
moderated?: Moderated
/** Matrix only - the sender's full MXID, for targeting moderation actions. */
senderId?: string
/**
@@ -743,11 +718,6 @@ export interface MessageBadge {
text: string
/** Months subscribed, for the badges that count. */
count?: number
/**
* A picture to draw it with, for the badges Kick sends as one - level,
* event and founder badges, a channel's own. Drawn instead of the word.
*/
image?: string
}
export interface CustomEmoji {