Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2b17c4d
Virtualize channel timeline with Virtua
Jul 9, 2026
8ac959d
Polish virtualized channel viewport
Jul 9, 2026
cdfa835
fix(desktop): include overlay clearance in virtual timeline
Jul 10, 2026
8ea2420
fix(desktop): clear Virtua prepend shift after render
Jul 10, 2026
dcfa94a
fix(desktop): anchor stationary timeline reflows
Jul 10, 2026
0e0852a
fix(desktop): bound timeline window and preserve live threads
Jul 10, 2026
0bc3838
fix(desktop): prerender timeline beyond viewport
Jul 10, 2026
fedc6ea
fix(desktop): anchor timeline measurements at center
Jul 10, 2026
6f6e7c2
fix(desktop): let Virtua own timeline anchoring
Jul 10, 2026
300564a
Replace timeline virtualization with retained windows
Jul 10, 2026
a26b7a4
Restore smooth virtualized timeline scrolling
Jul 10, 2026
31fe8d3
Preload loaded timeline images
Jul 10, 2026
c4face9
fix(desktop): preserve timeline anchor across prepends
Jul 10, 2026
33d2769
Revert "fix(desktop): preserve timeline anchor across prepends"
Jul 10, 2026
698810e
fix(desktop): stabilize deep timeline prepends
Jul 10, 2026
659ebd0
fix(desktop): prevent transient prepend jumps
Jul 10, 2026
3e5ca6e
fix(desktop): cancel upward momentum at history boundary
Jul 10, 2026
439086c
fix(desktop): premeasure timeline rows before viewport
Jul 10, 2026
ba1482e
fix(desktop): mount loaded timeline rows
Jul 10, 2026
de73023
fix(desktop): stop boundary scroll rollback
Jul 10, 2026
ca8eade
fix(desktop): retire stale prepend anchors
Jul 10, 2026
d49ecc5
fix(desktop): gate older-page render commit on scroller rest
Jul 10, 2026
6cfc9ba
fix(desktop): preserve Virtua prepend cache alignment
Jul 10, 2026
e7c598c
fix(desktop): stabilize virtual timeline lifecycle
Jul 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 0 additions & 2 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ import { useFeatureEnabled } from "@/shared/features";
import { useIdentityQuery } from "@/shared/api/hooks";
import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal";
import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup";
import { useWebviewScrollBoundaryLock } from "@/shared/hooks/useWebviewScrollBoundaryLock";
import { joinChannel } from "@/shared/api/tauri";
import type { SearchHit } from "@/shared/api/types";
import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext";
Expand All @@ -96,7 +95,6 @@ const LazySettingsScreen = React.lazy(async () => {
export function AppShell() {
useWebviewZoomShortcuts();
useTauriWindowDrag();
useWebviewScrollBoundaryLock();

const workspacesHook = useWorkspaces();
const workspaceRailEnabled = useFeatureEnabled("workspaceRail");
Expand Down
5 changes: 3 additions & 2 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -689,7 +690,7 @@ export const ChannelPane = React.memo(function ChannelPane({
</div>
) : (
<div
className="pointer-events-none absolute inset-x-0 bottom-0 z-40"
className="pointer-events-none absolute inset-x-0 bottom-0 z-40 bg-background/70 backdrop-blur-md supports-[backdrop-filter]:bg-background/55"
data-testid="channel-composer-overlay"
ref={composerWrapperRef}
>
Expand Down Expand Up @@ -735,7 +736,7 @@ export const ChannelPane = React.memo(function ChannelPane({
}
showTopBorder={false}
/>
<div className="min-h-8 overflow-visible bg-background px-5 pb-1.5 pt-0">
<div className="min-h-8 overflow-visible bg-transparent px-5 pb-1.5 pt-0">
<div className="flex h-full w-full items-center gap-2 overflow-visible">
{hasComposerBotActivity ? (
<div className="flex min-w-0 flex-1 overflow-visible">
Expand Down
101 changes: 101 additions & 0 deletions desktop/src/features/messages/lib/timelineImagePreload.test.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
});
63 changes: 63 additions & 0 deletions desktop/src/features/messages/lib/timelineImagePreload.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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<HTMLImageElement>;
requestedUrls: Set<string>;
};

/** 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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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 (
<div
aria-hidden="true"
className="flex shrink-0 items-center"
data-testid="message-dm-intro-avatar-stack"
>
{visibleParticipants.map((participant, index) => (
<div
className={index > 0 ? "-ml-5" : ""}
data-testid="message-dm-intro-avatar-stack-participant"
key={participant.pubkey}
style={{
zIndex: index + 1,
...(index < stackItemCount - 1 && {
mask: "radial-gradient(circle 34px at calc(100% + 10px) 50%, transparent 99%, #fff 100%)",
WebkitMask:
"radial-gradient(circle 34px at calc(100% + 10px) 50%, transparent 99%, #fff 100%)",
}),
}}
>
<UserAvatar
avatarUrl={participant.avatarUrl}
className="h-[60px] w-[60px] text-base"
displayName={participant.displayName}
size="md"
/>
</div>
))}
{hiddenCount > 0 ? (
<div
className={visibleParticipants.length > 0 ? "-ml-5" : ""}
data-testid="message-dm-intro-avatar-stack-more"
style={{ zIndex: stackItemCount }}
>
<span className="flex h-[60px] w-[60px] items-center justify-center rounded-full bg-secondary font-semibold text-secondary-foreground shadow-xs">
<span className="text-lg leading-none">+{hiddenCount}</span>
</span>
</div>
) : null}
</div>
);
}
2 changes: 1 addition & 1 deletion desktop/src/features/messages/ui/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -902,7 +902,7 @@ function MessageComposerImpl({
>
<div
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-5 bg-background"
className="absolute inset-x-0 bottom-0 h-5 bg-transparent"
/>
<div className="relative flex w-full flex-col gap-0">
<ComposerReplyEditBanner
Expand Down
Loading
Loading