Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd44e76970 | ||
|
|
9b29285f71 | ||
|
|
987644ebf1 | ||
|
|
8e3d1100c9 | ||
|
|
a7ef798df5 | ||
|
|
3aff255375 |
@@ -1,23 +0,0 @@
|
||||
# On a merge, .gitmodules keeps the answer this branch already has.
|
||||
#
|
||||
# master names the daemon at github.com because master is what a stranger
|
||||
# clones, and development names it at the Gitea instance where the work
|
||||
# happens. Without this, a merge that touched .gitmodules on both sides would
|
||||
# stop and ask which host is right - and the answer is always "the one the
|
||||
# branch being merged into already said".
|
||||
#
|
||||
# `merge=ours` needs a driver by that name, which is one line per clone and
|
||||
# not something a repository can carry for you:
|
||||
#
|
||||
# git config merge.ours.driver true
|
||||
#
|
||||
# Without it git falls back to an ordinary merge, which conflicts rather than
|
||||
# doing the wrong thing quietly - so a clone that has not run it is no worse
|
||||
# off than before this file existed.
|
||||
#
|
||||
# The edge, because it is a real one: this keeps the *whole file*, not just the
|
||||
# URL. If development ever adds a second submodule, master takes none of that
|
||||
# entry while still taking the gitlink the merge brings with it - a submodule
|
||||
# with nothing in .gitmodules to say where it comes from. Adding a submodule is
|
||||
# therefore a thing to re-do by hand on master, and the only such thing.
|
||||
/.gitmodules merge=ours
|
||||
@@ -1,7 +1,8 @@
|
||||
# Maintainer: Salastil <[email protected]>
|
||||
pkgname=moho-git
|
||||
pkgver=r1.268751e
|
||||
pkgrel=1
|
||||
pkgdesc="Unified desktop chat client for IRC, Discord, Matrix, Kick, Sneedchat and Steam"
|
||||
pkgdesc="Unified desktop chat client for IRC, Discord, Sneedchat and Matrix"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/Moho-Chat/moho"
|
||||
license=('GPL3')
|
||||
|
||||
+1
-1
Submodule nobilis updated: 289b5db54a...4c17be082b
@@ -0,0 +1,99 @@
|
||||
// The page the Kick video probe plays in: one hls.js player per configuration, measured while it plays.
|
||||
import Hls from 'hls.js'
|
||||
import { ivsAbr, ivsLoader } from '../../src/renderer/src/lib/ivsplaylist'
|
||||
|
||||
interface Config {
|
||||
name: string
|
||||
url: string
|
||||
seconds: number
|
||||
prefetch: number
|
||||
hls: Record<string, unknown>
|
||||
cap?: number
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
runProbe: (config: Config) => Promise<Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
|
||||
window.runProbe = async (cfg) => {
|
||||
// How far this clock is from the server's, from the time the playlist says it was made at.
|
||||
const sent = Date.now()
|
||||
const text = await (await fetch(cfg.url)).text()
|
||||
const received = Date.now()
|
||||
const server = Number(/SERVER-TIME",VALUE="([0-9.]+)"/.exec(text)?.[1] ?? NaN)
|
||||
const offset = server * 1000 - (sent + received) / 2
|
||||
return new Promise((resolve) => {
|
||||
const video = document.getElementById('v') as HTMLVideoElement
|
||||
const hls = new Hls({ ...cfg.hls, ...(cfg.prefetch > 0 ? { loader: ivsLoader(cfg.prefetch), abrController: ivsAbr() } : {}) } as never)
|
||||
const latency: number[] = []
|
||||
const levels = new Set<string>()
|
||||
const fatal: string[] = []
|
||||
let stalls = 0
|
||||
let waits = 0
|
||||
let switches = 0
|
||||
let waitStart = 0
|
||||
let waited = 0
|
||||
let firstFrame = 0
|
||||
const began = Date.now()
|
||||
video.muted = true
|
||||
video.onwaiting = (): void => {
|
||||
waits++
|
||||
if (!waitStart && firstFrame) waitStart = Date.now()
|
||||
}
|
||||
video.onplaying = (): void => {
|
||||
if (!firstFrame) firstFrame = Date.now() - began
|
||||
if (waitStart) {
|
||||
waited += Date.now() - waitStart
|
||||
waitStart = 0
|
||||
}
|
||||
}
|
||||
hls.on(Hls.Events.ERROR, (_e, d) => {
|
||||
if (d.details === 'bufferStalledError') stalls++
|
||||
if (d.fatal) fatal.push(d.details)
|
||||
})
|
||||
hls.on(Hls.Events.LEVEL_SWITCHED, (_e, d) => {
|
||||
switches++
|
||||
levels.add(`${hls.levels[d.level]?.height}p`)
|
||||
})
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
if (cfg.cap) {
|
||||
const best = hls.levels.reduce((b, l, i) => (l.height <= (cfg.cap ?? 720) && l.height >= (hls.levels[b]?.height ?? 0) ? i : b), -1)
|
||||
if (best >= 0) hls.autoLevelCapping = best
|
||||
}
|
||||
void video.play().catch(() => {})
|
||||
})
|
||||
hls.attachMedia(video)
|
||||
hls.loadSource(cfg.url)
|
||||
const timer = setInterval(() => {
|
||||
const at = hls.playingDate
|
||||
if (at && !video.paused && firstFrame) latency.push((Date.now() + offset - at.getTime()) / 1000)
|
||||
if (Date.now() - began < cfg.seconds * 1000) return
|
||||
clearInterval(timer)
|
||||
const q = video.getVideoPlaybackQuality()
|
||||
const sorted = [...latency].sort((a, b) => a - b)
|
||||
const pick = (p: number): number => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] ?? NaN
|
||||
const settled = sorted.slice(Math.floor(sorted.length * 0.2))
|
||||
const mean = settled.length ? settled.reduce((a, b) => a + b, 0) / settled.length : NaN
|
||||
hls.destroy()
|
||||
resolve({
|
||||
name: cfg.name,
|
||||
startedInMs: firstFrame,
|
||||
samples: latency.length,
|
||||
latencyMin: +Math.min(...latency).toFixed(2),
|
||||
latencyMax: +Math.max(...latency).toFixed(2),
|
||||
latencyMedian: +pick(0.5).toFixed(2),
|
||||
latencyP95: +pick(0.95).toFixed(2),
|
||||
latencyMeanAfterSettling: +mean.toFixed(2),
|
||||
rebufferSeconds: +(waited / 1000).toFixed(2),
|
||||
waits,
|
||||
stallErrors: stalls,
|
||||
dropped: `${q.droppedVideoFrames}/${q.totalVideoFrames}`,
|
||||
levelSwitches: switches,
|
||||
levels: [...levels].join(','),
|
||||
fatal: fatal.join(',')
|
||||
})
|
||||
}, 1000)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Plays a live Kick stream with several hls.js configurations, one after the other, in the app's own
|
||||
* Chromium, and says for each how far behind the broadcaster it ran, how often it stopped to wait,
|
||||
* and how many frames it dropped. Nothing is signed in: the playlist is public.
|
||||
*
|
||||
* node scripts/probe-kick-video.mjs [channel] [seconds] (defaults: spreen, 75)
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const channel = process.argv[2] ?? 'spreen'
|
||||
const seconds = Number(process.argv[3] ?? 75)
|
||||
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..')
|
||||
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'kick-video-'))
|
||||
execFileSync(path.join(root, 'node_modules/.bin/esbuild'), [path.join(root, 'scripts/lib/hlsprobe.ts'), '--bundle', '--format=iife', `--outfile=${path.join(work, 'probe.js')}`, '--log-level=error'])
|
||||
fs.writeFileSync(path.join(work, 'probe.html'), '<!doctype html><body style="margin:0"><video id="v" width="1280" height="720" autoplay muted playsinline></video><script src="probe.js"></script></body>')
|
||||
|
||||
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
|
||||
const playlist = async () => {
|
||||
const r = await fetch(`https://kick.com/api/v2/channels/${channel}/playback-url`, { headers: { 'User-Agent': UA, Accept: 'application/json' } })
|
||||
return (await r.json()).data
|
||||
}
|
||||
|
||||
const base = { capLevelToPlayerSize: true }
|
||||
const configs = [
|
||||
{ name: 'as it is now (3 target durations, no prefetch)', prefetch: 0, hls: { ...base, liveSyncDurationCount: 3 } },
|
||||
{ name: 'seconds: sync 4s, no prefetch', prefetch: 0, hls: { ...base, liveSyncDuration: 4, liveMaxLatencyDuration: 10 } },
|
||||
{ name: 'prefetch 2, sync 4s', prefetch: 2, hls: { ...base, liveSyncDuration: 4, liveMaxLatencyDuration: 10 } },
|
||||
{ name: 'prefetch 2, sync 3s, catch-up 1.1', prefetch: 2, hls: { ...base, liveSyncDuration: 3, liveMaxLatencyDuration: 8, maxLiveSyncPlaybackRate: 1.1 } },
|
||||
{ name: 'prefetch 1, sync 3s, catch-up 1.1', prefetch: 1, hls: { ...base, liveSyncDuration: 3, liveMaxLatencyDuration: 8, maxLiveSyncPlaybackRate: 1.1 } },
|
||||
{ name: 'prefetch 2, sync 5s, catch-up 1.1', prefetch: 2, hls: { ...base, liveSyncDuration: 5, liveMaxLatencyDuration: 10, maxLiveSyncPlaybackRate: 1.1 } }
|
||||
]
|
||||
if (process.env.LIST === 'steady') {
|
||||
configs.length = 0
|
||||
configs.push(
|
||||
{ name: 'prefetch 2, sync 3s', prefetch: 2, hls: { ...base, liveSyncDuration: 3, liveMaxLatencyDuration: 10 } },
|
||||
{ name: 'prefetch 2, sync 4s', prefetch: 2, hls: { ...base, liveSyncDuration: 4, liveMaxLatencyDuration: 10 } },
|
||||
{ name: 'prefetch 2, sync 5s', prefetch: 2, hls: { ...base, liveSyncDuration: 5, liveMaxLatencyDuration: 10 } }
|
||||
)
|
||||
}
|
||||
const only = process.env.ONLY ? process.env.ONLY.split(',').map(Number) : null
|
||||
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
try {
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
await sleep(2500)
|
||||
await page.call('Page.navigate', { url: `file://${path.join(work, 'probe.html')}` })
|
||||
await sleep(1500)
|
||||
for (const [i, cfg] of configs.entries()) {
|
||||
if (only && !only.includes(i)) continue
|
||||
const url = await playlist()
|
||||
const out = await page.evaluate(`window.runProbe(${JSON.stringify({ ...cfg, url, seconds, cap: 720 })})`)
|
||||
console.log(JSON.stringify(out))
|
||||
}
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import fs from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const ev = (c) => page.evaluate(c)
|
||||
try {
|
||||
try { const cl = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString()); for (const c of cl.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`]) } catch {}
|
||||
await sleep(2500)
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
|
||||
const a = await rpc('addKickAccount', {})
|
||||
await rpc('joinBuffer', { accountId: a.accountId, name: 'spreen' })
|
||||
const id = `${a.accountId}|spreen`
|
||||
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
|
||||
await sleep(20000)
|
||||
console.log(await ev(`JSON.stringify({ images: document.querySelectorAll('.sender-badge-image').length, loaded: [...document.querySelectorAll('.sender-badge-image')].filter((i) => i.complete && i.naturalWidth > 0).length, words: [...document.querySelectorAll('.sender-badge')].map((b) => b.textContent).slice(0, 8), titles: [...new Set([...document.querySelectorAll('.sender-badge-image')].map((i) => i.title))].slice(0, 8) })`))
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(process.env.HOME + '/.local/share/moho-dev/shots/kick-badges.png', Buffer.from(data, 'base64'))
|
||||
} finally { await stop() }
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Watches a real live Kick stream in a scratch moho with no account signed in, and checks what the
|
||||
* player does: that it plays, how far behind it runs, that the resolution menu changes the picture, that
|
||||
* the CC button shows the captions, and that the player is built once and not again as the address is
|
||||
* renewed. Photographs the stream, each menu and the captions.
|
||||
*
|
||||
* node scripts/shot-kick-stream.mjs <output directory> [channel] [idle seconds]
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
const channel = process.argv[3] ?? 'spreen'
|
||||
const idle = Number(process.argv[4] ?? 60)
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
|
||||
const ev = (code) => page.evaluate(code)
|
||||
const shot = async (name) => {
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
}
|
||||
const click = (selector) => ev(`(() => { const b = document.querySelector(${JSON.stringify(selector)}); if (!b || b.disabled) return false; b.click(); return true })()`)
|
||||
const video = (expr) => ev(`(() => { const v = document.querySelector('video.video-tile-picture'); return v ? (${expr}) : null })()`)
|
||||
try {
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
await sleep(2500)
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
|
||||
const added = await rpc('addKickAccount', {})
|
||||
say('account', JSON.stringify(added))
|
||||
say('join', JSON.stringify(await rpc('joinBuffer', { accountId: added.accountId, name: channel })))
|
||||
const id = `${added.accountId}|${channel}`
|
||||
let live = null
|
||||
for (let i = 0; i < 40 && !live?.playbackUrl; i++) {
|
||||
await sleep(1000)
|
||||
live = await ev(`window.__mohoShots.state().kickStreams[${JSON.stringify(id)}] || null`)
|
||||
}
|
||||
say('stream', JSON.stringify({ live: live?.live, hasUrl: !!live?.playbackUrl, expires: live?.playbackExpires, livestreamId: live?.livestreamId }))
|
||||
await ev(`(async () => { await window.__mohoShots.store.selectBuffer(${JSON.stringify(id)}); window.__mohoShots.store.watchStream(${JSON.stringify(id)}) })()`)
|
||||
for (let i = 0; i < 40; i++) {
|
||||
if ((await video('v.readyState >= 3 && v.currentTime > 1')) === true) break
|
||||
await sleep(500)
|
||||
}
|
||||
say('playing', await video('({ t: v.currentTime, w: v.videoWidth, h: v.videoHeight, paused: v.paused })').then(JSON.stringify))
|
||||
await sleep(4000)
|
||||
await shot('stream-playing.png')
|
||||
|
||||
// Resolution.
|
||||
say('quality menu', await click('button[title^="Resolution"]'))
|
||||
await sleep(400)
|
||||
say('quality choices', JSON.stringify(await ev(`[...document.querySelectorAll('.stream-menu .context-menu-item')].map((b) => b.textContent.trim())`)))
|
||||
await shot('stream-quality-menu.png')
|
||||
for (const want of ['480p', 'Auto']) {
|
||||
await ev(`(() => { const b = [...document.querySelectorAll('.stream-menu .context-menu-item')].find((x) => x.textContent.trim().startsWith(${JSON.stringify(want)})); b && b.click() })()`)
|
||||
await sleep(8000)
|
||||
say(`after choosing ${want}`, JSON.stringify(await video('({ h: v.videoHeight, t: +v.currentTime.toFixed(1) })')), JSON.stringify(await ev(`document.querySelector('button[title^="Resolution"]')?.title`)))
|
||||
if (want === '480p') await click('button[title^="Resolution"]')
|
||||
}
|
||||
|
||||
// Captions.
|
||||
say('captions button', await ev(`JSON.stringify({ title: document.querySelector('button[title*="aptions"]')?.title, disabled: document.querySelector('button[title*="aptions"]')?.disabled })`))
|
||||
say('captions menu', await click('button[title*="aptions"]'))
|
||||
await sleep(400)
|
||||
say('caption choices', JSON.stringify(await ev(`[...document.querySelectorAll('.stream-menu .context-menu-item')].map((b) => b.textContent.trim())`)))
|
||||
await shot('stream-captions-menu.png')
|
||||
await ev(`(() => { const b = [...document.querySelectorAll('.stream-menu .context-menu-item')].find((x) => x.textContent.trim() === 'English'); b && b.click() })()`)
|
||||
await sleep(15000)
|
||||
say('text tracks', await video(`JSON.stringify([...v.textTracks].map((t) => ({ lang: t.language, mode: t.mode, cues: t.cues ? t.cues.length : 0 })))`))
|
||||
await shot('stream-captions-on.png')
|
||||
say('captions button now', await ev(`document.querySelector('button[title*="aptions"]')?.title`))
|
||||
|
||||
// Built once.
|
||||
const sources = new Set()
|
||||
const began = Date.now()
|
||||
while (Date.now() - began < idle * 1000) {
|
||||
sources.add(await video('v.src'))
|
||||
await sleep(1000)
|
||||
}
|
||||
const later = await ev(`window.__mohoShots.state().kickStreams[${JSON.stringify(id)}]`)
|
||||
say(`over ${idle}s: ${sources.size} player source(s); address expires`, later?.playbackExpires, 'changed:', later?.playbackUrl !== live?.playbackUrl)
|
||||
say('quality', JSON.stringify(await video('(() => { const q = v.getVideoPlaybackQuality(); return { dropped: q.droppedVideoFrames, total: q.totalVideoFrames } })()')))
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Reads a busy live Kick channel in a scratch moho and checks what replies and removals look like: that a
|
||||
* reply carries its thread, that the thread opens from it, and that a removed message is struck through in
|
||||
* red, left out or replaced by a notice according to Settings -> Kick. Removal is the one thing it cannot
|
||||
* wait for, so one is injected through the store the way the daemon's event would arrive.
|
||||
*
|
||||
* node scripts/shot-kick-threads.mjs <output directory> [channel]
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
const channel = process.argv[3] ?? 'xqc'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
|
||||
const ev = (code) => page.evaluate(code)
|
||||
const shot = async (name) => {
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
}
|
||||
try {
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
await sleep(2500)
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
|
||||
const added = await rpc('addKickAccount', {})
|
||||
say('join', JSON.stringify(await rpc('joinBuffer', { accountId: added.accountId, name: channel })))
|
||||
const id = `${added.accountId}|${channel}`
|
||||
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
|
||||
|
||||
// Replies, as the daemon read them: each should name the thread it belongs to.
|
||||
let replies = []
|
||||
for (let i = 0; i < 120 && replies.length < 2; i++) {
|
||||
await sleep(1000)
|
||||
replies = JSON.parse(await ev(`JSON.stringify((window.__mohoShots.state().messagesByBuffer[${JSON.stringify(id)}] || []).filter((m) => m.replyTo).map((m) => ({ id: m.id, to: m.replyTo.id, root: m.replyTo.threadRoot, thread: m.replyTo.thread })))`))
|
||||
}
|
||||
say('replies seen', replies.length, JSON.stringify(replies.slice(0, 4)))
|
||||
say('every reply has a thread root', replies.every((r) => r.root))
|
||||
await sleep(1500)
|
||||
await shot('kick-replies.png')
|
||||
|
||||
await ev(`document.querySelector('.reply-preview')?.scrollIntoView({ block: 'center' })`)
|
||||
await sleep(500)
|
||||
await shot('kick-reply-row.png')
|
||||
// Open a thread from a reply.
|
||||
say('thread button', await ev(`(() => { const b = document.querySelector('.reply-thread-open'); if (!b) return false; b.click(); return true })()`))
|
||||
await sleep(1200)
|
||||
say('thread panel', await ev(`JSON.stringify({ open: !!document.querySelector('.thread-pane'), rows: document.querySelectorAll('.thread-pane .message-row').length, title: document.querySelector('.thread-header')?.textContent })`))
|
||||
await shot('kick-thread.png')
|
||||
await ev(`window.__mohoShots.store.closeThreadPanel()`)
|
||||
await sleep(300)
|
||||
|
||||
// A removal, as the daemon would announce it, in each of the three ways.
|
||||
const victim = await ev(`(() => { const ms = window.__mohoShots.state().messagesByBuffer[${JSON.stringify(id)}] || []; const m = [...ms].reverse().find((x) => x.kind === 'chat' && x.body && !x.replyTo); return m && m.id })()`)
|
||||
await ev(`window.__mohoShots.store.handleEvent({ event: 'messageModerated', data: { bufferId: ${JSON.stringify(id)}, id: ${JSON.stringify(victim)}, moderated: { ai: true, rules: ['hate'] } } })`)
|
||||
await sleep(500)
|
||||
await ev(`document.querySelector('.message-row.removed')?.scrollIntoView({ block: 'center' })`)
|
||||
await sleep(500)
|
||||
say('removed row (default: strike)', await ev(`(() => { const r = document.querySelector('.message-row.removed .message-body'); if (!r) return null; const s = getComputedStyle(r); return JSON.stringify({ text: r.textContent.slice(0, 40), color: s.color, line: s.textDecorationLine, title: r.title }) })()`))
|
||||
await shot('kick-removed-strike.png')
|
||||
|
||||
for (const [label, name] of [['Replace with notice', 'replace'], ['Delete', 'remove'], ['Strikethrough', 'strike']]) {
|
||||
await ev(`window.__mohoShots.store.setActivePanel('settings')`)
|
||||
await sleep(500)
|
||||
await ev(`[...document.querySelectorAll('.settings-rail-item, .settings-rail button')].find((b) => b.textContent.trim().startsWith('Kick'))?.click()`)
|
||||
await sleep(500)
|
||||
say('setting', label, await ev(`(() => { const b = [...document.querySelectorAll('.setting-segmented button')].find((x) => x.textContent.trim() === ${JSON.stringify(label)}); if (!b) return false; b.click(); return true })()`))
|
||||
await sleep(300)
|
||||
if (name === 'replace') await shot('kick-settings.png')
|
||||
await ev(`window.__mohoShots.store.setActivePanel('')`)
|
||||
await sleep(500)
|
||||
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
|
||||
await sleep(700)
|
||||
say(name, await ev(`JSON.stringify({ removedRows: document.querySelectorAll('.message-row.removed').length, text: document.querySelector('.message-row.removed .message-body')?.textContent.slice(0, 50) })`))
|
||||
await ev(`document.querySelector('.message-row.removed')?.scrollIntoView({ block: 'center' })`)
|
||||
await sleep(300)
|
||||
if (name !== 'strike') await shot(`kick-removed-${name}.png`)
|
||||
}
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Reads a busy live Kick channel in a scratch moho and reports how the chatters count grows against the
|
||||
* names listed, and whether drawing a long list holds the window up.
|
||||
*
|
||||
* node scripts/watch-kick-roster.mjs [channel] [seconds]
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const channel = process.argv[2] ?? 'spreen'
|
||||
const seconds = Number(process.argv[3] ?? 150)
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const ev = (code) => page.evaluate(code)
|
||||
try {
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
await sleep(2500)
|
||||
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
|
||||
const added = await rpc('addKickAccount', {})
|
||||
await rpc('joinBuffer', { accountId: added.accountId, name: channel })
|
||||
const id = `${added.accountId}|${channel}`
|
||||
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
|
||||
await ev(`(() => { window.__long = 0; window.__longMs = 0; new PerformanceObserver((l) => { for (const e of l.getEntries()) { window.__long++; window.__longMs += e.duration } }).observe({ entryTypes: ['longtask'] }) })()`)
|
||||
const began = Date.now()
|
||||
while (Date.now() - began < seconds * 1000) {
|
||||
await sleep(15000)
|
||||
console.log(await ev(`JSON.stringify({ header: document.querySelector('.nicklist-header span')?.textContent, rows: document.querySelectorAll('.nicklist .member-row, .nicklist-scroll > div > div:not(.nicklist-group)').length, state: (() => { const s = window.__mohoShots.state(); return { listed: (s.presenceByBuffer[${JSON.stringify(id)}] || []).length, total: s.presenceTotalByBuffer[${JSON.stringify(id)}] } })(), longTasks: window.__long, longMs: Math.round(window.__longMs) })`))
|
||||
}
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Watches a live Kick stream in a scratch moho and reports every time the picture waits for data: when,
|
||||
* for how long, and how much was buffered ahead of the playhead just before.
|
||||
*
|
||||
* node scripts/watch-kick-stalls.mjs [channel] [seconds]
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const channel = process.argv[2] ?? 'spreen'
|
||||
const seconds = Number(process.argv[3] ?? 90)
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const ev = (code) => page.evaluate(code)
|
||||
try {
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
await sleep(2500)
|
||||
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
|
||||
const added = await rpc('addKickAccount', {})
|
||||
await rpc('joinBuffer', { accountId: added.accountId, name: channel })
|
||||
const id = `${added.accountId}|${channel}`
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await sleep(1000)
|
||||
if (await ev(`!!window.__mohoShots.state().kickStreams[${JSON.stringify(id)}]?.playbackUrl`)) break
|
||||
}
|
||||
await ev(`(async () => { await window.__mohoShots.store.selectBuffer(${JSON.stringify(id)}); window.__mohoShots.store.watchStream(${JSON.stringify(id)}) })()`)
|
||||
for (let i = 0; i < 40; i++) {
|
||||
if (await ev(`!!document.querySelector('video.video-tile-picture')`)) break
|
||||
await sleep(500)
|
||||
}
|
||||
await ev(`(() => {
|
||||
const v = document.querySelector('video.video-tile-picture')
|
||||
const log = (window.__stalls = { events: [], samples: [], t0: performance.now() })
|
||||
const at = () => +((performance.now() - log.t0) / 1000).toFixed(1)
|
||||
const ahead = () => { const b = v.buffered; return b.length ? +(b.end(b.length - 1) - v.currentTime).toFixed(2) : 0 }
|
||||
for (const n of ['waiting', 'stalled', 'playing', 'seeking', 'seeked', 'ratechange']) v.addEventListener(n, () => log.events.push({ t: at(), n, ahead: ahead(), rate: v.playbackRate, ct: +v.currentTime.toFixed(2) }))
|
||||
setInterval(() => log.samples.push({ t: at(), ahead: ahead(), ct: +v.currentTime.toFixed(2), rate: v.playbackRate }), 500)
|
||||
})()`)
|
||||
await sleep(seconds * 1000)
|
||||
const r = JSON.parse(await ev('JSON.stringify(window.__stalls)'))
|
||||
const waits = r.events.filter((e) => e.n === 'waiting')
|
||||
console.log(`${waits.length} waiting events in ${seconds}s`)
|
||||
for (const e of r.events) console.log(JSON.stringify(e))
|
||||
const a = r.samples.map((s) => s.ahead)
|
||||
console.log('buffer ahead: min', Math.min(...a), 'max', Math.max(...a), 'mean', +(a.reduce((x, y) => x + y, 0) / a.length).toFixed(2))
|
||||
console.log('rates seen', [...new Set(r.samples.map((s) => s.rate))])
|
||||
console.log('quality', await ev(`JSON.stringify((() => { const q = document.querySelector('video.video-tile-picture').getVideoPlaybackQuality(); return { dropped: q.droppedVideoFrames, total: q.totalVideoFrames } })())`))
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -1115,6 +1115,35 @@ button.video-tile {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* The live captions of a stream, drawn by the video element itself. */
|
||||
.video-tile-picture::cue {
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
color: #fff;
|
||||
font-size: clamp(13px, 2.2vw, 20px);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* A list over the picture, in place of a menu in the window: a stream popped out is another document. */
|
||||
.stream-menu-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.context-menu.stream-menu {
|
||||
position: absolute;
|
||||
top: var(--spacing-s);
|
||||
right: var(--spacing-s);
|
||||
z-index: 3;
|
||||
max-height: calc(100% - 2 * var(--spacing-s));
|
||||
overflow-y: auto;
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
.stream-menu-title {
|
||||
padding: 2px var(--spacing-s) var(--spacing-xs);
|
||||
}
|
||||
|
||||
/* Nothing to look at: the name and a face, which is what a call without
|
||||
cameras actually is. */
|
||||
.video-tile-picture.audio-only {
|
||||
@@ -10913,3 +10942,42 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { useStore } from '../state/hooks'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import { useEscapeLayer } from '../lib/layers'
|
||||
|
||||
/**
|
||||
@@ -43,6 +43,13 @@ export function KickPoints({ bufferId }: { bufferId: string }): JSX.Element | nu
|
||||
|
||||
const [points, setPoints] = useState<number | null>(null)
|
||||
|
||||
// Told by Kick the moment it changes, where the daemon is following it; the
|
||||
// timer below is for a channel that is not live, which is not followed.
|
||||
const pushed = useChat((s) => s.kickPointsByBuffer[bufferId])
|
||||
useEffect(() => {
|
||||
if (pushed !== undefined) setPoints(pushed)
|
||||
}, [pushed])
|
||||
|
||||
const refresh = (): void => {
|
||||
void window.moho
|
||||
.rpc<Answer>('listKickRewards', { bufferId })
|
||||
|
||||
@@ -12,6 +12,7 @@ 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'
|
||||
|
||||
/**
|
||||
@@ -234,6 +235,8 @@ export function MessageList(): JSX.Element {
|
||||
const [showPolls] = usePref<boolean>('kick.showPolls', true)
|
||||
const [showStream] = usePref<boolean>('kick.showStreamEvents', true)
|
||||
const [showKickMod] = usePref<boolean>('kick.showModeration', true)
|
||||
// What Kick took down: left out, struck through, or replaced by a notice.
|
||||
const [removedMode] = usePref<RemovedMessages>('kick.removedMessages', 'strike')
|
||||
const [showReaders] = usePref<boolean>('matrix.showReadReceipts', true)
|
||||
const [blockedNicks] = usePref<string[]>('blockedNicks', [])
|
||||
const readers = useChat((s) => s.readersByBuffer)[bufferId]
|
||||
@@ -256,7 +259,8 @@ export function MessageList(): JSX.Element {
|
||||
const threadReplies = useMemo(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const m of all) {
|
||||
if (m.replyTo?.thread) counts[m.replyTo.id] = (counts[m.replyTo.id] ?? 0) + 1
|
||||
const root = m.replyTo?.thread ? m.replyTo.id : m.replyTo?.threadRoot
|
||||
if (root) counts[root] = (counts[root] ?? 0) + 1
|
||||
}
|
||||
return counts
|
||||
}, [all])
|
||||
@@ -279,14 +283,17 @@ export function MessageList(): JSX.Element {
|
||||
stream: showStream,
|
||||
moderation: showKickMod
|
||||
}
|
||||
return all.filter((m) => {
|
||||
if (accountId && blockedNicks.includes(`${accountId}|${m.from}`)) return false
|
||||
const kind = m.kind || 'chat'
|
||||
if (isChatKind(kind) || kind === 'system') return true
|
||||
return allowed[kind] !== false
|
||||
})
|
||||
return applyRemoved(
|
||||
all.filter((m) => {
|
||||
if (accountId && blockedNicks.includes(`${accountId}|${m.from}`)) return false
|
||||
const kind = m.kind || 'chat'
|
||||
if (isChatKind(kind) || kind === 'system') return true
|
||||
return allowed[kind] !== false
|
||||
}),
|
||||
removedMode
|
||||
)
|
||||
}, [
|
||||
all, accountId, blockedNicks,
|
||||
all, accountId, blockedNicks, removedMode,
|
||||
showJoin, showPart, showNick, showTopic, showMode,
|
||||
showMxJoin, showMxInvite, showMxKick, showMxQuit,
|
||||
showRedemptions, showSubs, showRaids, showPolls, showStream, showKickMod
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
nickColor
|
||||
} from '../lib/util'
|
||||
import { useEscapeLayer } from '../lib/layers'
|
||||
import { removedReason } from '../lib/moderation'
|
||||
|
||||
/**
|
||||
* A badge, short enough to sit beside a name.
|
||||
@@ -350,6 +351,10 @@ function MessageRowBody({
|
||||
// preview. The object itself, so the row only re-renders when that one
|
||||
// message changes.
|
||||
const replyId = message.replyTo && !message.replyTo.thread && !message.replyTo.forwarded ? message.replyTo.id : ''
|
||||
// The thread open beside the room, if any: a reply to its first message has
|
||||
// nothing to say that the panel's own heading does not.
|
||||
const openRoot = useChat((s) => s.openThread?.rootId)
|
||||
const [showBadgePictures] = usePref<boolean>('kick.showBadgePictures', true)
|
||||
const original = useChat((s) => (replyId ? s.messagesByBuffer[bufferId]?.find((m) => m.id === replyId) : undefined))
|
||||
// Which message is open for editing is the window's, not each row's, so
|
||||
// Up arrow in the message box can open the last one.
|
||||
@@ -815,6 +820,7 @@ 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',
|
||||
@@ -928,15 +934,31 @@ function MessageRowBody({
|
||||
{timeLabel}
|
||||
</span>
|
||||
)}
|
||||
{message.badges?.map((badge) => (
|
||||
<span
|
||||
key={badge.type}
|
||||
className={`sender-badge small ${badge.type}`}
|
||||
title={badge.count ? `${badge.text} — ${badge.count} months` : badge.text}
|
||||
>
|
||||
{badgeLabel(badge)}
|
||||
</span>
|
||||
))}
|
||||
{message.badges?.map((badge) =>
|
||||
badge.image ? (
|
||||
// Kick's own pictures, unless they are turned off. The tooltip
|
||||
// and the alt text say what it is for anybody who cannot see it.
|
||||
showBadgePictures && (
|
||||
<img
|
||||
key={badge.type}
|
||||
className="sender-badge-image"
|
||||
src={media(badge.image)}
|
||||
alt={badge.text}
|
||||
title={badge.text}
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<span
|
||||
key={badge.type}
|
||||
className={`sender-badge small ${badge.type}`}
|
||||
title={badge.count ? `${badge.text} — ${badge.count} months` : badge.text}
|
||||
>
|
||||
{badgeLabel(badge)}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -977,6 +999,7 @@ function MessageRowBody({
|
||||
the panel's title already says. */}
|
||||
{message.replyTo &&
|
||||
!(inThread && message.replyTo.thread) &&
|
||||
!(inThread && message.replyTo.id === openRoot) &&
|
||||
(message.replyTo.thread && !inThread ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1024,6 +1047,21 @@ function MessageRowBody({
|
||||
{message.replyTo.from}
|
||||
</span>
|
||||
<span className="ellipsis">{message.replyTo.body}</span>
|
||||
{/* Kick groups a chain of replies under the message it began
|
||||
with; a reply is a way into that conversation. */}
|
||||
{!inThread && message.replyTo.threadRoot && (
|
||||
<button
|
||||
type="button"
|
||||
className="reply-thread-open small"
|
||||
title="Open the thread"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void store.openThreadPanel(bufferId, message.replyTo!.threadRoot!)
|
||||
}}
|
||||
>
|
||||
<Icon name="forum" size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1094,7 +1132,7 @@ function MessageRowBody({
|
||||
<KickEventCard embed={kickEvent} />
|
||||
) : (
|
||||
parts.html && (
|
||||
<span className="message-body selectable">
|
||||
<span className="message-body selectable" title={message.moderated && !message.moderated.replaced ? removedReason(message) : undefined}>
|
||||
<RichText
|
||||
html={parts.html}
|
||||
// Ours unless the sender sent their own formatting, which
|
||||
|
||||
@@ -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): string | undefined {
|
||||
return service === 'kick'
|
||||
? 'Kick has no viewer list — this is everybody who has spoken here recently'
|
||||
: undefined
|
||||
function rosterNote(service: string | undefined, total: number, listed: number): string | undefined {
|
||||
if (service !== 'kick') return undefined
|
||||
const base = '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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,6 +140,7 @@ export function NickList(): JSX.Element {
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const members = (buffer && presenceByBuffer[buffer.id]) || []
|
||||
const total = useChat((s) => (buffer ? s.presenceTotalByBuffer[buffer.id] : undefined)) ?? members.length
|
||||
const account = buffer && accounts.find((a) => a.id === buffer.accountId)
|
||||
// Any protocol that reports presence gets the online/offline split.
|
||||
const hasPresence = members.some((m) => m.status !== undefined)
|
||||
@@ -263,8 +264,8 @@ export function NickList(): JSX.Element {
|
||||
// Named for what the list actually is. Kick has no viewer list to
|
||||
// ask for - it is built from who has spoken - and calling that
|
||||
// "members" would claim to know something nobody here can know.
|
||||
<span className="small muted ellipsis" title={rosterNote(account?.service)}>
|
||||
{members.length} {account?.service === 'kick' ? 'recently talking' : 'members'}
|
||||
<span className="small muted ellipsis" title={rosterNote(account?.service, total, members.length)}>
|
||||
{total.toLocaleString()} {account?.service === 'kick' ? 'recently talking' : 'members'}
|
||||
</span>
|
||||
)}
|
||||
<IconButton
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Hls from 'hls.js'
|
||||
import { IconButton } from './Icon'
|
||||
import { Icon, IconButton } from './Icon'
|
||||
import { ivsAbr, ivsLoader } from '../lib/ivsplaylist'
|
||||
import { autoCeiling, captionTrack, menuOrder, renditionLabel, renditionOf, type CaptionTrack, type Rendition } from '../lib/streamplayer'
|
||||
import { useEscapeLayer } from '../lib/layers'
|
||||
import { VideoStage } from './VideoStage'
|
||||
import { fitTiles } from './CallStage'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import { useChat, usePref, useStore } from '../state/hooks'
|
||||
import { bufferDisplayName } from '../lib/util'
|
||||
import { StageWindow, STREAM_WINDOW_NAME } from './stage/StageWindow'
|
||||
|
||||
@@ -24,8 +27,68 @@ import { StageWindow, STREAM_WINDOW_NAME } from './stage/StageWindow'
|
||||
* stream has one picture, and the grid a call draws would be a grid of one.
|
||||
*/
|
||||
|
||||
/** The rendition ceiling. See the comment where it is used. */
|
||||
const MAX_HEIGHT = 720
|
||||
/** What automatic choice of rendition may go up to: a picture this size beside a chat has no use for more. */
|
||||
const AUTO_MAX_HEIGHT = 720
|
||||
|
||||
/**
|
||||
* How far behind the newest segment the player settles, in seconds.
|
||||
*
|
||||
* In seconds because hls.js counts the other way in multiples of the playlist's target duration, and Kick's
|
||||
* playlists say six seconds for segments that are two: "three segments" was eighteen seconds behind, which is
|
||||
* where most of the delay between the broadcaster and the picture came from. With the prefetched segments
|
||||
* (see lib/ivsplaylist.ts) this is a few seconds behind the broadcaster, and no catch-up speed: playing a little
|
||||
* fast to close the gap was measured to stall.
|
||||
*/
|
||||
const LIVE_SYNC_SECONDS = 4
|
||||
const LIVE_MAX_LATENCY_SECONDS = 10
|
||||
|
||||
/** The renditions of a player's playlist, as the choices are made from them. */
|
||||
function renditionsOf(player: Hls): Rendition[] {
|
||||
return player.levels.map((l) => ({ height: l.height, frameRate: Number(l.attrs?.['FRAME-RATE']) || undefined, bitrate: l.bitrate }))
|
||||
}
|
||||
|
||||
interface MenuChoice {
|
||||
label: string
|
||||
detail?: string
|
||||
checked: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
/** A short list drawn over the picture, since a popped-out window is a document of its own and a menu in this one would open in the wrong place. */
|
||||
function StreamMenu({ title, choices, onClose }: { title: string; choices: MenuChoice[]; onClose: () => void }): JSX.Element {
|
||||
useEscapeLayer(onClose)
|
||||
return (
|
||||
<>
|
||||
<div className="stream-menu-scrim" onClick={onClose} />
|
||||
<div className="context-menu stream-menu" role="menu" aria-label={title}>
|
||||
<div className="stream-menu-title small muted">{title}</div>
|
||||
{choices.map((c) => (
|
||||
<button
|
||||
key={c.label}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={c.checked}
|
||||
className="context-menu-item"
|
||||
onClick={() => {
|
||||
c.onClick()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<span className="context-menu-label">{c.label}</span>
|
||||
{c.detail && <span className="small muted">{c.detail}</span>}
|
||||
{c.checked && <Icon name="check" size={16} className="context-menu-check" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface Choice {
|
||||
index: number
|
||||
label: string
|
||||
height: number
|
||||
}
|
||||
|
||||
export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'window' }): JSX.Element | null {
|
||||
const store = useStore()
|
||||
@@ -36,17 +99,30 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
|
||||
const video = useRef<HTMLVideoElement>(null)
|
||||
const frame = useRef<HTMLDivElement>(null)
|
||||
const hls = useRef<Hls | null>(null)
|
||||
/** The newest playback URL, for the error path to reach without being
|
||||
* rebuilt every time one arrives. */
|
||||
/** The newest playback URL, for a player that has to begin again to reach without being rebuilt every time
|
||||
* one arrives. */
|
||||
const latest = useRef('')
|
||||
const [room, setRoom] = useState({ width: 640, height: 260 })
|
||||
const [muted, setMuted] = useState(false)
|
||||
/** What the stream offers, once its playlist has been read. */
|
||||
const [choices, setChoices] = useState<Choice[]>([])
|
||||
const [captions, setCaptions] = useState<CaptionTrack[]>([])
|
||||
/** What automatic choice is playing now, for the menu to say. */
|
||||
const [playing, setPlaying] = useState('')
|
||||
const [menu, setMenu] = useState<'quality' | 'captions' | null>(null)
|
||||
// Remembered between streams: 0 is automatic, and an empty language is captions off.
|
||||
const [quality, setQuality] = usePref<number>('stream.quality', 0)
|
||||
const [captionLanguage, setCaptionLanguage] = usePref<string>('stream.captions', '')
|
||||
|
||||
const stream = watching ? streams[watching.bufferId] : undefined
|
||||
// The current one rather than the one this started with: the signature in a
|
||||
// playback URL expires, and the refresh already running is what replaces it.
|
||||
// The newest address rather than the one this began with: the signature in it expires, and the daemon asks for
|
||||
// a new one before it does. The player is not rebuilt for it - it is only where a player that has to start
|
||||
// over, after being refused, starts from.
|
||||
const url = stream?.playbackUrl ?? ''
|
||||
latest.current = url
|
||||
// A different picture is a different stream or a different broadcast; the same one with a new address is not.
|
||||
const picture = `${watching?.bufferId ?? ''}|${stream?.livestreamId ?? ''}`
|
||||
const hasUrl = url !== ''
|
||||
|
||||
// The same measuring the call stage does, for the same reason: this is a
|
||||
// different shape in a narrow window, a full-screen one, and the corner.
|
||||
@@ -78,49 +154,61 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
|
||||
}
|
||||
}, [mode, minimized, watching])
|
||||
|
||||
// The player itself, torn down and rebuilt only when the picture changes -
|
||||
// not when the viewer count ticks over, which it does every minute.
|
||||
// The player itself, torn down and rebuilt only when the picture changes - not when the viewer count ticks over,
|
||||
// and not when the address is renewed, which it is every few minutes.
|
||||
useEffect(() => {
|
||||
const el = video.current
|
||||
if (!el || !url || minimized) return
|
||||
if (!el || !hasUrl || minimized) return
|
||||
if (!Hls.isSupported()) {
|
||||
store.toast('error', 'This build cannot play a stream')
|
||||
return
|
||||
}
|
||||
const player = new Hls({
|
||||
// Enough buffer to ride out a hiccup, not so much that "live" means two
|
||||
// minutes ago: this is a chat window beside the stream, and people talk
|
||||
// about what just happened.
|
||||
liveSyncDurationCount: 3,
|
||||
// Never fetch a rendition larger than the box it is drawn in.
|
||||
capLevelToPlayerSize: true
|
||||
// Kick's playlists, which list what is being made as well as what is made.
|
||||
loader: ivsLoader(2),
|
||||
abrController: ivsAbr(),
|
||||
// How far behind to play, in seconds: see LIVE_SYNC_SECONDS.
|
||||
liveSyncDuration: LIVE_SYNC_SECONDS,
|
||||
liveMaxLatencyDuration: LIVE_MAX_LATENCY_SECONDS,
|
||||
// Never fetch a rendition larger than the box it is drawn in, when the choice is automatic.
|
||||
capLevelToPlayerSize: true,
|
||||
// A stream that runs for hours must not keep them all.
|
||||
backBufferLength: 20
|
||||
})
|
||||
hls.current = player
|
||||
player.attachMedia(el)
|
||||
player.loadSource(url)
|
||||
player.loadSource(latest.current)
|
||||
|
||||
player.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
// 1080p60 is 5.8 Mbps and a real amount of CPU to decode beside a chat
|
||||
// client, and the ladder below it is cheaper for a picture this size.
|
||||
// A ceiling rather than a fixed choice: the bitrate ladder underneath
|
||||
// still adapts to the connection.
|
||||
const ceiling = player.levels.reduce(
|
||||
(best, level, i) => (level.height <= MAX_HEIGHT && level.height >= (player.levels[best]?.height ?? 0) ? i : best),
|
||||
-1
|
||||
)
|
||||
if (ceiling >= 0) player.autoLevelCapping = ceiling
|
||||
setChoices(menuOrder(renditionsOf(player)))
|
||||
void el.play().catch(() => {
|
||||
/* A picture that will not start is not worth an error dialog. */
|
||||
})
|
||||
})
|
||||
player.on(Hls.Events.LEVEL_LOADED, (_e, data) => {
|
||||
// Behind by a segment and a half at the least: where they are 5 seconds long, 4 seconds is less than one.
|
||||
const longest = Math.max(0, ...data.details.fragments.map((f) => f.duration))
|
||||
const behind = Math.max(LIVE_SYNC_SECONDS, longest * 1.5)
|
||||
if (player.config.liveSyncDuration !== behind) {
|
||||
player.config.liveSyncDuration = behind
|
||||
player.config.liveMaxLatencyDuration = Math.max(LIVE_MAX_LATENCY_SECONDS, behind * 2.5)
|
||||
}
|
||||
})
|
||||
player.on(Hls.Events.LEVEL_SWITCHED, (_e, data) => {
|
||||
const level = player.levels[data.level]
|
||||
if (level) setPlaying(renditionLabel({ height: level.height, frameRate: Number(level.attrs?.['FRAME-RATE']) || undefined }))
|
||||
})
|
||||
player.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_e, data) => {
|
||||
setCaptions(data.subtitleTracks.map((t) => ({ id: t.id, name: t.name, lang: t.lang })))
|
||||
})
|
||||
|
||||
player.on(Hls.Events.ERROR, (_e, data) => {
|
||||
if (!data.fatal) return
|
||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||
// A refused segment is an expired token, not a dead stream: the
|
||||
// daemon re-asks Kick about the channel every minute, so the next
|
||||
// playback URL is already on its way and this only has to wait for
|
||||
// it. Reloading the source picks up whichever one is current.
|
||||
// A refused segment is an expired token, not a dead stream: the daemon
|
||||
// renews the address before it expires, so the next one is already
|
||||
// here and this only has to begin from it. Reloading the source picks
|
||||
// up whichever one is current.
|
||||
if (latest.current) player.loadSource(latest.current)
|
||||
player.startLoad()
|
||||
return
|
||||
@@ -136,8 +224,34 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
|
||||
return () => {
|
||||
player.destroy()
|
||||
hls.current = null
|
||||
setChoices([])
|
||||
setCaptions([])
|
||||
setPlaying('')
|
||||
}
|
||||
}, [url, minimized, store, watching?.bufferId])
|
||||
}, [hasUrl, picture, minimized, store])
|
||||
|
||||
// Which rendition: automatic, or the height that was chosen when this stream offers it.
|
||||
useEffect(() => {
|
||||
const player = hls.current
|
||||
if (!player || choices.length === 0) return
|
||||
const wanted = quality > 0 ? renditionOf(renditionsOf(player), quality) : -1
|
||||
if (wanted >= 0) {
|
||||
player.autoLevelCapping = -1
|
||||
player.currentLevel = wanted
|
||||
} else {
|
||||
player.currentLevel = -1
|
||||
player.autoLevelCapping = autoCeiling(renditionsOf(player), AUTO_MAX_HEIGHT)
|
||||
}
|
||||
}, [quality, choices])
|
||||
|
||||
// Which captions: none, or the language that was chosen when this stream has captions in it.
|
||||
useEffect(() => {
|
||||
const player = hls.current
|
||||
if (!player) return
|
||||
const track = captionTrack(captions, captionLanguage, navigator.language)
|
||||
player.subtitleDisplay = track >= 0
|
||||
player.subtitleTrack = track
|
||||
}, [captionLanguage, captions])
|
||||
|
||||
// A stream that has ended is not a picture any more, and the surface should
|
||||
// not sit there showing the last frame of it.
|
||||
@@ -152,6 +266,7 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
|
||||
|
||||
const where = buffers.find((b) => b.id === watching.bufferId)?.name ?? ''
|
||||
const fit = fitTiles(1, room.width, room.height, Infinity)
|
||||
const captionsOn = captionTrack(captions, captionLanguage, navigator.language) >= 0
|
||||
const watchers = stream?.viewers
|
||||
const subtitle = `Live${watchers ? ` · ${watchers.toLocaleString()} watching` : ''}${
|
||||
where ? ` · ${bufferDisplayName(where)}` : ''
|
||||
@@ -171,21 +286,59 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
|
||||
onPopOut={mode === 'window' ? undefined : () => store.setStreamPoppedOut(true)}
|
||||
onBringBack={() => store.setStreamPoppedOut(false)}
|
||||
controls={
|
||||
<IconButton
|
||||
name={muted ? 'volume_off' : 'volume_up'}
|
||||
title={muted ? 'Unmute' : 'Mute'}
|
||||
className={muted ? 'calling' : undefined}
|
||||
onClick={() => {
|
||||
const next = !muted
|
||||
setMuted(next)
|
||||
if (video.current) video.current.muted = next
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<IconButton
|
||||
name={captionsOn ? 'closed_caption' : 'closed_caption_disabled'}
|
||||
title={captions.length === 0 ? 'No captions for this stream' : captionsOn ? 'Captions on' : 'Captions off'}
|
||||
className={captionsOn ? 'active' : undefined}
|
||||
disabled={captions.length === 0}
|
||||
onClick={() => {
|
||||
if (captions.length === 1) setCaptionLanguage(captionsOn ? '' : captions[0].lang || captions[0].name)
|
||||
else setMenu(menu === 'captions' ? null : 'captions')
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
name="high_quality"
|
||||
title={quality > 0 ? `Resolution: ${quality}p` : 'Resolution: automatic'}
|
||||
disabled={choices.length === 0}
|
||||
onClick={() => setMenu(menu === 'quality' ? null : 'quality')}
|
||||
/>
|
||||
<IconButton
|
||||
name={muted ? 'volume_off' : 'volume_up'}
|
||||
title={muted ? 'Unmute' : 'Mute'}
|
||||
className={muted ? 'calling' : undefined}
|
||||
onClick={() => {
|
||||
const next = !muted
|
||||
setMuted(next)
|
||||
if (video.current) video.current.muted = next
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={mode === 'window' ? 'video-stage in-window' : 'video-stage'} ref={frame}>
|
||||
<div className="video-tile" style={{ width: `${Math.floor(fit.tileWidth)}px` }}>
|
||||
<video ref={video} className="video-tile-picture" autoPlay playsInline />
|
||||
{menu === 'quality' && (
|
||||
<StreamMenu
|
||||
title="Resolution"
|
||||
onClose={() => setMenu(null)}
|
||||
choices={[
|
||||
{ label: 'Auto', detail: quality === 0 && playing ? playing : undefined, checked: quality === 0 || !choices.some((c) => c.height === quality), onClick: () => setQuality(0) },
|
||||
...choices.map((c) => ({ label: c.label, checked: quality === c.height, onClick: () => setQuality(c.height) }))
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{menu === 'captions' && (
|
||||
<StreamMenu
|
||||
title="Captions"
|
||||
onClose={() => setMenu(null)}
|
||||
choices={[
|
||||
{ label: 'Off', checked: !captionsOn, onClick: () => setCaptionLanguage('') },
|
||||
...captions.map((t) => ({ label: t.name, checked: captionsOn && captionTrack(captions, captionLanguage, navigator.language) === t.id, onClick: () => setCaptionLanguage(t.lang || t.name) }))
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</VideoStage>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IconButton } from './Icon'
|
||||
import { Composer } from './Composer'
|
||||
import { useChat, usePref, useStore } from '../state/hooks'
|
||||
import type { ChatMessage } from '../state/store'
|
||||
import { applyRemoved, type RemovedMessages } from '../lib/moderation'
|
||||
|
||||
/**
|
||||
* A thread, beside the room it belongs to.
|
||||
@@ -26,6 +27,7 @@ export function ThreadPanel(): JSX.Element | null {
|
||||
// to ask for `ui.*`, which nothing writes, so it ignored both.
|
||||
const [comfy] = usePref<'classic' | 'comfy' | 'bubbles'>('display.messageMode', 'comfy')
|
||||
const [relativeTimestamps] = usePref<boolean>('display.relativeTimestamps', false)
|
||||
const [removedMode] = usePref<RemovedMessages>('kick.removedMessages', 'strike')
|
||||
const store = useStore()
|
||||
// Before the early return below, as every hook here has to be.
|
||||
const shared = useRowContext(thread?.bufferId ?? '')
|
||||
@@ -36,15 +38,15 @@ export function ThreadPanel(): JSX.Element | null {
|
||||
const messages = useMemo((): ChatMessage[] => {
|
||||
if (!thread) return []
|
||||
const live = (messagesByBuffer[thread.bufferId] || []).filter(
|
||||
(m) => m.id === thread.rootId || (m.replyTo?.thread && m.replyTo.id === thread.rootId)
|
||||
(m) => m.id === thread.rootId || (m.replyTo?.thread && m.replyTo.id === thread.rootId) || m.replyTo?.threadRoot === thread.rootId
|
||||
)
|
||||
// By id, so a message that is both fetched and live appears once. The
|
||||
// live copy wins: it is the one carrying edits and reactions that have
|
||||
// happened since the fetch.
|
||||
const byId = new Map<string, ChatMessage>()
|
||||
for (const m of [...thread.messages, ...live]) byId.set(m.id, m)
|
||||
return [...byId.values()].sort((a, b) => a.ts - b.ts)
|
||||
}, [thread, messagesByBuffer])
|
||||
return applyRemoved([...byId.values()].sort((a, b) => a.ts - b.ts), removedMode)
|
||||
}, [thread, messagesByBuffer, removedMode])
|
||||
|
||||
// Opened on, and following, the newest reply: a thread is read from its end.
|
||||
const count = messages.length
|
||||
@@ -53,6 +55,10 @@ export function ThreadPanel(): JSX.Element | null {
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}, [thread?.rootId, count])
|
||||
|
||||
// Not counting the message it grew from - which is not always here: a thread can outlive the scrollback
|
||||
// its first message was in, and then every message shown is a reply.
|
||||
const replyCount = messages.filter((m) => m.id !== thread?.rootId).length
|
||||
|
||||
if (!thread) return null
|
||||
|
||||
return (
|
||||
@@ -60,7 +66,7 @@ export function ThreadPanel(): JSX.Element | null {
|
||||
<div className="thread-header">
|
||||
<span className="thread-title ellipsis">Thread</span>
|
||||
<span className="small muted">
|
||||
{Math.max(0, messages.length - 1) === 1 ? '1 reply' : `${Math.max(0, messages.length - 1)} replies`}
|
||||
{replyCount === 1 ? '1 reply' : `${replyCount} replies`}
|
||||
</span>
|
||||
<IconButton name="close" title="Close thread" onClick={() => store.closeThreadPanel()} />
|
||||
</div>
|
||||
|
||||
@@ -1222,6 +1222,35 @@ 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { withPrefetch } from './ivsplaylist'
|
||||
|
||||
/** A rendition's playlist as Kick's stream serves one: two-second segments, six-second target, two prefetch lines. */
|
||||
const playlist = [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-VERSION:3',
|
||||
'#EXT-X-TARGETDURATION:6',
|
||||
'#EXT-X-MEDIA-SEQUENCE:2939',
|
||||
'#EXT-X-PROGRAM-DATE-TIME:2026-10-13T22:58:41.442Z',
|
||||
'#EXTINF:2.000,live',
|
||||
'https://cdn.example/v1/segment/a',
|
||||
'#EXT-X-PROGRAM-DATE-TIME:2026-10-13T22:58:43.442Z',
|
||||
'#EXTINF:2.000,live',
|
||||
'https://cdn.example/v1/segment/b',
|
||||
'#EXT-X-PREFETCH:https://cdn.example/v1/segment/c',
|
||||
'#EXT-X-PREFETCH:https://cdn.example/v1/segment/d',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
describe('a playlist that lists what is being made', () => {
|
||||
it('has each prefetch line become a segment as long as the last complete one, after the complete ones', () => {
|
||||
const lines = withPrefetch(playlist).split('\n').filter(Boolean)
|
||||
expect(lines.slice(-4)).toEqual(['#EXTINF:2.000,prefetch', 'https://cdn.example/v1/segment/c', '#EXTINF:2.000,prefetch', 'https://cdn.example/v1/segment/d'])
|
||||
expect(lines.some((l) => l.startsWith('#EXT-X-PREFETCH'))).toBe(false)
|
||||
expect(lines.filter((l) => l.startsWith('https://')).length).toBe(4)
|
||||
})
|
||||
|
||||
it('can take only some of them, or none', () => {
|
||||
expect(withPrefetch(playlist, 1).split('\n').filter((l) => l.startsWith('https://'))).toHaveLength(3)
|
||||
expect(withPrefetch(playlist, 0)).toBe(playlist)
|
||||
})
|
||||
|
||||
it('is left as it was when it has none, when it is of a stream that has ended, or when nothing is complete to measure by', () => {
|
||||
const plain = playlist.replace(/#EXT-X-PREFETCH:\S+\n/g, '')
|
||||
expect(withPrefetch(plain)).toBe(plain)
|
||||
const ended = `${playlist}#EXT-X-ENDLIST\n`
|
||||
expect(withPrefetch(ended)).toBe(ended)
|
||||
const none = ['#EXTM3U', '#EXT-X-TARGETDURATION:6', '#EXT-X-PREFETCH:https://cdn.example/v1/segment/c', ''].join('\n')
|
||||
expect(withPrefetch(none)).toBe(none)
|
||||
})
|
||||
|
||||
it('keeps everything else about the playlist, in the order it had it', () => {
|
||||
const out = withPrefetch(playlist).split('\n')
|
||||
expect(out.slice(0, 5)).toEqual(playlist.split('\n').slice(0, 5))
|
||||
expect(out).toContain('#EXT-X-PROGRAM-DATE-TIME:2026-10-13T22:58:43.442Z')
|
||||
})
|
||||
})
|
||||
|
||||
describe('withPrefetch on segments of uneven length', () => {
|
||||
it('leaves the playlist as it is when the last segment is no guide to the next', () => {
|
||||
const playlist = ['#EXTM3U', '#EXTINF:3.200,live', 'a.ts', '#EXTINF:5.166,live', 'b.ts', '#EXT-X-PREFETCH:c.ts', ''].join('\n')
|
||||
expect(withPrefetch(playlist)).toBe(playlist)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import Hls from 'hls.js'
|
||||
|
||||
/**
|
||||
* Amazon IVS's low-latency playlists, which Kick's streams are, list what is already complete and then
|
||||
* what is being made: `#EXT-X-PREFETCH:<address>`, one per segment of the next few seconds. The address
|
||||
* answers as soon as it is asked and sends the segment as it is produced, so asking early is the way to
|
||||
* be seconds nearer the broadcaster. IVS's own player does that. hls.js does not know the tag and
|
||||
* leaves it out, which is why a stream played with it runs several seconds behind one played with
|
||||
* IVS's.
|
||||
*
|
||||
* Here each of those lines becomes a segment of the playlist like any other, the same length as the
|
||||
* last complete one, so hls.js asks for it and plays it as it arrives.
|
||||
*/
|
||||
/**
|
||||
* The addresses that were listed as prefetch, which hls.js must not judge the connection by: one of
|
||||
* them is sent as it is made, so it takes as long to arrive as it takes to broadcast (two seconds of
|
||||
* picture in two seconds), and bandwidth worked out from that is the stream's own bitrate. Taken for
|
||||
* the connection's, it moved the picture down a rendition at every other segment.
|
||||
*/
|
||||
const ahead = new Set<string>()
|
||||
|
||||
function remember(address: string): void {
|
||||
ahead.add(address)
|
||||
// Only the last few matter; a stream that runs for hours must not grow this for ever.
|
||||
if (ahead.size > 64) ahead.delete(ahead.values().next().value as string)
|
||||
}
|
||||
|
||||
/** How far apart, in seconds, the longest and shortest segment may be for the next to be taken as their length. */
|
||||
const UNIFORM_WITHIN = 0.25
|
||||
|
||||
export function withPrefetch(playlist: string, take = 2): string {
|
||||
if (take <= 0 || !playlist.includes('#EXT-X-PREFETCH:') || playlist.includes('#EXT-X-ENDLIST')) return playlist
|
||||
const lines = playlist.split('\n')
|
||||
const kept: string[] = []
|
||||
const ahead_: string[] = []
|
||||
let length = ''
|
||||
const lengths: number[] = []
|
||||
for (const line of lines) {
|
||||
const prefetch = /^#EXT-X-PREFETCH:(\S+)/.exec(line.trim())
|
||||
if (prefetch) {
|
||||
ahead_.push(prefetch[1])
|
||||
continue
|
||||
}
|
||||
const segment = /^#EXTINF:([0-9.]+)/.exec(line)
|
||||
if (segment) {
|
||||
length = segment[1]
|
||||
lengths.push(Number(segment[1]))
|
||||
}
|
||||
kept.push(line)
|
||||
}
|
||||
// Nothing to say how long they are if no segment is complete yet.
|
||||
if (!length || ahead_.length === 0) return playlist
|
||||
// How long a segment still being made will turn out to be is a guess from the ones before it, and only a
|
||||
// good one where they are all alike. A broadcaster that is not re-encoded by IVS (one that sends its own
|
||||
// segments) has them of 3 to 5 seconds, and a guess of 3 for a segment of 5 leaves a gap the picture then
|
||||
// stops in, every few seconds.
|
||||
if (Math.max(...lengths) - Math.min(...lengths) > UNIFORM_WITHIN) return playlist
|
||||
while (kept.length > 0 && kept[kept.length - 1].trim() === '') kept.pop()
|
||||
for (const address of ahead_.slice(0, take)) {
|
||||
remember(address)
|
||||
kept.push(`#EXTINF:${length},prefetch`, address)
|
||||
}
|
||||
return `${kept.join('\n')}\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* hls.js's own judge of the connection, told to leave the prefetch segments out of it. See `ahead`.
|
||||
*/
|
||||
export function ivsAbr(): typeof Hls.DefaultConfig.abrController {
|
||||
const Base = Hls.DefaultConfig.abrController as unknown as new (hls: unknown) => Record<string, unknown>
|
||||
class Abr extends Base {
|
||||
// Private in hls.js's types and called by each of its three handlers that measure a segment.
|
||||
ignoreFragment(frag: { url?: string }): boolean {
|
||||
const parent = (Base.prototype as { ignoreFragment?: (f: unknown) => boolean }).ignoreFragment
|
||||
return ahead.has(frag.url ?? '') || (parent ? parent.call(this, frag) : false)
|
||||
}
|
||||
}
|
||||
return Abr as unknown as typeof Hls.DefaultConfig.abrController
|
||||
}
|
||||
|
||||
/**
|
||||
* hls.js's own loader, with the playlist of a rendition read through `withPrefetch`. Everything else it
|
||||
* loads - the master playlist, the segments, the captions - is as it was.
|
||||
*/
|
||||
export function ivsLoader(take = 2): typeof Hls.DefaultConfig.loader {
|
||||
const Base = Hls.DefaultConfig.loader as unknown as new (config: unknown) => {
|
||||
load(context: Record<string, unknown>, config: unknown, callbacks: Record<string, (...args: unknown[]) => void>): void
|
||||
}
|
||||
class Loader extends Base {
|
||||
load(context: Record<string, unknown>, config: unknown, callbacks: Record<string, (...args: unknown[]) => void>): void {
|
||||
if (context.type !== 'level') return super.load(context, config, callbacks)
|
||||
super.load(context, config, {
|
||||
...callbacks,
|
||||
onSuccess: (response, stats, ctx, details) => {
|
||||
const r = response as { data?: unknown }
|
||||
if (typeof r.data === 'string') response = { ...r, data: withPrefetch(r.data, take) }
|
||||
callbacks.onSuccess(response, stats, ctx, details)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
return Loader as unknown as typeof Hls.DefaultConfig.loader
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ChatMessage } from '../state/store'
|
||||
import { applyRemoved, removedReason, REMOVED_NOTICE } from './moderation'
|
||||
|
||||
const said = (id: string, extra: Partial<ChatMessage> = {}): ChatMessage =>
|
||||
({ id, bufferId: 'b', from: 'x', body: `text ${id}`, ts: 1, isAction: false, isHighlight: false, kind: 'chat', ...extra }) as ChatMessage
|
||||
|
||||
const log = [said('a'), said('b', { moderated: { ai: true, rules: ['hate'] }, html: '<b>x</b>', embeds: [{ kind: 'gif' } as never] }), said('c')]
|
||||
|
||||
describe('removed messages', () => {
|
||||
it('are left out when that is chosen', () => {
|
||||
expect(applyRemoved(log, 'remove').map((m) => m.id)).toEqual(['a', 'c'])
|
||||
})
|
||||
it('keep their words when struck through is chosen, and the list is the same one', () => {
|
||||
expect(applyRemoved(log, 'strike')).toBe(log)
|
||||
})
|
||||
it('have a notice put where their words were when that is chosen', () => {
|
||||
const [, removed] = applyRemoved(log, 'replace')
|
||||
expect(removed.body).toBe(REMOVED_NOTICE)
|
||||
expect(removed.html).toBeUndefined()
|
||||
expect(removed.embeds).toEqual([])
|
||||
expect(removed.moderated).toEqual({ ai: true, rules: ['hate'], replaced: true })
|
||||
// And only that one.
|
||||
expect(applyRemoved(log, 'replace')[0].body).toBe('text a')
|
||||
})
|
||||
it('change nothing in a log where nothing was removed', () => {
|
||||
const clean = [said('a')]
|
||||
expect(applyRemoved(clean, 'remove')).toBe(clean)
|
||||
expect(applyRemoved(clean, 'replace')).toBe(clean)
|
||||
})
|
||||
it('say why they were removed when they know', () => {
|
||||
expect(removedReason(log[1])).toBe("Removed by Kick's automatic moderation: hate")
|
||||
expect(removedReason(said('d', { moderated: {} }))).toBe('Removed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ChatMessage } from '../state/store'
|
||||
|
||||
/**
|
||||
* What to do with a message the service has taken down. The daemon keeps the
|
||||
* text and says it is gone; this is the window's say in how that looks.
|
||||
*/
|
||||
export type RemovedMessages = 'remove' | 'strike' | 'replace'
|
||||
|
||||
export const REMOVED_NOTICE = 'This message was removed by moderation'
|
||||
|
||||
/** What a removed message says it was removed for, for a tooltip. */
|
||||
export function removedReason(m: ChatMessage): string {
|
||||
const rules = m.moderated?.rules ?? []
|
||||
const by = m.moderated?.ai ? "Removed by Kick's automatic moderation" : 'Removed'
|
||||
return rules.length > 0 ? `${by}: ${rules.join(', ')}` : by
|
||||
}
|
||||
|
||||
/**
|
||||
* The messages to draw, with those that were removed handled as chosen:
|
||||
* left out, kept as they were (the row draws them struck through), or with
|
||||
* what they said swapped for a notice. A list with none removed comes back
|
||||
* as it was, so nothing downstream re-renders for a setting it does not use.
|
||||
*/
|
||||
export function applyRemoved(messages: ChatMessage[], mode: RemovedMessages): ChatMessage[] {
|
||||
if (mode === 'strike' || !messages.some((m) => m.moderated)) return messages
|
||||
if (mode === 'remove') return messages.filter((m) => !m.moderated)
|
||||
return messages.map((m) =>
|
||||
m.moderated
|
||||
? { ...m, body: REMOVED_NOTICE, html: undefined, embeds: [], attachments: [], components: [], moderated: { ...m.moderated, replaced: true } }
|
||||
: m
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { autoCeiling, captionTrack, menuOrder, renditionLabel, renditionOf } from './streamplayer'
|
||||
|
||||
const ladder = [
|
||||
{ height: 160, frameRate: 30, bitrate: 230000 },
|
||||
{ height: 1080, frameRate: 60, bitrate: 8519450 },
|
||||
{ height: 720, frameRate: 60, bitrate: 3422999 },
|
||||
{ height: 480, frameRate: 30, bitrate: 1427999 },
|
||||
{ height: 360, frameRate: 30, bitrate: 630000 }
|
||||
]
|
||||
|
||||
describe('the renditions a stream offers', () => {
|
||||
it('are named as Kick names them: the height, and the frame rate where it is above thirty', () => {
|
||||
expect(renditionLabel({ height: 720, frameRate: 60 })).toBe('720p60')
|
||||
expect(renditionLabel({ height: 480, frameRate: 30 })).toBe('480p')
|
||||
expect(renditionLabel({ height: 360 })).toBe('360p')
|
||||
expect(renditionLabel({ height: 1080, frameRate: 59.94 })).toBe('1080p60')
|
||||
})
|
||||
|
||||
it('are listed tallest first, with where each is in the player\'s own list, and one to a height', () => {
|
||||
expect(menuOrder(ladder).map((r) => [r.label, r.index])).toEqual([['1080p60', 1], ['720p60', 2], ['480p', 3], ['360p', 4], ['160p', 0]])
|
||||
expect(menuOrder([...ladder, { height: 720, frameRate: 30, bitrate: 1 }]).filter((r) => r.height === 720)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('are found by the height somebody wants, or by nothing when it is not offered', () => {
|
||||
expect(renditionOf(ladder, 480)).toBe(3)
|
||||
expect(renditionOf(ladder, 900)).toBe(-1)
|
||||
})
|
||||
|
||||
it('have a ceiling for automatic choice: the tallest that fits, the shortest when none does, none when there is no ceiling', () => {
|
||||
expect(autoCeiling(ladder, 720)).toBe(2)
|
||||
expect(autoCeiling(ladder, 1440)).toBe(1)
|
||||
expect(autoCeiling(ladder, 100)).toBe(0)
|
||||
expect(autoCeiling(ladder, 0)).toBe(-1)
|
||||
expect(autoCeiling([], 720)).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the captions a stream offers', () => {
|
||||
const tracks = [{ id: 0, name: 'English', lang: 'en' }, { id: 1, name: 'Portuguese', lang: 'pt' }, { id: 2, name: 'Spanish', lang: 'es' }]
|
||||
|
||||
it('are off when nothing is wanted or there is nothing to show', () => {
|
||||
expect(captionTrack(tracks, '')).toBe(-1)
|
||||
expect(captionTrack([], 'en')).toBe(-1)
|
||||
})
|
||||
|
||||
it('are the language that was chosen, however it was written', () => {
|
||||
expect(captionTrack(tracks, 'pt')).toBe(1)
|
||||
expect(captionTrack(tracks, 'PT-BR')).toBe(1)
|
||||
})
|
||||
|
||||
it('fall back to the person\'s own language, and then to the first, when the one chosen is not offered', () => {
|
||||
expect(captionTrack(tracks, 'ja', 'es')).toBe(2)
|
||||
expect(captionTrack(tracks, 'ja', 'xx')).toBe(0)
|
||||
expect(captionTrack(tracks, 'ja')).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -333,11 +333,15 @@ 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 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.
|
||||
* 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.
|
||||
*/
|
||||
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. */
|
||||
@@ -535,6 +539,10 @@ 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>
|
||||
@@ -786,6 +794,8 @@ const INITIAL: ChatState = {
|
||||
joinPanelAccountId: '',
|
||||
messagesByBuffer: {},
|
||||
presenceByBuffer: {},
|
||||
presenceTotalByBuffer: {},
|
||||
kickPointsByBuffer: {},
|
||||
loadedBuffers: {},
|
||||
loadingMore: {},
|
||||
loadingNewer: {},
|
||||
@@ -2603,7 +2613,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) => m.id === rootId || (m.replyTo?.thread && m.replyTo.id === rootId) || m.replyTo?.threadRoot === rootId
|
||||
)
|
||||
this.set({ openThread: { bufferId, rootId, messages: known, loading: true } })
|
||||
try {
|
||||
@@ -2632,15 +2642,22 @@ 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,
|
||||
{
|
||||
id: open.rootId,
|
||||
from: root?.from ?? '',
|
||||
body: root?.body ?? '',
|
||||
thread: true
|
||||
},
|
||||
earliest
|
||||
? { id: earliest.id, from: earliest.from, body: earliest.body }
|
||||
: {
|
||||
id: open.rootId,
|
||||
from: root?.from ?? '',
|
||||
body: root?.body ?? '',
|
||||
thread: true
|
||||
},
|
||||
attachment,
|
||||
spoilers
|
||||
)
|
||||
@@ -3592,6 +3609,17 @@ 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
|
||||
@@ -3951,7 +3979,8 @@ export class ChatStore {
|
||||
|
||||
case 'presenceChange':
|
||||
this.set({
|
||||
presenceByBuffer: { ...this.state.presenceByBuffer, [data.bufferId]: data.members }
|
||||
presenceByBuffer: { ...this.state.presenceByBuffer, [data.bufferId]: data.members },
|
||||
presenceTotalByBuffer: { ...this.state.presenceTotalByBuffer, [data.bufferId]: data.total ?? data.members.length }
|
||||
})
|
||||
break
|
||||
|
||||
|
||||
@@ -419,6 +419,29 @@ 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -588,6 +611,8 @@ 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
|
||||
/**
|
||||
@@ -718,6 +743,11 @@ 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