Skip to content
Merged
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
24 changes: 20 additions & 4 deletions src/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import type { MessageOperations } from './messageOperations';
import { nowNs } from './utils/time';
import { WithSubscriptions } from './utils/WithSubscriptions';
import { MessagePaginator } from './pagination';
import type { MergeNewestPageOptions } from './pagination';
import type { MergeNewestPageOptions, PaginatorCursor } from './pagination';
import { applyInstanceConfiguration } from './configuration/utils/applyInstanceConfiguration';
import { ConfigController } from './configuration/ConfigController';
import { deepFreezeConfig } from './configuration/utils/deepFreezeConfig';
Expand All @@ -37,6 +37,17 @@ import {
} from './configuration/utils/declarativeSlices';
import type { PipelineEvent } from './EventHandlerPipeline';

const getCursorFromReplies = (
replies: LocalMessage[],
reachesTail: boolean,
): PaginatorCursor => {
const oldest = replies.reduce<LocalMessage | undefined>(
(acc, reply) => (!acc || reply.created_at < acc.created_at ? reply : acc),
undefined,
);
return { headward: null, tailward: reachesTail ? null : (oldest?.id ?? null) };
};

export type ThreadState = {
/**
* Determines if the thread is currently opened and on-screen. When the thread is active,
Expand Down Expand Up @@ -249,10 +260,13 @@ export class Thread extends WithMessageOperations(WithSubscriptions) {
// tail only when it already contains every reply. Threads built from a bare parent message
// carry no replies to seed and fall back to a paginator fetch on first open.
if (threadData?.latest_replies?.length) {
const replies = threadData.latest_replies.map(formatMessage);
const isLastPage = replies.length === (threadData.reply_count ?? 0);
this.messagePaginator.setItems({
valueOrFactory: threadData.latest_replies.map(formatMessage),
valueOrFactory: replies,
cursor: getCursorFromReplies(replies, isLastPage),
isFirstPage: true,
isLastPage: threadData.latest_replies.length === (threadData.reply_count ?? 0),
isLastPage,
});
}

Expand Down Expand Up @@ -503,10 +517,12 @@ export class Thread extends WithMessageOperations(WithSubscriptions) {
// anchored head and leaves seeding to the query path), so hydrating through it would drop the
// replies this request just fetched and leave the panel empty until something else queried
// them. Seed instead, exactly as the constructor does for a thread response's `latest_replies`.
const isLastPage = incomingReplies.length === replyCount;
this.messagePaginator.setItems({
valueOrFactory: incomingReplies,
cursor: getCursorFromReplies(incomingReplies, isLastPage),
isFirstPage: true,
isLastPage: incomingReplies.length === replyCount,
isLastPage,
});
} else {
this.messagePaginator.mergeNewestPage(incomingReplies, options?.reconcile);
Expand Down
61 changes: 61 additions & 0 deletions test/unit/threads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,36 @@ describe('Threads 2.0', () => {
expect(paginatorState.hasMoreTail).to.be.false;
});

it('continues from the oldest hydrated reply when a thread opened from a message loads', async () => {
// The seed has to carry the cursor a query would have set. Without it the first toTail()
// refetches the newest page, and RN, which pages once per list length, never asks again.
const thread = createMinimalThread();
const newer = generateMsg({
parent_id: parentMessageResponse.id,
created_at: convertDateToTimestamp('2020-01-04T00:00:00.000Z'),
}) as MessageResponse;
const older = generateMsg({
parent_id: parentMessageResponse.id,
created_at: convertDateToTimestamp('2020-01-03T00:00:00.000Z'),
}) as MessageResponse;
const hydrationThread = createTestThread({
latest_replies: [newer, older],
parentMessageOverrides: { reply_count: 5 },
reply_count: 5,
});

thread.hydrateState(hydrationThread);
const getRepliesStub = sinon
.stub(thread.channel.getClient(), 'getReplies')
.resolves({ messages: [], duration: '' } as unknown as ReturnType<
StreamChat['getReplies']
>);

await thread.messagePaginator.toTail();

expect(getRepliesStub.firstCall.args[0].id_lt).to.equal(older.id);
});

it('copies state of the instance with the same id', () => {
const thread = createTestThread();
const hydrationThread = createTestThread();
Expand Down Expand Up @@ -1103,6 +1133,37 @@ describe('Threads 2.0', () => {
// paginator's own suite covers the query-shape/cursor mechanics; these assert the end-to-end
// wiring through a real Thread (seeded from latest_replies) which the paginator suite doesn't.
describe('reply pagination (messagePaginator)', () => {
it('continues from the oldest seeded reply on the first toTail() (id_lt), not from the newest page', async () => {
// Seeded out of order on purpose: the cursor is the oldest by created_at, not latest_replies[0].
const newest = makeReply({
created_at: convertDateToTimestamp('2020-01-04T00:00:00.000Z'),
});
const oldestSeeded = makeReply({
created_at: convertDateToTimestamp('2020-01-03T00:00:00.000Z'),
});
const thread = createTestThread({
latest_replies: [newest, oldestSeeded],
reply_count: 5,
});
const getRepliesStub = sinon
.stub(thread.channel.getClient(), 'getReplies')
.resolves({ messages: [], duration: '' } as unknown as ReturnType<
StreamChat['getReplies']
>);

await thread.messagePaginator.toTail();

expect(getRepliesStub.firstCall.args[0].id_lt).to.equal(oldestSeeded.id);
});

it('seeds no tailward cursor when latest_replies already holds every reply', () => {
const thread = createTestThread({
latest_replies: [makeReply(), makeReply()],
reply_count: 2,
});
expect(thread.messagePaginator.cursor?.tailward).to.be.null;
});

it('loads older replies via toTail() and scopes the request to the thread parent', async () => {
// Seeded newest window with older replies still to load (reply_count > loaded).
const newest = makeReply({
Expand Down
Loading