diff --git a/nobilis b/nobilis index f6b3696..117dda1 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit f6b3696dbffc0b46274ce17f7056b6340a8fa461 +Subproject commit 117dda11fe12543b9211d3e1a2dc99f1fd06c92d diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index 4285f5d..0571941 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -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; diff --git a/src/renderer/src/components/MessageList.tsx b/src/renderer/src/components/MessageList.tsx index 86b7ae9..9ee517b 100644 --- a/src/renderer/src/components/MessageList.tsx +++ b/src/renderer/src/components/MessageList.tsx @@ -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. */} + {/* 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. */} +
{/* One wrapper so the whole log has a single measurable height; the observer above needs an element that grows with the content, which diff --git a/src/renderer/src/components/PinnedBar.tsx b/src/renderer/src/components/PinnedBar.tsx new file mode 100644 index 0000000..e66f745 --- /dev/null +++ b/src/renderer/src/components/PinnedBar.tsx @@ -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('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 ( +
+ +
+ Pinned by {pin.from} + {/* `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. */} + +
+ +
+ ) +} diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/state/store.ts index e0337a8..46411ab 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/state/store.ts @@ -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 /** The poll and prediction running in each channel, by `cardSlot`. */ livePolls: Record + /** + * 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 /** 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'