fix(desktop): classify timeline prepends so history loads don't bump unread (#1416)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-02 08:49:06 -06:00
committed by GitHub
parent b779a3ee2f
commit 9967b97f59
3 changed files with 76 additions and 6 deletions

View File

@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
classifyTimelineMessageDelta,
BOTTOM_THRESHOLD_PX,
buildDayGroupBoundaries,
isDeferredTimelineSnapshotStale,
@@ -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

View File

@@ -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;
};

View File

@@ -1,5 +1,6 @@
import * as React from "react";
import { classifyTimelineMessageDelta } from "@/features/messages/lib/timelineSnapshot";
import type { TimelineMessage } from "@/features/messages/types";
/**
@@ -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
@@ -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;
@@ -363,6 +366,7 @@ export function useAnchoredScroll({
prevLastMessageIdRef.current = messages[messages.length - 1]?.id;
prevFirstMessageIdRef.current = messages[0]?.id;
prevMessageCountRef.current = messages.length;
prevMessagesRef.current = messages;
return;
}
@@ -370,7 +374,6 @@ export function useAnchoredScroll({
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;
@@ -378,12 +381,13 @@ export function useAnchoredScroll({
// 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,
@@ -399,6 +403,7 @@ export function useAnchoredScroll({
prevLastMessageIdRef.current = lastMessage?.id;
prevFirstMessageIdRef.current = firstMessage?.id;
prevMessageCountRef.current = messages.length;
prevMessagesRef.current = messages;
return;
}
@@ -436,6 +441,7 @@ export function useAnchoredScroll({
prevLastMessageIdRef.current = lastMessage?.id;
prevFirstMessageIdRef.current = firstMessage?.id;
prevMessageCountRef.current = messages.length;
prevMessagesRef.current = messages;
}, [
isLoading,
messages,