Pinned messages, in the header and on the message

A pin icon appears on a Matrix room that has any, with the count in its
tooltip, and opens the list over the chat the way the poll history does.
Pressing one goes to it.

Right-clicking a message offers to pin or unpin it. Offered to everybody
rather than gated on a power level read here: the server decides, and it
refuses in words worth showing - hiding the action from somebody who could
have used it is the worse mistake.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-04 14:55:27 -04:00
co-authored by Claude Opus 5
parent c5aabac8ce
commit f8083dd4e3
4 changed files with 114 additions and 1 deletions
+1 -1
Submodule nobilis updated: c6d198834d...93f2d5c571
@@ -156,6 +156,77 @@ function winning(card: LiveCard): string {
return best ? `${best.label} (${best.votes})` : 'no answers'
}
/**
* What a room has told everyone to read first.
*
* A panel rather than a banner: rooms pin their rules, their current topic of
* business and the thing everyone is meant to have read, and any of those can
* be long. The count on the button is what makes it worth opening.
*/
function PinnedMessages({ buffer }: { buffer: BufferEntry }): JSX.Element | null {
const store = useStore()
const count = (useChat((s) => s.matrixPinned)[buffer.id] || []).length
const [open, setOpen] = useState(false)
const [rows, setRows] = useState<Message[] | null>(null)
const button = useRef<HTMLSpanElement>(null)
const show = (): void => {
if (open) {
setOpen(false)
return
}
setOpen(true)
setRows(null)
void window.moho
.rpc<{ pinned: Message[] }>('listMatrixPinned', { bufferId: buffer.id })
.then((answer) => setRows(answer.pinned))
.catch((e: Error) => {
store.toast('error', e.message)
setRows([])
})
}
// Nothing pinned is nothing to offer: a button that always opens an empty
// panel is a button people stop pressing.
if (count === 0) return null
return (
<>
<span ref={button} className="header-anchor">
<IconButton
name="push_pin"
title={`${count} pinned ${count === 1 ? 'message' : 'messages'}`}
className={open ? 'active' : undefined}
onClick={show}
/>
</span>
{open && (
<HeaderPopover anchor={button.current} width={400} onClose={() => setOpen(false)}>
<div className="small muted">Pinned in this room</div>
{rows === null && <div className="small muted">Looking…</div>}
{rows?.map((message) => (
<button
key={message.id}
type="button"
className="history-row"
onClick={() => {
store.setJumpTarget(message.id)
setOpen(false)
}}
>
<span className="small">
<span className="search-result-from">{message.from}</span>{' '}
<span className="muted">{message.ts ? formatFullTime(message.ts) : ''}</span>
</span>
<span className="small ellipsis">{message.body}</span>
</button>
))}
</HeaderPopover>
)}
</>
)
}
/**
* What a channel's restrictions mean, for the ones worth explaining.
*
@@ -421,6 +492,8 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
there is no friend list to choose from and nothing to look up. */}
{buffer.accountId.startsWith('matrix:') && <InviteToRoom buffer={buffer} />}
{isMatrix && buffer.kind !== 'server' && <PinnedMessages buffer={buffer} />}
{/* What this channel has asked before now. Two buttons rather than one
list, because a poll and a prediction are different questions -
which one is worth going back to is not a filter you want to apply
@@ -174,6 +174,7 @@ export function MessageRow({
const canEditDelete =
!!message.isOwn && (service === 'discord' || service === 'sneedchat' || service === 'matrix')
const canReact = service === 'discord' || service === 'matrix'
const pinned = useChat((s) => s.matrixPinned)[bufferId]?.includes(message.id) ?? false
const isSystem = !isChatKind(message.kind)
// Said to you rather than to the room. Drawn differently on purpose: the
// whole risk with a private message is reading it as a public one.
@@ -252,6 +253,19 @@ export function MessageRow({
] as MenuEntry[])
: []),
{ label: 'Reply', icon: 'reply', onClick: () => reply() },
// What the room tells everyone to read first. Offered to everybody
// rather than gated on a power level read here: the server decides, and
// it refuses in words worth showing - hiding the action from somebody who
// could have used it is the worse mistake.
...(service === 'matrix' && !isSystem
? ([
{
label: pinned ? 'Unpin from this room' : 'Pin to this room',
icon: pinned ? 'keep_off' : 'push_pin',
onClick: () => store.setPinned(bufferId, message.id, !pinned)
}
] as MenuEntry[])
: []),
// Beside Reply because it is the same gesture aimed somewhere else, and
// only where the service has whispers at all.
...(service === 'sneedchat' && !message.isOwn && message.from && !isSystem
+26
View File
@@ -364,6 +364,8 @@ export interface ChatState {
hiddenPolls: string[]
/** An old poll or prediction being read back, over the conversation. */
reviewCard: LiveCard | null
/** Which messages each Matrix room has pinned, newest last. */
matrixPinned: Record<string, string[]>
/**
* The profile being shown, or null.
*
@@ -438,6 +440,7 @@ const INITIAL: ChatState = {
livePolls: {},
hiddenPolls: [],
reviewCard: null,
matrixPinned: {},
profile: null,
replyingTo: null,
toasts: [],
@@ -1437,6 +1440,20 @@ export class ChatStore {
this.set({ hiddenPolls: this.state.hiddenPolls.filter((k) => k !== key) })
}
/**
* Pins a message in a Matrix room, or takes the pin off.
*
* Offered to everybody rather than gated on a power level read here: the
* server decides, and it says no in words worth showing. Hiding the action
* from somebody who could have used it is the worse mistake.
*/
setPinned(bufferId: string, eventId: string, pinned: boolean): void {
void window.moho
.rpc('setMatrixPinned', { bufferId, eventId, pinned })
.then(() => this.toast('info', pinned ? 'Pinned' : 'Unpinned'))
.catch((e: Error) => this.toast('error', e.message))
}
/** Follows a Kick channel, or stops. The header reads the answer back. */
setFollowing(bufferId: string, follow: boolean): void {
void window.moho
@@ -1597,6 +1614,15 @@ export class ChatStore {
break
}
case 'matrixPinned':
this.set({
matrixPinned: {
...this.state.matrixPinned,
[data.bufferId as string]: (data.pinned as string[]) || []
}
})
break
case 'pollCard': {
const bufferId = data.bufferId as string
const kind = (data.kind as string) || 'poll'