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:
+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
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user