mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Fix task channel smoke regressions
This commit is contained in:
@@ -812,6 +812,8 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
followThreadById={followThreadById}
|
||||
hasComposerOverlay={hasMainComposerOverlay}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
huddleMemberPubkeys={huddleMemberPubkeys}
|
||||
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
|
||||
isFetchingOlder={isFetchingOlder}
|
||||
isFollowingThreadById={isFollowingThreadById}
|
||||
isMessageUnreadById={isMessageUnreadById}
|
||||
|
||||
@@ -364,14 +364,19 @@ export function ChannelScreen({
|
||||
}
|
||||
return pubkeys;
|
||||
}, [channelMembers, managedAgents, messageProfilesQuery.data, relayAgents]);
|
||||
const agentLookupPending =
|
||||
channelMembersQuery.isPending ||
|
||||
channelMembersQuery.isFetching ||
|
||||
managedAgentsQuery.isPending ||
|
||||
managedAgentsQuery.isFetching ||
|
||||
relayAgentsQuery.isPending ||
|
||||
relayAgentsQuery.isFetching ||
|
||||
(messageProfilePubkeys.length > 0 &&
|
||||
(messageProfilesQuery.isPending ||
|
||||
messageProfilesQuery.isFetching ||
|
||||
messageProfilesQuery.isPlaceholderData));
|
||||
const agentPubkeysPending =
|
||||
activeChannel?.channelType === "dm" &&
|
||||
(channelMembersQuery.isPending ||
|
||||
managedAgentsQuery.isPending ||
|
||||
relayAgentsQuery.isPending ||
|
||||
(messageProfilePubkeys.length > 0 &&
|
||||
(messageProfilesQuery.isPending ||
|
||||
messageProfilesQuery.isPlaceholderData)));
|
||||
activeChannel?.channelType === "dm" && agentLookupPending;
|
||||
const {
|
||||
agentSessionCandidates,
|
||||
botTypingEntries,
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
export const WAVE_MESSAGE_MARKER = "<!-- buzz:wave:v1 -->";
|
||||
const WAVE_TARGET_PREFIX = "<!-- buzz:wave-target:";
|
||||
const WAVE_TARGET_SUFFIX = "-->";
|
||||
const WAVE_TARGET_AGENT_LINE = "<!-- buzz:wave-target-agent:1 -->";
|
||||
|
||||
export type WaveMessageContent = {
|
||||
fallbackText: string;
|
||||
targetPubkey?: string;
|
||||
targetIsAgent?: boolean;
|
||||
};
|
||||
|
||||
export function buildWaveMessageContent(senderName: string): string {
|
||||
export function buildWaveMessageContent(
|
||||
senderName: string,
|
||||
targetPubkey?: string,
|
||||
options?: { targetIsAgent?: boolean },
|
||||
): string {
|
||||
const trimmedName = senderName.trim() || "Someone";
|
||||
return `${WAVE_MESSAGE_MARKER}\n${trimmedName} waved at you.`;
|
||||
const targetLine = targetPubkey
|
||||
? `\n${WAVE_TARGET_PREFIX}${targetPubkey.toLowerCase()}${WAVE_TARGET_SUFFIX}`
|
||||
: "";
|
||||
const targetAgentLine = options?.targetIsAgent
|
||||
? `\n${WAVE_TARGET_AGENT_LINE}`
|
||||
: "";
|
||||
return `${WAVE_MESSAGE_MARKER}${targetLine}${targetAgentLine}\n${trimmedName} waved at you.`;
|
||||
}
|
||||
|
||||
export function parseWaveMessageContent(
|
||||
@@ -18,9 +33,27 @@ export function parseWaveMessageContent(
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackText = trimmedContent.slice(WAVE_MESSAGE_MARKER.length).trim();
|
||||
let body = trimmedContent.slice(WAVE_MESSAGE_MARKER.length).trim();
|
||||
let targetPubkey: string | undefined;
|
||||
let targetIsAgent = false;
|
||||
if (body.startsWith(WAVE_TARGET_PREFIX)) {
|
||||
const suffixIndex = body.indexOf(WAVE_TARGET_SUFFIX);
|
||||
if (suffixIndex >= 0) {
|
||||
targetPubkey = body
|
||||
.slice(WAVE_TARGET_PREFIX.length, suffixIndex)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
body = body.slice(suffixIndex + WAVE_TARGET_SUFFIX.length).trim();
|
||||
}
|
||||
}
|
||||
if (body.startsWith(WAVE_TARGET_AGENT_LINE)) {
|
||||
targetIsAgent = true;
|
||||
body = body.slice(WAVE_TARGET_AGENT_LINE.length).trim();
|
||||
}
|
||||
|
||||
return {
|
||||
fallbackText: fallbackText || "Someone waved at you.",
|
||||
fallbackText: body || "Someone waved at you.",
|
||||
targetPubkey,
|
||||
targetIsAgent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -383,10 +383,13 @@ export const MessageRow = React.memo(
|
||||
if (waveMessage) {
|
||||
return (
|
||||
<WaveMessageAttachment
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
fallbackText={waveMessage.fallbackText}
|
||||
huddleMemberPubkeys={huddleMemberPubkeys}
|
||||
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
|
||||
targetIsAgent={waveMessage.targetIsAgent}
|
||||
targetPubkey={waveMessage.targetPubkey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -249,6 +249,9 @@ const MessageTimelineBase = React.forwardRef<
|
||||
liveCount: messages.length,
|
||||
});
|
||||
const showTimelineSkeleton = timelineBodySurface === "skeleton";
|
||||
const isHistoryPrependPending =
|
||||
isFetchingOlder ||
|
||||
isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages);
|
||||
|
||||
const {
|
||||
highlightedMessageId,
|
||||
@@ -262,6 +265,7 @@ const MessageTimelineBase = React.forwardRef<
|
||||
channelId,
|
||||
contentRef,
|
||||
isLoading: showTimelineSkeleton,
|
||||
isHistoryPrependPending,
|
||||
messages: deferredMessages,
|
||||
onTargetReached,
|
||||
resetKey: scrollIdentityKey,
|
||||
@@ -273,7 +277,7 @@ const MessageTimelineBase = React.forwardRef<
|
||||
hasChannelIntro: channelIntro !== null && directMessageIntro === null,
|
||||
hasDirectMessageIntro: directMessageIntro !== null,
|
||||
hasReachedChannelStart:
|
||||
!isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages) &&
|
||||
!isHistoryPrependPending &&
|
||||
(messages.length === 0 || (!hasOlderMessages && !isFetchingOlder)),
|
||||
isSkeletonVisible: showTimelineSkeleton,
|
||||
});
|
||||
@@ -411,8 +415,7 @@ const MessageTimelineBase = React.forwardRef<
|
||||
) : null}
|
||||
{/* `isFetchingOlder` clears on fetch resolve, but rows paint a frame
|
||||
later off the deferred snapshot — keep the spinner up until then. */}
|
||||
{isFetchingOlder ||
|
||||
isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages) ? (
|
||||
{isHistoryPrependPending ? (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 z-20 flex translate-y-3 justify-center px-4",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { toast } from "sonner";
|
||||
|
||||
import { channelsQueryKey } from "@/features/channels/hooks";
|
||||
import { useHuddle } from "@/features/huddle";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentAction,
|
||||
@@ -17,6 +18,9 @@ import {
|
||||
type WaveMessageAttachmentProps = {
|
||||
channelId?: string | null;
|
||||
fallbackText: string;
|
||||
targetPubkey?: string;
|
||||
targetIsAgent?: boolean;
|
||||
agentPubkeys?: ReadonlySet<string>;
|
||||
huddleMemberPubkeys?: readonly string[];
|
||||
huddleMemberPubkeysPending?: boolean;
|
||||
};
|
||||
@@ -24,13 +28,41 @@ type WaveMessageAttachmentProps = {
|
||||
export function WaveMessageAttachment({
|
||||
channelId,
|
||||
fallbackText,
|
||||
targetPubkey,
|
||||
targetIsAgent = false,
|
||||
agentPubkeys,
|
||||
huddleMemberPubkeys = [],
|
||||
huddleMemberPubkeysPending = false,
|
||||
}: WaveMessageAttachmentProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { isStarting, startHuddle } = useHuddle();
|
||||
const normalizedTargetPubkey = targetPubkey
|
||||
? normalizePubkey(targetPubkey)
|
||||
: null;
|
||||
const targetAgentPubkey = React.useMemo(() => {
|
||||
if (!targetPubkey || !normalizedTargetPubkey || !agentPubkeys) return null;
|
||||
return agentPubkeys.has(normalizedTargetPubkey) ? targetPubkey : null;
|
||||
}, [agentPubkeys, normalizedTargetPubkey, targetPubkey]);
|
||||
const resolvedHuddleMemberPubkeys = React.useMemo(() => {
|
||||
if (!targetAgentPubkey) return huddleMemberPubkeys;
|
||||
const seen = new Set<string>();
|
||||
return [...huddleMemberPubkeys, targetAgentPubkey].filter((pubkey) => {
|
||||
const normalizedPubkey = normalizePubkey(pubkey);
|
||||
if (seen.has(normalizedPubkey)) return false;
|
||||
seen.add(normalizedPubkey);
|
||||
return true;
|
||||
});
|
||||
}, [huddleMemberPubkeys, targetAgentPubkey]);
|
||||
const targetAgentLookupPending =
|
||||
targetIsAgent &&
|
||||
normalizedTargetPubkey !== null &&
|
||||
(huddleMemberPubkeysPending ||
|
||||
(targetAgentPubkey === null &&
|
||||
!resolvedHuddleMemberPubkeys.some(
|
||||
(pubkey) => normalizePubkey(pubkey) === normalizedTargetPubkey,
|
||||
)));
|
||||
const startHuddleDisabled =
|
||||
!channelId || isStarting || huddleMemberPubkeysPending;
|
||||
!channelId || isStarting || targetAgentLookupPending;
|
||||
|
||||
const handleStartHuddle = React.useCallback(
|
||||
async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
@@ -42,7 +74,7 @@ export function WaveMessageAttachment({
|
||||
}
|
||||
|
||||
try {
|
||||
await startHuddle(channelId, [...huddleMemberPubkeys]);
|
||||
await startHuddle(channelId, [...resolvedHuddleMemberPubkeys]);
|
||||
await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
@@ -52,7 +84,7 @@ export function WaveMessageAttachment({
|
||||
},
|
||||
[
|
||||
channelId,
|
||||
huddleMemberPubkeys,
|
||||
resolvedHuddleMemberPubkeys,
|
||||
queryClient,
|
||||
startHuddle,
|
||||
startHuddleDisabled,
|
||||
|
||||
@@ -47,6 +47,10 @@ type UseAnchoredScrollOptions = {
|
||||
/** Source of truth for the rendered list. Used to detect new-at-bottom
|
||||
* arrivals and to seed/refresh the anchor pre-render. */
|
||||
messages: TimelineMessage[];
|
||||
/** True while older history is being fetched or the rendered snapshot is
|
||||
* waiting to catch up to a prepend. During this window, native scroll
|
||||
* anchoring can fire scroll events before React's post-commit restore runs. */
|
||||
isHistoryPrependPending?: boolean;
|
||||
|
||||
/** When set, scroll to and highlight this message on mount and on change. */
|
||||
targetMessageId?: string | null;
|
||||
@@ -103,19 +107,20 @@ function isAtTrueBottom(
|
||||
/**
|
||||
* Pick an anchor for the current scroll position.
|
||||
*
|
||||
* Top-crossing walk: chronological children, top-down. The first
|
||||
* `data-message-id` row whose bottom edge has crossed below the container
|
||||
* top is the anchor — that's the row the reader's eye is on when they've
|
||||
* scrolled up through history. `topOffset` is the row's top relative to
|
||||
* the container's top and may be negative when the row straddles the edge.
|
||||
* Top-visible walk: chronological children, top-down. The first
|
||||
* `data-message-id` row whose top edge is inside the container is the anchor —
|
||||
* that's the row the reader's eye is on when they've scrolled up through
|
||||
* history. If the viewport starts in the middle of a row, we fall back to that
|
||||
* clipped row. `topOffset` is the row's top relative to the container's top and
|
||||
* may be negative when the fallback row straddles the edge.
|
||||
*
|
||||
* If no such row exists (e.g. nothing scrolled past the top, list shorter
|
||||
* than the viewport, etc.) the anchor is `at-bottom`.
|
||||
*
|
||||
* Algorithm credit: Sami's [13] in the buzz-bugs scroll-redesign thread,
|
||||
* supersedes the Matrix-style bottom-up walk in [7]. The top-crossing
|
||||
* choice is what keeps the row the reader is *reading* fixed under
|
||||
* in-viewport reflow (image-load, embed expansion).
|
||||
* supersedes the Matrix-style bottom-up walk in [7]. The top-visible choice is
|
||||
* what keeps the row the reader is *reading* fixed under in-viewport reflow
|
||||
* (image-load, embed expansion).
|
||||
*/
|
||||
function computeAnchor(container: HTMLDivElement): AnchorState {
|
||||
if (isAtBottomNow(container)) {
|
||||
@@ -124,23 +129,100 @@ function computeAnchor(container: HTMLDivElement): AnchorState {
|
||||
|
||||
const containerTop = container.getBoundingClientRect().top;
|
||||
const rows = container.querySelectorAll<HTMLElement>("[data-message-id]");
|
||||
let firstVisibleRow: HTMLElement | null = null;
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
const rect = row.getBoundingClientRect();
|
||||
if (rect.bottom > containerTop) {
|
||||
const messageId = row.dataset.messageId;
|
||||
if (messageId) {
|
||||
return {
|
||||
firstVisibleRow ??= row;
|
||||
if (rect.top >= containerTop) {
|
||||
const messageId = row.dataset.messageId;
|
||||
if (messageId) {
|
||||
return {
|
||||
kind: "message",
|
||||
messageId,
|
||||
topOffset: rect.top - containerTop,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (firstVisibleRow?.dataset.messageId) {
|
||||
return {
|
||||
kind: "message",
|
||||
messageId: firstVisibleRow.dataset.messageId,
|
||||
topOffset: firstVisibleRow.getBoundingClientRect().top - containerTop,
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: "at-bottom" };
|
||||
}
|
||||
|
||||
function findNearestNewerMessageId(
|
||||
container: HTMLDivElement,
|
||||
messages: TimelineMessage[],
|
||||
anchorId: string,
|
||||
): string | null {
|
||||
const anchorIndex = messages.findIndex((message) => message.id === anchorId);
|
||||
if (anchorIndex < 0) return null;
|
||||
|
||||
for (let index = anchorIndex + 1; index < messages.length; index += 1) {
|
||||
const candidate = messages[index];
|
||||
const el = container.querySelector(
|
||||
`[data-message-id="${CSS.escape(candidate.id)}"]`,
|
||||
);
|
||||
if (el) return candidate.id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function restoreAnchorToMessage(
|
||||
container: HTMLDivElement,
|
||||
messages: TimelineMessage[],
|
||||
anchor: Extract<AnchorState, { kind: "message" }>,
|
||||
): AnchorState {
|
||||
let anchorEl = container.querySelector<HTMLElement>(
|
||||
`[data-message-id="${CSS.escape(anchor.messageId)}"]`,
|
||||
);
|
||||
let activeAnchor: Extract<AnchorState, { kind: "message" }> = anchor;
|
||||
|
||||
if (!anchorEl) {
|
||||
const fallbackId = findNearestNewerMessageId(
|
||||
container,
|
||||
messages,
|
||||
anchor.messageId,
|
||||
);
|
||||
if (fallbackId) {
|
||||
anchorEl = container.querySelector<HTMLElement>(
|
||||
`[data-message-id="${CSS.escape(fallbackId)}"]`,
|
||||
);
|
||||
if (anchorEl) {
|
||||
activeAnchor = {
|
||||
kind: "message",
|
||||
messageId,
|
||||
topOffset: rect.top - containerTop,
|
||||
messageId: fallbackId,
|
||||
topOffset: anchor.topOffset,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "at-bottom" };
|
||||
if (!anchorEl) {
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
||||
return { kind: "at-bottom" };
|
||||
}
|
||||
|
||||
const currentTopOffset =
|
||||
anchorEl.getBoundingClientRect().top -
|
||||
container.getBoundingClientRect().top;
|
||||
const drift = currentTopOffset - activeAnchor.topOffset;
|
||||
if (Math.abs(drift) > 0.5) {
|
||||
container.scrollBy(0, drift);
|
||||
}
|
||||
|
||||
return activeAnchor;
|
||||
}
|
||||
|
||||
export function useAnchoredScroll({
|
||||
@@ -150,6 +232,7 @@ export function useAnchoredScroll({
|
||||
resetKey = channelId ?? null,
|
||||
isLoading,
|
||||
messages,
|
||||
isHistoryPrependPending = false,
|
||||
|
||||
targetMessageId = null,
|
||||
onTargetReached,
|
||||
@@ -158,6 +241,7 @@ export function useAnchoredScroll({
|
||||
// both on scroll (commit-time read) and in the layout effect (post-render
|
||||
// restoration). useState would force re-renders we don't want.
|
||||
const anchorRef = React.useRef<AnchorState>({ kind: "at-bottom" });
|
||||
const messagesRef = React.useRef<TimelineMessage[]>(messages);
|
||||
const [isAtBottom, setIsAtBottom] = React.useState(true);
|
||||
const [newMessageCount, setNewMessageCount] = React.useState(0);
|
||||
const [highlightedMessageId, setHighlightedMessageId] = React.useState<
|
||||
@@ -168,11 +252,13 @@ export function useAnchoredScroll({
|
||||
const prevLastMessageIdRef = React.useRef<string | undefined>(undefined);
|
||||
const prevFirstMessageIdRef = React.useRef<string | undefined>(undefined);
|
||||
const prevMessageCountRef = React.useRef(0);
|
||||
const pendingPrependAnchorRef = React.useRef<AnchorState | null>(null);
|
||||
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
|
||||
// on channel switch (the channelId reset effect clears it).
|
||||
const mountPinRafIdRef = React.useRef<number | null>(null);
|
||||
const prependRestoreRafIdRef = React.useRef<number | null>(null);
|
||||
// One-shot: the consumer calls `scrollToBottomOnNextUpdate()` right before
|
||||
// it sends a message (see ChannelPane). When the user's own message then
|
||||
// appends, we snap to bottom even if they had scrolled up to read history.
|
||||
@@ -183,13 +269,27 @@ export function useAnchoredScroll({
|
||||
// ignores transient gaps and keeps chasing the floor. A `ref`, not state — the
|
||||
// guard runs on a native scroll event, outside React's render cycle.
|
||||
const settlingRef = React.useRef(false);
|
||||
const anchorUpdateLockedRef = React.useRef(false);
|
||||
|
||||
// Reset everything when the route's scroll identity changes — the layout
|
||||
// effect that runs immediately after this reset is responsible for either
|
||||
// jumping to bottom or to the target message for the new view.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: resetKey is intentionally the sole trigger — it includes channel identity plus route-specific layout state.
|
||||
React.useLayoutEffect(() => {
|
||||
void scrollScopeKey;
|
||||
messagesRef.current = messages;
|
||||
}, [messages]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (isHistoryPrependPending && !anchorUpdateLockedRef.current) {
|
||||
const container = scrollContainerRef.current;
|
||||
const anchor = container ? computeAnchor(container) : anchorRef.current;
|
||||
pendingPrependAnchorRef.current = anchor;
|
||||
anchorRef.current = anchor;
|
||||
anchorUpdateLockedRef.current = true;
|
||||
}
|
||||
}, [isHistoryPrependPending, scrollContainerRef]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
anchorRef.current = { kind: "at-bottom" };
|
||||
setIsAtBottom(true);
|
||||
setNewMessageCount(0);
|
||||
@@ -198,9 +298,11 @@ export function useAnchoredScroll({
|
||||
prevLastMessageIdRef.current = undefined;
|
||||
prevFirstMessageIdRef.current = undefined;
|
||||
prevMessageCountRef.current = 0;
|
||||
pendingPrependAnchorRef.current = null;
|
||||
handledTargetIdRef.current = null;
|
||||
forceBottomOnNextAppendRef.current = false;
|
||||
settlingRef.current = false;
|
||||
anchorUpdateLockedRef.current = false;
|
||||
if (highlightTimeoutRef.current !== null) {
|
||||
window.clearTimeout(highlightTimeoutRef.current);
|
||||
highlightTimeoutRef.current = null;
|
||||
@@ -209,6 +311,10 @@ export function useAnchoredScroll({
|
||||
cancelAnimationFrame(mountPinRafIdRef.current);
|
||||
mountPinRafIdRef.current = null;
|
||||
}
|
||||
if (prependRestoreRafIdRef.current !== null) {
|
||||
cancelAnimationFrame(prependRestoreRafIdRef.current);
|
||||
prependRestoreRafIdRef.current = null;
|
||||
}
|
||||
}, [resetKey]);
|
||||
|
||||
const scrollToBottomImperative = React.useCallback(
|
||||
@@ -316,10 +422,14 @@ export function useAnchoredScroll({
|
||||
return;
|
||||
}
|
||||
}
|
||||
anchorRef.current = computeAnchor(container);
|
||||
const atBottom = anchorRef.current.kind === "at-bottom";
|
||||
const nextAnchor = computeAnchor(container);
|
||||
const atBottom = nextAnchor.kind === "at-bottom";
|
||||
if (!anchorUpdateLockedRef.current || atBottom) {
|
||||
anchorRef.current = nextAnchor;
|
||||
}
|
||||
setIsAtBottom((prev) => (prev === atBottom ? prev : atBottom));
|
||||
if (atBottom) {
|
||||
anchorUpdateLockedRef.current = false;
|
||||
setNewMessageCount(0);
|
||||
}
|
||||
}, [scrollContainerRef]);
|
||||
@@ -410,31 +520,61 @@ export function useAnchoredScroll({
|
||||
// Stick to bottom across the append.
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
||||
if (newLatestArrived) setNewMessageCount(0);
|
||||
} else if (messagesArrived > 0) {
|
||||
// Anchored mid-history. An older-history prepend grows the content above
|
||||
// the reading row; the browser's native scroll anchoring does NOT correct
|
||||
// this at the top edge (no anchor node above the viewport when scrollTop
|
||||
// is ~0), so re-pin the anchored row to its saved offset by id. This is
|
||||
// the single scroll writer for the prepend — the load-older observer only
|
||||
// triggers the fetch. We run it in this post-commit layout effect (not the
|
||||
// observer's promise callback) because the prepended rows commit on a
|
||||
// deferred snapshot a few frames later, so the row's true position is only
|
||||
// known here.
|
||||
const row = container.querySelector<HTMLElement>(
|
||||
`[data-message-id="${CSS.escape(anchor.messageId)}"]`,
|
||||
);
|
||||
if (row) {
|
||||
const currentTopOffset =
|
||||
row.getBoundingClientRect().top -
|
||||
container.getBoundingClientRect().top;
|
||||
const drift = currentTopOffset - anchor.topOffset;
|
||||
if (Math.abs(drift) > 0.5) {
|
||||
container.scrollBy(0, drift);
|
||||
}
|
||||
if (!isHistoryPrependPending) {
|
||||
anchorUpdateLockedRef.current = false;
|
||||
}
|
||||
if (!isPrepend) {
|
||||
} else if (messagesArrived > 0) {
|
||||
// Anchored mid-history while rows arrive. Preserve the reading row by id.
|
||||
// During older-history prepends, scroll events from native anchoring are
|
||||
// ignored until this restore runs so they don't replace the saved anchor.
|
||||
if (isPrepend && pendingPrependAnchorRef.current?.kind === "message") {
|
||||
const restorePrependAnchor = () =>
|
||||
restoreAnchorToMessage(
|
||||
container,
|
||||
messages,
|
||||
pendingPrependAnchorRef.current as Extract<
|
||||
AnchorState,
|
||||
{ kind: "message" }
|
||||
>,
|
||||
);
|
||||
const nextAnchor = restorePrependAnchor();
|
||||
anchorRef.current = nextAnchor;
|
||||
if (prependRestoreRafIdRef.current !== null) {
|
||||
cancelAnimationFrame(prependRestoreRafIdRef.current);
|
||||
}
|
||||
prependRestoreRafIdRef.current = requestAnimationFrame(() => {
|
||||
prependRestoreRafIdRef.current = null;
|
||||
if (!pendingPrependAnchorRef.current) return;
|
||||
anchorRef.current = restorePrependAnchor();
|
||||
if (!isHistoryPrependPending) {
|
||||
anchorUpdateLockedRef.current = false;
|
||||
pendingPrependAnchorRef.current = null;
|
||||
}
|
||||
});
|
||||
if (nextAnchor.kind === "at-bottom") {
|
||||
setIsAtBottom(true);
|
||||
setNewMessageCount(0);
|
||||
prevLastMessageIdRef.current = lastMessage?.id;
|
||||
prevFirstMessageIdRef.current = firstMessage?.id;
|
||||
prevMessageCountRef.current = messages.length;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const nextAnchor = restoreAnchorToMessage(container, messages, anchor);
|
||||
anchorRef.current = nextAnchor;
|
||||
if (nextAnchor.kind === "at-bottom") {
|
||||
setIsAtBottom(true);
|
||||
setNewMessageCount(0);
|
||||
prevLastMessageIdRef.current = lastMessage?.id;
|
||||
prevFirstMessageIdRef.current = firstMessage?.id;
|
||||
prevMessageCountRef.current = messages.length;
|
||||
return;
|
||||
}
|
||||
setNewMessageCount((current) => current + messagesArrived);
|
||||
}
|
||||
} else if (!isHistoryPrependPending) {
|
||||
anchorUpdateLockedRef.current = false;
|
||||
pendingPrependAnchorRef.current = null;
|
||||
}
|
||||
|
||||
prevLastMessageIdRef.current = lastMessage?.id;
|
||||
@@ -442,6 +582,7 @@ export function useAnchoredScroll({
|
||||
prevMessageCountRef.current = messages.length;
|
||||
}, [
|
||||
isLoading,
|
||||
isHistoryPrependPending,
|
||||
messages,
|
||||
onTargetReached,
|
||||
scrollContainerRef,
|
||||
@@ -451,12 +592,9 @@ export function useAnchoredScroll({
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content resize: while stuck to the bottom, an in-viewport reflow (image
|
||||
// decode, embed expand, late font load) that React isn't driving grows
|
||||
// `scrollHeight` without a `messages` change, so the layout effect doesn't
|
||||
// fire — re-pin to the new floor here to stay glued. When anchored
|
||||
// mid-history, native scroll anchoring (overflow-anchor) holds the reading
|
||||
// row across the reflow, so there's nothing to do.
|
||||
// Content resize: late layout changes (image decode, embeds, font load)
|
||||
// happen outside React's message commits, so re-apply the same anchor restore
|
||||
// used after prepends. This keeps the row under the reader's eye fixed.
|
||||
// ---------------------------------------------------------------------------
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: resetKey is a deliberate re-subscription trigger — the effect body reads only the stable refs, but on route identity changes the keyed scroll container remounts and contentRef.current becomes a fresh node, so the observer must disconnect from the previous route's detached node and re-observe the live one.
|
||||
React.useEffect(() => {
|
||||
@@ -466,8 +604,20 @@ export function useAnchoredScroll({
|
||||
const observer = new ResizeObserver(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
if (anchorRef.current.kind === "at-bottom") {
|
||||
const anchor = anchorRef.current;
|
||||
if (anchor.kind === "at-bottom") {
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
||||
return;
|
||||
}
|
||||
const nextAnchor = restoreAnchorToMessage(
|
||||
container,
|
||||
messagesRef.current,
|
||||
anchor,
|
||||
);
|
||||
anchorRef.current = nextAnchor;
|
||||
if (nextAnchor.kind === "at-bottom") {
|
||||
setIsAtBottom(true);
|
||||
setNewMessageCount(0);
|
||||
}
|
||||
});
|
||||
observer.observe(content);
|
||||
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
useChannelsQuery,
|
||||
useOpenDmMutation,
|
||||
} from "@/features/channels/hooks";
|
||||
import { useProfileQuery, useUserProfileQuery } from "@/features/profile/hooks";
|
||||
import {
|
||||
useProfileQuery,
|
||||
useUserProfileQuery,
|
||||
useUsersBatchQuery,
|
||||
} from "@/features/profile/hooks";
|
||||
import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys";
|
||||
import {
|
||||
useRelayAgentsQuery,
|
||||
@@ -188,6 +192,9 @@ export function UserProfilePopover({
|
||||
enabled: open,
|
||||
});
|
||||
const userStatusQuery = useUserStatusQuery(open ? [pubkey] : []);
|
||||
const usersBatchQuery = useUsersBatchQuery(open ? [pubkey] : [], {
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const { onOpenAgentSession } = useAgentSession();
|
||||
const { openProfilePanel } = useProfilePanel();
|
||||
@@ -202,6 +209,11 @@ export function UserProfilePopover({
|
||||
role !== "bot" &&
|
||||
(relayAgentsQuery.isPending || managedAgentsQuery.isPending);
|
||||
const profile = profileQuery.data;
|
||||
const isAgentByProfile = Boolean(
|
||||
usersBatchQuery.data?.profiles[normalizePubkey(pubkey)]?.isAgent,
|
||||
);
|
||||
const isAgentTarget =
|
||||
isBotProfile || isAgentByProfile || isAgentClassificationPending;
|
||||
const displayName = profile?.displayName ?? truncatePubkey(pubkey);
|
||||
// Owner signal mirrors UserProfilePanel: a declared NIP-OA owner whose agent
|
||||
// runs elsewhere holds no local seckey, so key custody (`isOwner`) alone
|
||||
@@ -374,7 +386,9 @@ export function UserProfilePopover({
|
||||
selfProfileQuery.data?.displayName?.trim() ||
|
||||
identity.displayName.trim() ||
|
||||
truncatePubkey(identity.pubkey);
|
||||
const content = buildWaveMessageContent(senderName);
|
||||
const content = buildWaveMessageContent(senderName, pubkey, {
|
||||
targetIsAgent: isAgentTarget,
|
||||
});
|
||||
const queryKey = channelMessagesKey(dm.id);
|
||||
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
@@ -439,6 +453,7 @@ export function UserProfilePopover({
|
||||
currentPubkey,
|
||||
goChannel,
|
||||
identityQuery.data,
|
||||
isAgentTarget,
|
||||
openDmMutation,
|
||||
pendingAction,
|
||||
pubkey,
|
||||
|
||||
Reference in New Issue
Block a user