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 (
+
+ )
+}
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': {