A Kick settings page, and kick.com links that open here

Closes the client half of #83.

Six toggles for the things nobody said - redemptions, subscriptions,
raids, polls, going live, moderation - each separable, because on a busy
channel they can outnumber the conversation and they are not all wanted at
once. Same reasoning as IRC's join and part filters, and now possible
because the daemon says which kind each line is.

A kick.com link in a message opens the channel here rather than the
browser. Only the bare `kick.com/<handle>` form: a link to a clip, a video
or a category is a page moho cannot show, and opening the channel instead
would quietly take somebody somewhere they did not ask to go.

The scheme registration list is deliberately left alone. A kick.com link
is an ordinary web address, and registering for https would be
volunteering to be the system browser.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-02 02:25:25 -04:00
co-authored by Claude Opus 5
parent 80a52ac980
commit e518431326
6 changed files with 149 additions and 7 deletions
+1 -1
Submodule nobilis updated: 0a3135a11a...8dae46fab3
+18 -2
View File
@@ -122,6 +122,15 @@ export function MessageList(): JSX.Element {
const [showMxInvite] = usePref<boolean>('matrix.showInviteMessages', true)
const [showMxKick] = usePref<boolean>('matrix.showKickMessages', true)
const [showMxQuit] = usePref<boolean>('matrix.showQuitMessages', true)
// Kick's chat carries a great deal nobody said, and on a busy channel it can
// outnumber the conversation. Separable for the same reason IRC's join and
// part lines are: each is worth having, and not all at once.
const [showRedemptions] = usePref<boolean>('kick.showRedemptions', true)
const [showSubs] = usePref<boolean>('kick.showSubscriptions', true)
const [showRaids] = usePref<boolean>('kick.showRaids', true)
const [showPolls] = usePref<boolean>('kick.showPolls', true)
const [showStream] = usePref<boolean>('kick.showStreamEvents', true)
const [showKickMod] = usePref<boolean>('kick.showModeration', true)
const [blockedNicks] = usePref<string[]>('blockedNicks', [])
const accountId = buffers.find((b) => b.id === bufferId)?.accountId || ''
@@ -137,7 +146,13 @@ export function MessageList(): JSX.Element {
matrixJoin: showMxJoin,
matrixInvite: showMxInvite,
matrixKick: showMxKick,
matrixQuit: showMxQuit
matrixQuit: showMxQuit,
reward: showRedemptions,
sub: showSubs,
raid: showRaids,
poll: showPolls,
stream: showStream,
moderation: showKickMod
}
return all.filter((m) => {
if (accountId && blockedNicks.includes(`${accountId}|${m.from}`)) return false
@@ -148,7 +163,8 @@ export function MessageList(): JSX.Element {
}, [
all, accountId, blockedNicks,
showJoin, showPart, showNick, showTopic, showMode,
showMxJoin, showMxInvite, showMxKick, showMxQuit
showMxJoin, showMxInvite, showMxKick, showMxQuit,
showRedemptions, showSubs, showRaids, showPolls, showStream, showKickMod
])
/**
@@ -23,6 +23,7 @@ const CATEGORIES = [
{ id: 'sockchat', label: 'Sneedchat', available: true },
{ id: 'tor', label: 'Tor', available: true },
{ id: 'matrix', label: 'Matrix', available: true },
{ id: 'kick', label: 'Kick', available: true },
{ id: 'about', label: 'About', available: true },
{ id: 'discord', label: 'Discord', available: false },
{ id: 'slack', label: 'Slack', available: false }
@@ -139,6 +140,7 @@ export function SettingsPanel(): JSX.Element {
{selected === 'sockchat' && <SneedchatSettings />}
{selected === 'tor' && <TorSettings />}
{selected === 'matrix' && <MatrixSettings />}
{selected === 'kick' && <KickSettings />}
{selected === 'about' && <AboutSettings />}
{(selected === 'discord' || selected === 'slack') && (
<ComingSoon label={CATEGORIES.find((c) => c.id === selected)!.label} />
@@ -544,6 +546,60 @@ function SneedchatSettings(): JSX.Element {
)
}
/**
* Kick's chat carries a great deal that nobody said.
*
* Redemptions, subscriptions, gifted subs, raids and polls all arrive as
* events, and on a busy channel they can outnumber the conversation. Each is
* worth having and not all are worth having at once, so they are separable -
* the same reasoning as IRC's join and part filters, for the same reason.
*/
function KickSettings(): JSX.Element {
return (
<>
<SettingsSection
title="Kick"
description="Channels are watched per account - open the Accounts pane, expand a Kick account and use Sync my follows, or add one by handle with the + beside the account. Kick chat is public, so an account signed out still reads everything."
/>
<SettingsSection
title="What to show besides chat"
description="These are drawn on a plate of their own so they survive being scrolled past, which is also what makes them worth turning off individually."
>
<ToggleSetting
settingKey="kick.showRedemptions"
label="Show reward redemptions"
description='"someone redeemed CAT PATS"'
defaultValue={true}
/>
<ToggleSetting
settingKey="kick.showSubscriptions"
label="Show subscriptions and gifted subs"
defaultValue={true}
/>
<ToggleSetting settingKey="kick.showRaids" label="Show raids" defaultValue={true} />
<ToggleSetting
settingKey="kick.showPolls"
label="Show polls"
description="The question and the running tally, as the streamer changes it"
defaultValue={true}
/>
<ToggleSetting
settingKey="kick.showStreamEvents"
label="Show going live and going offline"
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}
/>
</SettingsSection>
</>
)
}
/**
* There is exactly one embedded Tor client shared by the whole daemon, not one
* per account, so everything here is daemon-global.
+9 -1
View File
@@ -142,9 +142,17 @@ export function isWhisper(kind: string | undefined): boolean {
* fast enough that these are the reason people are watching, a redemption
* styled like a join message is a redemption nobody sees - so it gets a plate
* of its own.
*
* Several kinds rather than one, because they are turned off separately -
* somebody who wants redemptions and not raids has to be able to say so, and
* a single kind made that a choice between all of them and none.
*
* Moderation and stream events are deliberately not here: a timeout is not a
* thing somebody spent something on, and a plate would give it a weight it
* does not have.
*/
export function isReward(kind: string | undefined): boolean {
return kind === 'reward'
return kind === 'reward' || kind === 'sub' || kind === 'raid' || kind === 'poll'
}
export function bufferKindGlyph(kind: string): string {
+21
View File
@@ -822,6 +822,27 @@ export class ChatStore {
return
}
// A kick.com link names a streamer, and watching one needs no more than
// that - so it opens the channel rather than the browser. Any Kick
// account will do: they all read the same public chat, and the signed-in
// one is preferred only because it can also talk.
if (link.service === 'kick') {
const account =
this.state.accounts.find((a) => a.service === 'kick' && a.hasPassword) ??
this.state.accounts.find((a) => a.service === 'kick')
if (!account) {
this.toast('error', 'Add a Kick account to open that link here')
void window.moho.openExternal(url)
return
}
this.set({ activePanel: '' })
void window.moho
.rpc('joinBuffer', { accountId: account.id, name: link.handle })
.then(() => this.awaitBuffer(account.id, link.handle))
.catch((e: Error) => this.toast('error', `Couldn't open ${link.handle}: ${e.message}`))
return
}
// An IRC account is identified as "nick@host", which is the only place the
// host survives out here - the wire account carries what to show, not what
// it connected to.
+44 -3
View File
@@ -21,12 +21,50 @@ export interface IrcLink {
channels: string[]
}
/** Schemes this understands. Anything else is not ours and is left alone. */
/** A Kick channel, named by the handle in a kick.com link. */
export interface KickLink {
service: 'kick'
handle: string
}
export type DeepLink = IrcLink | KickLink
/**
* Schemes moho asks the desktop to send it.
*
* Deliberately not the same list as what `isDeepLink` accepts: a kick.com
* link is an ordinary web address, and registering for `https` would mean
* volunteering to be the system browser.
*/
export const DEEP_LINK_SCHEMES = ['irc', 'ircs']
/** Whether a string is a link this client would act on. */
export function isDeepLink(value: string): boolean {
return DEEP_LINK_SCHEMES.some((s) => value.toLowerCase().startsWith(`${s}:`))
const lower = value.toLowerCase()
return DEEP_LINK_SCHEMES.some((s) => lower.startsWith(`${s}:`)) || !!kickHandle(value)
}
/**
* The handle in a kick.com link, if it is one that names a channel.
*
* Only the bare `kick.com/<handle>` form. A link to a clip, a video or a
* category is a page moho cannot show, and opening the channel instead would
* quietly take somebody somewhere they did not ask to go - those keep going
* to the browser.
*/
export function kickHandle(value: string): string | null {
let url: URL
try {
url = new URL(value.trim())
} catch {
return null
}
if (!/^https?:$/.test(url.protocol)) return null
if (url.hostname.replace(/^www\./, '').toLowerCase() !== 'kick.com') return null
const parts = url.pathname.split('/').filter(Boolean)
if (parts.length !== 1) return null
const handle = parts[0]
return /^[A-Za-z0-9_-]+$/.test(handle) ? handle : null
}
/**
@@ -50,7 +88,10 @@ export function isDeepLink(value: string): boolean {
* - `ircs://` means TLS. `irc://` on port 6697 does too, whatever it says,
* because that port is TLS everywhere and a link that names it means it.
*/
export function parseDeepLink(value: string): IrcLink | null {
export function parseDeepLink(value: string): DeepLink | null {
const kick = kickHandle(value)
if (kick) return { service: 'kick', handle: kick }
let url: URL
try {
url = new URL(value.trim())