Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3a1efc1ef | ||
|
|
322cbc8407 |
+1
-1
Submodule nobilis updated: 4c17be082b...cd8a44cbf9
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Reads a busy live Twitch channel in a scratch moho with no sign-in, and checks what the window shows:
|
||||
* that the channel joins, messages arrive with their colours, badges and emote pictures, replies carry
|
||||
* their quote, the room's modes are in the header and the chatters are counted.
|
||||
*
|
||||
* node scripts/shot-twitch-chat.mjs <output directory> [channel] [seconds]
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
const channel = process.argv[3] ?? 'xqc'
|
||||
const seconds = Number(process.argv[4] ?? 40)
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
|
||||
const ev = (code) => page.evaluate(code)
|
||||
try {
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
await sleep(2500)
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
|
||||
// HISTORY=1 turns on the third-party recent messages, which is off until a person says so.
|
||||
if (process.env.HISTORY) await ev(`window.moho.prefs.set('twitch.recentHistory', true)`)
|
||||
const added = await rpc('addTwitchAccount', {})
|
||||
say('account', JSON.stringify(added))
|
||||
await sleep(2500)
|
||||
say('accounts', await ev(`JSON.stringify(window.__mohoShots.state().accounts.map((a) => ({ id: a.id, service: a.service, state: a.state, name: a.displayName, hasPassword: a.hasPassword })))`))
|
||||
say('join', JSON.stringify(await rpc('joinBuffer', { accountId: added.accountId, name: channel })))
|
||||
const id = `${added.accountId}|${channel}`
|
||||
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
|
||||
say('a channel that does not exist', JSON.stringify(await rpc('joinBuffer', { accountId: added.accountId, name: 'zzzz_no_such_channel_zzzz' })))
|
||||
if (process.env.HISTORY) {
|
||||
await sleep(6000)
|
||||
say('rows six seconds after opening, with the past', await ev(`(window.__mohoShots.state().messagesByBuffer[${JSON.stringify(id)}] || []).length`))
|
||||
}
|
||||
await sleep(seconds * 1000)
|
||||
say('buffers', await ev(`JSON.stringify(window.__mohoShots.state().buffers.filter((b) => b.accountId === ${JSON.stringify(added.accountId)}).map((b) => ({ id: b.id, modes: b.channelModes })))`))
|
||||
say('page', await ev(`JSON.stringify({
|
||||
rows: document.querySelectorAll('.message-row').length,
|
||||
emotes: document.querySelectorAll('.message-row img.custom-emoji').length,
|
||||
emotesLoaded: [...document.querySelectorAll('.message-row img.custom-emoji')].filter((i) => i.complete && i.naturalWidth > 0).length,
|
||||
badges: [...new Set([...document.querySelectorAll('.message-row .sender-badge')].map((b) => b.textContent))],
|
||||
replies: document.querySelectorAll('.message-row .reply-preview').length,
|
||||
header: document.querySelector('.nicklist-header span')?.textContent,
|
||||
coloured: document.querySelectorAll('.message-row .message-from[style*="color"]').length
|
||||
})`))
|
||||
await shot('twitch-chat.png')
|
||||
async function shot(name) {
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
}
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Opens the Accounts pane in a scratch moho, starts a Twitch sign-in and photographs the code it shows,
|
||||
* then cancels it. Nothing is approved, so no account is signed in and nothing is saved.
|
||||
*
|
||||
* node scripts/shot-twitch-signin.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
|
||||
const ev = (code) => page.evaluate(code)
|
||||
const shot = async (name) => {
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
}
|
||||
const clickText = (selector, text) => ev(`(() => { const b = [...document.querySelectorAll(${JSON.stringify(selector)})].find((x) => x.textContent.trim().startsWith(${JSON.stringify(text)})); if (!b) return false; b.click(); return true })()`)
|
||||
try {
|
||||
try {
|
||||
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
|
||||
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
|
||||
} catch {}
|
||||
await sleep(2500)
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1100, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
await ev(`window.__mohoShots.store.setActivePanel('accounts')`)
|
||||
await sleep(600)
|
||||
say('twitch chip', await clickText('.service-chip', 'Twitch'))
|
||||
await sleep(500)
|
||||
await shot('twitch-signin-form.png')
|
||||
say('sign in', await clickText('button', 'Sign in with Twitch'))
|
||||
for (let i = 0; i < 20 && !(await ev(`!!document.querySelector('.twitch-code')`)); i++) await sleep(500)
|
||||
say('code shown', await ev(`JSON.stringify({ code: document.querySelector('.twitch-code')?.textContent, buttons: [...document.querySelectorAll('.add-form button')].map((b) => b.textContent.trim()) })`))
|
||||
await shot('twitch-signin-code.png')
|
||||
say('cancel', await clickText('button', 'Cancel sign-in'))
|
||||
await sleep(500)
|
||||
say('back to the form', await ev(`!document.querySelector('.twitch-code') && !!([...document.querySelectorAll('button')].find((b) => b.textContent.trim() === 'Sign in with Twitch'))`))
|
||||
say('accounts saved', await ev(`window.__mohoShots.state().accounts.filter((a) => a.service === 'twitch').length`))
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
@@ -10981,3 +10981,17 @@ html[data-reduce-motion] *::after {
|
||||
object-fit: contain;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* The code Twitch gives to be approved on twitch.tv: read off one screen and
|
||||
typed on another, so it is large and spaced. */
|
||||
.twitch-code {
|
||||
align-self: flex-start;
|
||||
padding: 10px 18px;
|
||||
border: 1px solid var(--surface-variant-text);
|
||||
border-radius: var(--radius-xs);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 28px;
|
||||
font-weight: var(--weight-bold);
|
||||
letter-spacing: 0.28em;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<!-- Twitch's mark as a plain silhouette: the speech bubble with its two bars. -->
|
||||
<path fill="#fff" d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0 1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143-3.428 3.428h-3.43l-3 3v-3H6.857V1.714h13.714z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 383 B |
@@ -4,6 +4,7 @@ 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'
|
||||
@@ -24,7 +25,7 @@ import type { Account } from '../../../shared/wire'
|
||||
* that gains a backend appears here as soon as it gains a form, and one the
|
||||
* daemon stops offering disappears without this file being touched.
|
||||
*/
|
||||
const FORMS = ['irc', 'discord', 'sneedchat', 'matrix', 'kick', 'steam'] as const
|
||||
const FORMS = ['irc', 'discord', 'sneedchat', 'matrix', 'kick', 'steam', 'twitch'] as const
|
||||
type AddableService = (typeof FORMS)[number]
|
||||
|
||||
/** What `listProtocols` answers with. */
|
||||
@@ -136,6 +137,7 @@ 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
|
||||
@@ -459,6 +461,7 @@ 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' && (
|
||||
<>
|
||||
@@ -1472,6 +1475,69 @@ 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.hasPassword) {
|
||||
if (account && (account.service === 'kick' || account.service === 'twitch') && !account.hasPassword) {
|
||||
return (
|
||||
<div className="composer">
|
||||
<div className="divider-h" />
|
||||
<div className="composer-watching small muted">
|
||||
<Icon name="visibility" size={16} />
|
||||
<span>Watching signed out. Sign in to Kick to talk here.</span>
|
||||
<span>Watching signed out. Sign in to {account.service === 'twitch' ? 'Twitch' : 'Kick'} to talk here.</span>
|
||||
<button type="button" className="button" onClick={() => store.setActivePanel('accounts')}>
|
||||
Sign in
|
||||
</button>
|
||||
|
||||
@@ -74,6 +74,8 @@ 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':
|
||||
@@ -82,7 +84,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'].includes(a.service))
|
||||
const joinable = all.filter((a) => ['irc', 'matrix', 'discord', 'kick', 'sneedchat', 'steam', 'twitch'].includes(a.service))
|
||||
|
||||
return (
|
||||
<div className="join-frame">
|
||||
@@ -361,6 +363,23 @@ 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
|
||||
|
||||
@@ -85,7 +85,11 @@ function badgeLabel(badge: MessageBadge): string {
|
||||
// Not a title like the rest of these - a warning that whoever sent this
|
||||
// was using a device nobody has vouched for. Without its own short form
|
||||
// the fallback below would abbreviate the explanation into "SEN".
|
||||
unverified: '⚠'
|
||||
unverified: '⚠',
|
||||
// Twitch's: somebody's first message in a channel, and a message mirrored
|
||||
// from another channel of a shared chat.
|
||||
'first-message': 'NEW',
|
||||
'shared-chat': 'SHARED'
|
||||
}
|
||||
return short[badge.type] ?? badge.text.slice(0, 3).toUpperCase()
|
||||
}
|
||||
|
||||
@@ -89,8 +89,8 @@ 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') return undefined
|
||||
const base = 'Kick has no viewer list — this counts everybody who has spoken here since you opened it'
|
||||
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
|
||||
}
|
||||
|
||||
@@ -265,7 +265,7 @@ export function NickList(): JSX.Element {
|
||||
// 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' ? 'recently talking' : 'members'}
|
||||
{total.toLocaleString()} {account?.service === 'kick' || account?.service === 'twitch' ? 'recently talking' : 'members'}
|
||||
</span>
|
||||
)}
|
||||
<IconButton
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useStore } from '../state/hooks'
|
||||
|
||||
/**
|
||||
* Signing in to Twitch.
|
||||
*
|
||||
* Nothing is typed here, and no password is ever seen. Twitch gives a short
|
||||
* code, this shows it, and the person approves it on twitch.tv in their own
|
||||
* browser, signed in however they sign in (see nobilis backend/twitch/login.rs).
|
||||
* Waiting can take minutes, so it is a conversation of events rather than one
|
||||
* request: the code, what it is doing, and the result.
|
||||
*/
|
||||
interface Scope {
|
||||
scope: string
|
||||
why: string
|
||||
}
|
||||
|
||||
interface Code {
|
||||
userCode: string
|
||||
verificationUri: string
|
||||
expiresIn: number
|
||||
scopes: Scope[]
|
||||
at: number
|
||||
}
|
||||
|
||||
/** The scopes the sign-in asks for, said before it is started so nobody has to guess. */
|
||||
const ASKS: Scope[] = [
|
||||
{ scope: 'chat:read', why: 'read chat' },
|
||||
{ scope: 'chat:edit', why: 'send messages' },
|
||||
{ scope: 'user:write:chat', why: 'hear why Twitch turned a message down' },
|
||||
{ scope: 'user:read:follows', why: 'list the channels you follow' },
|
||||
{ scope: 'user:read:emotes', why: 'show the emotes you can use' }
|
||||
]
|
||||
|
||||
export function TwitchSignIn({
|
||||
useTor,
|
||||
onDone,
|
||||
label = 'Sign in with Twitch',
|
||||
children
|
||||
}: {
|
||||
useTor: boolean
|
||||
onDone: () => void
|
||||
label?: string
|
||||
/** Other things to offer beside the sign-in button, while nothing is waiting. */
|
||||
children?: React.ReactNode
|
||||
}): JSX.Element {
|
||||
const store = useStore()
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [detail, setDetail] = useState('')
|
||||
const [code, setCode] = useState<Code | null>(null)
|
||||
const [left, setLeft] = useState(0)
|
||||
|
||||
// The id lives in a ref as well as state, because the first event can be
|
||||
// handled before the render that follows the answer to the request that
|
||||
// started the sign-in. Nothing else is signing in to Twitch from here, so an
|
||||
// event arriving before the id is known can only be this one's.
|
||||
const current = useRef('')
|
||||
|
||||
useEffect(() => {
|
||||
const off = window.moho.onEvent((e) => {
|
||||
if (!e.event.startsWith('twitchLogin')) return
|
||||
const d = e.data as {
|
||||
loginId?: string
|
||||
detail?: string
|
||||
userCode?: string
|
||||
verificationUri?: string
|
||||
expiresIn?: number
|
||||
scopes?: Scope[]
|
||||
success?: boolean
|
||||
error?: string
|
||||
}
|
||||
if (current.current && d.loginId !== current.current) return
|
||||
if (e.event === 'twitchLoginCode' && d.userCode && d.verificationUri) {
|
||||
setCode({ userCode: d.userCode, verificationUri: d.verificationUri, expiresIn: d.expiresIn ?? 1800, scopes: d.scopes ?? ASKS, at: Date.now() })
|
||||
} else if (e.event === 'twitchLoginStatus') {
|
||||
setDetail(d.detail || '')
|
||||
} else if (e.event === 'twitchLoginResult') {
|
||||
current.current = ''
|
||||
setBusy(false)
|
||||
setCode(null)
|
||||
setDetail('')
|
||||
if (d.success) {
|
||||
void store.refreshAccounts()
|
||||
onDone()
|
||||
} else {
|
||||
store.toast('error', d.error || 'Twitch sign-in failed')
|
||||
}
|
||||
}
|
||||
})
|
||||
return off
|
||||
}, [onDone, store])
|
||||
|
||||
// Closing the form on a sign-in that is still waiting tells the daemon
|
||||
// nobody is, so it stops asking Twitch and frees what it was holding.
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (current.current) void window.moho.rpc('cancelTwitchLogin', { loginId: current.current }).catch(() => {})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// How long the code has left, counted down so nobody types one that has run out.
|
||||
useEffect(() => {
|
||||
if (!code) return
|
||||
const tick = (): void => setLeft(Math.max(0, code.expiresIn - Math.floor((Date.now() - code.at) / 1000)))
|
||||
tick()
|
||||
const timer = setInterval(tick, 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [code])
|
||||
|
||||
const begin = (): void => {
|
||||
setBusy(true)
|
||||
setDetail('')
|
||||
current.current = ''
|
||||
void window.moho
|
||||
.rpc<{ loginId: string }>('startTwitchLogin', { useTor })
|
||||
.then((r) => {
|
||||
current.current = r.loginId
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
setBusy(false)
|
||||
store.toast('error', e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const cancel = (): void => {
|
||||
if (current.current) void window.moho.rpc('cancelTwitchLogin', { loginId: current.current }).catch(() => {})
|
||||
current.current = ''
|
||||
setBusy(false)
|
||||
setCode(null)
|
||||
setDetail('')
|
||||
}
|
||||
|
||||
if (code) {
|
||||
const minutes = Math.floor(left / 60)
|
||||
const seconds = String(left % 60).padStart(2, '0')
|
||||
return (
|
||||
<div className="add-form">
|
||||
<p>Approve this on twitch.tv to finish signing in.</p>
|
||||
<div className="twitch-code" aria-label="Your sign-in code">
|
||||
{code.userCode}
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<button type="button" className="button primary" onClick={() => void window.moho.openExternal(code.verificationUri)}>
|
||||
Open twitch.tv
|
||||
</button>
|
||||
<button type="button" className="button subtle" onClick={() => void window.moho.copyText(code.userCode)}>
|
||||
Copy code
|
||||
</button>
|
||||
<button type="button" className="button" onClick={cancel}>
|
||||
Cancel sign-in
|
||||
</button>
|
||||
</div>
|
||||
<p className="small muted">
|
||||
The page opens with the code already filled in. If it asks for it, type the code above. It is good for{' '}
|
||||
{left > 0 ? `${minutes}:${seconds}` : 'no time at all - start again'}.
|
||||
</p>
|
||||
<p className="small muted">
|
||||
moho will be allowed to: {code.scopes.map((s) => s.why).join(', ')}. You can end it any time at
|
||||
twitch.tv/settings/connections, and removing the account here ends it too.
|
||||
</p>
|
||||
{detail && <p className="small muted">{detail}…</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="small muted">
|
||||
You sign in on twitch.tv, in your own browser - moho never sees your password. It asks to be allowed to:{' '}
|
||||
{ASKS.map((s) => s.why).join(', ')}.
|
||||
</p>
|
||||
<div className="field-row">
|
||||
<button type="button" className="button primary" disabled={busy} onClick={begin}>
|
||||
{busy ? 'Asking Twitch…' : label}
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
{busy && detail && <p className="small muted">{detail}…</p>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -58,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.hasPassword && status !== 'connecting' && status !== 'offline'
|
||||
const watching = (account.service === 'kick' || account.service === 'twitch') && !account.hasPassword && status !== 'connecting' && status !== 'offline'
|
||||
// Mute and deafen are the whole client's, for whatever call is up - so
|
||||
// shown on a service that has calls, or anywhere while one is running,
|
||||
// and not beside an IRC or Kick account with nothing to apply them to.
|
||||
|
||||
@@ -126,6 +126,7 @@ const CATEGORIES: { id: string; label: string; group: string; icon?: string; ser
|
||||
{ id: 'discord', label: 'Discord', group: 'Networks', service: 'discord' },
|
||||
{ id: 'matrix', label: 'Matrix', group: 'Networks', service: 'matrix' },
|
||||
{ id: 'kick', label: 'Kick', group: 'Networks', service: 'kick' },
|
||||
{ id: 'twitch', label: 'Twitch', group: 'Networks', service: 'twitch' },
|
||||
{ id: 'about', label: 'About', group: 'About' }
|
||||
]
|
||||
|
||||
@@ -244,6 +245,8 @@ function Page({ id }: { id: string }): JSX.Element | null {
|
||||
return <DiscordSettings />
|
||||
case 'kick':
|
||||
return <KickSettings />
|
||||
case 'twitch':
|
||||
return <TwitchSettings />
|
||||
case 'about':
|
||||
return <AboutSettings />
|
||||
default:
|
||||
@@ -1255,6 +1258,58 @@ function KickSettings(): JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* There is exactly one embedded Tor client shared by the whole daemon, not one
|
||||
* per account, so everything here is daemon-global.
|
||||
|
||||
@@ -82,6 +82,18 @@ 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.
|
||||
*
|
||||
@@ -135,6 +147,8 @@ 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))
|
||||
@@ -556,6 +570,13 @@ 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
|
||||
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -72,6 +73,8 @@ 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':
|
||||
@@ -105,6 +108,8 @@ export function serviceLabel(service: string): string {
|
||||
return 'Kick'
|
||||
case 'steam':
|
||||
return 'Steam'
|
||||
case 'twitch':
|
||||
return 'Twitch'
|
||||
case 'slack':
|
||||
return 'Slack'
|
||||
case 'jabber':
|
||||
|
||||
@@ -4519,6 +4519,7 @@ 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
|
||||
@@ -4695,6 +4696,31 @@ 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
-1
@@ -3,7 +3,7 @@
|
||||
|
||||
export interface Account {
|
||||
id: string
|
||||
service: 'irc' | 'discord' | 'sneedchat' | 'matrix' | 'kick' | 'steam' | 'jabber' | 'slack'
|
||||
service: 'irc' | 'discord' | 'sneedchat' | 'matrix' | 'kick' | 'steam' | 'twitch' | 'jabber' | 'slack'
|
||||
displayName: string
|
||||
/**
|
||||
* How the user is presenting: "online" | "idle". Distinct from
|
||||
|
||||
Reference in New Issue
Block a user