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:
@@ -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) => {
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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': {
|
||||
|
||||
Reference in New Issue
Block a user