diff --git a/scripts/ui-shots.mjs b/scripts/ui-shots.mjs index 3f7a77b..60a5e12 100644 --- a/scripts/ui-shots.mjs +++ b/scripts/ui-shots.mjs @@ -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) => { diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index 53c810d..42bf611 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -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; } diff --git a/src/renderer/src/components/ConversationTools.tsx b/src/renderer/src/components/ConversationTools.tsx index 59e0098..cd33ada 100644 --- a/src/renderer/src/components/ConversationTools.tsx +++ b/src/renderer/src/components/ConversationTools.tsx @@ -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 && {count > 99 ? '99+' : count}} {open && ( setOpen(false)}> diff --git a/src/renderer/src/components/PinnedBar.tsx b/src/renderer/src/components/PinnedBar.tsx index e66f745..02c38a0 100644 --- a/src/renderer/src/components/PinnedBar.tsx +++ b/src/renderer/src/components/PinnedBar.tsx @@ -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 + return +} + +function PinnedLine({ pin }: { bufferId: string; pin: { id: string; from: string; body: string } }): JSX.Element | null { const [dismissed, setDismissed] = usePref('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 ) } + +/** Conversations whose pins have been asked for this session, so a failed ask is not repeated on every switch. */ +const asked = new Set() +/** Banners put away this session, by conversation: the pins are still in the header. */ +const putAway = new Set() + +/** + * 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 ( +
+ + + { + putAway.add(bufferId) + redraw((n) => n + 1) + }} + /> +
+ ) +} diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/state/store.ts index c2123b3..147e8b9 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/state/store.ts @@ -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 + /** + * 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 /** Friends and pending requests per Discord account, as they change. */ discordFriends: Record /** @@ -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 { + 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': {