mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): keep thread replies from scrolling channel (#1109)
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr
parent
dd9ce09020
commit
d24eac61a8
@@ -8,7 +8,10 @@ import {
|
||||
MessageThreadPanel,
|
||||
MessageThreadPanelSkeleton,
|
||||
} from "@/features/messages/ui/MessageThreadPanel";
|
||||
import { MessageTimeline } from "@/features/messages/ui/MessageTimeline";
|
||||
import {
|
||||
MessageTimeline,
|
||||
type MessageTimelineHandle,
|
||||
} from "@/features/messages/ui/MessageTimeline";
|
||||
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
|
||||
import { buildDirectMessageIntro } from "@/features/channels/lib/dmParticipantDisplay";
|
||||
import {
|
||||
@@ -239,6 +242,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
typingPubkeys,
|
||||
}: ChannelPaneProps) {
|
||||
const timelineScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const messageTimelineRef = React.useRef<MessageTimelineHandle>(null);
|
||||
const composerWrapperRef = React.useRef<HTMLDivElement>(null);
|
||||
const completedWelcomeBannerChannelIdsRef = React.useRef(new Set<string>());
|
||||
const welcomeComposerDismissTimerRef = React.useRef<number | null>(null);
|
||||
@@ -414,6 +418,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
(containsWelcomePersonaMention(content) ||
|
||||
mentionsKnownAgent(mentionPubkeys, knownAgentPubkeys));
|
||||
|
||||
messageTimelineRef.current?.scrollToBottomOnNextUpdate();
|
||||
await onSendMessage(content, mentionPubkeys, mediaTags);
|
||||
|
||||
if (shouldCompleteWelcomeBanner) {
|
||||
@@ -636,6 +641,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
</div>
|
||||
) : null}
|
||||
<MessageTimeline
|
||||
ref={messageTimelineRef}
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={activeChannel?.id}
|
||||
channelIntro={channelIntro}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
isNearBottomMetrics,
|
||||
resolveDeepLinkTarget,
|
||||
selectDeferredListRenderState,
|
||||
selectLatestMessageAutoScrollBehavior,
|
||||
selectLatestMessageKey,
|
||||
selectTimelineBodySurface,
|
||||
selectTimelineIntroSurface,
|
||||
@@ -103,6 +104,63 @@ test("selectLatestMessageKey: detects a newly arrived latest message", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("selectLatestMessageAutoScrollBehavior: keeps sticky timelines pinned automatically", () => {
|
||||
assert.equal(
|
||||
selectLatestMessageAutoScrollBehavior({
|
||||
hasExplicitBottomRequest: false,
|
||||
isAtBottom: false,
|
||||
shouldStickToBottom: true,
|
||||
targetMessageId: null,
|
||||
}),
|
||||
"auto",
|
||||
);
|
||||
assert.equal(
|
||||
selectLatestMessageAutoScrollBehavior({
|
||||
hasExplicitBottomRequest: false,
|
||||
isAtBottom: true,
|
||||
shouldStickToBottom: false,
|
||||
targetMessageId: null,
|
||||
}),
|
||||
"auto",
|
||||
);
|
||||
});
|
||||
|
||||
test("selectLatestMessageAutoScrollBehavior: explicit send requests smooth bottom scroll", () => {
|
||||
assert.equal(
|
||||
selectLatestMessageAutoScrollBehavior({
|
||||
hasExplicitBottomRequest: true,
|
||||
isAtBottom: false,
|
||||
shouldStickToBottom: false,
|
||||
targetMessageId: null,
|
||||
}),
|
||||
"smooth",
|
||||
);
|
||||
});
|
||||
|
||||
test("selectLatestMessageAutoScrollBehavior: self-authored inserts do not imply scroll", () => {
|
||||
assert.equal(
|
||||
selectLatestMessageAutoScrollBehavior({
|
||||
hasExplicitBottomRequest: false,
|
||||
isAtBottom: false,
|
||||
shouldStickToBottom: false,
|
||||
targetMessageId: null,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("selectLatestMessageAutoScrollBehavior: target navigation suppresses latest-message autoscroll", () => {
|
||||
assert.equal(
|
||||
selectLatestMessageAutoScrollBehavior({
|
||||
hasExplicitBottomRequest: true,
|
||||
isAtBottom: true,
|
||||
shouldStickToBottom: true,
|
||||
targetMessageId: "message-a",
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
// --- day dividers -------------------------------------------------------------
|
||||
|
||||
test("buildDayGroupBoundaries: empty snapshot has no groups", () => {
|
||||
|
||||
@@ -58,6 +58,34 @@ export function selectLatestMessageKey(
|
||||
return latest.renderKey ?? latest.id;
|
||||
}
|
||||
|
||||
export type LatestMessageAutoScrollBehavior = "auto" | "smooth" | null;
|
||||
|
||||
export function selectLatestMessageAutoScrollBehavior({
|
||||
hasExplicitBottomRequest,
|
||||
isAtBottom,
|
||||
shouldStickToBottom,
|
||||
targetMessageId,
|
||||
}: {
|
||||
hasExplicitBottomRequest: boolean;
|
||||
isAtBottom: boolean;
|
||||
shouldStickToBottom: boolean;
|
||||
targetMessageId?: string | null;
|
||||
}): LatestMessageAutoScrollBehavior {
|
||||
if (targetMessageId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hasExplicitBottomRequest) {
|
||||
return "smooth";
|
||||
}
|
||||
|
||||
if (shouldStickToBottom || isAtBottom) {
|
||||
return "auto";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A single day boundary in the timeline: where it starts and how many messages it covers. */
|
||||
export type DayGroupBoundary = {
|
||||
/** Stable key for the day section. */
|
||||
|
||||
@@ -20,6 +20,10 @@ import { TimelineMessageList } from "./TimelineMessageList";
|
||||
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
|
||||
import { useTimelineScrollManager } from "./useTimelineScrollManager";
|
||||
|
||||
export type MessageTimelineHandle = {
|
||||
scrollToBottomOnNextUpdate: () => void;
|
||||
};
|
||||
|
||||
type MessageTimelineProps = {
|
||||
agentPubkeys?: ReadonlySet<string>;
|
||||
channelId?: string | null;
|
||||
@@ -110,45 +114,51 @@ type DirectMessageIntroParticipant = {
|
||||
pubkey: string;
|
||||
};
|
||||
|
||||
export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
agentPubkeys,
|
||||
channelId,
|
||||
channelIntro = null,
|
||||
directMessageIntro = null,
|
||||
messages,
|
||||
isLoading = false,
|
||||
emptyTitle = "No messages yet",
|
||||
emptyDescription = "Send the first message to start the thread.",
|
||||
currentPubkey,
|
||||
fetchOlder,
|
||||
hasComposerOverlay = true,
|
||||
hasOlderMessages = true,
|
||||
isFetchingOlder = false,
|
||||
followThreadById,
|
||||
isFollowingThreadById,
|
||||
messageFooters,
|
||||
personaLookup,
|
||||
profiles,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onMarkUnread,
|
||||
onReply,
|
||||
channelName,
|
||||
channelType,
|
||||
isSendingVideoReviewComment = false,
|
||||
onSendVideoReviewComment,
|
||||
onToggleReaction,
|
||||
unfollowThreadById,
|
||||
scrollContainerRef: externalScrollRef,
|
||||
searchActiveMessageId = null,
|
||||
searchMatchingMessageIds,
|
||||
searchQuery,
|
||||
targetMessageId = null,
|
||||
onTargetReached,
|
||||
firstUnreadMessageId = null,
|
||||
unreadCount = 0,
|
||||
threadUnreadCounts,
|
||||
}: MessageTimelineProps) {
|
||||
const MessageTimelineBase = React.forwardRef<
|
||||
MessageTimelineHandle,
|
||||
MessageTimelineProps
|
||||
>(function MessageTimeline(
|
||||
{
|
||||
agentPubkeys,
|
||||
channelId,
|
||||
channelIntro = null,
|
||||
directMessageIntro = null,
|
||||
messages,
|
||||
isLoading = false,
|
||||
emptyTitle = "No messages yet",
|
||||
emptyDescription = "Send the first message to start the thread.",
|
||||
currentPubkey,
|
||||
fetchOlder,
|
||||
hasComposerOverlay = true,
|
||||
hasOlderMessages = true,
|
||||
isFetchingOlder = false,
|
||||
followThreadById,
|
||||
isFollowingThreadById,
|
||||
messageFooters,
|
||||
personaLookup,
|
||||
profiles,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onMarkUnread,
|
||||
onReply,
|
||||
channelName,
|
||||
channelType,
|
||||
isSendingVideoReviewComment = false,
|
||||
onSendVideoReviewComment,
|
||||
onToggleReaction,
|
||||
unfollowThreadById,
|
||||
scrollContainerRef: externalScrollRef,
|
||||
searchActiveMessageId = null,
|
||||
searchMatchingMessageIds,
|
||||
searchQuery,
|
||||
targetMessageId = null,
|
||||
onTargetReached,
|
||||
firstUnreadMessageId = null,
|
||||
unreadCount = 0,
|
||||
threadUnreadCounts,
|
||||
}: MessageTimelineProps,
|
||||
ref,
|
||||
) {
|
||||
const internalScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = externalScrollRef ?? internalScrollRef;
|
||||
const topSentinelRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -200,6 +210,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
newMessageCount,
|
||||
restoreScrollPosition,
|
||||
scrollToBottom,
|
||||
scrollToBottomOnNextUpdate,
|
||||
scrollToMessage,
|
||||
syncScrollState,
|
||||
} = useTimelineScrollManager({
|
||||
@@ -211,6 +222,14 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
targetMessageId,
|
||||
});
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
scrollToBottomOnNextUpdate,
|
||||
}),
|
||||
[scrollToBottomOnNextUpdate],
|
||||
);
|
||||
|
||||
// The unread pill is a transient, per-open affordance: dismiss it once the
|
||||
// user acts on it (jumps to the oldest unread) or catches up by reaching the
|
||||
// bottom of the timeline. Reset when the channel changes so a freshly opened
|
||||
@@ -540,6 +559,8 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
);
|
||||
});
|
||||
|
||||
export const MessageTimeline = React.memo(MessageTimelineBase);
|
||||
|
||||
function DirectMessageIntroAvatarStack({
|
||||
participants,
|
||||
}: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as React from "react";
|
||||
import {
|
||||
isNearBottom,
|
||||
resolveDeepLinkTarget,
|
||||
selectLatestMessageAutoScrollBehavior,
|
||||
selectLatestMessageKey,
|
||||
} from "@/features/messages/lib/timelineSnapshot";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
@@ -41,6 +42,7 @@ export function useTimelineScrollManager({
|
||||
const previousLastMessageKeyRef = React.useRef<string | undefined>(undefined);
|
||||
const previousMessageCountRef = React.useRef(0);
|
||||
const handledTargetMessageIdRef = React.useRef<string | null>(null);
|
||||
const scrollToBottomOnNextUpdateRef = React.useRef(false);
|
||||
// Mirror isLoading into a ref so the ResizeObservers (which subscribe once)
|
||||
// can skip reacting while the skeleton is up — reacting to height churn under
|
||||
// a streaming-in list is what makes the timeline thrash on entry.
|
||||
@@ -63,6 +65,7 @@ export function useTimelineScrollManager({
|
||||
previousLastMessageKeyRef.current = undefined;
|
||||
previousMessageCountRef.current = 0;
|
||||
handledTargetMessageIdRef.current = null;
|
||||
scrollToBottomOnNextUpdateRef.current = false;
|
||||
setIsAtBottom(true);
|
||||
setHighlightedMessageId(null);
|
||||
setNewMessageCount(0);
|
||||
@@ -108,6 +111,10 @@ export function useTimelineScrollManager({
|
||||
messages.length > 0 ? messages[messages.length - 1] : undefined;
|
||||
const latestMessageKey = selectLatestMessageKey(messages);
|
||||
|
||||
const scrollToBottomOnNextUpdate = React.useCallback(() => {
|
||||
scrollToBottomOnNextUpdateRef.current = true;
|
||||
}, []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref passed from the parent — its identity never changes
|
||||
const syncScrollState = React.useCallback(() => {
|
||||
const timeline = timelineRef.current;
|
||||
@@ -330,13 +337,19 @@ export function useTimelineScrollManager({
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!targetMessageId &&
|
||||
(shouldStickToBottomRef.current ||
|
||||
isAtBottomRef.current ||
|
||||
latestMessage.accent)
|
||||
) {
|
||||
scrollToBottom(latestMessage.accent ? "smooth" : "auto");
|
||||
const shouldHonorExplicitBottomRequest =
|
||||
scrollToBottomOnNextUpdateRef.current;
|
||||
scrollToBottomOnNextUpdateRef.current = false;
|
||||
|
||||
const autoScrollBehavior = selectLatestMessageAutoScrollBehavior({
|
||||
hasExplicitBottomRequest: shouldHonorExplicitBottomRequest,
|
||||
isAtBottom: isAtBottomRef.current,
|
||||
shouldStickToBottom: shouldStickToBottomRef.current,
|
||||
targetMessageId,
|
||||
});
|
||||
|
||||
if (autoScrollBehavior) {
|
||||
scrollToBottom(autoScrollBehavior);
|
||||
} else {
|
||||
setNewMessageCount((current) => {
|
||||
const addedMessages = Math.max(
|
||||
@@ -444,6 +457,7 @@ export function useTimelineScrollManager({
|
||||
newMessageCount,
|
||||
restoreScrollPosition,
|
||||
scrollToBottom,
|
||||
scrollToBottomOnNextUpdate,
|
||||
scrollToMessage,
|
||||
syncScrollState,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user