Skip to content
Open
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: 0 additions & 2 deletions desktop/src/features/messages/ui/MessageTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,6 @@ export const MessageTimeline = React.memo(function MessageTimeline({
highlightedMessageId,
isAtBottom,
newMessageCount,
restoreScrollPosition,
scrollToBottom,
syncScrollState,
} = useTimelineScrollManager({
Expand Down Expand Up @@ -177,7 +176,6 @@ export const MessageTimeline = React.memo(function MessageTimeline({
fetchOlder,
hasOlderMessages,
isLoading,
restoreScrollPosition,
scrollContainerRef,
sentinelRef: topSentinelRef,
});
Expand Down
61 changes: 43 additions & 18 deletions desktop/src/features/messages/ui/useLoadOlderOnScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ type UseLoadOlderOnScrollOptions = {
fetchOlder?: () => Promise<void>;
hasOlderMessages: boolean;
isLoading: boolean;
restoreScrollPosition: (scrollTop: number) => void;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
sentinelRef: React.RefObject<HTMLDivElement | null>;
};
Expand All @@ -18,13 +17,39 @@ export function useLoadOlderOnScroll({
fetchOlder,
hasOlderMessages,
isLoading,
restoreScrollPosition,
scrollContainerRef,
sentinelRef,
}: UseLoadOlderOnScrollOptions) {
const restoreScrollPositionRef = React.useRef(restoreScrollPosition);
React.useEffect(() => {
restoreScrollPositionRef.current = restoreScrollPosition;
const [, scheduleRestore] = React.useReducer((count: number) => count + 1, 0);
const pendingRestoreRef = React.useRef<{
messageId: string;
top: number;
} | null>(null);

React.useLayoutEffect(() => {
const pendingRestore = pendingRestoreRef.current;
const container = scrollContainerRef.current;
if (!pendingRestore || !container) {
return;
}

pendingRestoreRef.current = null;
const anchor = container.querySelector<HTMLElement>(
`[data-message-id="${pendingRestore.messageId}"]`,
);
if (!anchor) {
return;
}

const delta = anchor.getBoundingClientRect().top - pendingRestore.top;
if (delta !== 0) {
// Single synchronous pre-paint write. We deliberately do NOT route this
// through useTimelineScrollManager.restoreScrollPosition: that helper
// schedules a 2-rAF locked-write loop (correct for ResizeObserver-driven
// resizes that may settle across frames, wrong for prepend), which
// fights live wheel input for 2–3 frames after every fetchOlder.
container.scrollTop = container.scrollTop + delta;
}
});

React.useEffect(() => {
Expand Down Expand Up @@ -56,20 +81,20 @@ export function useLoadOlderOnScroll({

currentObserver?.disconnect();

const previousHeight = container.scrollHeight;
const previousScrollTop = container.scrollTop;
void fetchOlder().then(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const newHeight = container.scrollHeight;
const delta = newHeight - previousHeight;
if (delta > 0) {
restoreScrollPositionRef.current(previousScrollTop + delta);
}
observe();
});
const anchor =
container.querySelector<HTMLElement>("[data-message-id]");
const messageId = anchor?.dataset.messageId;
const top = anchor?.getBoundingClientRect().top;
void fetchOlder()
.then(() => {
if (messageId && top !== undefined) {
pendingRestoreRef.current = { messageId, top };
scheduleRestore();
}
})
.finally(() => {
observe();
});
});
},
{ root: container, rootMargin: "200px 0px 0px 0px" },
);
Expand Down
69 changes: 61 additions & 8 deletions desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,9 @@ type MockFilter = {
"#h"?: string[];
authors?: string[];
kinds?: number[];
limit?: number;
since?: number;
until?: number;
};

type MockSocket = {
Expand Down Expand Up @@ -574,6 +577,9 @@ declare global {
kind?: number;
mentionPubkeys?: string[];
extraTags?: string[][];
createdAt?: number;
id?: string;
emitLive?: boolean;
}) => RelayEvent;
__BUZZ_E2E_EMIT_MOCK_TYPING__?: (input: {
channelName: string;
Expand Down Expand Up @@ -2163,9 +2169,29 @@ function getMockMessageStore(channelId: string): RelayEvent[] {
return seeded;
}

function emitMockHistory(socket: MockSocket, subId: string, channelId: string) {
const events = getMockMessageStore(channelId);
for (const event of events) {
function filterMockHistory(channelId: string, filter: MockFilter) {
return getMockMessageStore(channelId)
.filter((event) =>
filter.kinds ? filter.kinds.includes(event.kind) : true,
)
.filter((event) =>
filter.since !== undefined ? event.created_at >= filter.since : true,
)
.filter((event) =>
filter.until !== undefined ? event.created_at <= filter.until : true,
)
.sort((left, right) => right.created_at - left.created_at)
.slice(0, filter.limit ?? 50)
.sort((left, right) => left.created_at - right.created_at);
}

function emitMockHistory(
socket: MockSocket,
subId: string,
channelId: string,
filter: MockFilter,
) {
for (const event of filterMockHistory(channelId, filter)) {
sendWsText(socket.handler, ["EVENT", subId, event]);
}
sendWsText(socket.handler, ["EOSE", subId]);
Expand Down Expand Up @@ -2275,6 +2301,9 @@ function emitMockChannelMessage(
kind?: number,
mentionPubkeys?: string[],
extraTags?: string[][],
createdAt?: number,
id?: string,
emitLive = true,
) {
const eventKind = kind ?? 9;
if (!parentEventId) {
Expand All @@ -2284,9 +2313,18 @@ function emitMockChannelMessage(
pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey,
);
if (extraTags) tags.push(...extraTags);
const event = createMockEvent(eventKind, content, tags, pubkey);
const event = createMockEvent(
eventKind,
content,
tags,
pubkey,
createdAt,
id,
);
recordMockMessage(channelId, event);
emitMockLiveEvent(channelId, event);
if (emitLive) {
emitMockLiveEvent(channelId, event);
}
return event;
}

Expand All @@ -2309,9 +2347,18 @@ function emitMockChannelMessage(
mentionPubkeys,
);
if (extraTags) tags.push(...extraTags);
const event = createMockEvent(eventKind, content, tags, authorPubkey);
const event = createMockEvent(
eventKind,
content,
tags,
authorPubkey,
createdAt,
id,
);
recordMockMessage(channelId, event);
emitMockLiveEvent(channelId, event);
if (emitLive) {
emitMockLiveEvent(channelId, event);
}
return event;
}

Expand Down Expand Up @@ -5644,7 +5691,7 @@ function sendToMockSocket(args: {
return;
}

emitMockHistory(socket, subId, channelId);
emitMockHistory(socket, subId, channelId, filter);
return;
}

Expand Down Expand Up @@ -5785,6 +5832,9 @@ export function maybeInstallE2eTauriMocks() {
kind,
mentionPubkeys,
extraTags,
createdAt,
id,
emitLive = true,
}) => {
const channel = mockChannels.find(
(candidate) => candidate.name === channelName,
Expand All @@ -5801,6 +5851,9 @@ export function maybeInstallE2eTauriMocks() {
kind,
mentionPubkeys,
extraTags,
createdAt,
id,
emitLive,
);
};
window.__BUZZ_E2E_EMIT_MOCK_TYPING__ = ({ channelName, pubkey }) => {
Expand Down
109 changes: 109 additions & 0 deletions desktop/tests/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,115 @@ test("supports multiline drafts with Ctrl+Enter and sends with Enter", async ({
);
});

test("keeps viewport anchored when older messages load above", async ({
page,
}) => {
const baseTimestamp = Math.floor(Date.now() / 1000) - 10_000;

await page.goto("/");
await page.evaluate((base) => {
const emit = (
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
createdAt?: number;
emitLive?: boolean;
id?: string;
}) => void;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__;

if (!emit) {
throw new Error("Mock message emitter is unavailable.");
}

for (let index = 0; index < 260; index += 1) {
emit({
channelName: "general",
content: `Paged history seed ${index.toString().padStart(3, "0")}`,
createdAt: base + index,
emitLive: false,
id: `paged-history-${index.toString().padStart(3, "0")}`,
});
}
}, baseTimestamp);

await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByText("Paged history seed 259")).toBeVisible();

const timeline = page.getByTestId("message-timeline");
await expect(timeline).not.toContainText("Paged history seed 000");
const flickerMaxDelta = await timeline.evaluate(async (element) => {
const scrollContainer = element as HTMLDivElement;
const anchor = scrollContainer.querySelector<HTMLElement>(
'[data-message-id="paged-history-160"]',
);
if (!anchor) {
throw new Error("Oldest loaded message was not rendered.");
}

scrollContainer.scrollTop = 0;
scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));

const startTop = anchor.getBoundingClientRect().top;
let maxDelta = 0;
let pendingFrame = false;
const sampleAnchor = () => {
pendingFrame = false;
const currentAnchor = scrollContainer.querySelector<HTMLElement>(
'[data-message-id="paged-history-160"]',
);
if (!currentAnchor) {
return;
}
const top = currentAnchor.getBoundingClientRect().top;
maxDelta = Math.max(maxDelta, Math.abs(top - startTop));
};
const observer = new MutationObserver(() => {
if (pendingFrame) {
return;
}
pendingFrame = true;
requestAnimationFrame(sampleAnchor);
});
observer.observe(scrollContainer, { childList: true, subtree: true });

await new Promise<void>((resolve) => {
const deadline = performance.now() + 1_000;
const waitForOlderHistory = () => {
if (
scrollContainer.textContent?.includes("Paged history seed 000") ||
performance.now() >= deadline
) {
resolve();
return;
}
requestAnimationFrame(waitForOlderHistory);
};
requestAnimationFrame(waitForOlderHistory);
});
await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
);
await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
);
observer.disconnect();

return {
maxDelta,
scrollTop: scrollContainer.scrollTop,
};
});

await expect(timeline).toContainText("Paged history seed 000");

expect(flickerMaxDelta.scrollTop).toBeGreaterThan(0);
expect(flickerMaxDelta.maxDelta).toBeLessThanOrEqual(2);
});

test("does not shift the timeline when the composer grows", async ({
page,
}) => {
Expand Down