Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md), [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
- Backup HTML export has a bounded implementation for selected backup chats, including media resolved through `Manifest.db`; it is enabled in the desktop UI after real local backup export smoke.
- The desktop source screen separates the available WhatsApp export ZIP viewer from iPhone-backup proof work.
- The desktop search field filters WhatsApp export ZIP messages through shared frontend domain helpers, searches iPhone-backup chat names through a bounded backend query, and searches selected iPhone-backup chats through a bounded backend query for latest matching messages.
- The desktop timeline renders a bounded recent-message window with a tested "show earlier" path for already-loaded chats, while source importers avoid returning unbounded message vectors for huge backup or ZIP histories.
- The desktop timeline renders a bounded recent-message window with a tested "show earlier" path and DOM virtualization for already-loaded chats, while source importers avoid returning unbounded message vectors for huge backup or ZIP histories.
- The public synthetic demo renders a safe inline image preview and image-preview modal without using private media files.
- The README links to a committed synthetic MP4 generated through the Playwright plus `playwright-recast` workflow.
- The desktop visual suite covers keyboard focus visibility, accessible names for icon-only controls, contrast floors for core text, and removal of fake or unsupported action chrome.
Expand Down
134 changes: 119 additions & 15 deletions apps/desktop/src/components/ConversationView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ import { Download, File, X } from "lucide-react";
import {
type ChangeEvent,
type FormEvent,
memo,
type RefObject,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
Expand All @@ -18,6 +22,10 @@ import {
} from "../domain/chat";
import { attachmentRenderKind, canRequestAttachmentPreview } from "../domain/media";
import { DEFAULT_SOURCE_KIND, sourceProfile } from "../domain/source";
import {
createTrailingVirtualTimelineWindow,
createVirtualTimelineWindow,
} from "../domain/virtualTimeline";
import type {
Attachment,
AttachmentPreview,
Expand Down Expand Up @@ -199,17 +207,13 @@ export function ConversationView({
</button>
) : null}
{visibleMessages.length > 0 ? (
visibleMessages.map((message) => (
<MessageBubble
key={message.id}
message={message}
source={source}
onOpenImagePreview={setImagePreview}
attachments={message.attachment_ids
.map((id) => attachmentMap.get(id))
.filter((attachment): attachment is Attachment => Boolean(attachment))}
/>
))
<VirtualizedMessageTimeline
messages={visibleMessages}
source={source}
attachmentMap={attachmentMap}
scrollParentRef={messageCanvasRef}
onOpenImagePreview={setImagePreview}
/>
) : (
<div className="no-results">
{hasActiveFilters ? "No messages match these filters." : "No messages to show."}
Expand All @@ -223,18 +227,118 @@ export function ConversationView({
);
}

function MessageBubble({
function VirtualizedMessageTimeline({
messages,
source,
attachmentMap,
scrollParentRef,
onOpenImagePreview,
}: {
messages: Message[];
source: LoadedChatSource | null;
attachmentMap: Map<string, Attachment>;
scrollParentRef: RefObject<HTMLDivElement | null>;
onOpenImagePreview: (preview: { dataUrl: string; alt: string; caption: string }) => void;
}) {
const listRef = useRef<HTMLDivElement>(null);
const [virtualWindow, setVirtualWindow] = useState(() =>
createTrailingVirtualTimelineWindow(messages.length),
);

const updateVirtualWindow = useCallback(() => {
const scrollParent = scrollParentRef.current;
const list = listRef.current;
if (!scrollParent || !list) {
setVirtualWindow(createTrailingVirtualTimelineWindow(messages.length));
return;
}

setVirtualWindow(createVirtualTimelineWindow({
itemCount: messages.length,
scrollTop: scrollParent.scrollTop,
viewportHeight: scrollParent.clientHeight,
listTop: list.offsetTop,
}));
}, [messages.length, scrollParentRef]);

useLayoutEffect(() => {
updateVirtualWindow();
}, [updateVirtualWindow]);

useEffect(() => {
const scrollParent = scrollParentRef.current;
if (!scrollParent) {
return;
}

let animationFrame = 0;
const scheduleUpdate = () => {
cancelAnimationFrame(animationFrame);
animationFrame = requestAnimationFrame(updateVirtualWindow);
};

scrollParent.addEventListener("scroll", scheduleUpdate, { passive: true });
window.addEventListener("resize", scheduleUpdate);
scheduleUpdate();

return () => {
cancelAnimationFrame(animationFrame);
scrollParent.removeEventListener("scroll", scheduleUpdate);
window.removeEventListener("resize", scheduleUpdate);
};
}, [scrollParentRef, updateVirtualWindow]);

const renderedMessages = useMemo(
() => messages.slice(virtualWindow.startIndex, virtualWindow.endIndex),
[messages, virtualWindow.endIndex, virtualWindow.startIndex],
);

return (
<div
className="virtual-message-list"
ref={listRef}
data-testid={TEST_IDS.virtualMessageList}
data-total-messages={messages.length}
data-rendered-messages={virtualWindow.renderedCount}
>
<div
className="virtual-message-spacer"
style={{ height: virtualWindow.beforeHeight }}
aria-hidden="true"
/>
{renderedMessages.map((message) => (
<MessageBubble
key={message.id}
message={message}
source={source}
onOpenImagePreview={onOpenImagePreview}
attachmentMap={attachmentMap}
/>
))}
<div
className="virtual-message-spacer"
style={{ height: virtualWindow.afterHeight }}
aria-hidden="true"
/>
</div>
);
}

const MessageBubble = memo(function MessageBubble({
message,
source,
attachments,
attachmentMap,
onOpenImagePreview,
}: {
message: Message;
source: LoadedChatSource | null;
attachments: Attachment[];
attachmentMap: Map<string, Attachment>;
onOpenImagePreview: (preview: { dataUrl: string; alt: string; caption: string }) => void;
}) {
const outgoing = isOutgoingMessage(message);
const attachments = message.attachment_ids
.map((id) => attachmentMap.get(id))
.filter((attachment): attachment is Attachment => Boolean(attachment));

return (
<article
Expand Down Expand Up @@ -262,7 +366,7 @@ function MessageBubble({
</div>
</article>
);
}
});

function AttachmentBlock({
attachment,
Expand Down
70 changes: 70 additions & 0 deletions apps/desktop/src/domain/virtualTimeline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";

import {
createTrailingVirtualTimelineWindow,
createVirtualTimelineWindow,
} from "./virtualTimeline";

describe("virtual timeline windowing", () => {
it("returns an empty window for empty timelines", () => {
expect(createVirtualTimelineWindow({
itemCount: 0,
scrollTop: 0,
viewportHeight: 720,
})).toEqual({
startIndex: 0,
endIndex: 0,
beforeHeight: 0,
afterHeight: 0,
totalHeight: 0,
renderedCount: 0,
});
});

it("renders all rows when the timeline is smaller than the minimum window", () => {
expect(createVirtualTimelineWindow({
itemCount: 12,
scrollTop: 0,
viewportHeight: 720,
estimatedItemHeight: 80,
minimumRenderedItems: 36,
})).toMatchObject({
startIndex: 0,
endIndex: 12,
renderedCount: 12,
beforeHeight: 0,
afterHeight: 0,
});
});

it("keeps only a bounded row window around the current scroll position", () => {
const window = createVirtualTimelineWindow({
itemCount: 5_000,
scrollTop: 120_000,
viewportHeight: 900,
estimatedItemHeight: 75,
overscanItems: 10,
minimumRenderedItems: 40,
});

expect(window.renderedCount).toBeGreaterThanOrEqual(40);
expect(window.renderedCount).toBeLessThan(80);
expect(window.startIndex).toBeGreaterThan(1_500);
expect(window.endIndex).toBeLessThan(1_700);
expect(window.beforeHeight).toBe(window.startIndex * 75);
expect(window.afterHeight).toBe((5_000 - window.endIndex) * 75);
});

it("starts at the latest rows for the initial bottom-anchored render", () => {
const window = createTrailingVirtualTimelineWindow(900, {
estimatedItemHeight: 72,
overscanItems: 18,
minimumRenderedItems: 36,
});

expect(window.renderedCount).toBe(36);
expect(window.startIndex).toBe(864);
expect(window.endIndex).toBe(900);
expect(window.afterHeight).toBe(0);
});
});
Loading
Loading