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'