mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Fix scrollback pagination and IntersectionObserver lifecycle (#212)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,14 +32,15 @@ const rules = [
|
||||
const overrides = new Map([
|
||||
["src-tauri/src/managed_agents/personas.rs", 600], // built-in persona system prompts (Solo, Ralph, Strategist) are long string literals
|
||||
["src-tauri/src/managed_agents/persona_card.rs", 772], // PNG/ZIP persona card codec + provider/model fields + 27 unit tests (~350 lines of tests); rustfmt adds line breaks around long literals/builders
|
||||
["src/app/AppShell.tsx", 810], // message edit state + handlers + ChannelPane edit prop threading
|
||||
["src/app/AppShell.tsx", 820], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination
|
||||
["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation
|
||||
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
|
||||
["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates
|
||||
["src/features/messages/ui/MessageComposer.tsx", 700], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape)
|
||||
["src/features/settings/ui/SettingsView.tsx", 600],
|
||||
["src/features/sidebar/ui/AppSidebar.tsx", 850], // channels + forums creation forms
|
||||
["src/features/tokens/ui/TokenSettingsCard.tsx", 800],
|
||||
["src/shared/api/relayClientSession.ts", 740], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator
|
||||
["src/shared/api/relayClientSession.ts", 790], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore
|
||||
["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions
|
||||
["src-tauri/src/commands/agents.rs", 849], // remote agent lifecycle routing (local + provider branches) + scope enforcement; rustfmt adds line breaks around long tuple/closure blocks
|
||||
["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
useChannelSubscription,
|
||||
useToggleReactionMutation,
|
||||
} from "@/features/messages/hooks";
|
||||
import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys";
|
||||
import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages";
|
||||
import {
|
||||
collectMessageAuthorPubkeys,
|
||||
formatTimelineMessages,
|
||||
@@ -117,6 +119,8 @@ export function AppShell() {
|
||||
const activeChannelId = activeChannel?.id ?? null;
|
||||
const messagesQuery = useChannelMessagesQuery(activeChannel);
|
||||
useChannelSubscription(activeChannel);
|
||||
const { fetchOlder, isFetchingOlder, hasOlderMessages } =
|
||||
useFetchOlderMessages(activeChannel);
|
||||
const latestActiveMessage =
|
||||
messagesQuery.data?.[messagesQuery.data.length - 1];
|
||||
const activeReadAt = latestActiveMessage
|
||||
@@ -486,7 +490,7 @@ export function AppShell() {
|
||||
}
|
||||
|
||||
queryClient.setQueryData<RelayEvent[]>(
|
||||
["channel-messages", activeChannel.id],
|
||||
channelMessagesKey(activeChannel.id),
|
||||
(current = []) => mergeMessages(current, event),
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -732,6 +736,9 @@ export function AppShell() {
|
||||
<ChannelPane
|
||||
activeChannel={activeChannel}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
fetchOlder={fetchOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
isFetchingOlder={isFetchingOlder}
|
||||
editTarget={
|
||||
editTargetMessage
|
||||
? {
|
||||
|
||||
@@ -15,6 +15,9 @@ type ChannelPaneProps = {
|
||||
body: string;
|
||||
id: string;
|
||||
} | null;
|
||||
fetchOlder?: () => Promise<void>;
|
||||
hasOlderMessages?: boolean;
|
||||
isFetchingOlder?: boolean;
|
||||
isSending: boolean;
|
||||
isTimelineLoading: boolean;
|
||||
messages: TimelineMessage[];
|
||||
@@ -45,6 +48,9 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
activeChannel,
|
||||
currentPubkey,
|
||||
editTarget = null,
|
||||
fetchOlder,
|
||||
hasOlderMessages,
|
||||
isFetchingOlder,
|
||||
isSending,
|
||||
isTimelineLoading,
|
||||
messages,
|
||||
@@ -68,6 +74,9 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
channelId={activeChannel?.id}
|
||||
activeReplyTargetId={replyTargetId}
|
||||
currentPubkey={currentPubkey}
|
||||
fetchOlder={fetchOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
isFetchingOlder={isFetchingOlder}
|
||||
profiles={profiles}
|
||||
emptyDescription={
|
||||
activeChannel?.channelType === "forum"
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
channelsQueryKey,
|
||||
updateChannelLastMessageAt,
|
||||
} from "@/features/channels/hooks";
|
||||
import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys";
|
||||
import { getChannelIdFromTags } from "@/features/messages/lib/threading";
|
||||
import { mergeTimelineCacheMessages } from "@/features/messages/hooks";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
@@ -196,7 +197,7 @@ export function useUnreadChannels(
|
||||
|
||||
updateChannelLastMessageAt(queryClient, channelId, messageTimestamp);
|
||||
queryClient.setQueryData<RelayEvent[]>(
|
||||
["channel-messages", channelId],
|
||||
channelMessagesKey(channelId),
|
||||
(current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
|
||||
@@ -2,6 +2,12 @@ import { useEffect, useEffectEvent } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { updateChannelLastMessageAt } from "@/features/channels/hooks";
|
||||
import {
|
||||
channelMessagesKey,
|
||||
dedupeMessagesById,
|
||||
normalizeTimelineMessages,
|
||||
sortMessages,
|
||||
} from "@/features/messages/lib/messageQueryKeys";
|
||||
import {
|
||||
buildReplyTags,
|
||||
normalizeMentionPubkeys,
|
||||
@@ -20,47 +26,10 @@ import { KIND_STREAM_MESSAGE } from "@/shared/constants/kinds";
|
||||
type MessageQueryContext = {
|
||||
optimisticId: string;
|
||||
previousMessages: RelayEvent[];
|
||||
queryKey: readonly ["channel-messages", string];
|
||||
queryKey: ReturnType<typeof channelMessagesKey>;
|
||||
};
|
||||
|
||||
const CHANNEL_HISTORY_LIMIT = 200;
|
||||
const MAX_TIMELINE_MESSAGES = CHANNEL_HISTORY_LIMIT * 2;
|
||||
|
||||
function dedupeMessagesById(messages: RelayEvent[]) {
|
||||
const seenIds = new Set<string>();
|
||||
const deduped: RelayEvent[] = [];
|
||||
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
|
||||
if (seenIds.has(message.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenIds.add(message.id);
|
||||
deduped.push(message);
|
||||
}
|
||||
|
||||
return deduped.reverse();
|
||||
}
|
||||
|
||||
function sortMessages(messages: RelayEvent[]) {
|
||||
return dedupeMessagesById(messages).sort(
|
||||
(left, right) => left.created_at - right.created_at,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTimelineMessages(messages: RelayEvent[]) {
|
||||
const normalized = sortMessages(messages);
|
||||
|
||||
if (normalized.length <= MAX_TIMELINE_MESSAGES) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Keep the live timeline bounded so de-virtualized rendering does not grow
|
||||
// into an unbounded DOM during long-lived channel sessions.
|
||||
return normalized.slice(-MAX_TIMELINE_MESSAGES);
|
||||
}
|
||||
|
||||
function mergeMessagesWithNormalizer(
|
||||
current: RelayEvent[],
|
||||
@@ -145,7 +114,7 @@ function createOptimisticMessage(
|
||||
|
||||
export function useChannelMessagesQuery(channel: Channel | null) {
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = ["channel-messages", channel?.id ?? "none"] as const;
|
||||
const queryKey = channelMessagesKey(channel?.id ?? "none");
|
||||
|
||||
return useQuery({
|
||||
enabled: channel !== null && channel.channelType !== "forum",
|
||||
@@ -189,7 +158,7 @@ export function useChannelSubscription(channel: Channel | null) {
|
||||
);
|
||||
|
||||
queryClient.setQueryData<RelayEvent[]>(
|
||||
["channel-messages", channelId],
|
||||
channelMessagesKey(channelId),
|
||||
(current = []) => {
|
||||
const mergedHistory = normalizeTimelineMessages([
|
||||
...current,
|
||||
@@ -212,7 +181,7 @@ export function useChannelSubscription(channel: Channel | null) {
|
||||
new Date(event.created_at * 1_000).toISOString(),
|
||||
);
|
||||
queryClient.setQueryData<RelayEvent[]>(
|
||||
["channel-messages", channelId],
|
||||
channelMessagesKey(channelId),
|
||||
(current = []) => mergeTimelineCacheMessages(current, event),
|
||||
);
|
||||
});
|
||||
@@ -309,10 +278,9 @@ export function useSendMessageMutation(
|
||||
// validation runs. The WebSocket path does not validate imeta tags.
|
||||
if (parentEventId || (mediaTags && mediaTags.length > 0)) {
|
||||
const cachedMessages =
|
||||
queryClient.getQueryData<RelayEvent[]>([
|
||||
"channel-messages",
|
||||
channel.id,
|
||||
]) ?? [];
|
||||
queryClient.getQueryData<RelayEvent[]>(
|
||||
channelMessagesKey(channel.id),
|
||||
) ?? [];
|
||||
const result = await sendChannelMessage(
|
||||
channel.id,
|
||||
content,
|
||||
@@ -373,7 +341,7 @@ export function useSendMessageMutation(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const queryKey = ["channel-messages", channel.id] as const;
|
||||
const queryKey = channelMessagesKey(channel.id);
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
|
||||
const previousMessages =
|
||||
@@ -477,7 +445,7 @@ export function useEditMessageMutation(channel: Channel | null) {
|
||||
}
|
||||
|
||||
queryClient.setQueryData<RelayEvent[]>(
|
||||
["channel-messages", channel.id],
|
||||
channelMessagesKey(channel.id),
|
||||
(current = []) =>
|
||||
current.map((message) =>
|
||||
message.id === eventId ? { ...message, content } : message,
|
||||
|
||||
46
desktop/src/features/messages/lib/messageQueryKeys.ts
Normal file
46
desktop/src/features/messages/lib/messageQueryKeys.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
|
||||
const MAX_TIMELINE_MESSAGES = 2_000;
|
||||
|
||||
export function channelMessagesKey(channelId: string) {
|
||||
return ["channel-messages", channelId] as const;
|
||||
}
|
||||
|
||||
export function dedupeMessagesById(messages: RelayEvent[]) {
|
||||
const seenIds = new Set<string>();
|
||||
const deduped: RelayEvent[] = [];
|
||||
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
|
||||
if (seenIds.has(message.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenIds.add(message.id);
|
||||
deduped.push(message);
|
||||
}
|
||||
|
||||
return deduped.reverse();
|
||||
}
|
||||
|
||||
export function sortMessages(messages: RelayEvent[]) {
|
||||
return dedupeMessagesById(messages).sort(
|
||||
(left, right) => left.created_at - right.created_at,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort, dedupe, and cap the timeline at {@link MAX_TIMELINE_MESSAGES} so
|
||||
* de-virtualized rendering does not grow into an unbounded DOM during
|
||||
* long-lived channel sessions.
|
||||
*/
|
||||
export function normalizeTimelineMessages(messages: RelayEvent[]) {
|
||||
const normalized = sortMessages(messages);
|
||||
|
||||
if (normalized.length <= MAX_TIMELINE_MESSAGES) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return normalized.slice(-MAX_TIMELINE_MESSAGES);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { ArrowDown } from "lucide-react";
|
||||
import { ArrowDown, Loader2 } from "lucide-react";
|
||||
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
@@ -7,6 +7,7 @@ import { Button } from "@/shared/ui/button";
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
import { TimelineSkeleton } from "./TimelineSkeleton";
|
||||
import { TimelineMessageList } from "./TimelineMessageList";
|
||||
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
|
||||
import { useTimelineScrollManager } from "./useTimelineScrollManager";
|
||||
|
||||
type MessageTimelineProps = {
|
||||
@@ -17,6 +18,9 @@ type MessageTimelineProps = {
|
||||
emptyDescription?: string;
|
||||
activeReplyTargetId?: string | null;
|
||||
currentPubkey?: string;
|
||||
fetchOlder?: () => Promise<void>;
|
||||
hasOlderMessages?: boolean;
|
||||
isFetchingOlder?: boolean;
|
||||
profiles?: UserProfileLookup;
|
||||
onEdit?: (message: TimelineMessage) => void;
|
||||
onReply?: (message: TimelineMessage) => void;
|
||||
@@ -37,6 +41,9 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
emptyDescription = "Send the first message to start the thread.",
|
||||
activeReplyTargetId = null,
|
||||
currentPubkey,
|
||||
fetchOlder,
|
||||
hasOlderMessages = true,
|
||||
isFetchingOlder = false,
|
||||
profiles,
|
||||
onEdit,
|
||||
onReply,
|
||||
@@ -45,6 +52,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
onTargetReached,
|
||||
}: MessageTimelineProps) {
|
||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
const topSentinelRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
bottomAnchorRef,
|
||||
@@ -52,6 +60,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
highlightedMessageId,
|
||||
isAtBottom,
|
||||
newMessageCount,
|
||||
restoreScrollPosition,
|
||||
scrollToBottom,
|
||||
syncScrollState,
|
||||
} = useTimelineScrollManager({
|
||||
@@ -63,6 +72,15 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
targetMessageId,
|
||||
});
|
||||
|
||||
useLoadOlderOnScroll({
|
||||
fetchOlder,
|
||||
hasOlderMessages,
|
||||
isLoading,
|
||||
restoreScrollPosition,
|
||||
scrollContainerRef,
|
||||
sentinelRef: topSentinelRef,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div
|
||||
@@ -75,6 +93,27 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
className="mx-auto flex w-full max-w-4xl flex-col gap-2"
|
||||
ref={contentRef}
|
||||
>
|
||||
<div ref={topSentinelRef} aria-hidden className="h-px" />
|
||||
|
||||
{isFetchingOlder ? (
|
||||
<div className="flex justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!hasOlderMessages && !isLoading && messages.length > 0 ? (
|
||||
<div
|
||||
className="flex items-center gap-3 py-2"
|
||||
data-testid="message-timeline-beginning"
|
||||
>
|
||||
<Separator className="flex-1" />
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||
Beginning of conversation
|
||||
</p>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="flex items-center gap-3"
|
||||
data-testid="message-timeline-day-divider"
|
||||
|
||||
92
desktop/src/features/messages/ui/useLoadOlderOnScroll.ts
Normal file
92
desktop/src/features/messages/ui/useLoadOlderOnScroll.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react";
|
||||
|
||||
type UseLoadOlderOnScrollOptions = {
|
||||
fetchOlder?: () => Promise<void>;
|
||||
hasOlderMessages: boolean;
|
||||
isLoading: boolean;
|
||||
restoreScrollPosition: (scrollTop: number) => void;
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
sentinelRef: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Triggers `fetchOlder` when a sentinel element near the top of the scroll
|
||||
* container enters the viewport, then restores the scroll position so the
|
||||
* visible content doesn't jump.
|
||||
*/
|
||||
export function useLoadOlderOnScroll({
|
||||
fetchOlder,
|
||||
hasOlderMessages,
|
||||
isLoading,
|
||||
restoreScrollPosition,
|
||||
scrollContainerRef,
|
||||
sentinelRef,
|
||||
}: UseLoadOlderOnScrollOptions) {
|
||||
const restoreScrollPositionRef = React.useRef(restoreScrollPosition);
|
||||
React.useEffect(() => {
|
||||
restoreScrollPositionRef.current = restoreScrollPosition;
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
const container = scrollContainerRef.current;
|
||||
if (
|
||||
!sentinel ||
|
||||
!container ||
|
||||
!fetchOlder ||
|
||||
isLoading ||
|
||||
!hasOlderMessages
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let currentObserver: IntersectionObserver | null = null;
|
||||
|
||||
const observe = () => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry.isIntersecting || disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
{ root: container, rootMargin: "200px 0px 0px 0px" },
|
||||
);
|
||||
|
||||
currentObserver.observe(sentinel);
|
||||
};
|
||||
|
||||
observe();
|
||||
return () => {
|
||||
disposed = true;
|
||||
currentObserver?.disconnect();
|
||||
};
|
||||
}, [
|
||||
fetchOlder,
|
||||
hasOlderMessages,
|
||||
isLoading,
|
||||
scrollContainerRef,
|
||||
sentinelRef,
|
||||
]);
|
||||
}
|
||||
@@ -380,6 +380,7 @@ export function useTimelineScrollManager({
|
||||
highlightedMessageId,
|
||||
isAtBottom,
|
||||
newMessageCount,
|
||||
restoreScrollPosition,
|
||||
scrollToBottom,
|
||||
syncScrollState,
|
||||
};
|
||||
|
||||
89
desktop/src/features/messages/useFetchOlderMessages.ts
Normal file
89
desktop/src/features/messages/useFetchOlderMessages.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
channelMessagesKey,
|
||||
sortMessages,
|
||||
} from "@/features/messages/lib/messageQueryKeys";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import type { Channel, RelayEvent } from "@/shared/api/types";
|
||||
|
||||
const OLDER_MESSAGES_BATCH_SIZE = 100;
|
||||
|
||||
export function useFetchOlderMessages(channel: Channel | null) {
|
||||
const queryClient = useQueryClient();
|
||||
const channelId = channel?.id ?? null;
|
||||
const [isFetchingOlder, setIsFetchingOlder] = useState(false);
|
||||
const [hasOlderMessages, setHasOlderMessages] = useState(true);
|
||||
const isFetchingOlderRef = useRef(false);
|
||||
const hasOlderMessagesRef = useRef(true);
|
||||
|
||||
const previousChannelIdRef = useRef(channelId);
|
||||
if (previousChannelIdRef.current !== channelId) {
|
||||
previousChannelIdRef.current = channelId;
|
||||
hasOlderMessagesRef.current = true;
|
||||
setHasOlderMessages(true);
|
||||
}
|
||||
|
||||
const fetchOlder = useCallback(async () => {
|
||||
if (
|
||||
!channelId ||
|
||||
isFetchingOlderRef.current ||
|
||||
!hasOlderMessagesRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queryKey = channelMessagesKey(channelId);
|
||||
const currentMessages =
|
||||
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
|
||||
if (currentMessages.length === 0) {
|
||||
hasOlderMessagesRef.current = false;
|
||||
setHasOlderMessages(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the oldest timestamp directly — `until` is inclusive so the relay will
|
||||
// return the boundary message again, but `sortMessages` deduplicates by id.
|
||||
// Subtracting 1 risks skipping messages that share the same second.
|
||||
const oldestTimestamp = currentMessages[0].created_at;
|
||||
isFetchingOlderRef.current = true;
|
||||
setIsFetchingOlder(true);
|
||||
|
||||
try {
|
||||
const olderMessages = await relayClient.fetchChannelHistoryBefore(
|
||||
channelId,
|
||||
oldestTimestamp,
|
||||
OLDER_MESSAGES_BATCH_SIZE,
|
||||
);
|
||||
|
||||
if (olderMessages.length < OLDER_MESSAGES_BATCH_SIZE) {
|
||||
hasOlderMessagesRef.current = false;
|
||||
setHasOlderMessages(false);
|
||||
}
|
||||
|
||||
if (olderMessages.length > 0) {
|
||||
queryClient.setQueryData<RelayEvent[]>(queryKey, (current = []) =>
|
||||
sortMessages([...current, ...olderMessages]),
|
||||
);
|
||||
|
||||
const updatedMessages =
|
||||
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
|
||||
if (
|
||||
updatedMessages.length > 0 &&
|
||||
updatedMessages[0].created_at === oldestTimestamp
|
||||
) {
|
||||
hasOlderMessagesRef.current = false;
|
||||
setHasOlderMessages(false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch older messages", channelId, error);
|
||||
} finally {
|
||||
isFetchingOlderRef.current = false;
|
||||
setIsFetchingOlder(false);
|
||||
}
|
||||
}, [channelId, queryClient]);
|
||||
|
||||
return { fetchOlder, isFetchingOlder, hasOlderMessages };
|
||||
}
|
||||
@@ -46,6 +46,18 @@ export class RelayClient {
|
||||
private notifyReconnectListeners = false;
|
||||
|
||||
async fetchChannelHistory(channelId: string, limit = 50) {
|
||||
return this.fetchHistory(this.buildChannelFilter(channelId, limit));
|
||||
}
|
||||
|
||||
async fetchChannelHistoryBefore(
|
||||
channelId: string,
|
||||
before: number,
|
||||
limit = 50,
|
||||
) {
|
||||
return this.fetchHistory(this.buildChannelFilter(channelId, limit, before));
|
||||
}
|
||||
|
||||
private async fetchHistory(filter: RelaySubscriptionFilter) {
|
||||
await this.ensureConnected();
|
||||
|
||||
return new Promise<RelayEvent[]>((resolve, reject) => {
|
||||
@@ -64,11 +76,7 @@ export class RelayClient {
|
||||
timeout,
|
||||
});
|
||||
|
||||
void this.sendRaw([
|
||||
"REQ",
|
||||
subId,
|
||||
this.buildChannelFilter(channelId, limit),
|
||||
]).catch((error) => {
|
||||
void this.sendRaw(["REQ", subId, filter]).catch((error) => {
|
||||
window.clearTimeout(timeout);
|
||||
this.subscriptions.delete(subId);
|
||||
reject(
|
||||
@@ -244,12 +252,19 @@ export class RelayClient {
|
||||
private buildChannelFilter(
|
||||
channelId: string,
|
||||
limit: number,
|
||||
until?: number,
|
||||
): RelaySubscriptionFilter {
|
||||
return {
|
||||
const filter: RelaySubscriptionFilter = {
|
||||
kinds: [...CHANNEL_EVENT_KINDS],
|
||||
"#h": [channelId],
|
||||
limit,
|
||||
};
|
||||
|
||||
if (until !== undefined) {
|
||||
filter.until = until;
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
private buildGlobalStreamFilter(limit: number): RelaySubscriptionFilter {
|
||||
|
||||
@@ -5,6 +5,7 @@ export type RelaySubscriptionFilter = {
|
||||
"#h"?: string[];
|
||||
limit: number;
|
||||
since?: number;
|
||||
until?: number;
|
||||
};
|
||||
|
||||
type HistorySubscription = {
|
||||
|
||||
Reference in New Issue
Block a user