Pins: a banner over the log for Discord and Matrix with a click-through to each, and the count on the header button (#311)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
2026-10-08 17:42:25 -04:00
co-authored by Claude Sonnet 5.5
parent 87ea3b8c31
commit 3a74cf030b
5 changed files with 174 additions and 8 deletions
+18
View File
@@ -818,6 +818,24 @@ const SCENES = [
},
clip: { x: 277, y: 420, width: 823, height: 380 }
},
{
// Pins for Discord (#311): the banner over the log, and the count in the header.
name: 'discord-pinned-banner',
setup: async (p) => {
await discord(false)(p)
await p.evaluate(`(() => {
const shots = window.__mohoShots
const s = shots.state()
const id = s.activeBufferId
const list = s.messagesByBuffer[id] || []
const pick = ['craft fair', 'amigurumi', 'I can man'].map((t) => list.find((m) => m.body.includes(t))).filter(Boolean)
shots.patch({ pinnedRows: { ...s.pinnedRows, [id]: pick }, pinnedMessages: { ...s.pinnedMessages, [id]: pick.map((m) => m.id) } })
return true
})()`)
await sleep(600)
},
clip: { x: 277, y: 44, width: 823, height: 260 }
},
{
name: 'discord-media-grid',
setup: async (p) => {
+44
View File
@@ -473,6 +473,49 @@
/* 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-banner {
align-items: center;
}
.pinned-banner .pinned-mark {
margin-top: 0;
}
/* The line is the way to the message: one row, and the whole of it a button. */
.pinned-banner-body {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
padding: 2px var(--spacing-xs);
border-radius: var(--corner-radius-small);
text-align: left;
color: var(--surface-text);
transition: background var(--motion-fast) var(--ease);
}
.pinned-banner-body:hover {
background: var(--surface-hover);
}
/* A count on the corner of a header button. */
.header-count {
position: absolute;
top: -2px;
right: -2px;
min-width: 14px;
padding: 0 3px;
border-radius: var(--radius-pill);
background: var(--primary);
color: var(--on-accent);
font-size: 10px;
font-weight: var(--weight-bold);
line-height: 14px;
text-align: center;
pointer-events: none;
}
.pinned-body {
flex: 1 1 auto;
min-width: 0;
@@ -5678,6 +5721,7 @@ video.lightbox-media {
}
.header-anchor {
position: relative;
display: inline-flex;
}
@@ -467,9 +467,9 @@ function PinnedMessages({ buffer }: { buffer: BufferEntry }): JSX.Element | null
}
setOpen(true)
setRows(null)
void window.moho
.rpc<{ pinned: Message[] }>('listPinned', { bufferId: buffer.id })
.then((answer) => setRows(answer.pinned))
void store
.loadPins(buffer.id)
.then((pinned) => setRows(pinned))
.catch((e: Error) => {
store.toast('error', e.message)
setRows([])
@@ -494,6 +494,7 @@ function PinnedMessages({ buffer }: { buffer: BufferEntry }): JSX.Element | null
className={open ? 'active' : undefined}
onClick={show}
/>
{count > 0 && <span className="header-count">{count > 99 ? '99+' : count}</span>}
</span>
{open && (
<HeaderPopover anchor={button.current} width={400} onClose={() => setOpen(false)}>
+79 -4
View File
@@ -1,6 +1,6 @@
import { useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Icon, IconButton } from './Icon'
import { useChat, usePref } from '../state/hooks'
import { useChat, usePref, useStore } from '../state/hooks'
import { formatMessage, normalizeBBCode } from '../lib/format'
import { RichText } from '../lib/richtext'
@@ -35,13 +35,20 @@ const REMEMBERED_DISMISSALS = 100
*/
export function PinnedBar({ bufferId }: { bufferId: string }): JSX.Element | null {
const pin = useChat((s) => s.pinnedByBuffer)[bufferId]
// Kick pins one line and sends it unasked; Discord and Matrix pin many and
// are asked. The same place for both, since it is the same thing.
if (!pin) return <PinnedMessagesBanner bufferId={bufferId} />
return <PinnedLine bufferId={bufferId} pin={pin} />
}
function PinnedLine({ pin }: { bufferId: string; pin: { id: string; from: string; body: string } }): JSX.Element | null {
const [dismissed, setDismissed] = usePref<string[]>('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])
const html = useMemo(() => formatMessage(normalizeBBCode(pin.body || '')), [pin.body])
if (!pin || dismissed.includes(pin.id)) return null
if (dismissed.includes(pin.id)) return null
const dismiss = (): void =>
setDismissed([...dismissed.filter((id) => id !== pin.id), pin.id].slice(-REMEMBERED_DISMISSALS))
@@ -60,3 +67,71 @@ export function PinnedBar({ bufferId }: { bufferId: string }): JSX.Element | nul
</div>
)
}
/** Conversations whose pins have been asked for this session, so a failed ask is not repeated on every switch. */
const asked = new Set<string>()
/** Banners put away this session, by conversation: the pins are still in the header. */
const putAway = new Set<string>()
/**
* What a Discord channel or a Matrix room has pinned, over the log: the
* newest, with how many there are, and a click that goes to it.
*
* Element has this banner and Discord has only the header's list; the list is
* kept (it is where a pin is taken off) and this is what makes a pin something
* that is seen rather than something somebody knows to look for. Clicking goes
* to the message and moves on to the next pin, so a run of pins is read by
* clicking through it.
*/
function PinnedMessagesBanner({ bufferId }: { bufferId: string }): JSX.Element | null {
const store = useStore()
const account = useChat((s) => s.buffers).find((b) => b.id === bufferId)?.accountId ?? ''
const rows = useChat((s) => s.pinnedRows)[bufferId]
const [at, setAt] = useState(0)
const [, redraw] = useState(0)
const hasPins = account.startsWith('discord:') || account.startsWith('matrix:')
useEffect(() => {
setAt(0)
if (!hasPins || rows || asked.has(bufferId)) return
asked.add(bufferId)
void store.loadPins(bufferId).catch(() => {})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bufferId, hasPins])
if (!hasPins || !rows || rows.length === 0 || putAway.has(bufferId)) return null
const shown = rows[Math.min(at, rows.length - 1)]
const first = (shown.body || '').split('\n')[0].trim() || 'A message with no text'
return (
<div className="pinned-bar pinned-banner">
<Icon name="push_pin" size={14} className="pinned-mark" />
<button
type="button"
className="pinned-banner-body"
title="Go to the pinned message"
onClick={() => {
void store.jumpToMessage(bufferId, shown.id).then((there) => {
if (there) store.setJumpTarget(shown.id)
else store.toast('info', 'That message could not be reached')
})
if (rows.length > 1) setAt((at + 1) % rows.length)
}}
>
<span className="small muted">
{rows.length > 1 ? `Pinned · ${Math.min(at, rows.length - 1) + 1} of ${rows.length}` : 'Pinned'} · {shown.from}
</span>
<span className="ellipsis">{first}</span>
</button>
<IconButton
name="close"
size={14}
title="Hide this banner (the pins stay in the header)"
onClick={() => {
putAway.add(bufferId)
redraw((n) => n + 1)
}}
/>
</div>
)
}
+29 -1
View File
@@ -539,6 +539,12 @@ export interface ChatState {
/** Which messages each Matrix room has pinned, newest last. */
/** Which messages a conversation has pinned, where the service says so. */
pinnedMessages: Record<string, string[]>
/**
* The pinned messages themselves, once somebody has asked for them. The ids
* above say that something is pinned; these say what, for the banner over
* the log and for the list in the header, which are the same answer.
*/
pinnedRows: Record<string, Message[]>
/** Friends and pending requests per Discord account, as they change. */
discordFriends: Record<string, DiscordFriend[]>
/**
@@ -727,6 +733,7 @@ const INITIAL: ChatState = {
pinnedByBuffer: {},
reviewCard: null,
pinnedMessages: {},
pinnedRows: {},
discordFriends: {},
ringingCall: null,
callMinimized: false,
@@ -2362,10 +2369,29 @@ export class ChatStore {
setPinned(bufferId: string, messageId: string, pinned: boolean): void {
void window.moho
.rpc('setPinned', { bufferId, messageId, pinned })
.then(() => this.toast('info', pinned ? 'Pinned' : 'Unpinned'))
.then(() => {
this.toast('info', pinned ? 'Pinned' : 'Unpinned')
// The banner and the header's count are of what is pinned now.
void this.loadPins(bufferId).catch(() => {})
})
.catch((e: Error) => this.toast('error', e.message))
}
/**
* What a conversation has pinned, newest first as the service gives it.
* Kept, so the banner over the log and the list in the header share one
* answer rather than each asking.
*/
async loadPins(bufferId: string): Promise<Message[]> {
const answer = await window.moho.rpc<{ pinned: Message[] }>('listPinned', { bufferId })
const rows = answer.pinned ?? []
this.set({
pinnedRows: { ...this.state.pinnedRows, [bufferId]: rows },
pinnedMessages: { ...this.state.pinnedMessages, [bufferId]: rows.map((m) => m.id) }
})
return rows
}
/**
* Presses a button on a message, or answers one of its menus.
*
@@ -3345,6 +3371,8 @@ export class ChatStore {
[data.bufferId as string]: (data.pinned as string[]) || []
}
})
// The pins changed under rows already read: read them again.
if (this.state.pinnedRows[data.bufferId as string]) void this.loadPins(data.bufferId as string).catch(() => {})
break
case 'pinnedMessage': {