Skip to content
Open
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
67 changes: 56 additions & 11 deletions examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@ import {
useChatViewContext,
useChatViewNavigation,
} from 'stream-chat-react/slot-layout';
import type { Channel, ChannelManager, StreamChat, Thread } from 'stream-chat';
import { formatMessage, Thread as StreamThread } from 'stream-chat';
import type {
Channel,
ChannelManager,
LocalMessage,
StreamChat,
Thread,
} from 'stream-chat';

/**
* Full-workspace URL sync for the vite example.
Expand Down Expand Up @@ -190,6 +197,23 @@ const resolveChannel = (client: StreamChat, cid: string): Channel | undefined =>
return type && id ? client.channel(type, id) : undefined;
};

/**
* One message by id, for a thread deep link whose parent is outside every loaded window (a link
* into an old thread, or a cold Back). `GET /messages/:id` answers for any message, including one
* with no replies — unlike the thread endpoint, which 404s until the first reply exists.
*/
const fetchMessage = async (
client: StreamChat,
id: string,
): Promise<LocalMessage | undefined> => {
try {
const { message } = await client.getMessage({ id });
return message ? formatMessage(message) : undefined;
} catch {
return undefined;
}
};

const resolveBinding = async (
client: StreamChat,
token: ParsedToken,
Expand All @@ -210,17 +234,38 @@ const resolveBinding = async (
}
case 'thread': {
// Paginator-first: a thread the thread-list already holds is reused as-is — no round-trip.
// Only when it isn't loaded (deep-link straight to a thread past page 1, or a cold Back into a
// never-visited thread) do we fall back to fetching it by id.
const thread =
client.threads.threadsById[token.key] ??
(await client
.getThreadAndHydrate(token.key, { watch: true })
.catch(() => undefined));
if (!thread) return undefined;
const listed = client.threads.threadsById[token.key];
if (listed) {
return {
binding: { key: listed.id ?? undefined, kind: 'thread', source: listed },
channel: listed.channel ?? undefined,
};
}

// Otherwise build the instance from its parent message rather than querying the thread.
//
// Two reasons not to call `getThreadAndHydrate` here. A thread does not exist server-side
// until its parent message has a reply, so restoring a link to a reply-less thread would
// answer 404 — and the query is redundant even for a real thread, because `<Thread>` loads
// its own replies once the parent reports some. Deciding that is the component's job; this
// resolver only has to produce the instance to bind.
const parentMessage =
client.messageStore.get(token.key) ?? (await fetchMessage(client, token.key));
if (!parentMessage?.cid) return undefined;

const channel = resolveChannel(client, parentMessage.cid);
if (!channel) return undefined;
// Same watch the bound `<Channel>` would issue (see the channel case) — moved earlier so the
// thread's channel config, members and read state are loaded when the panel renders.
if (!channel.initialized) await channel.watch().catch(() => undefined);

return {
binding: { key: thread.id ?? undefined, kind: 'thread', source: thread },
channel: thread.channel ?? undefined,
binding: {
key: token.key,
kind: 'thread',
source: new StreamThread({ channel, client, parentMessage }),
},
channel,
};
}
case 'userProfile':
Expand Down
12 changes: 10 additions & 2 deletions src/components/MessageList/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import type { InfiniteScrollPaginatorProps } from '../InfiniteScrollPaginator/In
import { InfiniteScrollPaginator } from '../InfiniteScrollPaginator/InfiniteScrollPaginator';
import { useMessagePaginator } from '../../hooks';
import { ScrollToLatestMessageButton } from './ScrollToLatestMessageButton';
import { useCanPaginateReplies } from './hooks/useCanPaginateReplies';

type MessageListWithContextProps = MessageListProps;

Expand Down Expand Up @@ -233,6 +234,9 @@ const MessageListWithContext = (props: MessageListWithContextProps) => {

const messageListClass = customClasses?.messageList || 'str-chat__message-list';

// An empty thread would otherwise ask for a page at both ends the moment the scroller mounts.
const canPaginateReplies = useCanPaginateReplies();

const loadOlderMessages = React.useCallback(async () => {
if (loadingOlderRef.current) return;
loadingOlderRef.current = true;
Expand Down Expand Up @@ -385,8 +389,12 @@ const MessageListWithContext = (props: MessageListWithContextProps) => {
className='str-chat__message-list-scroll'
data-testid='reverse-infinite-scroll'
element={internalListElement}
loadNextOnScrollToBottom={messagePaginator.toHead}
loadNextOnScrollToTop={loadOlderMessages}
loadNextOnScrollToBottom={
canPaginateReplies ? messagePaginator.toHead : undefined
}
loadNextOnScrollToTop={
canPaginateReplies ? loadOlderMessages : undefined
}
onScroll={onScroll}
ref={setListElement}
threshold={loadMoreScrollThreshold}
Expand Down
8 changes: 7 additions & 1 deletion src/components/MessageList/VirtualizedMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import type {
UserResponse,
} from 'stream-chat';
import type { UnknownType } from '../../types/types';
import { useCanPaginateReplies } from './hooks/useCanPaginateReplies';
import { useStableId } from '../UtilityComponents/useStableId';
import { useLastDeliveredData } from './hooks/useLastDeliveredData';
import { useLastOwnMessage } from './hooks/useLastOwnMessage';
Expand Down Expand Up @@ -490,18 +491,23 @@ const VirtualizedMessageListWithContext = (
[],
);

const canPaginateReplies = useCanPaginateReplies();

const atBottomStateChange = (isAtBottom: boolean) => {
atBottom.current = isAtBottom;
setIsMessageListScrolledToBottom(isAtBottom);

if (isAtBottom) {
messagePaginator.toHead();
// An empty thread is at both ends at once, so Virtuoso reports both on mount — see
// `useCanPaginateReplies` for why that must not become a request.
if (canPaginateReplies) messagePaginator.toHead();
// loadMoreNewer?.(messageLimit);
setNewMessagesNotification?.(false);
}
};
const atTopStateChange = (isAtTop: boolean) => {
if (isAtTop) {
if (!canPaginateReplies) return;
if (loadingOlderRef.current) return;
loadingOlderRef.current = true;
setSuppressAutoscrollWhileLoadingOlder(true);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import React from 'react';
import { renderHook } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { StateStore } from 'stream-chat';
import { describe, expect, it } from 'vitest';

import type { PropsWithChildren } from 'react';
import type { LocalMessage, Thread as StreamThread, ThreadState } from 'stream-chat';

import { ThreadProvider } from '../../../Threads';
import { useCanPaginateReplies } from '../useCanPaginateReplies';
import { generateMessage } from '../../../../mock-builders';

const makeThread = ({
items,
replyCount,
}: {
items?: LocalMessage[];
replyCount: number;
}) =>
fromPartial<StreamThread>({
messagePaginator: {
state: new StateStore<{ items: LocalMessage[] | undefined }>({ items }),
},
state: new StateStore<ThreadState>(fromPartial<ThreadState>({ replyCount })),
});

const renderWithThread = (thread?: StreamThread) =>
renderHook(() => useCanPaginateReplies(), {
wrapper: ({ children }: PropsWithChildren) => (
<ThreadProvider thread={thread}>{children}</ThreadProvider>
),
});

describe('useCanPaginateReplies', () => {
it('allows pagination outside a thread', () => {
const { result } = renderWithThread(undefined);

expect(result.current).toBe(true);
});

it('refuses on a thread with no replies', () => {
// Nothing to fetch, and until the first reply the thread does not exist server-side.
const { result } = renderWithThread(makeThread({ items: undefined, replyCount: 0 }));

expect(result.current).toBe(false);
});

it('refuses when the list already holds every reply', () => {
// The state right after the first reply is sent: it is ingested locally and the parent's count
// has caught up, so arming the scroller would fetch a page that is already in hand.
const { result } = renderWithThread(
makeThread({ items: [generateMessage() as LocalMessage], replyCount: 1 }),
);

expect(result.current).toBe(false);
});

it('allows pagination when the parent reports replies the list does not hold', () => {
const { result } = renderWithThread(
makeThread({ items: [generateMessage() as LocalMessage], replyCount: 5 }),
);

expect(result.current).toBe(true);
});

it('refuses while nothing is loaded, even on a thread that has replies', () => {
// The first page belongs to `Thread.reload()` (`GET /threads/:id`, which also hydrates and
// watches). Arming here would fetch the same page again as `GET /messages/:id/replies`.
const { result } = renderWithThread(makeThread({ items: undefined, replyCount: 2 }));

expect(result.current).toBe(false);
});

it('refuses on a reopened thread whose paginator was disposed', () => {
// `unregisterSubscriptions` leaves `items` as `[]` rather than `undefined`; the stale reload
// provides the first page, so the scroller still must not race it.
const { result } = renderWithThread(makeThread({ items: [], replyCount: 2 }));

expect(result.current).toBe(false);
});

it('arms once a page is loaded and the parent reports more', () => {
const thread = makeThread({ items: undefined, replyCount: 120 });
const { rerender, result } = renderWithThread(thread);
expect(result.current).toBe(false);

thread.messagePaginator.state.partialNext({
items: Array.from({ length: 50 }, () => generateMessage() as LocalMessage),
});
rerender();

expect(result.current).toBe(true);
});
});
53 changes: 53 additions & 0 deletions src/components/MessageList/hooks/useCanPaginateReplies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useThreadContext } from '../../Threads';
import { useStateStore } from '../../../store';

import type { LocalMessage, ThreadState } from 'stream-chat';

const threadSelector = ({ replyCount }: ThreadState) => ({ replyCount });

const paginatorSelector = ({ items }: { items: LocalMessage[] | undefined }) => ({
loadedCount: items?.length ?? 0,
});

/**
* Whether the list this hook is rendered in has replies left to fetch by scrolling.
*
* Always `true` outside a thread — a channel list paginates regardless.
*
* It exists because of the shape of a short list: it sits within the scroll threshold of BOTH its
* top and its bottom, so the infinite scroller asks for a page in each direction as soon as it
* observes its own size. The reply paginator cannot refuse while it has never queried — "more
* headward/tailward" is optimistically true then, which is right in general and wrong here. The
* counts are the missing piece, and they live on the thread.
*
* Two rules, in order:
*
* - **Nothing loaded yet → no.** The first page is the thread's own job: `Thread.reload()` fetches
* it through `GET /threads/:id`, which hydrates participants, read state and a watch alongside
* the replies. Arming here would ask for the same page again through
* `GET /messages/:id/replies`. A thread with no replies at all is the same rule — there is
* nothing to load, and until the first reply the thread does not exist server-side.
* - **Otherwise, only when the parent reports replies the list does not hold.** Which also covers
* the moment the first reply is sent: it is ingested locally and the count catches up, so there
* is nothing left to ask for.
*
* The count is the raw window length, so a message the server never acknowledged (a failed send,
* say) counts toward it. That can only under-arm, and only for a window that is BOTH partially
* loaded and padded with enough local-only messages to reach `reply_count` — narrow enough not to
* pay for a per-emission scan of the list.
*
* Not sticky: `replyCount` projects the parent message's `reply_count`, which the server keeps
* current over the WS, and `loadedCount` follows the paginator, so both re-evaluate on their own.
*/
export const useCanPaginateReplies = (): boolean => {
const thread = useThreadContext();
const { replyCount } = useStateStore(thread?.state, threadSelector) ?? {};
const { loadedCount } = useStateStore(
thread?.messagePaginator?.state,
paginatorSelector,
) ?? { loadedCount: 0 };

if (!thread) return true;
if (loadedCount === 0) return false;
return (replyCount ?? 0) > loadedCount;
};
20 changes: 17 additions & 3 deletions src/components/Thread/Thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export const Thread = (props: ThreadProps) => {
const selector = (nextValue: ThreadState) => ({
isStateStale: nextValue.isStateStale,
parentMessage: nextValue.parentMessage,
replyCount: nextValue.replyCount,
});

const messagePaginatorSelector = ({
Expand Down Expand Up @@ -107,7 +108,7 @@ const ThreadInner = (props: ThreadProps & { key: string }) => {
const { ThreadHead = DefaultThreadHead, ThreadHeader = DefaultThreadHeader } =
useComponentContext();

const { isStateStale, parentMessage } =
const { isStateStale, parentMessage, replyCount } =
useStateStore(threadInstance?.state, selector) ?? {};
const threadPaginatorState = useStateStore(
threadInstance?.messagePaginator?.state,
Expand Down Expand Up @@ -137,24 +138,37 @@ const ThreadInner = (props: ThreadProps & { key: string }) => {
// which the virtualized list applies to its own subtree), so nothing is resolved here.
const ThreadMessageList = virtualized ? VirtualizedMessageList : MessageList;

// A thread exists server-side only once its parent has a reply, so loading one with `replyCount`
// 0 is a request that can only 404 — `Thread.reload()` swallows exactly that and returns without
// state, so it buys nothing.
//
// This defers the load, it does not cancel it. `isStateStale` is only cleared by a successful
// reload (`thread.ts:589`), so while a thread stays stale, `replyCount` flipping to > 0 re-runs
// the effect below and the catch-up happens then. That covers the `user.watching.stop` case: we
// learn about replies missed while unwatched as soon as the parent message copy is refreshed,
// which is the same moment every other reply-count affordance in the UI learns about them.
const hasServerSideThread = (replyCount ?? 0) > 0;

useEffect(() => {
if (!threadInstance) return;
if (isThreadManaged) return;
if (!hasServerSideThread) return;
if (threadPaginatorState?.items !== undefined || threadPaginatorState?.isLoading)
return;
void threadInstance.reload();
}, [
hasServerSideThread,
isThreadManaged,
threadInstance,
threadPaginatorState?.isLoading,
threadPaginatorState?.items,
]);

useEffect(() => {
if (threadInstance && isStateStale) {
if (threadInstance && isStateStale && hasServerSideThread) {
void threadInstance.reload();
}
}, [isStateStale, threadInstance]);
}, [hasServerSideThread, isStateStale, threadInstance]);

useEffect(() => {
if (!threadInstance || isThreadManaged) return;
Expand Down
Loading