diff --git a/nobilis b/nobilis index 81f1b0b..dbbc483 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit 81f1b0b91317fd57649c08c0c5a3b5606207f532 +Subproject commit dbbc483a32607bf036670b3ed2f0b8c515a0300b diff --git a/src/renderer/src/components/Avatar.tsx b/src/renderer/src/components/Avatar.tsx index e2c76a4..7a309f3 100644 --- a/src/renderer/src/components/Avatar.tsx +++ b/src/renderer/src/components/Avatar.tsx @@ -39,6 +39,11 @@ export function Avatar({ src={resolveMediaUrl(url)} alt="" style={{ width: size, height: size }} + // A member list or a long log is hundreds of these, most of them + // below the fold: fetched and decoded when they are reached rather + // than all at once on the way past. + loading="lazy" + decoding="async" onError={() => setBroken(true)} /> ) : ( diff --git a/src/renderer/src/components/MessageList.tsx b/src/renderer/src/components/MessageList.tsx index 16d2f28..7a23070 100644 --- a/src/renderer/src/components/MessageList.tsx +++ b/src/renderer/src/components/MessageList.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { Icon } from './Icon' -import { MessageRow, type MessageMode } from './MessageRow' +import { MessageRow, useRowContext, type MessageMode } from './MessageRow' import { LiveCards } from './LiveCards' import { CallView } from './CallView' import { CallStage, RoomCallBar } from './CallStage' @@ -113,6 +113,28 @@ export function anchorVerdict(frame: ScrollFrame): 'release' | 'hold' | 'take' { /** Consecutive messages from the same author inside this window are grouped. */ const GROUP_WINDOW_SECS = 300 +/** + * How much of a conversation is actually built as rows. + * + * A loaded buffer can hold two thousand messages and every one of them used to + * be a mounted row: its avatar, its embeds, its pictures, its reactions, all + * alive at once for a reader looking at a dozen of them. So the log is drawn + * as a tail of the list rather than the whole of it, and reading upwards + * lengthens the tail - the same gesture that already fetches an older page, + * one step earlier in the same sequence. + * + * A tail rather than a floating window on purpose: a chat log is read from the + * bottom, so what is above the rendered part is always older and what is below + * it is nothing. That means no spacer element, no estimated row heights, and + * no second opinion about how tall the page is - the scroll machinery below + * measures exactly what it did before. + */ +const RENDER_WINDOW = 120 +/** How much more of it to build when the reader reaches the top of what is. */ +const RENDER_STEP = 120 +/** Rows kept below a jumped-to message, so it has somewhere to sit. */ +const JUMP_MARGIN = 40 + export function MessageList(): JSX.Element { const store = useStore() const bufferId = useChat((s) => s.activeBufferId) @@ -241,6 +263,15 @@ export function MessageList(): JSX.Element { const contentRef = useRef(null) const [anchored, setAnchored] = useState(true) const [missedCount, setMissedCount] = useState(0) + /** How many of the newest messages are built as rows. */ + const [shown, setShown] = useState(RENDER_WINDOW) + // Read once for the conversation rather than once in each of its rows. + const shared = useRowContext(bufferId) + + // Where the drawn part of the log starts. Everything before this is loaded + // and not built; everything from here is on the page. + const start = Math.max(0, messages.length - shown) + const view = useMemo(() => (start === 0 ? messages : messages.slice(start)), [messages, start]) const lastBufferRef = useRef(bufferId) /** The newest message already accounted for, so growth is told from a prepend. */ @@ -314,6 +345,21 @@ export function MessageList(): JSX.Element { * pinned every one of those growths pulls it back to the bottom - so a * scroll issued from outside is undone a moment after it lands. */ + /** + * Somewhere in the loaded log has been asked for that is not built yet. + * + * The tail is the newest hundred-odd messages; a search result from an hour + * ago is in the list and not on the page. Lengthening it first is what makes + * the jump below find its row - the effect after this one re-runs on `shown` + * for exactly that reason. + */ + useEffect(() => { + if (!jumpTarget) return + const at = messages.findIndex((m) => m.id === jumpTarget) + if (at < 0) return + setShown((n) => Math.max(n, messages.length - at + JUMP_MARGIN)) + }, [jumpTarget, messages]) + useEffect(() => { if (!jumpTarget) return const row = scrollRef.current?.querySelector(`[data-msg-id="${CSS.escape(jumpTarget)}"]`) @@ -333,7 +379,10 @@ export function MessageList(): JSX.Element { if (holdRef.current === jumpTarget) holdRef.current = '' }, 2500) store.setJumpTarget('') - }, [jumpTarget, messages, anchor, store]) + // `shown` is a dependency because the row may not have existed on the + // pass that first saw the target: the effect above lengthens the log, and + // this one has to look again once it has. + }, [jumpTarget, messages, shown, anchor, store]) // A buffer switch is a fresh view: land at the bottom, anchored, with no // carried-over "missed messages" count from the previous buffer. @@ -342,6 +391,9 @@ export function MessageList(): JSX.Element { lastBufferRef.current = bufferId lastIdRef.current = messages[messages.length - 1]?.id preLoadHeightRef.current = 0 + // A fresh conversation is a fresh tail. Whatever was built for the last + // one goes with it. + setShown(RENDER_WINDOW) anchor(true) setMissedCount(0) scrollToBottom() @@ -377,7 +429,10 @@ export function MessageList(): JSX.Element { // Not found means the message the count was last taken from has been // trimmed off the top; one is the honest floor rather than a guess. setMissedCount((n) => n + (seen >= 0 ? messages.length - 1 - seen : 1)) - }, [messages, isLoadingMore]) + // `shown` alongside `messages`: rows appearing above the reader move the + // page under them whether they came from the daemon or from the list this + // window already held, and both need the position putting back. + }, [messages, shown, isLoadingMore]) const onScroll = useCallback(() => { const el = scrollRef.current @@ -416,16 +471,40 @@ export function MessageList(): JSX.Element { else if (verdict === 'take') { anchor(true) setMissedCount(0) + // Back at the present under their own steam, so the rows built by + // whatever reading-back they did are let go of - the same release as + // the jump button, for the same gesture arrived at by scrolling. Safe + // to shorten the page here precisely because the view is pinned to its + // bottom: the observer above puts it back there as the height falls. + setShown(RENDER_WINDOW) } // Ask for an older page once the user reaches the top, but only when the // view is actually scrollable - an empty or short buffer sits at // scrollTop 0 permanently and would otherwise request pages forever. - if (el.scrollTop < 80 && el.scrollHeight > el.clientHeight && !isLoadingMore && messages.length > 0) { - preLoadHeightRef.current = el.scrollHeight - void store.loadMoreHistory(bufferId) + // Only for a reader who has actually gone looking. A list that has just + // been built sits at the top for the moment before it is scrolled to the + // bottom, and reading that as "show me more" both lengthened the page and + // asked the daemon for a page nobody wanted, every time a conversation was + // opened. The pin is released above the instant a reader moves upwards, + // so by here it says which of the two this is. + if ( + !anchoredRef.current && + el.scrollTop < 80 && + el.scrollHeight > el.clientHeight && + messages.length > 0 + ) { + // Two steps at the same gesture, in order: what this window is already + // holding but has not drawn, and then what it does not hold at all. + if (shown < messages.length) { + preLoadHeightRef.current = el.scrollHeight + setShown((n) => n + RENDER_STEP) + } else if (!isLoadingMore) { + preLoadHeightRef.current = el.scrollHeight + void store.loadMoreHistory(bufferId) + } } - }, [anchor, bufferId, isLoadingMore, messages.length, store]) + }, [anchor, bufferId, isLoadingMore, messages.length, shown, store]) // Reset alongside the buffer: the position in one conversation says nothing // about the next, and a stale one would read the first scroll there as a @@ -439,6 +518,10 @@ export function MessageList(): JSX.Element { const jumpToPresent = (): void => { anchor(true) setMissedCount(0) + // Whatever reading back built is let go of here rather than left mounted + // for the rest of the session: this is somebody saying they are done with + // it. Scrolling back up builds it again from the same place. + setShown(RENDER_WINDOW) scrollToBottom('smooth') } @@ -482,7 +565,12 @@ export function MessageList(): JSX.Element { Loading older messages… )} - {messages.map((msg, i) => ( + {view.map((msg, offset) => { + // The index in the whole loaded list, not in the drawn tail: + // grouping, the new-message divider and the history gap are all + // about where a message sits in the conversation. + const i = start + offset + return (
{i === dividerIndex && (
@@ -506,7 +594,10 @@ export function MessageList(): JSX.Element { bufferId={bufferId} service={service} channels={channelMentions} - grouped={comfy !== 'classic' && isGrouped(messages, i)} + // The first drawn row starts its own run whatever came + // before it: the message it would group with is not on the + // page, so hiding this one's name would leave it unattributed. + grouped={comfy !== 'classic' && offset > 0 && isGrouped(messages, i)} mode={comfy} // The newest message is always the end of its own run. lastInRun={i === messages.length - 1 || !isGrouped(messages, i + 1)} @@ -516,9 +607,11 @@ export function MessageList(): JSX.Element { contentSniffing={contentSniffing} readers={showReaders ? readers?.[msg.id] : undefined} threadReplies={threadReplies[msg.id]} + shared={shared} />
- ))} + ) + })} {/* A room that has been joined but not yet heard from is not an empty room, and saying "no messages here yet" of one would be a claim about its contents that nothing has established. */} diff --git a/src/renderer/src/components/MessageRow.tsx b/src/renderer/src/components/MessageRow.tsx index 4a0b718..53e7e4d 100644 --- a/src/renderer/src/components/MessageRow.tsx +++ b/src/renderer/src/components/MessageRow.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from 'react' +import { memo, useMemo, useRef, useState } from 'react' import { Icon } from './Icon' import { Avatar } from './Avatar' import { ReasonPrompt } from './ReasonPrompt' @@ -10,7 +10,14 @@ import { RichText } from '../lib/richtext' import { useChat, usePref, useStore } from '../state/hooks' import { useSniffedTypes, sniffUrl } from '../lib/sniff' import type { ChatMessage } from '../state/store' -import type { MessageBadge, MessageComponent, MessageReader } from '../../../shared/wire' +import type { + CustomEmoji, + MessageBadge, + MessageComponent, + MessageReader, + RoomPermissions +} from '../../../shared/wire' +import type { SmilieIndex } from '../lib/format' import { embedColor, extractCodeBlocks, @@ -107,6 +114,51 @@ interface Props { threadReplies?: number /** Whether this row is being drawn inside the thread panel itself. */ inThread?: boolean + /** What every row in this conversation reads alike - see RowContext. */ + shared: RowContext +} + +/** + * What every row in a conversation reads alike. + * + * These are properties of the conversation, not of the message: which emoji it + * has, what this account may do in it, which messages are pinned. Each row + * used to subscribe to all of them itself, so a log of two thousand rows held + * twelve thousand live subscriptions and re-ran every one of them each time + * anything anywhere in the client changed - which is most of what made a busy + * window feel slow. Read once by whoever is drawing the list and handed down. + */ +export interface RowContext { + permissions: RoomPermissions + smilies: SmilieIndex | null + emoji: CustomEmoji[] + /** Already the conversation with this person, so offering to open it would + * be a menu entry that reselects the buffer you are reading. */ + inDirectMessage: boolean + pinned: string[] + /** Colour is how IRC has always been written, and also how a bot shouts. */ + ircColours: boolean +} + +/** The above, for one conversation. Called once per list, not once per row. */ +export function useRowContext(bufferId: string): RowContext { + const permissions = useChat((s) => s.matrixPermissions)[bufferId] + const smilies = useChat((s) => s.smilieIndex) + const emoji = useChat((s) => s.bufferEmoji)[bufferId] + const kind = useChat((s) => s.buffers).find((b) => b.id === bufferId)?.kind + const pinned = useChat((s) => s.pinnedMessages)[bufferId] + const [ircColours] = usePref('irc.renderColours', true) + return useMemo( + () => ({ + permissions: permissions ?? {}, + smilies, + emoji: emoji ?? [], + inDirectMessage: kind === 'dm', + pinned: pinned ?? [], + ircColours + }), + [permissions, smilies, emoji, kind, pinned, ircColours] + ) } /** How many faces fit before the row starts costing more than it says. */ @@ -138,7 +190,7 @@ function ReadMarkers({ readers }: { readers: MessageReader[] }): JSX.Element { ) } -export function MessageRow({ +function MessageRowBody({ message, bufferId, service, @@ -152,7 +204,8 @@ export function MessageRow({ contentSniffing, readers, threadReplies, - inThread + inThread, + shared }: Props): JSX.Element { const store = useStore() const { menu, open, close } = useContextMenu() @@ -166,17 +219,16 @@ export function MessageRow({ const [forwarding, setForwarding] = useState(false) const reactButtonRef = useRef(null) - const permissions = useChat((s) => s.matrixPermissions)[bufferId] || {} - const smilieIndex = useChat((s) => s.smilieIndex) - const bufferEmoji = useChat((s) => s.bufferEmoji)[bufferId] || [] - // Already the conversation with this person, so offering to open it would - // be a menu entry that reselects the buffer you are reading. - const inDirectMessage = useChat((s) => s.buffers).find((b) => b.id === bufferId)?.kind === 'dm' + // All of this is the conversation's rather than the message's, and is read + // once for the whole list rather than once per row - see RowContext. + const { + permissions, + smilies: smilieIndex, + emoji: bufferEmoji, + inDirectMessage, + ircColours + } = shared const sniffed = useSniffedTypes() - // Colour is how IRC has always been written, and it is also how a bot - // shouts. Somebody who wants the words without the decoration says so here - // and the codes are removed instead of drawn. - const [ircColours] = usePref('irc.renderColours', true) const isSneedchat = service === 'sneedchat' // Discord, Sneedchat and Matrix support editing and deleting your own @@ -184,7 +236,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.pinnedMessages)[bufferId]?.includes(message.id) ?? false + const pinned = shared.pinned.includes(message.id) 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. @@ -481,7 +533,12 @@ export function MessageRow({ {!grouped && (message.avatarUrl ? ( - + ) : ( {message.from.slice(0, 1).toUpperCase()} @@ -995,3 +1052,14 @@ function MessageControls({
) } + +/** + * A row only redraws when its own message does. + * + * Without this every row in the log rebuilt whenever anything in the client + * changed - a message in another channel, somebody's presence, a toast - and + * the cost of that grows with how much of the conversation is on screen. The + * props above are the whole of what a row draws from, and all of them are + * either values or references held steady by whoever draws the list. + */ +export const MessageRow = memo(MessageRowBody) diff --git a/src/renderer/src/components/ThreadPanel.tsx b/src/renderer/src/components/ThreadPanel.tsx index c4ac435..13367b7 100644 --- a/src/renderer/src/components/ThreadPanel.tsx +++ b/src/renderer/src/components/ThreadPanel.tsx @@ -1,5 +1,5 @@ import { useMemo, useRef, useState } from 'react' -import { MessageRow } from './MessageRow' +import { MessageRow, useRowContext } from './MessageRow' import { IconButton } from './Icon' import { useChat, usePref, useStore } from '../state/hooks' import type { ChatMessage } from '../state/store' @@ -24,6 +24,8 @@ export function ThreadPanel(): JSX.Element | null { const [comfy] = usePref<'classic' | 'comfy' | 'bubbles'>('ui.messageMode', 'comfy') const [relativeTimestamps] = usePref('ui.relativeTimestamps', false) const store = useStore() + // Before the early return below, as every hook here has to be. + const shared = useRowContext(thread?.bufferId ?? '') const [draft, setDraft] = useState('') const inputRef = useRef(null) @@ -74,6 +76,7 @@ export function ThreadPanel(): JSX.Element | null { mediaLoop={false} contentSniffing={false} inThread + shared={shared} /> ))} {thread.loading && messages.length === 0 && ( diff --git a/src/renderer/src/lib/sniff.ts b/src/renderer/src/lib/sniff.ts index 2897266..a3c3e07 100644 --- a/src/renderer/src/lib/sniff.ts +++ b/src/renderer/src/lib/sniff.ts @@ -17,6 +17,17 @@ import { useCallback, useSyncExternalStore } from 'react' export type SniffKind = 'image' | 'video' | 'none' +/** + * How many answers to keep. + * + * This is a cache, and it used to be a list: one entry per link ever seen, + * held for the life of the window, copied in full on every new answer. A + * session that had read a few busy channels was carrying tens of thousands of + * URLs to save a request it was never going to make again. The oldest go + * first, which for a chat log is the ones furthest up the scroll. + */ +const MAX_REMEMBERED = 500 + const resolved: Record = {} const inFlight = new Set() const listeners = new Set<() => void>() @@ -36,6 +47,10 @@ export function sniffUrl(url: string): void { const finish = (kind: SniffKind): void => { inFlight.delete(url) resolved[url] = kind + // Insertion order is the age order here, and a plain object keeps it for + // string keys that are not array indices - which a URL never is. + const keys = Object.keys(resolved) + for (let i = 0; i < keys.length - MAX_REMEMBERED; i++) delete resolved[keys[i]] notify() } diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/state/store.ts index 7c9bfc6..b2a9915 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/state/store.ts @@ -770,6 +770,18 @@ const MAX_MESSAGES_PER_BUFFER = 500 * the first person to speak. */ const MAX_MESSAGES_AFTER_LOADING = 2000 +/** + * What a conversation keeps once somebody is reading a different one. + * + * A window subscribed to four hundred buffers was holding every one of them at + * the ceiling above, which is a ceiling meant for the conversation on screen - + * so an evening of traffic became a few hundred thousand messages that nothing + * was drawing and nothing would release. This is the tail a buffer is cut back + * to when it stops being the one in view: enough to fill the screen the moment + * it is opened again, with everything behind it read back out of the daemon's + * store by the same paging that already serves scrolling upwards. + */ +const MAX_MESSAGES_IDLE = 200 /** As many mentions as the inbox will hold; the daemon's own limit matches. */ const MAX_MENTIONS = 100 const BACKLOG_PAGE = 200 @@ -2718,6 +2730,27 @@ export class ChatStore { this.set({ messagesByBuffer: { ...this.state.messagesByBuffer, [bufferId]: unique } }) } + /** How much of this buffer to keep, which depends on whether it is in view. */ + private retentionFor(bufferId: string): number { + return bufferId === this.state.activeBufferId ? MAX_MESSAGES_AFTER_LOADING : MAX_MESSAGES_IDLE + } + + /** + * Cuts a conversation back to its tail now that something else is being + * read. + * + * Nothing is lost by this: every message here was written to the daemon's + * store as it arrived, and reopening the buffer pages it straight back. What + * it costs is the one thing worth spending - the rest of a long scroll + * upwards has to be fetched again if the reader returns to it, which is the + * same fetch they would have made had they never been there. + */ + private trimIdleBuffer(bufferId: string): void { + const list = this.state.messagesByBuffer[bufferId] + if (!list || list.length <= MAX_MESSAGES_IDLE) return + this.setMessages(bufferId, list.slice(-MAX_MESSAGES_IDLE)) + } + private mapMessage( bufferId: string, id: string, @@ -2738,7 +2771,10 @@ export class ChatStore { if (existing.some((m) => m.id === msg.id)) return const list = [...existing, msg as ChatMessage] - this.setMessages(bufferId, list.slice(-MAX_MESSAGES_AFTER_LOADING)) + // The generous ceiling is for the conversation being read, where it stops + // a jump into history being thrown away by the next person to speak. + // Everywhere else a message is arriving into a list nobody is looking at. + this.setMessages(bufferId, list.slice(-this.retentionFor(bufferId))) // Being on screen in a window of its own counts as being open, because it // is: badging a channel somebody is watching in a second window asks them @@ -2795,6 +2831,7 @@ export class ChatStore { // Snapshot the divider before clearing unread, so the "New messages" line // lands where the user actually left off rather than at the bottom. const buffer = this.state.buffers.find((b) => b.id === bufferId)! + const leaving = this.state.activeBufferId const dividerTs = this.state.dividerTsByBuffer[bufferId] const patch: Partial = { activeBufferId: bufferId, @@ -2814,6 +2851,12 @@ export class ChatStore { } this.set(patch) + // The conversation just left goes back to holding a screenful. Done here + // rather than on a timer because this is the moment its size stops being + // anybody's business: it is no longer drawn, and what a reader who comes + // back wants is the recent end of it, which is what it keeps. + if (leaving && leaving !== bufferId) this.trimIdleBuffer(leaving) + // Which conversation to reopen at startup is the main window's to // remember. A popout writing its own here would decide it for everyone. if (!this.state.pinnedBufferId) void window.moho.prefs.set('ui.activeBufferId', bufferId)