One pin panel, for whichever service has pins

The panel and the message-menu action were Matrix-only by spelling rather
than by nature - Discord pins the same way and shows the same list. The
store field and both calls lose the protocol from their names.

One difference survives and is handled where it belongs: a Matrix room's
pins arrive as room state, so an empty list is known and the button hides
itself, while Discord's have to be asked for - so there the button is
offered and the count appears once something has asked, which is what
Discord's own client does.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-05 13:36:46 -04:00
co-authored by Claude Opus 5
parent cab7f85608
commit ba38eafc70
4 changed files with 27 additions and 20 deletions
+1 -1
Submodule nobilis updated: bdfa712097...70911bbd03
@@ -255,7 +255,7 @@ interface ThreadSummary {
*/
function PinnedMessages({ buffer }: { buffer: BufferEntry }): JSX.Element | null {
const store = useStore()
const count = (useChat((s) => s.matrixPinned)[buffer.id] || []).length
const known = useChat((s) => s.pinnedMessages)[buffer.id]
const [open, setOpen] = useState(false)
const [rows, setRows] = useState<Message[] | null>(null)
const button = useRef<HTMLSpanElement>(null)
@@ -268,7 +268,7 @@ function PinnedMessages({ buffer }: { buffer: BufferEntry }): JSX.Element | null
setOpen(true)
setRows(null)
void window.moho
.rpc<{ pinned: Message[] }>('listMatrixPinned', { bufferId: buffer.id })
.rpc<{ pinned: Message[] }>('listPinned', { bufferId: buffer.id })
.then((answer) => setRows(answer.pinned))
.catch((e: Error) => {
store.toast('error', e.message)
@@ -277,23 +277,29 @@ function PinnedMessages({ buffer }: { buffer: BufferEntry }): JSX.Element | null
}
// 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
// panel is a button people stop pressing. That test needs the list, though,
// and only Matrix sends one unasked - a room's pins are part of its state,
// while Discord's are a question you have to go and ask. So where the
// answer is not known yet the button is offered, which is what Discord's
// own client does, and the count appears once something has asked.
const count = known?.length ?? 0
if (known && count === 0) return null
return (
<>
<span ref={button} className="header-anchor">
<IconButton
name="push_pin"
title={`${count} pinned ${count === 1 ? 'message' : 'messages'}`}
title={known ? `${count} pinned ${count === 1 ? 'message' : 'messages'}` : 'Pinned 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>
<div className="small muted">Pinned here</div>
{rows === null && <div className="small muted">Looking…</div>}
{rows?.length === 0 && <div className="small muted">Nothing is pinned.</div>}
{rows?.map((message) => (
<button
key={message.id}
@@ -583,7 +589,7 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
{buffer.accountId.startsWith('matrix:') && <InviteToRoom buffer={buffer} />}
{isMatrix && buffer.kind !== 'server' && <ThreadList buffer={buffer} />}
{isMatrix && buffer.kind !== 'server' && <PinnedMessages buffer={buffer} />}
{(isMatrix || isDiscord) && 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 -
+3 -3
View File
@@ -178,7 +178,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 pinned = useChat((s) => s.pinnedMessages)[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.
@@ -277,10 +277,10 @@ export function MessageRow({
// 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
...((service === 'matrix' || service === 'discord') && !isSystem
? ([
{
label: pinned ? 'Unpin from this room' : 'Pin to this room',
label: pinned ? 'Unpin from this conversation' : 'Pin to this conversation',
icon: pinned ? 'keep_off' : 'push_pin',
onClick: () => store.setPinned(bufferId, message.id, !pinned)
}
+10 -9
View File
@@ -374,7 +374,8 @@ export interface ChatState {
/** 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[]>
/** Which messages a conversation has pinned, where the service says so. */
pinnedMessages: Record<string, string[]>
/** Who each Matrix account has asked never to hear from. */
matrixIgnored: Record<string, string[]>
/** Rooms each Matrix account has been invited to and not answered. */
@@ -453,7 +454,7 @@ const INITIAL: ChatState = {
livePolls: {},
hiddenPolls: [],
reviewCard: null,
matrixPinned: {},
pinnedMessages: {},
matrixIgnored: {},
matrixInvites: {},
profile: null,
@@ -1513,15 +1514,15 @@ export class ChatStore {
}
/**
* Pins a message in a Matrix room, or takes the pin off.
* Pins a message where the service has pins, or takes the pin off.
*
* Offered to everybody rather than gated on a power level read here: the
* Offered to everybody rather than gated on a permission 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 {
setPinned(bufferId: string, messageId: string, pinned: boolean): void {
void window.moho
.rpc('setMatrixPinned', { bufferId, eventId, pinned })
.rpc('setPinned', { bufferId, messageId, pinned })
.then(() => this.toast('info', pinned ? 'Pinned' : 'Unpinned'))
.catch((e: Error) => this.toast('error', e.message))
}
@@ -1704,10 +1705,10 @@ export class ChatStore {
})
break
case 'matrixPinned':
case 'pinnedMessages':
this.set({
matrixPinned: {
...this.state.matrixPinned,
pinnedMessages: {
...this.state.pinnedMessages,
[data.bufferId as string]: (data.pinned as string[]) || []
}
})