Skip to content

Commit 7f0506c

Browse files
authored
fix: clean up thread state remnants (#1804)
## CLA - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required). - [ ] Code changes are tested ## Description of the changes, What, Why and How? ## Changelog -
1 parent 720519d commit 7f0506c

6 files changed

Lines changed: 216 additions & 521 deletions

File tree

src/channel.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2369,12 +2369,18 @@ export class Channel {
23692369
if (event.message) {
23702370
this._extendEventWithOwnReactions(event);
23712371
const formattedMessage = formatMessage(event.message);
2372+
const isThreadReply =
2373+
!!event.message.parent_id && !event.message.show_in_channel;
23722374
if (event.hard_delete) {
23732375
channelState.removeMessage(event.message);
2374-
this.messagePaginator.removeItem({ id: event.message.id });
2376+
if (!isThreadReply) {
2377+
this.messagePaginator.removeItem({ id: event.message.id });
2378+
}
23752379
} else {
23762380
channelState.addMessageSorted(event.message, false, false);
2377-
this.messagePaginator.ingestItem(formattedMessage);
2381+
if (!isThreadReply) {
2382+
this.messagePaginator.ingestItem(formattedMessage);
2383+
}
23782384
}
23792385
this.messagePaginator.reflectQuotedMessageUpdate(formattedMessage);
23802386

src/index.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,7 @@ export * from './segment';
2525
export * from './signing';
2626
export * from './store';
2727
export { Thread } from './thread';
28-
export type {
29-
ThreadState,
30-
ThreadReadState,
31-
ThreadRepliesPagination,
32-
ThreadUserReadState,
33-
} from './thread';
28+
export type { ThreadState, ThreadReadState, ThreadUserReadState } from './thread';
3429
export * from './thread_manager';
3530
export * from './token_manager';
3631
export * from './types';

src/messageDelivery/MessageDeliveryReporter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ export class MessageDeliveryReporter {
140140
lastDeliveredAt = ownReadState?.last_delivered_at;
141141
key = collection.cid;
142142
} else if (isThread(collection)) {
143-
latestMessages = collection.state.getLatestValue().replies;
143+
latestMessages = collection.messagePaginator.state.getLatestValue().items ?? [];
144144
const ownReadState =
145145
collection.state.getLatestValue().read[ownUserId] ?? ({} as ThreadUserReadState);
146146
lastReadAt = ownReadState?.lastReadAt;

src/thread.ts

Lines changed: 23 additions & 158 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { StateStore } from './store';
2-
import { addToMessageList, findIndexInSortedArray, formatMessage } from './utils';
2+
import { formatMessage } from './utils';
33
import type {
44
AscDesc,
55
DraftResponse,
66
EventAPIResponse,
77
EventTypes,
88
LocalMessage,
99
MarkReadOptions,
10-
MessagePaginationOptions,
1110
MessageResponse,
1211
ReadResponse,
1312
ThreadResponse,
@@ -26,10 +25,6 @@ import { MessageOperations } from './messageOperations';
2625
import { WithSubscriptions } from './utils/WithSubscriptions';
2726
import { MessagePaginator } from './pagination';
2827

29-
type QueryRepliesOptions = {
30-
sort?: { created_at: AscDesc }[];
31-
} & MessagePaginationOptions & { user?: UserResponse; user_id?: string };
32-
3328
export type ThreadState = {
3429
/**
3530
* Determines if the thread is currently opened and on-screen. When the thread is active,
@@ -42,27 +37,18 @@ export type ThreadState = {
4237
deletedAt: Date | null;
4338
isLoading: boolean;
4439
isStateStale: boolean;
45-
pagination: ThreadRepliesPagination;
4640
/**
4741
* Thread is identified by and has a one-to-one relation with its parent message.
4842
* We use parent message id as a thread id.
4943
*/
5044
parentMessage: LocalMessage;
5145
participants: ThreadResponse['thread_participants'];
5246
read: ThreadReadState;
53-
replies: Array<LocalMessage>;
5447
replyCount: number;
5548
title: string;
5649
updatedAt: Date | null;
5750
};
5851

59-
export type ThreadRepliesPagination = {
60-
isLoadingNext: boolean;
61-
isLoadingPrev: boolean;
62-
nextCursor: string | null;
63-
prevCursor: string | null;
64-
};
65-
6652
export type ThreadUserReadState = {
6753
lastReadAt: Date;
6854
unreadMessageCount: number;
@@ -175,16 +161,19 @@ export class Thread extends WithSubscriptions {
175161
createdAt: new Date(threadData.created_at),
176162
// rest
177163
deletedAt: threadData.deleted_at ? new Date(threadData.deleted_at) : null,
178-
pagination: repliesPaginationFromInitialThread(threadData),
179164
parentMessage: formatMessage(threadData.parent_message),
180165
participants: threadData.thread_participants,
181166
read: formatReadState(
182167
!threadData.read || threadData.read.length === 0
183168
? getPlaceholderReadResponse(client.userID)
184169
: threadData.read,
185170
),
186-
replies: threadData.latest_replies.map(formatMessage),
187-
replyCount: threadData.reply_count ?? 0,
171+
// Use the parent message's reply_count, not the top-level threadData.reply_count. The
172+
// thread endpoints (getThread/queryThreads) return a top level reply_count that EXCLUDES
173+
// soft-deleted replies, while parent_message.reply_count (and the channel's own copy)
174+
// INCLUDE them so the top level value renders fewer replies than the channel badge shows.
175+
// parent_message.reply_count is the authoritative, channel consistent count.
176+
replyCount: threadData.parent_message.reply_count ?? 0,
188177
updatedAt: threadData.updated_at ? new Date(threadData.updated_at) : null,
189178
title: threadData.title,
190179
custom: constructCustomDataObject(threadData),
@@ -215,16 +204,9 @@ export class Thread extends WithSubscriptions {
215204
deletedAt: formattedParentMessage.deleted_at,
216205
isLoading: false,
217206
isStateStale: false,
218-
pagination: {
219-
isLoadingNext: false,
220-
isLoadingPrev: false,
221-
nextCursor: null,
222-
prevCursor: null,
223-
},
224207
parentMessage: formattedParentMessage,
225208
participants: [],
226209
read: formatReadState(getPlaceholderReadResponse(client.userID)),
227-
replies: [],
228210
replyCount: parentMessage.reply_count ?? 0,
229211
title: '',
230212
updatedAt: parentMessage.updated_at ? new Date(parentMessage.updated_at) : null,
@@ -385,16 +367,16 @@ export class Thread extends WithSubscriptions {
385367
custom,
386368
title,
387369
deletedAt,
388-
pagination,
389370
parentMessage,
390371
participants,
391372
read,
392373
replyCount,
393-
replies,
394374
updatedAt,
395375
} = thread.state.getLatestValue();
396376

397-
// Preserve pending replies and append them to the updated list of replies
377+
// Preserve pending (failed) replies so they survive the hydrate. The messagePaginator is now
378+
// the sole reply source, so we merge the incoming newest page into it and re-ingest the
379+
// pending replies (mirrors the previous state.replies concat behavior).
398380
const pendingReplies = Array.from(this.failedRepliesMap.values());
399381

400382
this.state.partialNext({
@@ -406,13 +388,14 @@ export class Thread extends WithSubscriptions {
406388
participants,
407389
read,
408390
replyCount,
409-
pagination,
410-
replies: pendingReplies.length ? replies.concat(pendingReplies) : replies,
411391
updatedAt,
412392
isStateStale: false,
413393
});
414394

415-
this.messagePaginator.mergeNewestPage(replies);
395+
this.messagePaginator.mergeNewestPage(
396+
thread.messagePaginator.state.getLatestValue().items ?? [],
397+
);
398+
pendingReplies.forEach((reply) => this.messagePaginator.ingestItem(reply));
416399
};
417400

418401
public registerSubscriptions = () => {
@@ -520,23 +503,15 @@ export class Thread extends WithSubscriptions {
520503
}
521504

522505
const isOwnMessage = event.message.user?.id === this.client.userID;
523-
const { active, read, replies } = this.state.getLatestValue();
524-
const hasReplyAlready =
525-
replies.some((reply) => reply.id === event.message?.id) ||
526-
!!this.messagePaginator.getItem(event.message.id);
506+
const { active, read } = this.state.getLatestValue();
527507

528-
this.messagePaginator.ingestItem(formatMessage(event.message));
529508
this.upsertReplyLocally({
530509
message: event.message,
531510
// Message from current user could have been added optimistically,
532511
// so the actual timestamp might differ in the event
533512
timestampChanged: isOwnMessage,
534513
});
535514

536-
if (!hasReplyAlready) {
537-
this.incrementReplyCountLocally();
538-
}
539-
540515
if (active) {
541516
this.throttledMarkRead();
542517
}
@@ -650,14 +625,6 @@ export class Thread extends WithSubscriptions {
650625
this.client.on(eventType, (event) => {
651626
if (event.message) {
652627
this.updateParentMessageOrReplyLocally(event.message);
653-
if (
654-
['reaction.new', 'reaction.deleted', 'reaction.updated'].includes(
655-
eventType,
656-
) &&
657-
event.message.parent_id === this.id
658-
) {
659-
this.messagePaginator.ingestItem(formatMessage(event.message));
660-
}
661628
this.messagePaginator.reflectQuotedMessageUpdate(
662629
formatMessage(event.message),
663630
);
@@ -676,34 +643,18 @@ export class Thread extends WithSubscriptions {
676643

677644
// todo: can be removed with the next breaking change and use MessagePaginator only
678645
public deleteReplyLocally = ({ message }: { message: MessageResponse }) => {
679-
const { replies } = this.state.getLatestValue();
680-
681-
const index = findIndexInSortedArray({
682-
needle: formatMessage(message),
683-
sortedArray: replies,
684-
sortDirection: 'ascending',
685-
selectValueToCompare: (reply) => reply.created_at.getTime(),
686-
selectKey: (reply) => reply.id,
687-
});
688-
689-
if (replies[index]?.id !== message.id) {
690-
return;
691-
}
692-
693-
const updatedReplies = [...replies];
694-
updatedReplies.splice(index, 1);
695-
696-
this.state.partialNext({
697-
replies: updatedReplies,
698-
});
646+
// The reply messagePaginator is the reply list source. removeItem is a no-op when the reply
647+
// isn't loaded, so it's safe to run unconditionally.
648+
this.messagePaginator.removeItem({ id: message.id });
699649
};
700650

701651
// todo: can be removed with the next breaking change and use MessagePaginator only
702652
public upsertReplyLocally = ({
703653
message,
704-
timestampChanged = false,
705654
}: {
706655
message: MessageResponse | LocalMessage;
656+
// Accepted for backward compatibility but no longer used — the messagePaginator repositions
657+
// by created_at on ingest, so a changed timestamp is handled without an explicit flag.
707658
timestampChanged?: boolean;
708659
}) => {
709660
if (message.parent_id !== this.id) {
@@ -720,10 +671,8 @@ export class Thread extends WithSubscriptions {
720671
this.failedRepliesMap.delete(message.id);
721672
}
722673

723-
this.state.next((current) => ({
724-
...current,
725-
replies: addToMessageList(current.replies, formattedMessage, timestampChanged),
726-
}));
674+
// The reply messagePaginator is the reply list source.
675+
this.messagePaginator.ingestItem(formattedMessage);
727676
};
728677

729678
// todo: can be removed with the next breaking change and use MessagePaginator only
@@ -795,9 +744,7 @@ export class Thread extends WithSubscriptions {
795744
/**
796745
* Updates a message with optimistic local state update.
797746
*
798-
* NOTE: This updates message state via `messagePaginator` only. If you still rely on
799-
* `Thread.state.replies` as UI source of truth, make sure it is wired to paginator updates
800-
* (or keep upserting separately until migration is complete).
747+
* The update flows through `messagePaginator`, which is the sole reply source.
801748
*/
802749
async updateMessageWithLocalUpdate(params: UpdateMessageWithStateUpdateParams) {
803750
await this.messageOperations.update(
@@ -839,72 +786,6 @@ export class Thread extends WithSubscriptions {
839786
*/
840787
public markAsRead = ({ force = false }: { force?: boolean } = {}) =>
841788
this.markRead({ force });
842-
843-
// todo: can be removed with the next breaking change and use MessagePaginator only
844-
public queryReplies = ({
845-
limit = DEFAULT_PAGE_LIMIT,
846-
sort = DEFAULT_SORT,
847-
...otherOptions
848-
}: QueryRepliesOptions = {}) =>
849-
this.channel.getReplies(this.id, { limit, ...otherOptions }, sort);
850-
851-
// todo: can be removed with the next breaking change and use MessagePaginator only
852-
public loadNextPage = ({ limit = DEFAULT_PAGE_LIMIT }: { limit?: number } = {}) =>
853-
this.loadPage(limit);
854-
855-
// todo: can be removed with the next breaking change and use MessagePaginator only
856-
public loadPrevPage = ({ limit = DEFAULT_PAGE_LIMIT }: { limit?: number } = {}) =>
857-
this.loadPage(-limit);
858-
// todo: can be removed with the next breaking change and use MessagePaginator only
859-
private loadPage = async (count: number) => {
860-
const { pagination } = this.state.getLatestValue();
861-
const [loadingKey, cursorKey, insertionMethodKey] =
862-
count > 0
863-
? (['isLoadingNext', 'nextCursor', 'push'] as const)
864-
: (['isLoadingPrev', 'prevCursor', 'unshift'] as const);
865-
866-
if (pagination[loadingKey] || pagination[cursorKey] === null) return;
867-
868-
const queryOptions = { [count > 0 ? 'id_gt' : 'id_lt']: pagination[cursorKey] };
869-
const limit = Math.abs(count);
870-
871-
this.state.partialNext({ pagination: { ...pagination, [loadingKey]: true } });
872-
873-
try {
874-
const data = await this.queryReplies({ ...queryOptions, limit });
875-
const replies = data.messages.map(formatMessage);
876-
const maybeNextCursor = replies.at(count > 0 ? -1 : 0)?.id ?? null;
877-
878-
this.state.next((current) => {
879-
let nextReplies = current.replies;
880-
881-
// prevent re-creating array if there's nothing to add to the current one
882-
if (replies.length > 0) {
883-
nextReplies = [...current.replies];
884-
nextReplies[insertionMethodKey](...replies);
885-
}
886-
887-
return {
888-
...current,
889-
replies: nextReplies,
890-
pagination: {
891-
...current.pagination,
892-
[cursorKey]: data.messages.length < limit ? null : maybeNextCursor,
893-
[loadingKey]: false,
894-
},
895-
};
896-
});
897-
} catch (error) {
898-
this.client.logger('error', (error as Error).message);
899-
this.state.next((current) => ({
900-
...current,
901-
pagination: {
902-
...current.pagination,
903-
[loadingKey]: false,
904-
},
905-
}));
906-
}
907-
};
908789
}
909790

910791
type MessageThreadParticipant = NonNullable<
@@ -953,22 +834,6 @@ const getPlaceholderReadResponse = (currentUserId?: string): ReadResponse[] =>
953834
]
954835
: [];
955836

956-
const repliesPaginationFromInitialThread = (
957-
thread: ThreadResponse,
958-
): ThreadRepliesPagination => {
959-
const latestRepliesContainsAllReplies =
960-
thread.latest_replies.length === thread.reply_count;
961-
962-
return {
963-
nextCursor: null,
964-
prevCursor: latestRepliesContainsAllReplies
965-
? null
966-
: (thread.latest_replies.at(0)?.id ?? null),
967-
isLoadingNext: false,
968-
isLoadingPrev: false,
969-
};
970-
};
971-
972837
const ownUnreadCountSelector =
973838
(currentUserId: string | undefined) => (state: ThreadState) =>
974839
(currentUserId && state.read[currentUserId]?.unreadMessageCount) || 0;

0 commit comments

Comments
 (0)