diff --git a/src/features/identity-names/react.ts b/src/features/identity-names/react.ts index b09626e24..0f2a56fe9 100644 --- a/src/features/identity-names/react.ts +++ b/src/features/identity-names/react.ts @@ -1,5 +1,6 @@ import type { NamingIdentity } from "./policy"; import { useSyncExternalStore, useCallback } from "react"; +import { useListedChannel } from "../relay/listed-channel"; import type { RelaySession } from "../relay/session"; import type { IdentityNameView } from "./service"; const noop = () => () => {}; @@ -28,21 +29,17 @@ export function useIdentityNames(names: IdentityNameView | undefined) { ); } -const noChannels = () => undefined; const noMembers: readonly string[] = []; /** Channel membership defines ambiguity, not the community-wide profile cache. */ export function useChannelIdentityNames( session: RelaySession | undefined, channelId: string | undefined, ) { - const list = useSyncExternalStore( - session?.channels?.subscribeList ?? noop, - session?.channels?.list ?? noChannels, - noChannels, + const members = useListedChannel( + session?.channels, + channelId, + (channel) => channel?.members ?? noMembers, ); - const members = - list?.channels.find((channel) => channel.id === channelId)?.members ?? - noMembers; const resolve = useIdentityNames(session?.names); return useCallback( (pubkey: string, fallback: string) => resolve(pubkey, fallback, members), diff --git a/src/features/messages/MessageManagement.tsx b/src/features/messages/MessageManagement.tsx index f87630522..292ef8a8f 100644 --- a/src/features/messages/MessageManagement.tsx +++ b/src/features/messages/MessageManagement.tsx @@ -14,6 +14,7 @@ import { Button } from "../../shared/design-system/ui/Button"; import { AlertDialog } from "../../shared/design-system/ui/AlertDialog"; import { MenuItem, MenuIcon } from "../../shared/design-system/ui/Menu"; import type { ChannelMessage } from "../relay/contracts"; +import { useListedChannel } from "../relay/listed-channel"; import type { RelaySession } from "../relay/session"; import type { OutgoingEvent } from "../relay/outbox"; import { MessageEditScope, useMessageEditScope } from "./MessageEditScope"; @@ -172,9 +173,15 @@ export function MessageManagementItems({ const management = useContext(Management); const editor = useMessageEditScope(); const afterClose = useAfterMessageMenuClose(); - const channels = useSyncExternalStore( - session.channels.subscribeList, - session.channels.list, + const writable = useListedChannel( + session.channels, + row.channelId, + (channel) => !!channel && !channel.readOnly, + ); + const archived = useListedChannel( + session.channels, + row.channelId, + (channel) => !!channel?.archived, ); const target = { kind: "message" as const, @@ -192,17 +199,14 @@ export function MessageManagementItems({ (row.delivery && !["accepted", "seen"].includes(row.delivery)) ) return null; - const member = channels.channels.find( - (channel) => channel.id === row.channelId, - ); - if (!member || member.readOnly) return null; + if (!writable) return null; const busy = management.operations.some( (item) => ["sending", "accepted"].includes(item.delivery) && [5, 40003].includes(item.event.kind) && item.event.tags.some(([name, id]) => name === "e" && id === row.id), ); - const own = row.authorId === session.viewer && !member.archived; + const own = row.authorId === session.viewer && !archived; const canEdit = own && editor && lastEditableMessage(session, [row]); const canDelete = own && session.outbox?.supports(5); const attention = session.unread.attention(row.channelId, row.id); diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx index a4e09ab86..c134027fb 100644 --- a/src/features/messages/MessageRow.tsx +++ b/src/features/messages/MessageRow.tsx @@ -16,6 +16,7 @@ import { type FocusEvent, type ReactNode, } from "react"; +import { useListedChannel } from "../relay/listed-channel"; import type { RelaySession } from "../relay/session"; import type { UnreadCapability } from "../relay/unread"; import { MediaAttachment, type MediaPlayback } from "./MediaAttachment"; @@ -45,13 +46,6 @@ import { ToastNotice } from "../../shared/design-system/ui/Toast"; import { ReportMessageDialog } from "./ReportMessageDialog"; import { messageCopyLink, messageCopyText } from "./message-copy"; -const emptySubscribe = () => () => {}; -const EMPTY_CHANNEL_LIST = Object.freeze({ - status: "unavailable" as const, - channels: Object.freeze([]), -}); -const emptyChannelList = () => EMPTY_CHANNEL_LIST; - export type MessageRowProps = { row: ChannelMessage; session?: RelaySession | undefined; @@ -166,13 +160,26 @@ export const MessageRow = memo(function MessageRow({ row.channelId, row.threadRootId ?? row.id, ); - const channelList = useSyncExternalStore( - session?.channels.subscribeList ?? emptySubscribe, - session?.channels.list ?? emptyChannelList, - session?.channels.list ?? emptyChannelList, + const channels = session?.channels; + const listed = useListedChannel( + channels, + row.channelId, + (channel) => !!channel, + ); + const cached = useListedChannel( + channels, + row.channelId, + (channel) => !!channel?.cached, + ); + const archived = useListedChannel( + channels, + row.channelId, + (channel) => !!channel?.archived, ); - const cached = channelList.channels.some( - (channel) => channel.id === row.channelId && channel.cached, + const readOnly = useListedChannel( + channels, + row.channelId, + (channel) => !!channel?.readOnly, ); const unreadLabel = threadUnread?.manual === "local-only" @@ -212,12 +219,9 @@ export const MessageRow = memo(function MessageRow({ scope && session.outbox?.supports(7) && session.outbox.supports(5) && - (!session.channels.get || - channelList.channels.some((channel) => channel.id === row.channelId)) && - !channelList.channels.find((channel) => channel.id === row.channelId) - ?.archived && - !channelList.channels.find((channel) => channel.id === row.channelId) - ?.readOnly + (!session.channels.get || listed) && + !archived && + !readOnly ); const rowRef = useRef(null); const menuTrigger = useRef(null); @@ -415,14 +419,8 @@ export const MessageRow = memo(function MessageRow({ !!( row.delivery && !["accepted", "seen"].includes(row.delivery) ) || - !!channelList.channels.find( - (channel) => channel.id === row.channelId, - )?.archived || - (!!session?.channels.get && - !channelList.channels.some( - (channel) => - channel.id === row.channelId && !channel.readOnly, - )) + archived || + (!!session?.channels.get && (!listed || readOnly)) } link={messageCopyLink(row, scope)} copyText={() => diff --git a/src/features/messages/ReferenceText.tsx b/src/features/messages/ReferenceText.tsx index d2026457c..288ddc26e 100644 --- a/src/features/messages/ReferenceText.tsx +++ b/src/features/messages/ReferenceText.tsx @@ -2,7 +2,7 @@ import { useChannelIdentityNames } from "../identity-names/react"; import { useSyncExternalStore, type ReactNode } from "react"; import { AtIcon, RobotIcon } from "../../shared/design-system/icons/index"; import type { RelaySession } from "../relay/session"; -import type { Profile, ChannelSummary } from "../relay/contracts"; +import type { ChannelList, ChannelSummary, Profile } from "../relay/contracts"; import { messageReferences } from "./message-references"; import { MessageLink } from "../conversation/MessageLink"; import type { ConversationExtensions } from "../conversation/contracts"; @@ -11,7 +11,12 @@ import type { AgentLibrary } from "../agents/library"; import styles from "../../shared/InlineReference.module.css"; const emptyProfiles: ReadonlyMap = new Map(); -const emptyChannels: readonly ChannelSummary[] = []; +/** The only channel fields a reference may read; the rest can be out of date. */ +export type ReferenceChannel = Pick< + ChannelSummary, + "id" | "name" | "channelType" | "private" | "archived" +>; +const emptyChannels: readonly ReferenceChannel[] = []; const emptyAgents: AgentLibrary["identities"] = []; export const emptyReferenceDirectory = { profiles: emptyProfiles, @@ -20,8 +25,37 @@ export const emptyReferenceDirectory = { }; const noop = () => () => {}; const profilesSnapshot = () => emptyProfiles; -const channelsSnapshot = () => undefined; +const channelsSnapshot = () => emptyChannels; const agentsSnapshot = () => undefined; +type ChannelLists = Pick; +const referenced = new WeakMap< + ChannelLists, + { list: ChannelList; channels: readonly ReferenceChannel[] } +>(); +/** Previews and activity replace the list and its summaries on every message. + * Retain the prior channels while every field a reference reads is unchanged, + * so mounted rows render only when a label could change. */ +function referenceChannels(queries: ChannelLists) { + const list = queries.list(); + const prior = referenced.get(queries); + if (prior?.list === list) return prior.channels; + const channels = + prior?.channels.length === list.channels.length && + list.channels.every((channel, index) => { + const old = prior.channels[index]; + return ( + old?.id === channel.id && + old.name === channel.name && + old.channelType === channel.channelType && + old.private === channel.private && + old.archived === channel.archived + ); + }) + ? prior.channels + : list.channels; + referenced.set(queries, { list, channels }); + return channels; +} export function useReferenceDirectory( session: RelaySession | undefined, @@ -34,9 +68,10 @@ export function useReferenceDirectory( : (session?.profiles?.snapshot ?? profilesSnapshot), profilesSnapshot, ); + const queries = session?.channels; const channels = useSyncExternalStore( - session?.channels?.subscribeList ?? noop, - session?.channels?.list ?? channelsSnapshot, + queries?.subscribeList ?? noop, + queries?.list ? () => referenceChannels(queries) : channelsSnapshot, channelsSnapshot, ); const agents = useSyncExternalStore( @@ -46,7 +81,7 @@ export function useReferenceDirectory( ); return { profiles, - channels: channels?.channels ?? emptyChannels, + channels, agents: agents?.identities ?? emptyAgents, }; } @@ -54,7 +89,7 @@ export function useReferenceDirectory( /** Buzz links carry no community, so they always name a channel in the receiving one. */ export function channelForLink( url: string, - channels: readonly ChannelSummary[], + channels: readonly ReferenceChannel[], ) { const parsed = parseBuzzLink(url); const target = parsed?.format === "legacy" ? parsed : undefined; @@ -63,7 +98,7 @@ export function channelForLink( export function channelLinkLabel( url: string, - channels: readonly ChannelSummary[], + channels: readonly ReferenceChannel[], ) { const channel = channelForLink(url, channels); const parsed = parseBuzzLink(url); diff --git a/src/features/messages/RichComposerInput.tsx b/src/features/messages/RichComposerInput.tsx index 7923332eb..de75507cd 100644 --- a/src/features/messages/RichComposerInput.tsx +++ b/src/features/messages/RichComposerInput.tsx @@ -5,6 +5,7 @@ import { mentionCandidates } from "./mention-candidates"; import { useAgentChoices } from "../agents/use-choices"; import { useLayoutEffect, useMemo, useRef } from "react"; import { useIdentityNames } from "../identity-names/react"; +import { useListedChannel } from "../relay/listed-channel"; import { InlineChip } from "../../shared/design-system/ui/InlineChip"; import type { ConversationExtensions } from "../conversation/contracts"; import type { CustomEmoji } from "../relay/emoji"; @@ -42,6 +43,8 @@ export function RichComposerInput({ inviteAgents?: boolean; }) { const directory = useReferenceDirectory(session); + // Candidates read this channel's roster and state during render. + useListedChannel(session.channels, channelId, (channel) => channel); useMentionArchives(session); const roster = useContext(DraftMentionRoster); const profiles = new Map(directory.profiles); diff --git a/src/features/relay/budget.test.ts b/src/features/relay/budget.test.ts new file mode 100644 index 000000000..0aaae7e1a --- /dev/null +++ b/src/features/relay/budget.test.ts @@ -0,0 +1,17 @@ +import { expect, it } from "vitest"; +import { byteSize, listByteSize } from "./budget"; + +it("sizes a list exactly as byteSize does, including after it changes", () => { + const items = [ + { id: "a", content: "plain", tags: [["h", "channel"]] }, + { id: "b", content: 'quote " and \\ slash', tags: [] }, + { id: "c", content: "héllo 🐝 \n", tags: [["e", "a"]] }, + ]; + for (const count of [0, 1, 2, 3, 2, 3]) + expect(listByteSize(items.slice(0, count))).toBe( + byteSize(items.slice(0, count)), + ); + expect(listByteSize([...items].reverse())).toBe( + byteSize([...items].reverse()), + ); +}); diff --git a/src/features/relay/budget.ts b/src/features/relay/budget.ts index be42a79f4..7631d39f4 100644 --- a/src/features/relay/budget.ts +++ b/src/features/relay/budget.ts @@ -4,6 +4,23 @@ export const OUTBOX_INPUT_MAX_BYTES = 32 * 1024; export const byteSize = (value: unknown) => new TextEncoder().encode(JSON.stringify(value)).byteLength; +const sizes = new WeakMap(); +/** `byteSize` of a list whose members never change, such as verified events. + * A live window checks its budget on every message; each member is serialized + * once instead of the whole window each time. */ +export function listByteSize(values: readonly object[]) { + let bytes = 2 + Math.max(0, values.length - 1); + for (const value of values) { + let size = sizes.get(value); + if (size === undefined) { + size = byteSize(value); + sizes.set(value, size); + } + bytes += size; + } + return bytes; +} + export class ByteLru { private items = new Map(); private bytes = 0; diff --git a/src/features/relay/listed-channel.test.tsx b/src/features/relay/listed-channel.test.tsx new file mode 100644 index 000000000..14be9bf44 --- /dev/null +++ b/src/features/relay/listed-channel.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment jsdom +import { act, cleanup, render } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; +import type { ChannelList } from "./contracts"; +import { useListedChannel } from "./listed-channel"; + +afterEach(cleanup); + +it("renders only when the selected fact of its own channel changes", () => { + const members = ["a"]; + let list: ChannelList = { + status: "ready", + channels: [ + { id: "one", name: "One", members }, + { id: "two", name: "Two", members: [] }, + ], + }; + const listeners = new Set<() => void>(); + const queries = { + list: () => list, + subscribeList: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + const publish = (next: ChannelList) => + act(() => { + list = next; + for (const notify of listeners) notify(); + }); + const seen: unknown[] = []; + function Probe() { + const archived = useListedChannel(queries, "one", (c) => !!c?.archived); + const roster = useListedChannel(queries, "one", (c) => c?.members); + seen.push([archived, roster]); + return null; + } + render(); + expect(seen).toEqual([[false, members]]); + + // A message replaces the list and every summary; no selected fact changed. + publish({ + ...list, + channels: [ + { id: "one", name: "One", members, preview: "new" }, + { id: "two", name: "Two", members: [], archived: true }, + ], + }); + expect(seen).toHaveLength(1); + + publish({ + ...list, + channels: [{ id: "one", name: "One", members, archived: true }], + }); + expect(seen.at(-1)).toEqual([true, members]); + expect(seen).toHaveLength(2); + + publish({ ...list, channels: [] }); + expect(seen.at(-1)).toEqual([false, undefined]); +}); diff --git a/src/features/relay/listed-channel.ts b/src/features/relay/listed-channel.ts new file mode 100644 index 000000000..47826dc70 --- /dev/null +++ b/src/features/relay/listed-channel.ts @@ -0,0 +1,32 @@ +import { useSyncExternalStore } from "react"; +import type { ChannelList, ChannelQueries, ChannelSummary } from "./contracts"; + +const unlisted = () => () => {}; +const listed = new WeakMap>(); +/** One roster fact about one channel. Previews and activity replace the list and + * its summaries on every message, so `select` must return a primitive or a + * reference the store retains (such as `members`); the caller then renders only + * when that fact changes. */ +export function useListedChannel( + queries: Partial> | undefined, + channelId: string | undefined, + select: (channel: ChannelSummary | undefined) => T, +): T { + const snapshot = () => { + const list = queries?.list?.(); + if (!list || channelId === undefined) return select(undefined); + let index = listed.get(list); + if (!index) { + index = new Map(); + for (const channel of list.channels) + if (!index.has(channel.id)) index.set(channel.id, channel); + listed.set(list, index); + } + return select(index.get(channelId)); + }; + return useSyncExternalStore( + queries?.subscribeList ?? unlisted, + snapshot, + snapshot, + ); +} diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts index 439aef90e..8e0ee727f 100644 --- a/src/features/relay/store.ts +++ b/src/features/relay/store.ts @@ -16,7 +16,7 @@ import type { RelayReader, ReadOptions, Priority } from "./reader"; import type { ProfileDirectory } from "./profile-directory"; import { parseWindow, windowFilter, type WindowCursor } from "./window"; import { readSessionWindow } from "./session-window"; -import { ByteLru, byteSize } from "./budget"; +import { ByteLru, byteSize, listByteSize } from "./budget"; import type { HeadPersistence, SavedHead } from "./persistence"; import { createMediaPreparation, saveData } from "./media"; import { relayDebug } from "./debug"; @@ -161,6 +161,9 @@ export function createChannelStore( events: readonly RelayEvent[]; preview?: string | undefined; }>(64, 4 * 1024 * 1024); + /** `byteSize` of a tail, without serializing its retained events again. */ + const tailBytes = (tail: NonNullable>) => + byteSize({ ...tail, events: [] }) - 2 + listByteSize(tail.events); let media = createMediaPreparation(); const controllers = new Set(); const accessVersions = new Map(); @@ -1730,7 +1733,8 @@ export function createChannelStore( ).values(), ]), ); - tails.set(channelId, { events: retained, preview }); + const tail = { events: retained, preview }; + tails.set(channelId, tail, tailBytes(tail)); } for (const state of windows.values()) { if (disposed || generation !== epoch) return; @@ -1769,7 +1773,7 @@ export function createChannelStore( let retained = [...state.events, ...incoming]; let limited = false; if ( - byteSize(retained) > maxHistoryBytes || + listByteSize(retained) > maxHistoryBytes || retained.filter( (event) => channelRowKind(event.kind) && !localIds.has(event.id), ).length > maxHistoryRows @@ -1792,7 +1796,7 @@ export function createChannelStore( (tag) => tag[0] === "e" && keep.has(tag[1] ?? ""), )), ); - while (retained.length && byteSize(retained) > maxHistoryBytes) + while (retained.length && listByteSize(retained) > maxHistoryBytes) retained.splice(0, Math.max(1, Math.ceil(retained.length / 4))); const retainedIds = new Set(retained.map((event) => event.id)); for (const id of state.traffic.keys()) @@ -1809,11 +1813,10 @@ export function createChannelStore( for (const channelId of changedChannels) { const tail = tails.peek(channelId); const state = windows.get(channelId); - if (tail && state) - tails.set(channelId, { - ...tail, - preview: messagePreview(state.snapshot.rows), - }); + if (tail && state) { + const next = { ...tail, preview: messagePreview(state.snapshot.rows) }; + tails.set(channelId, next, tailBytes(next)); + } } setList(list); } diff --git a/src/features/relay/unread.ts b/src/features/relay/unread.ts index b73e5f77c..3f481903b 100644 --- a/src/features/relay/unread.ts +++ b/src/features/relay/unread.ts @@ -107,9 +107,14 @@ export interface UnreadCapability { } const contentKind = (event: RelayEvent) => event.kind === 9 || event.kind === 40002 || event.kind === 40008; +const channelIds = new WeakMap(); +/** Verified events are frozen, and every evidence index asks for each one. */ const channelOf = (event: RelayEvent) => { + if (channelIds.has(event)) return channelIds.get(event); const tags = event.tags.filter(([name]) => name === "h"); - return tags.length === 1 ? tags[0]?.[1] : undefined; + const channelId = tags.length === 1 ? tags[0]?.[1] : undefined; + channelIds.set(event, channelId); + return channelId; }; const auxiliaryKind = (event: RelayEvent) => event.kind === 40003 || event.kind === 5 || event.kind === 9005; @@ -235,6 +240,7 @@ export function createUnread({ } type Evidence = { event: RelayEvent; + channelId: string; rootId: string | undefined; mentioned: boolean; }; @@ -263,6 +269,7 @@ export function createUnread({ const rows = byChannel.get(channel) ?? []; rows.push({ event, + channelId: channel, rootId: threadReference(event) ? rootId : undefined, mentioned: event.tags.some( ([name, value]) => name === "p" && value === viewer, @@ -275,19 +282,17 @@ export function createUnread({ indexEvidence(); return tombstones.has(event.id); } - function inTarget(event: RelayEvent, target: ReadTarget) { + /** Evidence of the target's channel: the index already resolved its channel + * and reply root, which every selector would otherwise derive per event. */ + function inTarget({ event, rootId }: Evidence, target: ReadTarget) { return ( - channelOf(event) === target.channelId && - (target.kind === "channel" || - (target.kind === "message" && target.messageId === event.id) || - (target.kind === "thread" && - !!threadReference(event) && - root(event) === target.rootId)) + target.kind === "channel" || + (target.kind === "message" && target.messageId === event.id) || + (target.kind === "thread" && rootId === target.rootId) ); } - function isUnread({ event, rootId }: Evidence, state: ReadState) { - const channelId = channelOf(event); - if (!channelId || event.pubkey === viewer) return false; + function isUnread({ event, channelId, rootId }: Evidence, state: ReadState) { + if (event.pubkey === viewer) return false; const frontier = effectiveFrontier( state, `msg:${event.id}`, @@ -422,7 +427,7 @@ export function createUnread({ (event.created_at === latest.created_at && event.id < latest.id)) ) latest = event; - if (!inTarget(entry.event, target) || !isUnread(entry, state)) continue; + if (!inTarget(entry, target) || !isUnread(entry, state)) continue; count++; if (priority(entry, dm)) attention++; } @@ -472,11 +477,8 @@ export function createUnread({ indexEvidence(); const state = reads.state(); const grouped = new Map(); - const presented = new Map( - foldMessages(channelId, "", [...events.values()], { - includeReplies: true, - }).map((message) => [message.id, message.content]), - ); + // Only unread thread activity is presented; most publishes have none. + let presented: Map | undefined; for (const evidence of byChannel.get(channelId) ?? []) { const { event, rootId, mentioned } = evidence; const broadcast = event.tags.some( @@ -489,6 +491,11 @@ export function createUnread({ ) continue; const current = grouped.get(rootId); + presented ??= new Map( + foldMessages(channelId, "", [...events.values()], { + includeReplies: true, + }).map((message) => [message.id, message.content]), + ); const preview = presented.get(event.id) ?? event.content; if (!current) { grouped.set(