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..0fd2d28c2c 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -190,6 +190,7 @@ export const ChannelPane = React.memo(function ChannelPane({ timelineScrollRef, composerWrapperRef, `${activeChannelId}:${isSinglePanelView}:${hasMainComposerOverlay}`, + "css-variable", ); const clearWelcomeComposerDismissTimer = React.useCallback(() => { if (welcomeComposerDismissTimerRef.current !== null) { @@ -689,7 +690,7 @@ export const ChannelPane = React.memo(function ChannelPane({ ) : (
@@ -735,7 +736,7 @@ export const ChannelPane = React.memo(function ChannelPane({ } showTopBorder={false} /> -
+
{hasComposerBotActivity ? (
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..a486f726f3 --- /dev/null +++ b/desktop/src/features/messages/lib/timelineImagePreload.test.mjs @@ -0,0 +1,101 @@ +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 finds every image a timeline row can render", () => { + 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/one.png", + "https://example.com/two.webp", + "https://example.com/three.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/movie.mp4")); +}); + +test("timelineImageUrls deduplicates image URLs", () => { + const url = "https://example.com/same.png"; + assert.deepEqual( + timelineImageUrls( + message({ + avatarUrl: url, + body: `![](${url})`, + tags: [["imeta", `url ${url}`, "m image/png"]], + }), + ), + [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({ body: "![](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..af03b5e591 --- /dev/null +++ b/desktop/src/features/messages/lib/timelineImagePreload.ts @@ -0,0 +1,63 @@ +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import type { TimelineMessage } from "../types"; +import { parseImetaTags } from "./parseImeta"; + +const MARKDOWN_IMAGE_RE = /!\[[^\]]*\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g; + +/** + * Return every image URL a mounted timeline row can request. Keeping this + * projection independent of row rendering lets a virtualized timeline warm the + * browser cache before the row enters Virtua's mounted range. + */ +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); + + for (const match of message.body.matchAll(MARKDOWN_IMAGE_RE)) { + add(match[1]); + } + + if (message.tags) { + for (const entry of parseImetaTags(message.tags).values()) { + if (entry.m?.startsWith("image/")) add(entry.url); + // Video poster frames are images too, and otherwise arrive only when the + // virtualized row mounts its player. + 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/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({ >