11import { StateStore } from './store' ;
2- import { addToMessageList , findIndexInSortedArray , formatMessage } from './utils' ;
2+ import { formatMessage } from './utils' ;
33import 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';
2625import { WithSubscriptions } from './utils/WithSubscriptions' ;
2726import { MessagePaginator } from './pagination' ;
2827
29- type QueryRepliesOptions = {
30- sort ?: { created_at : AscDesc } [ ] ;
31- } & MessagePaginationOptions & { user ?: UserResponse ; user_id ?: string } ;
32-
3328export 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-
6652export 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
910791type 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-
972837const ownUnreadCountSelector =
973838 ( currentUserId : string | undefined ) => ( state : ThreadState ) =>
974839 ( currentUserId && state . read [ currentUserId ] ?. unreadMessageCount ) || 0 ;
0 commit comments