diff --git a/src/components/Reactions/MessageReactionsDetail.tsx b/src/components/Reactions/MessageReactionsDetail.tsx index beef3250d..8d49d6ac7 100644 --- a/src/components/Reactions/MessageReactionsDetail.tsx +++ b/src/components/Reactions/MessageReactionsDetail.tsx @@ -2,7 +2,6 @@ import React, { useMemo, useState } from 'react'; import type { ReactionSummary, ReactionType } from './types'; -import { useFetchReactions } from './hooks/useFetchReactions'; import { Avatar as DefaultAvatar } from '../Avatar'; import type { MessageContextValue } from '../../context'; import { @@ -14,8 +13,11 @@ import { import type { ReactionSort } from 'stream-chat'; import { defaultReactionOptions } from './reactionOptions'; import type { useProcessReactions } from './hooks/useProcessReactions'; +import { useReactionPaginator } from './hooks/useReactionPaginator'; import { IconEmojiAdd } from '../Icons'; import { ReactionSelector, type ReactionSelectorProps } from './ReactionSelector'; +import { LoadMorePaginator } from '../LoadMore'; +import { Button } from '../Button'; export type MessageReactionsDetailProps = Partial< Pick @@ -52,7 +54,6 @@ interface MessageReactionsDetailInterface { } export const MessageReactionsDetail: MessageReactionsDetailInterface = ({ - handleFetchReactions, handleReaction, onSelectedReactionTypeChange, own_reactions, @@ -62,8 +63,8 @@ export const MessageReactionsDetail: MessageReactionsDetailInterface = ({ selectedReactionType, totalReactionCount, }) => { - const [extendedReactionListOpen, setExtendedReactionListOpen] = useState(false); const { client } = useChatContext(); + const [extendedReactionListOpen, setExtendedReactionListOpen] = useState(false); const { Avatar = DefaultAvatar, LoadingIndicator = MessageReactionsDetailLoadingIndicator, @@ -82,13 +83,13 @@ export const MessageReactionsDetail: MessageReactionsDetailInterface = ({ propReactionDetailsSort ?? contextReactionDetailsSort ?? defaultReactionDetailsSort; const { + hasNext, isLoading: areReactionsLoading, + paginator, reactions: reactionDetails, refetch, - } = useFetchReactions({ - handleFetchReactions, + } = useReactionPaginator({ reactionType: selectedReactionType, - shouldFetch: true, sort: reactionDetailsSort, }); @@ -191,8 +192,27 @@ export const MessageReactionsDetail: MessageReactionsDetailInterface = ({ className='str-chat__message-reactions-detail__user-list' data-testid='all-reacting-users' > - {areReactionsLoading && } - {!areReactionsLoading && ( + { + if (isLoading) return null; + + return ( + + ); + }} + loadNextPage={paginator.next} + > <> {reactionDetails.map(({ type, user }) => { const belongsToCurrentUser = client.user?.id === user?.id; @@ -257,7 +277,8 @@ export const MessageReactionsDetail: MessageReactionsDetailInterface = ({ ); })} - )} + + {areReactionsLoading && } diff --git a/src/components/Reactions/hooks/ReactionPaginator.ts b/src/components/Reactions/hooks/ReactionPaginator.ts new file mode 100644 index 000000000..525a7a052 --- /dev/null +++ b/src/components/Reactions/hooks/ReactionPaginator.ts @@ -0,0 +1,74 @@ +import { + BasePaginator, + type PaginationQueryParams, + type PaginatorOptions, + type ReactionFilters, + type ReactionResponse, + type ReactionSort, + type StreamChat, +} from 'stream-chat'; + +export class ReactionPaginator extends BasePaginator { + private client: StreamChat; + private messageId: string; + private _filters: ReactionFilters; + private _sort: ReactionSort; + protected usesCursorPagination = true; + + get filters(): ReactionFilters | undefined { + return this._filters; + } + + get sort(): ReactionSort | undefined { + return this._sort; + } + + set filters(filters: ReactionFilters) { + this._filters = filters; + this.invalidate(); + } + + set sort(sort: ReactionSort) { + this._sort = sort; + this.invalidate(); + } + + constructor({ + client, + messageId, + options, + }: { + client: StreamChat; + messageId: string; + options?: PaginatorOptions; + }) { + super(options); + this.client = client; + this.messageId = messageId; + this._filters = {}; + this._sort = { created_at: -1 }; + } + + async query(params: PaginationQueryParams) { + const direction = params.direction; + + const response = await this.client.queryReactions( + this.messageId, + this._filters, + this._sort, + { + [direction]: direction === 'next' ? params.next : params.prev, + limit: this.pageSize, + }, + ); + + return { + items: response.reactions, + next: response.next, + }; + } + + public filterQueryResults(items: ReactionResponse[]) { + return items; + } +} diff --git a/src/components/Reactions/hooks/useReactionPaginator.ts b/src/components/Reactions/hooks/useReactionPaginator.ts new file mode 100644 index 000000000..efe23a0e3 --- /dev/null +++ b/src/components/Reactions/hooks/useReactionPaginator.ts @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useRef } from 'react'; +import type { PaginatorState, ReactionResponse, ReactionSort } from 'stream-chat'; + +import { useChatContext, useMessageContext } from '../../../context'; +import { ReactionPaginator } from './ReactionPaginator'; +import { useStateStore } from '../../../store'; +import type { ReactionType } from '../types'; + +export interface FetchReactionsOptions { + reactionType: ReactionType | null; + sort?: ReactionSort; +} + +const STABLE_ARRAY: ReactionResponse[] = []; +const reactionSelector = (currentState: PaginatorState) => ({ + hasNext: currentState.hasNext, + isLoading: currentState.isLoading, + reactions: currentState.items ?? STABLE_ARRAY, +}); + +// use null symbol instead of the actual null for the paginator key +const nullSymbol = Symbol('null'); + +export function useReactionPaginator({ reactionType, sort }: FetchReactionsOptions) { + const { client } = useChatContext(); + const { message } = useMessageContext('useReactionPaginator'); + + const paginatorByTypeRef = useRef<{ + [key: string | symbol]: ReactionPaginator | undefined; + }>({}); + + const normalizedReactionType = reactionType === null ? nullSymbol : reactionType; + + const getOrCreateInstance = () => { + const existingInstance = paginatorByTypeRef.current[normalizedReactionType]; + + if (existingInstance) return existingInstance; + + const instance = new ReactionPaginator({ + client, + messageId: message.id, + options: { pageSize: 25 }, + }); + if (reactionType) instance.filters = { type: reactionType }; + if (sort) instance.sort = sort; + + return (paginatorByTypeRef.current[normalizedReactionType] = instance); + }; + + const selectedPaginator = getOrCreateInstance(); + + const { hasNext, isLoading, reactions } = useStateStore( + selectedPaginator.state, + reactionSelector, + ); + + const refetch = useCallback(() => { + selectedPaginator.invalidate(); + selectedPaginator.next(); + }, [selectedPaginator]); + + useEffect(() => { + const data = selectedPaginator.state.getLatestValue().items; + + if (data?.length) return; + + selectedPaginator.next(); + }, [selectedPaginator]); + + return { + hasNext, + isLoading, + paginator: selectedPaginator, + reactions, + refetch, + } as const; +} diff --git a/src/components/Reactions/styling/MessageReactionsDetail.scss b/src/components/Reactions/styling/MessageReactionsDetail.scss index b8f28634a..e40fd7452 100644 --- a/src/components/Reactions/styling/MessageReactionsDetail.scss +++ b/src/components/Reactions/styling/MessageReactionsDetail.scss @@ -103,6 +103,15 @@ position: relative; padding-block-end: var(--str-chat__spacing-xxs); max-height: 180px; + display: flex; + flex-direction: column; + + .str-chat__button.str-chat__button--load-more { + align-self: center; + flex-shrink: 0; + margin-block-end: var(--str-chat__spacing-xs); + margin-block-start: var(--str-chat__spacing-xxs); + } .str-chat__message-reactions-detail__skeleton-item { padding-block: var(--str-chat__spacing-xxs);