A plaque for channel points, and a pane of what they buy
The window half of #204. At the foot of the viewer list, where Kick puts it and where it belongs: it is a standing in this room rather than a fact about the conversation, like the viewer count above it. Kick channels only, and not in a whisper, where there is no such thing. The pane opens upward. The plaque sits at the bottom of a full-height column, so a list growing downward would be drawn mostly off the window - anchoring to the plaque's top edge is the only direction with room. Capped at 60vh and scrolling, because a channel may offer more rewards than the window is tall, and a list running past the top edge is a list with no top. Rewards that cannot be had are shown and not pressable rather than hidden. The list is also how somebody finds out what a channel does, and a reward turned off by the streamer is a different thing from one never offered. Each says why it is unavailable: off, paused, not enough points, or the ones that ask the redeemer to type something, which moho cannot do yet. The balance after a redeem is the one the server reports, not this one minus the cost - a redeem that was refunded or priced differently should not leave the plaque disagreeing with Kick. Closes #204. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+1
-1
Submodule nobilis updated: 5d408bdc93...d4206362d4
@@ -5435,6 +5435,103 @@ video.lightbox-media {
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
/* --- Kick channel points ---------------------------------------------- */
|
||||
|
||||
/* The plaque sits at the foot of a full-height column, so its pane has to
|
||||
grow upward - downward would draw it off the window. `relative` here is
|
||||
what the pane is positioned against. */
|
||||
.kick-points {
|
||||
position: relative;
|
||||
flex: none;
|
||||
border-top: 1px solid var(--outline-variant);
|
||||
}
|
||||
|
||||
.kick-points-plaque {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.kick-points-plaque:hover,
|
||||
.kick-points-plaque.open {
|
||||
background: var(--surface-container-high);
|
||||
}
|
||||
|
||||
.kick-points-plaque .ellipsis {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Capped and scrolling: a channel may offer more rewards than the window is
|
||||
tall, and a list that runs past the top edge is a list with no top. */
|
||||
.kick-rewards {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: min(60vh, 420px);
|
||||
border: 1px solid var(--outline-medium);
|
||||
border-radius: var(--corner-radius) var(--corner-radius) 0 0;
|
||||
background: var(--surface-container-high);
|
||||
box-shadow: 0 -8px 28px rgba(0, 0, 0, 0.45);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kick-rewards-head {
|
||||
padding: 8px 10px 4px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.kick-rewards-list {
|
||||
overflow-y: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.kick-rewards-empty {
|
||||
padding: 6px 10px 10px;
|
||||
}
|
||||
|
||||
.kick-reward {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.kick-reward:hover:not(:disabled) {
|
||||
background: var(--surface-container-highest);
|
||||
}
|
||||
|
||||
/* Shown rather than hidden, because the list is also how somebody learns what
|
||||
a channel does - but plainly not pressable. */
|
||||
.kick-reward:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.kick-reward-cost {
|
||||
flex: none;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* --- Exporting a conversation ----------------------------------------- */
|
||||
|
||||
.export-choice {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { useStore } from '../state/hooks'
|
||||
|
||||
/**
|
||||
* Channel points, and what the streamer will trade them for.
|
||||
*
|
||||
* A plaque at the foot of the viewer list, because that is where Kick puts it
|
||||
* and because it belongs to the channel rather than to the conversation - it
|
||||
* is a fact about standing in this room, like the viewer count above it.
|
||||
*
|
||||
* The list opens upward. The plaque sits at the bottom of a full-height
|
||||
* column, so a pane growing downward from it would be drawn mostly off the
|
||||
* window; anchoring it to the plaque's top edge and letting it grow up is the
|
||||
* only direction with room. Its height is capped and it scrolls, because a
|
||||
* channel may offer more rewards than the window is tall.
|
||||
*/
|
||||
interface Reward {
|
||||
id: string
|
||||
title: string
|
||||
cost: number
|
||||
description?: string
|
||||
enabled: boolean
|
||||
paused: boolean
|
||||
needsInput: boolean
|
||||
}
|
||||
|
||||
interface Answer {
|
||||
rewards: Reward[]
|
||||
points: number | null
|
||||
}
|
||||
|
||||
export function KickPoints({ bufferId }: { bufferId: string }): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [answer, setAnswer] = useState<Answer | null>(null)
|
||||
const [busy, setBusy] = useState('')
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
|
||||
const refresh = (): void => {
|
||||
void window.moho
|
||||
.rpc<Answer>('listKickRewards', { bufferId })
|
||||
.then(setAnswer)
|
||||
.catch(() => setAnswer(null))
|
||||
}
|
||||
|
||||
// Asked on open rather than on every render: a balance is only worth being
|
||||
// current at the moment somebody is about to spend it.
|
||||
useEffect(() => {
|
||||
if (open) refresh()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, bufferId])
|
||||
|
||||
// Closing the channel takes the pane with it, so it cannot be left open
|
||||
// over a conversation it is not about.
|
||||
useEffect(() => setOpen(false), [bufferId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const away = (e: MouseEvent): void => {
|
||||
if (!box.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const key = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', away)
|
||||
window.addEventListener('keydown', key)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', away)
|
||||
window.removeEventListener('keydown', key)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const points = answer?.points ?? null
|
||||
|
||||
const redeem = (reward: Reward): void => {
|
||||
setBusy(reward.id)
|
||||
void window.moho
|
||||
.rpc<{ points: number | null }>('redeemKickReward', { bufferId, rewardId: reward.id })
|
||||
.then((a) => {
|
||||
store.toast('info', `Redeemed ${reward.title}`)
|
||||
// The balance the server now holds, rather than this one minus the
|
||||
// cost - a redeem that was refunded or cost something else should not
|
||||
// leave the plaque disagreeing with Kick.
|
||||
setAnswer((prev) => (prev ? { ...prev, points: a.points ?? prev.points } : prev))
|
||||
})
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
.finally(() => setBusy(''))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="kick-points" ref={box}>
|
||||
{open && (
|
||||
<div className="kick-rewards" role="menu">
|
||||
<div className="kick-rewards-head small muted">
|
||||
{answer ? `${answer.rewards.length} rewards` : 'Asking Kick…'}
|
||||
</div>
|
||||
<div className="kick-rewards-list">
|
||||
{answer?.rewards.map((r) => {
|
||||
// Shown whatever their state - the list is also how somebody
|
||||
// learns what this channel does - but only one of them can
|
||||
// actually be pressed.
|
||||
const why = !r.enabled
|
||||
? 'The streamer has this turned off'
|
||||
: r.paused
|
||||
? 'Paused right now'
|
||||
: r.needsInput
|
||||
? 'This one asks you to type something, which moho cannot do yet'
|
||||
: points !== null && points < r.cost
|
||||
? `You have ${points.toLocaleString()} of ${r.cost.toLocaleString()}`
|
||||
: ''
|
||||
return (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
className={`kick-reward${why ? ' locked' : ''}`}
|
||||
disabled={!!why || busy === r.id}
|
||||
title={why || r.description || r.title}
|
||||
onClick={() => redeem(r)}
|
||||
>
|
||||
<span className="ellipsis">{r.title}</span>
|
||||
<span className="kick-reward-cost small">{r.cost.toLocaleString()}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{answer?.rewards.length === 0 && (
|
||||
<div className="small muted kick-rewards-empty">This channel offers nothing for points.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`kick-points-plaque${open ? ' open' : ''}`}
|
||||
title="Channel points, and what they buy"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<Icon name="toll" size={15} />
|
||||
<span className="ellipsis">{points === null ? 'Points' : points.toLocaleString()}</span>
|
||||
<Icon name={open ? 'expand_more' : 'expand_less'} size={15} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Icon, IconButton } from './Icon'
|
||||
import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu'
|
||||
import { useActiveBuffer, useChat, useIdSetPref, useStore } from '../state/hooks'
|
||||
import { classes, hasDirectMessages, nickColor } from '../lib/util'
|
||||
import { KickPoints } from './KickPoints'
|
||||
import type { Member } from '../../../shared/wire'
|
||||
|
||||
/**
|
||||
@@ -388,6 +389,12 @@ export function NickList(): JSX.Element {
|
||||
{members.length === 0 && <div className="nicklist-empty small muted">No member list yet.</div>}
|
||||
{blocked.length > 0 && <div className="nicklist-group small muted">{blocked.length} blocked</div>}
|
||||
</div>
|
||||
{/* Kick's own plaque, at the foot of the list where Kick puts it. Only
|
||||
on Kick, and only in a channel - it is a standing in this room, and
|
||||
there is no such thing in a whisper. */}
|
||||
{account?.service === 'kick' && buffer?.kind === 'channel' && buffer && (
|
||||
<KickPoints bufferId={buffer.id} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user