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:
+1
-1
Submodule nobilis updated: 0a3135a11a...8dae46fab3
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user