Draw the part of the log somebody is reading
A window left open for a day was holding 690MB, and most of what it was holding was not on screen and could not be got to without scrolling. Every message in a conversation was a mounted row - avatar, embeds, pictures, reactions - however far up the log it sat, and a conversation could hold two thousand of them. So the log is drawn as a tail of what is loaded, and reading upwards lengthens the tail: the same gesture that fetches an older page, one step earlier in the same sequence. Coming back to the present lets it go again, whether by the button or by scrolling there. A tail rather than a floating window because a chat log is read from the bottom: there is nothing below the drawn part, so there is no spacer, no estimated row height, and no second opinion about how tall the page is. The scroll machinery measures what it always did. Underneath it, the same in memory: the two-thousand-message ceiling is for the conversation being read, and everything else keeps a screenful. Four hundred buffers were all being held at the ceiling meant for one of them, and every message was already written to the daemon's store on the way in - so what a trim costs is a page fetched again if somebody goes back, which is the fetch they would have made anyway. And two things that were making the client slow rather than large: every row subscribed to the conversation's emoji, permissions, pins and colour settings itself, so two thousand rows meant twelve thousand live subscriptions re-run on every change anywhere in the client; and nothing was memoised, so a message in one channel redrew every row of another. Read once per list now, and a row redraws when its own message does. Measured on #archlinux, opening and reading back three pages: 8,338 DOM nodes before and 3,794 after, and 1,374 rather than 8,336 once back at the present. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+1
-1
Submodule nobilis updated: 81f1b0b913...dbbc483a32
@@ -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)}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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<HTMLDivElement>(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 {
|
||||
<span>Loading older messages…</span>
|
||||
</div>
|
||||
)}
|
||||
{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 (
|
||||
<div key={msg.id}>
|
||||
{i === dividerIndex && (
|
||||
<div className="new-divider">
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
{/* 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. */}
|
||||
|
||||
@@ -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<boolean>('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<HTMLButtonElement>(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<boolean>('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({
|
||||
<span className="message-avatar">
|
||||
{!grouped &&
|
||||
(message.avatarUrl ? (
|
||||
<img src={resolveMediaUrl(message.avatarUrl)} alt="" />
|
||||
<img
|
||||
src={resolveMediaUrl(message.avatarUrl)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
<span className="avatar-fallback" style={{ background: message.senderColor || nickColor(message.from) }}>
|
||||
{message.from.slice(0, 1).toUpperCase()}
|
||||
@@ -995,3 +1052,14 @@ function MessageControls({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
@@ -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<boolean>('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<HTMLTextAreaElement>(null)
|
||||
|
||||
@@ -74,6 +76,7 @@ export function ThreadPanel(): JSX.Element | null {
|
||||
mediaLoop={false}
|
||||
contentSniffing={false}
|
||||
inThread
|
||||
shared={shared}
|
||||
/>
|
||||
))}
|
||||
{thread.loading && messages.length === 0 && (
|
||||
|
||||
@@ -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<string, SniffKind> = {}
|
||||
const inFlight = new Set<string>()
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ChatState> = {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user