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
24 changes: 24 additions & 0 deletions desktop/src/features/messages/lib/timelineSnapshot.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";

import {
classifyTimelineMessageDelta,
BOTTOM_THRESHOLD_PX,
buildDayGroupBoundaries,
isDeferredTimelineSnapshotStale,
Expand Down Expand Up @@ -339,6 +340,29 @@ test("no-tearing: stale snapshot keeps all three decisions internally consistent
assert.equal(latestKey, "b");
});

test("classifyTimelineMessageDelta: detects older-history prepends", () => {
const previous = [message({ id: "a" }), message({ id: "b" })];
const current = [message({ id: "older" }), ...previous];

assert.equal(classifyTimelineMessageDelta({ current, previous }), "prepend");
});

test("classifyTimelineMessageDelta: detects latest-message appends", () => {
const previous = [message({ id: "a" }), message({ id: "b" })];
const current = [...previous, message({ id: "c" })];

assert.equal(classifyTimelineMessageDelta({ current, previous }), "append");
});

test("classifyTimelineMessageDelta: unchanged snapshots do not count as arrivals", () => {
const previous = [message({ id: "a" }), message({ id: "b" })];

assert.equal(
classifyTimelineMessageDelta({ current: previous, previous }),
"none",
);
});

// --- deferred reply-list render state (thread side pane) --------------------
//
// When MessageThreadPanel gates its reply render behind useDeferredValue, the
Expand Down
40 changes: 40 additions & 0 deletions desktop/src/features/messages/lib/timelineSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,46 @@ export function selectTimelineBodySurface({
return renderState;
}

export type TimelineMessageDelta = "prepend" | "append" | "replace" | "none";

export function classifyTimelineMessageDelta({
current,
previous,
}: {
current: readonly Pick<TimelineMessage, "id">[];
previous: readonly Pick<TimelineMessage, "id">[];
}): TimelineMessageDelta {
if (previous.length === 0 || current.length === 0) {
return previous.length === current.length ? "none" : "replace";
}

const previousFirstId = previous[0]?.id;
const previousLastId = previous[previous.length - 1]?.id;
const currentFirstId = current[0]?.id;
const currentLastId = current[current.length - 1]?.id;

if (previousFirstId === currentFirstId && previousLastId === currentLastId) {
if (previous.length === current.length) {
return "none";
}
return current.length > previous.length ? "append" : "replace";
}

if (
previousFirstId !== undefined &&
currentFirstId !== previousFirstId &&
current.some((message) => message.id === previousFirstId)
) {
return "prepend";
}

if (previousLastId !== undefined && currentLastId !== previousLastId) {
return "append";
}

return "replace";
}

export type TimelineSnapshotIdentity = {
channelId: string | null;
};
Expand Down
18 changes: 12 additions & 6 deletions desktop/src/features/messages/ui/useAnchoredScroll.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from "react";

import { classifyTimelineMessageDelta } from "@/features/messages/lib/timelineSnapshot";
import type { TimelineMessage } from "@/features/messages/types";

/**
Expand Down Expand Up @@ -165,6 +166,7 @@ export function useAnchoredScroll({
const prevLastMessageIdRef = React.useRef<string | undefined>(undefined);
const prevFirstMessageIdRef = React.useRef<string | undefined>(undefined);
const prevMessageCountRef = React.useRef(0);
const prevMessagesRef = React.useRef<TimelineMessage[]>([]);
const handledTargetIdRef = React.useRef<string | null>(null);
const highlightTimeoutRef = React.useRef<number | null>(null);
// Tracks a pending rAF queued by pinToBottomOnMount so it can be cancelled
Expand Down Expand Up @@ -194,6 +196,7 @@ export function useAnchoredScroll({
prevLastMessageIdRef.current = undefined;
prevFirstMessageIdRef.current = undefined;
prevMessageCountRef.current = 0;
prevMessagesRef.current = [];
handledTargetIdRef.current = null;
forceBottomOnNextAppendRef.current = false;
settlingRef.current = false;
Expand Down Expand Up @@ -363,27 +366,28 @@ export function useAnchoredScroll({
prevLastMessageIdRef.current = messages[messages.length - 1]?.id;
prevFirstMessageIdRef.current = messages[0]?.id;
prevMessageCountRef.current = messages.length;
prevMessagesRef.current = messages;
return;
}

const anchor = anchorRef.current;
const lastMessage = messages[messages.length - 1];
const firstMessage = messages[0];
const prevLastId = prevLastMessageIdRef.current;
const prevFirstId = prevFirstMessageIdRef.current;
const prevCount = prevMessageCountRef.current;
const newLatestArrived =
lastMessage !== undefined && lastMessage.id !== prevLastId;
// Count growth, not tail-id change, is the reliable "messages arrived"
// signal. The relay can deliver a message that sorts ahead of an existing
// same-second row, so the list grows without the *last* id changing —
// `newLatestArrived` misses that case and the unread counter never bumps.
const prevMessages = prevMessagesRef.current;
const messagesArrived = messages.length - prevCount;
const frontChanged =
firstMessage !== undefined &&
prevFirstId !== undefined &&
firstMessage.id !== prevFirstId;
const isPrepend = frontChanged && !newLatestArrived;
const isPrepend =
classifyTimelineMessageDelta({
current: messages,
previous: prevMessages,
}) === "prepend";

// One-shot: an outbound send armed `scrollToBottomOnNextUpdate`. When the
// resulting append lands, snap to bottom regardless of the current anchor,
Expand All @@ -399,6 +403,7 @@ export function useAnchoredScroll({
prevLastMessageIdRef.current = lastMessage?.id;
prevFirstMessageIdRef.current = firstMessage?.id;
prevMessageCountRef.current = messages.length;
prevMessagesRef.current = messages;
return;
}

Expand Down Expand Up @@ -436,6 +441,7 @@ export function useAnchoredScroll({
prevLastMessageIdRef.current = lastMessage?.id;
prevFirstMessageIdRef.current = firstMessage?.id;
prevMessageCountRef.current = messages.length;
prevMessagesRef.current = messages;
}, [
isLoading,
messages,
Expand Down
Loading