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:
+1
-1
Submodule nobilis updated: f6b3696dbf...117dda11fe
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user