A pinned message, and putting it away for good

The bar above the log, where the live poll and prediction cards sit and for
the same reason: something that stays put while the chat moves underneath
belongs out of the scroll. Above those, because it outlasts them - a poll runs
for a minute and a pin stays until the channel replaces it.

**Dismissing it is remembered**, which is the part worth getting right. A pin
stays up for hours, so a bar that came back on every restart would be a bar
somebody dismisses every day. It is a preference rather than this window's
state, so it survives a restart and so a pin put away here is put away in a
popped-out window too.

Keyed on the pinned message's own id, not on the channel: putting this one
away must not put away the next one, and the next one is the whole reason a
channel pins anything. Capped at a hundred, oldest forgotten first - a channel
replaces its pin whenever the subject changes, and without a ceiling this is a
list that only grows and is written to disk each time.

The body goes through the same formatter every other message body does, so a
link in a pin is a link and an emote is an emote. A pin is very often exactly
a link, which is most of why one gets pinned.

It wraps and is capped in height rather than scrolling the page: a pinned wall
of text must not push the conversation off the screen, which is the one way
this could be worse than not having it at all.

Closes #130.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-10 20:56:20 -04:00
co-authored by Claude Opus 5
parent e4a0408d6e
commit 83088ebbf9
5 changed files with 141 additions and 1 deletions
+1 -1
Submodule nobilis updated: f6b3696dbf...117dda11fe
+34
View File
@@ -460,6 +460,40 @@
/* A stack, because a channel can have a poll and a prediction at once - and
an old one being read back over the top of both. */
/* The message a channel has pinned, above the log and above the poll cards -
it outlasts both. A row rather than a card: it is one line somebody put
there, not a thing with controls, and the only control on it is the one that
puts it away. */
.pinned-bar {
display: flex;
align-items: flex-start;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-s);
border-bottom: 1px solid var(--outline-light);
background: var(--surface-container);
flex: 0 0 auto;
}
.pinned-bar .pinned-mark {
color: var(--primary);
margin-top: 3px;
flex: 0 0 auto;
}
/* Wraps rather than scrolls, and is capped: a pinned wall of text must not
push the conversation off the screen, which is the one way this feature
could be worse than not having it. */
.pinned-body {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
max-height: 5.5em;
overflow-y: auto;
overflow-wrap: anywhere;
}
.poll-stack {
position: absolute;
z-index: 5;
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import { Icon } from './Icon'
import { MessageRow, useRowContext, type MessageMode } from './MessageRow'
import { LiveCards } from './LiveCards'
import { PinnedBar } from './PinnedBar'
import { CallView } from './CallView'
import { CallStage, RoomCallBar } from './CallStage'
import { StreamStage } from './StreamStage'
@@ -617,6 +618,9 @@ export function MessageList(): JSX.Element {
answered while the chat keeps moving underneath, and a line in the
log would scroll away mid-vote. */}
<LiveCards bufferId={bufferId} />
{/* Above the cards, and above the log, because it outlasts both: a poll
runs for a minute and a pin stays until the channel replaces it. */}
<PinnedBar bufferId={bufferId} />
<div className="messagelist-scroll" ref={scrollRef} onScroll={onScroll}>
{/* One wrapper so the whole log has a single measurable height; the
observer above needs an element that grows with the content, which
+62
View File
@@ -0,0 +1,62 @@
import { useMemo } from 'react'
import { Icon, IconButton } from './Icon'
import { useChat, usePref } from '../state/hooks'
import { formatMessage, normalizeBBCode } from '../lib/format'
import { RichText } from '../lib/richtext'
/**
* How many dismissed pins to remember before forgetting the oldest.
*
* A channel replaces its pin whenever the subject changes, and every one put
* away leaves an id behind. Without a ceiling this is a list that only grows
* and is written to disk each time. A hundred is far past any pin still on
* screen anywhere - the oldest to go is one from a stream that ended weeks
* ago.
*/
const REMEMBERED_DISMISSALS = 100
/**
* The message a channel has pinned, above the log.
*
* Kick pins one line and leaves it there - the link, the rules, the thing
* being talked about - and it is the one line a channel has deliberately put
* in front of everybody. It sits where the live poll and prediction cards sit,
* for the same reason: something that stays put while the chat moves
* underneath it belongs out of the scroll.
*
* Dismissing it is remembered. A pin stays up for hours, so a bar that came
* back on every restart would be a bar somebody dismisses every day - and one
* dismissed in this window should be dismissed in a popped-out one too, which
* is what a preference gets and this window's own state does not.
*
* Keyed on the pinned message's id rather than on the channel, so putting this
* one away does not put away the next one - and the next one is the whole
* reason a channel pins anything.
*/
export function PinnedBar({ bufferId }: { bufferId: string }): JSX.Element | null {
const pin = useChat((s) => s.pinnedByBuffer)[bufferId]
const [dismissed, setDismissed] = usePref<string[]>('kick.pinDismissed', [])
// Through the same pipeline every other body goes through, so a link in a
// pin is a link and an emote is an emote - a pin is very often exactly a
// link, which is most of why a channel pins one.
const html = useMemo(() => formatMessage(normalizeBBCode(pin?.body || '')), [pin?.body])
if (!pin || dismissed.includes(pin.id)) return null
const dismiss = (): void =>
setDismissed([...dismissed.filter((id) => id !== pin.id), pin.id].slice(-REMEMBERED_DISMISSALS))
return (
<div className="pinned-bar">
<Icon name="push_pin" size={14} className="pinned-mark" />
<div className="pinned-body">
<span className="small muted">Pinned by {pin.from}</span>
{/* `ownMarkup` because this client built the markup a line above,
out of what Kick sent as plain text - the flag is about who wrote
the HTML, not about who wrote the message. */}
<RichText html={html} ownMarkup />
</div>
<IconButton name="close" size={14} title="Dismiss this pin" onClick={dismiss} />
</div>
)
}
+40
View File
@@ -131,6 +131,19 @@ function autojoinList(autojoin: string | undefined): string[] {
* `votes` carries both: a poll counts people, a prediction counts points, and
* the card draws the same bar either way.
*/
/**
* The one message a channel has pinned above its chat.
*
* Kick only, for now. `id` is the pinned message's own, which is what a
* dismissal is keyed on - putting this one away must not put the next one
* away, and the next one is the whole reason a channel pins anything.
*/
export interface PinnedMessage {
id: string
from: string
body: string
}
export interface LiveCardOption {
/**
* Whatever the service calls this answer.
@@ -430,6 +443,14 @@ export interface ChatState {
kickStreams: Record<string, KickStream>
/** The poll and prediction running in each channel, by `cardSlot`. */
livePolls: Record<string, LiveCard>
/**
* What each Kick channel has pinned above its chat, by buffer.
*
* Standing state rather than a stream of events: a channel has one pin at a
* time and it stays until it is replaced, so this is the current answer
* rather than a log of answers.
*/
pinnedByBuffer: Record<string, PinnedMessage>
/** Cards the reader has folded away, by card id. */
/** An old poll or prediction being read back, over the conversation. */
reviewCard: LiveCard | null
@@ -562,6 +583,7 @@ const INITIAL: ChatState = {
matrixPermissions: {},
kickStreams: {},
livePolls: {},
pinnedByBuffer: {},
reviewCard: null,
pinnedMessages: {},
discordFriends: {},
@@ -2383,6 +2405,24 @@ export class ChatStore {
})
break
case 'pinnedMessage': {
const bufferId = data.bufferId as string
const pinned = { ...this.state.pinnedByBuffer }
// A cleared pin arrives as an event with no id rather than as no
// event at all - the bar is on screen and has to be told to go.
if (data.id) {
pinned[bufferId] = {
id: String(data.id),
from: String(data.from ?? ''),
body: String(data.body ?? '')
}
} else {
delete pinned[bufferId]
}
this.set({ pinnedByBuffer: pinned })
break
}
case 'pollCard': {
const bufferId = data.bufferId as string
const kind = (data.kind as string) || 'poll'