Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
335ac8b59d |
@@ -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,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
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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,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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user