A Kick channel's header says what is on air

The chat is watched beside a video moho does not show, so the header
carries the context that video would: a red dot while live, the stream's
title, the category, and how many people are watching. Offline says so
outright, because a channel that is quiet and one that is off air look
identical otherwise.

Each part shrinks before the row does, so a long stream title cannot push
the buttons beside it off the header.
This commit is contained in:
2026-09-03 20:41:35 -04:00
parent eb45b4a6ea
commit 82291f3ff5
4 changed files with 97 additions and 2 deletions
+1 -1
Submodule nobilis updated: 20d087d077...39f8ae3912
+36
View File
@@ -4629,3 +4629,39 @@ video.lightbox-media {
margin-left: auto;
max-width: 55%;
}
/* --- what a Kick channel is broadcasting ---------------------------------
The chat is watched beside a video this client does not show, so the header
carries the context that video would: what the stream is, what is being
played, and how many people are there. */
.stream-chip {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
min-width: 0;
max-width: 42%;
padding: 1px var(--spacing-xs);
border: 1px solid var(--outline-light);
border-radius: var(--corner-radius-small);
color: var(--surface-variant-text);
}
.stream-chip.live {
border-color: var(--error);
}
/* Red, and only present while live - the one thing on the chip that must be
readable at a glance from across a room. */
.stream-dot {
flex: none;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--error);
}
/* Each part shrinks before the row does, so a long stream title cannot push
the buttons beside it off the header. */
.stream-chip .ellipsis {
min-width: 2em;
}
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { Icon, IconButton } from './Icon'
import { AddToConversation } from './AddToConversation'
import { useChat, useStore } from '../state/hooks'
import { formatFullTime } from '../lib/util'
import { classes, formatFullTime } from '../lib/util'
import type { BufferEntry } from '../state/store'
import type { Message } from '../../../shared/wire'
@@ -110,6 +110,7 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
const box = useRef<HTMLDivElement>(null)
const isDiscord = buffer.accountId.startsWith('discord:')
const stream = useChat((s) => s.kickStreams)[buffer.id]
const isDm = buffer.kind === 'dm'
// This conversation's call, not merely a call on the same account: one
// account can only be in one, but the button has to be right about which.
@@ -203,6 +204,37 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
{buffer.channelModes}
</span>
)}
{/* What the stream is, for a channel that is one. Kick's chat is
watched beside a video this client does not show, so the title, the
game and the number of people watching are the context the chat is
missing - and "offline" is worth saying too, since a quiet channel
and an off-air one look identical otherwise. */}
{stream && (
<span
className={classes('stream-chip', 'small', stream.live && 'live')}
title={
stream.live
? [stream.title, stream.category, stream.viewers ? `${stream.viewers.toLocaleString()} watching` : null]
.filter(Boolean)
.join(' · ')
: 'Not streaming right now'
}
>
{stream.live ? (
<>
<span className="stream-dot" />
<span className="ellipsis">{stream.title || 'Live'}</span>
{stream.category && <span className="muted ellipsis">{stream.category}</span>}
{stream.viewers !== null && stream.viewers !== undefined && (
<span className="muted">{stream.viewers.toLocaleString()}</span>
)}
</>
) : (
<span className="muted">offline</span>
)}
</span>
)}
{isDiscord && isDm && (
<IconButton
name={inCall ? 'call_end' : 'call'}
+27
View File
@@ -106,6 +106,18 @@ function autojoinList(autojoin: string | undefined): string[] {
.filter(Boolean)
}
/** What a Kick channel is broadcasting, as the daemon last asked. */
export interface KickStream {
bufferId: string
live: boolean
title?: string | null
category?: string | null
viewers?: number | null
/** Unix seconds the stream started, for "live for 2h 14m". */
startedTs?: number | null
followers?: number | null
}
export type ActivePanel = '' | 'accounts' | 'settings' | 'join' | 'downloads'
export interface ChatState {
@@ -232,6 +244,14 @@ export interface ChatState {
/** Per-buffer "New messages" divider timestamp, snapshotted on open. */
dividerTsByBuffer: Record<string, number>
matrixPermissions: Record<string, RoomPermissions>
/**
* What each Kick channel is broadcasting, by buffer.
*
* A Kick channel is a stream as much as a chat, and moho showed only the
* chat - so the title, the game and the number of people watching, which is
* most of what a viewer wants to know, were nowhere.
*/
kickStreams: Record<string, KickStream>
/**
* The profile being shown, or null.
*
@@ -302,6 +322,7 @@ const INITIAL: ChatState = {
readersByBuffer: {},
openThread: null,
matrixPermissions: {},
kickStreams: {},
profile: null,
replyingTo: null,
toasts: [],
@@ -1388,6 +1409,12 @@ export class ChatStore {
break
}
case 'kickStream': {
const stream = data as unknown as KickStream
this.set({ kickStreams: { ...this.state.kickStreams, [stream.bufferId]: stream } })
break
}
case 'presenceChange':
this.set({
presenceByBuffer: { ...this.state.presenceByBuffer, [data.bufferId]: data.members }