diff --git a/desktop/package.json b/desktop/package.json index 1bad12fb11..f93ab6b81c 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -77,6 +77,7 @@ "tailwind-merge": "^3.5.0", "tiptap-markdown": "^0.9.0", "upng-js": "^2.1.0", + "virtua": "0.49.2", "yaml": "^2.8.3", "zod": "^4.4.3" }, diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index f7cd01b781..63a34eb192 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -76,6 +76,7 @@ export const ChannelPane = React.memo(function ChannelPane({ fetchOlder, header, hasOlderMessages, + historyExhausted, isFetchingOlder, followThreadById, isFollowingThread, @@ -190,6 +191,7 @@ export const ChannelPane = React.memo(function ChannelPane({ timelineScrollRef, composerWrapperRef, `${activeChannelId}:${isSinglePanelView}:${hasMainComposerOverlay}`, + "css-variable", ); const clearWelcomeComposerDismissTimer = React.useCallback(() => { if (welcomeComposerDismissTimerRef.current !== null) { @@ -616,6 +618,7 @@ export const ChannelPane = React.memo(function ChannelPane({ followThreadById={followThreadById} hasComposerOverlay={hasMainComposerOverlay} hasOlderMessages={hasOlderMessages} + historyExhausted={historyExhausted} huddleMemberPubkeys={huddleMemberPubkeys} huddleMemberPubkeysPending={huddleMemberPubkeysPending} isFetchingOlder={isFetchingOlder} @@ -689,7 +692,7 @@ export const ChannelPane = React.memo(function ChannelPane({ ) : (
@@ -735,7 +738,7 @@ export const ChannelPane = React.memo(function ChannelPane({ } showTopBorder={false} /> -
+
{hasComposerBotActivity ? (
diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index d5deeea100..c273f5e552 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -46,6 +46,8 @@ export type ChannelPaneProps = { fetchOlder?: () => Promise; header?: React.ReactNode; hasOlderMessages?: boolean; + /** True when the loaded window provably starts at the channel's beginning. */ + historyExhausted?: boolean; isFetchingOlder?: boolean; isJoining?: boolean; isSinglePanelView?: boolean; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 5d1b0e7d63..2e658da03f 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -190,7 +190,7 @@ export function ChannelScreen({ effectiveOpenThreadHeadId, ); useChannelSubscription(activeChannel); - const { fetchOlder, hasOlderMessages, isFetchingOlder } = + const { fetchOlder, hasOlderMessages, historyExhausted, isFetchingOlder } = useFetchOlderMessages(activeChannel); const latestActiveMessage = React.useMemo(() => { const messages = messagesQuery.data; @@ -878,6 +878,7 @@ export function ChannelScreen({ fetchOlder={fetchOlder} header={channelHeader} hasOlderMessages={hasOlderMessages} + historyExhausted={historyExhausted} onAddAgent={handleOpenAddBot} onCreateChannel={openCreateChannel} onOpenMembers={handleOpenMembersSidebar} diff --git a/desktop/src/features/messages/lib/channelWindowStore.test.mjs b/desktop/src/features/messages/lib/channelWindowStore.test.mjs index 197941a65d..7db4050bfa 100644 --- a/desktop/src/features/messages/lib/channelWindowStore.test.mjs +++ b/desktop/src/features/messages/lib/channelWindowStore.test.mjs @@ -3,6 +3,8 @@ import test from "node:test"; import { appendOlderChannelWindow, mapChannelWindowEvents, + channelWindowHasMore, + channelWindowHistoryExhausted, channelWindowThreadSummaries, emptyChannelWindowStore, flattenChannelWindowEvents, @@ -401,3 +403,26 @@ test("mapChannelWindowEvents rewrites live overlay events", () => { "edited-live", ); }); + +test("exhaustion is unresolved (false) on an empty store, unlike hasMore's default", () => { + const store = emptyChannelWindowStore(); + assert.equal(channelWindowHasMore(store), false); + // Both read "no more history", but exhaustion must NOT claim the boundary + // is proven before any page resolves — an unloaded window is unknown. + assert.equal(channelWindowHistoryExhausted(store), false); +}); + +test("exhaustion tracks only a resolved tail page's hasMore", () => { + const open = replaceNewestChannelWindow( + emptyChannelWindowStore(), + page(null, [event("newest", 100)], { hasMore: true }), + ); + assert.equal(channelWindowHistoryExhausted(open), false); + + const closed = appendOlderChannelWindow( + open, + page(open.pages[0].nextCursor, [event("oldest", 99)], { hasMore: false }), + ); + assert.equal(channelWindowHistoryExhausted(closed), true); + assert.equal(channelWindowHasMore(closed), false); +}); diff --git a/desktop/src/features/messages/lib/channelWindowStore.ts b/desktop/src/features/messages/lib/channelWindowStore.ts index 7ee883bf23..43b4e6b1d7 100644 --- a/desktop/src/features/messages/lib/channelWindowStore.ts +++ b/desktop/src/features/messages/lib/channelWindowStore.ts @@ -282,6 +282,17 @@ export function channelWindowHasMore(store: ChannelWindowStore) { return tail?.hasMore ?? false; } +/** + * Whether the loaded window PROVABLY starts at the channel's beginning. This + * is not `!channelWindowHasMore`: an empty store also reports "no more", but + * that means the boundary is unresolved (nothing has loaded), not exhausted. + * Only a resolved tail page saying `hasMore: false` proves the start. + */ +export function channelWindowHistoryExhausted(store: ChannelWindowStore) { + const tail = store.pages[store.pages.length - 1]; + return tail !== undefined && !tail.hasMore; +} + /** * Per-root thread summaries for badge rendering: authoritative page summaries * overlaid with any fresher relay-pushed live summaries. The live overlay also diff --git a/desktop/src/features/messages/lib/timelineImagePreload.test.mjs b/desktop/src/features/messages/lib/timelineImagePreload.test.mjs new file mode 100644 index 0000000000..8bc6a33fc8 --- /dev/null +++ b/desktop/src/features/messages/lib/timelineImagePreload.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { timelineImageUrls } from "./timelineImagePreload.ts"; + +function message(over = {}) { + return { + id: "m1", + createdAt: 0, + author: "a", + time: "now", + body: "", + depth: 0, + ...over, + }; +} + +test("timelineImageUrls warms chrome images but leaves attachments lazy", () => { + const urls = timelineImageUrls( + message({ + avatarUrl: "https://example.com/avatar.jpg", + body: [ + "![first](https://example.com/one.png)", + '![second](https://example.com/two.webp "caption")', + ].join("\n"), + tags: [ + ["imeta", "url https://example.com/three.jpg", "m image/jpeg"], + [ + "imeta", + "url https://example.com/movie.mp4", + "m video/mp4", + "image https://example.com/poster.jpg", + "thumb https://example.com/thumb.jpg", + ], + ["emoji", "party", "https://example.com/party.png"], + ], + reactions: [ + { + emoji: ":party:", + emojiUrl: "https://example.com/reaction.png", + count: 1, + users: [], + }, + ], + }), + ); + + assert.deepEqual( + new Set(urls), + new Set([ + "https://example.com/avatar.jpg", + "https://example.com/poster.jpg", + "https://example.com/thumb.jpg", + "https://example.com/party.png", + "https://example.com/reaction.png", + ]), + ); + assert.ok(!urls.includes("https://example.com/one.png")); + assert.ok(!urls.includes("https://example.com/three.jpg")); +}); + +test("timelineImageUrls deduplicates chrome image URLs", () => { + const url = "https://example.com/same.png"; + assert.deepEqual( + timelineImageUrls( + message({ + avatarUrl: url, + tags: [["emoji", "same", url]], + }), + ), + [url], + ); +}); + +test("preloadTimelineImages requests URLs once and keeps requests alive", async () => { + const { preloadTimelineImages } = await import("./timelineImagePreload.ts"); + const previousImage = globalThis.Image; + const requested = []; + class FakeImage extends EventTarget { + set src(url) { + requested.push(url); + } + } + globalThis.Image = FakeImage; + try { + const state = { activeImages: new Set(), requestedUrls: new Set() }; + const messages = [message({ avatarUrl: "https://example.com/one.png" })]; + preloadTimelineImages(messages, state); + preloadTimelineImages(messages, state); + + assert.deepEqual(requested, ["https://example.com/one.png"]); + assert.equal(state.activeImages.size, 1); + state.activeImages.values().next().value.dispatchEvent(new Event("load")); + assert.equal(state.activeImages.size, 0); + } finally { + globalThis.Image = previousImage; + } +}); diff --git a/desktop/src/features/messages/lib/timelineImagePreload.ts b/desktop/src/features/messages/lib/timelineImagePreload.ts new file mode 100644 index 0000000000..77320e765a --- /dev/null +++ b/desktop/src/features/messages/lib/timelineImagePreload.ts @@ -0,0 +1,57 @@ +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import type { TimelineMessage } from "../types"; +import { parseImetaTags } from "./parseImeta"; + +/** + * Return non-message-media image URLs worth warming before a virtualized row + * mounts. Inline image attachments deliberately stay out of this projection: + * their native-lazy thumbnail must load before the full-resolution request. + */ +export function timelineImageUrls(message: TimelineMessage): string[] { + const urls = new Set(); + const add = (url: string | null | undefined) => { + if (url) urls.add(rewriteRelayUrl(url)); + }; + + add(message.avatarUrl); + + if (message.tags) { + for (const entry of parseImetaTags(message.tags).values()) { + // Video poster frames are not part of the progressive image path. + if (entry.m?.startsWith("video/")) { + add(entry.image); + add(entry.thumb); + } + } + for (const tag of message.tags) { + if (tag[0] === "emoji") add(tag[2]); + } + } + + for (const reaction of message.reactions ?? []) add(reaction.emojiUrl); + return [...urls]; +} + +export type TimelineImagePreloadState = { + activeImages: Set; + requestedUrls: Set; +}; + +/** Start all image requests now, independently of Virtua row mounting. */ +export function preloadTimelineImages( + messages: readonly TimelineMessage[], + state: TimelineImagePreloadState, +): void { + for (const message of messages) { + for (const url of timelineImageUrls(message)) { + if (state.requestedUrls.has(url)) continue; + state.requestedUrls.add(url); + const image = new Image(); + state.activeImages.add(image); + const release = () => state.activeImages.delete(image); + image.addEventListener("load", release, { once: true }); + image.addEventListener("error", release, { once: true }); + image.src = url; + } + } +} diff --git a/desktop/src/features/messages/lib/virtualizedTimelineItems.test.mjs b/desktop/src/features/messages/lib/virtualizedTimelineItems.test.mjs new file mode 100644 index 0000000000..907ffa0f6c --- /dev/null +++ b/desktop/src/features/messages/lib/virtualizedTimelineItems.test.mjs @@ -0,0 +1,206 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildVirtualizedItems, + didPrependVirtualizedTimeline, + virtualizedItemKey, +} from "./virtualizedTimelineItems.ts"; + +// Timestamps: day A = 2026-06-01, day B = 2026-06-02, day Z = 2026-05-31. +function dayAt(year, month, day, hour = 12, minute = 0) { + return Math.floor( + new Date(year, month - 1, day, hour, minute, 0).getTime() / 1_000, + ); +} + +function messageItem(key) { + return { + kind: "message", + key, + entry: { message: { id: key } }, + isContinuation: false, + isFollowedByContinuation: false, + }; +} + +function group(dayKey, headingTimestamp, itemKeys) { + return { + key: dayKey, + headingTimestamp, + items: itemKeys.map(messageItem), + }; +} + +const DAY_A = dayAt(2026, 6, 1); +const DAY_B = dayAt(2026, 6, 2); +const DAY_Z = dayAt(2026, 5, 31); + +function keysOf(dayGroups, { leading = undefined, exhausted = false } = {}) { + return buildVirtualizedItems(dayGroups, leading, exhausted).map( + virtualizedItemKey, + ); +} + +/** + * Virtua's `shift` moves every cached slot uniformly by the length delta. + * With shift admitted, assert every previous key's cached height lands on the + * SAME logical item in the new list. + */ +function assertShiftAdmittedAndCacheClean(previousKeys, keys) { + assert.equal(didPrependVirtualizedTimeline(previousKeys, keys), true); + const delta = keys.length - previousKeys.length; + previousKeys.forEach((key, index) => { + assert.equal( + keys[index + delta], + key, + `cached height at slot ${index} (${key}) would land on ${keys[index + delta]}`, + ); + }); +} + +test("oldest day carries no divider while more history exists", () => { + const keys = keysOf([group("day-A", DAY_A, ["a3", "a4", "a5"])]); + assert.deepEqual(keys, ["a3", "a4", "a5", "bottom-spacer"]); +}); + +test("oldest day divider renders once history is exhausted", () => { + const keys = keysOf([group("day-A", DAY_A, ["a3", "a4", "a5"])], { + exhausted: true, + }); + assert.deepEqual(keys, [ + "day-divider:day-A", + "a3", + "a4", + "a5", + "bottom-spacer", + ]); +}); + +test("interior day boundaries always render dividers", () => { + const keys = keysOf([ + group("day-A", DAY_A, ["a3"]), + group("day-B", DAY_B, ["b1", "b2"]), + ]); + assert.deepEqual(keys, [ + "a3", + "day-divider:day-B", + "b1", + "b2", + "bottom-spacer", + ]); +}); + +test("undated group never renders a divider even when proven", () => { + const keys = keysOf( + [group("day-undated", null, ["u1"]), group("day-B", DAY_B, ["b1"])], + { exhausted: true }, + ); + assert.deepEqual(keys, ["u1", "day-divider:day-B", "b1", "bottom-spacer"]); +}); + +test("same-day prepend into the oldest day admits shift with a clean cache", () => { + const previous = keysOf([ + group("day-A", DAY_A, ["a3", "a4", "a5"]), + group("day-B", DAY_B, ["b1"]), + ]); + const next = keysOf([ + group("day-A", DAY_A, ["a1", "a2", "a3", "a4", "a5"]), + group("day-B", DAY_B, ["b1"]), + ]); + assertShiftAdmittedAndCacheClean(previous, next); +}); + +test("cross-day prepend materializes the newly proven divider as pure prefix", () => { + const previous = keysOf([ + group("day-A", DAY_A, ["a3", "a4", "a5"]), + group("day-B", DAY_B, ["b1"]), + ]); + // Day Z loads before day A: day A's boundary is now proven, so BOTH the + // z-rows and day A's divider enter as prefix. + const next = keysOf([ + group("day-Z", DAY_Z, ["z1", "z2"]), + group("day-A", DAY_A, ["a3", "a4", "a5"]), + group("day-B", DAY_B, ["b1"]), + ]); + assert.deepEqual( + next.slice(0, 4), + ["z1", "z2", "day-divider:day-A", "a3"], + "day-Z itself is now the unproven oldest day and carries no divider", + ); + assertShiftAdmittedAndCacheClean(previous, next); +}); + +test("mixed page (older day rows + same-day completion) admits shift", () => { + const previous = keysOf([ + group("day-A", DAY_A, ["a3", "a4", "a5"]), + group("day-B", DAY_B, ["b1"]), + ]); + const next = keysOf([ + group("day-Z", DAY_Z, ["z9"]), + group("day-A", DAY_A, ["a1", "a2", "a3", "a4", "a5"]), + group("day-B", DAY_B, ["b1"]), + ]); + assertShiftAdmittedAndCacheClean(previous, next); +}); + +test("history exhaustion with an empty page inserts the divider as pure prefix", () => { + const groups = [ + group("day-A", DAY_A, ["a3", "a4", "a5"]), + group("day-B", DAY_B, ["b1"]), + ]; + const previous = keysOf(groups); + const next = keysOf(groups, { exhausted: true }); + assert.deepEqual(next.slice(0, 2), ["day-divider:day-A", "a3"]); + assertShiftAdmittedAndCacheClean(previous, next); +}); + +test("leading content arriving with the final page stays a pure prefix", () => { + const previous = keysOf([ + group("day-A", DAY_A, ["a3", "a4", "a5"]), + group("day-B", DAY_B, ["b1"]), + ]); + const next = keysOf( + [ + group("day-A", DAY_A, ["a1", "a2", "a3", "a4", "a5"]), + group("day-B", DAY_B, ["b1"]), + ], + { leading: "intro", exhausted: true }, + ); + assert.deepEqual(next.slice(0, 2), ["leading-content", "day-divider:day-A"]); + assertShiftAdmittedAndCacheClean(previous, next); +}); + +test("divider count and order match proven boundaries exactly", () => { + const items = buildVirtualizedItems( + [ + group("day-Z", DAY_Z, ["z1"]), + group("day-A", DAY_A, ["a1"]), + group("day-B", DAY_B, ["b1"]), + ], + undefined, + true, + ); + const dividers = items.filter((item) => item.kind === "day-divider"); + assert.deepEqual( + dividers.map((item) => item.key), + ["day-divider:day-Z", "day-divider:day-A", "day-divider:day-B"], + ); + // Each divider precedes its day's first message. + const keys = items.map(virtualizedItemKey); + assert.equal(keys.indexOf("day-divider:day-Z"), keys.indexOf("z1") - 1); + assert.equal(keys.indexOf("day-divider:day-A"), keys.indexOf("a1") - 1); + assert.equal(keys.indexOf("day-divider:day-B"), keys.indexOf("b1") - 1); +}); + +test("naive counterexample stays rejected: same key cannot precede prepended rows", () => { + // Regression pin for the shape Wren vetoed: if the oldest day's divider + // existed BEFORE the same-day prepend, admission must fail. + assert.equal( + didPrependVirtualizedTimeline( + ["day-divider:day-A", "a3", "a4", "a5", "bottom-spacer"], + ["day-divider:day-A", "a1", "a2", "a3", "a4", "a5", "bottom-spacer"], + ), + false, + ); +}); diff --git a/desktop/src/features/messages/lib/virtualizedTimelineItems.ts b/desktop/src/features/messages/lib/virtualizedTimelineItems.ts new file mode 100644 index 0000000000..6ccfc743ea --- /dev/null +++ b/desktop/src/features/messages/lib/virtualizedTimelineItems.ts @@ -0,0 +1,106 @@ +/** + * Pure item-assembly for the virtualized timeline row stream. + * + * Kept free of React rendering (only a type-level ReactNode) so the + * exact-suffix `shift`-admission invariants are covered by the lib-level + * `*.test.mjs` suite. + */ + +import type * as React from "react"; + +import { + getTimelineItemKey, + type TimelineDayGroup, + type TimelineNonDayItem, +} from "./timelineItems"; + +export type VirtualizedTimelineItem = + | { + kind: "leading-content"; + content: React.ReactNode; + } + | { kind: "bottom-spacer" } + | { kind: "day-divider"; key: string; headingTimestamp: number } + | { + kind: "timeline-item"; + item: TimelineNonDayItem; + }; + +export function virtualizedItemKey(item: VirtualizedTimelineItem): string { + if (item.kind === "bottom-spacer") return "bottom-spacer"; + if (item.kind === "leading-content") return "leading-content"; + if (item.kind === "day-divider") return item.key; + return getTimelineItemKey(item.item); +} + +/** + * Flattens day groups into the virtual row stream. + * + * Day dividers are standalone items emitted only at PROVEN day boundaries: a + * boundary is proven when a strictly older loaded day precedes it in the + * window, or when history is exhausted (the window provably starts at the + * channel's beginning). The oldest loaded day gets no divider while more + * history exists — its "start" is just the arbitrary edge of the loaded + * window, and a divider item there would have to accept older same-day rows + * prepending BEHIND it, which breaks the exact-suffix key admission that + * Virtua's `shift` depends on. (The previous in-row divider avoided that key + * problem but made the day's head row change shape mid-commit when the + * divider migrated off it, causing a one-frame anchor jump at page merges.) + * A proven boundary is immutable — any strictly older message belongs to a + * strictly older day — so its divider only ever enters the stream as part of + * a prepended prefix and never mutates the existing key suffix. + */ +export function buildVirtualizedItems( + dayGroups: readonly TimelineDayGroup[], + leadingContent: React.ReactNode | undefined, + historyExhausted: boolean, +): VirtualizedTimelineItem[] { + const timelineItems = dayGroups.flatMap((group, groupIndex) => { + const boundaryProven = groupIndex > 0 || historyExhausted; + const divider = + group.headingTimestamp !== null && boundaryProven + ? [ + { + kind: "day-divider" as const, + // Namespaced so a divider key can never collide with any + // timeline item key (message ids, `unread-*`, sentinels). + key: `day-divider:${group.key}`, + headingTimestamp: group.headingTimestamp, + }, + ] + : []; + return [ + ...divider, + ...group.items.map((item) => ({ + kind: "timeline-item" as const, + item, + })), + ]; + }); + + return [ + ...(leadingContent + ? [ + { + kind: "leading-content" as const, + content: leadingContent, + }, + ] + : []), + ...timelineItems, + { kind: "bottom-spacer" as const }, + ]; +} + +export function didPrependVirtualizedTimeline( + previousKeys: readonly string[], + keys: readonly string[], +): boolean { + const prependedCount = keys.length - previousKeys.length; + // Virtua shifts its positional size cache by the length delta, so enable + // `shift` only when every previous slot is the exact suffix it expects. + return ( + prependedCount > 0 && + previousKeys.every((key, index) => key === keys[index + prependedCount]) + ); +} diff --git a/desktop/src/features/messages/ui/DirectMessageIntroAvatarStack.tsx b/desktop/src/features/messages/ui/DirectMessageIntroAvatarStack.tsx new file mode 100644 index 0000000000..1a1018fbe7 --- /dev/null +++ b/desktop/src/features/messages/ui/DirectMessageIntroAvatarStack.tsx @@ -0,0 +1,60 @@ +import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +export type DirectMessageIntroParticipant = { + avatarUrl: string | null; + displayName: string; + pubkey: string; +}; + +export function DirectMessageIntroAvatarStack({ + participants, +}: { + participants: DirectMessageIntroParticipant[]; +}) { + const { hiddenCount, visibleParticipants } = + getDmParticipantPreview(participants); + const stackItemCount = visibleParticipants.length + (hiddenCount > 0 ? 1 : 0); + + return ( + + ); +} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 11e6190127..39512fa0ae 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -902,7 +902,7 @@ function MessageComposerImpl({ >