diff --git a/docusaurus/docs/reactnative/ui-components/overlay-reactions.mdx b/docusaurus/docs/reactnative/ui-components/overlay-reactions.mdx index a46d23d3d4..f316ab61e6 100644 --- a/docusaurus/docs/reactnative/ui-components/overlay-reactions.mdx +++ b/docusaurus/docs/reactnative/ui-components/overlay-reactions.mdx @@ -17,6 +17,14 @@ This is the default component provided to the prop [`OverlayReactions`](../core- +### `messageId` + +The message ID for which the reactions are displayed. + +| Type | Default | +| ----------------------- | ----------- | +| `String` \| `undefined` | `undefined` | + ### `reactions` List of existing reactions which can be extracted from a message. @@ -31,9 +39,9 @@ const reactions = message.latest_reactions.map(reaction => ({ })); ``` -| Type | -| ----- | -| Array | +| Type | Default | +| ---------------------- | ----------- | +| `Array` \| `undefined` | `undefined` | ###
required
`showScreen` {#showscreen} diff --git a/package/src/components/MessageOverlay/MessageOverlay.tsx b/package/src/components/MessageOverlay/MessageOverlay.tsx index 6ede17f01d..49d9c6d739 100644 --- a/package/src/components/MessageOverlay/MessageOverlay.tsx +++ b/package/src/components/MessageOverlay/MessageOverlay.tsx @@ -44,10 +44,7 @@ import { mergeThemes, ThemeProvider, useTheme } from '../../contexts/themeContex import { useViewport } from '../../hooks/useViewport'; import type { DefaultStreamChatGenerics } from '../../types/types'; import { MessageTextContainer } from '../Message/MessageSimple/MessageTextContainer'; -import { - OverlayReactions as DefaultOverlayReactions, - Reaction, -} from '../MessageOverlay/OverlayReactions'; +import { OverlayReactions as DefaultOverlayReactions } from '../MessageOverlay/OverlayReactions'; import type { ReplyProps } from '../Reply/Reply'; const styles = StyleSheet.create({ @@ -464,26 +461,16 @@ const MessageOverlayWithContext = < message={message} /> )} - {!!messageReactionTitle && - message.latest_reactions && - message.latest_reactions.length > 0 ? ( + {!!messageReactionTitle && ( ({ - alignment: clientId && clientId === reaction.user?.id ? 'right' : 'left', - id: reaction?.user?.id || '', - image: reaction?.user?.image, - name: reaction?.user?.name || reaction.user_id || '', - type: reaction.type, - })) as Reaction[] - } showScreen={showScreen} supportedReactions={messagesContext?.supportedReactions} title={messageReactionTitle} /> - ) : null} + )} )} diff --git a/package/src/components/MessageOverlay/OverlayReactions.tsx b/package/src/components/MessageOverlay/OverlayReactions.tsx index 7c443fa59d..b71675bce1 100644 --- a/package/src/components/MessageOverlay/OverlayReactions.tsx +++ b/package/src/components/MessageOverlay/OverlayReactions.tsx @@ -1,8 +1,13 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { StyleSheet, Text, useWindowDimensions, View, ViewStyle } from 'react-native'; import { FlatList } from 'react-native-gesture-handler'; import Animated, { interpolate, useAnimatedStyle, useSharedValue } from 'react-native-reanimated'; -import Svg, { Circle } from 'react-native-svg'; + +import { ReactionSortBase } from 'stream-chat'; + +import { useFetchReactions } from './hooks/useFetchReactions'; + +import { OverlayReactionsItem } from './OverlayReactionsItem'; import type { Alignment } from '../../contexts/messageContext/MessageContext'; import type { MessageOverlayContextValue } from '../../contexts/messageOverlayContext/MessageOverlayContext'; @@ -12,7 +17,6 @@ import { LoveReaction, ThumbsDownReaction, ThumbsUpReaction, - Unknown, WutReaction, } from '../../icons'; @@ -23,21 +27,6 @@ const styles = StyleSheet.create({ avatarContainer: { padding: 8, }, - avatarInnerContainer: { - alignSelf: 'center', - }, - avatarName: { - flex: 1, - fontSize: 12, - fontWeight: '700', - paddingTop: 6, - textAlign: 'center', - }, - avatarNameContainer: { - alignItems: 'center', - flexDirection: 'row', - flexGrow: 1, - }, container: { alignItems: 'center', borderRadius: 16, @@ -52,18 +41,6 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingBottom: 12, }, - reactionBubble: { - alignItems: 'center', - borderRadius: 24, - justifyContent: 'center', - position: 'absolute', - }, - reactionBubbleBackground: { - borderRadius: 24, - height: 24, - position: 'absolute', - width: 24, - }, title: { fontSize: 16, fontWeight: '700', @@ -109,58 +86,62 @@ export type Reaction = { export type OverlayReactionsProps< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, > = Pick, 'OverlayReactionsAvatar'> & { - reactions: Reaction[]; showScreen: Animated.SharedValue; title: string; alignment?: Alignment; + messageId?: string; + reactions?: Reaction[]; supportedReactions?: ReactionData[]; }; -type ReactionIconProps = Pick & { - pathFill: string; - size: number; - supportedReactions: ReactionData[]; -}; - -const ReactionIcon = ({ pathFill, size, supportedReactions, type }: ReactionIconProps) => { - const Icon = supportedReactions.find((reaction) => reaction.type === type)?.Icon || Unknown; - return ; +const sort: ReactionSortBase = { + created_at: -1, }; /** * OverlayReactions - A high level component which implements all the logic required for message overlay reactions */ export const OverlayReactions = (props: OverlayReactionsProps) => { + const [itemHeight, setItemHeight] = React.useState(0); const { alignment: overlayAlignment, + messageId, OverlayReactionsAvatar, - reactions, + reactions: propReactions, showScreen, supportedReactions = reactionData, title, } = props; const layoutHeight = useSharedValue(0); const layoutWidth = useSharedValue(0); - - const [itemHeight, setItemHeight] = React.useState(0); + const { + loading, + loadNextPage, + reactions: fetchedReactions, + } = useFetchReactions({ + messageId, + sort, + }); + + const reactions = useMemo( + () => + propReactions || + (fetchedReactions.map((reaction) => ({ + alignment: 'left', + id: reaction.user?.id, + image: reaction.user?.image, + name: reaction.user?.name, + type: reaction.type, + })) as Reaction[]), + [propReactions, fetchedReactions], + ); const { theme: { - colors: { accent_blue, black, grey_gainsboro, white }, + colors: { black, white }, overlay: { padding: overlayPadding, - reactions: { - avatarContainer, - avatarName, - avatarSize, - container, - flatListContainer, - radius, - reactionBubble, - reactionBubbleBackground, - reactionBubbleBorderRadius, - title: titleStyle, - }, + reactions: { avatarContainer, avatarSize, container, flatListContainer, title: titleStyle }, }, }, } = useTheme(); @@ -185,100 +166,13 @@ export const OverlayReactions = (props: OverlayReactionsProps) => { (avatarSize + (Number(avatarContainer.padding || 0) || styles.avatarContainer.padding) * 2), ); - const renderItem = ({ item }: { item: Reaction }) => { - const { alignment = 'left', name, type } = item; - const x = avatarSize / 2 - (avatarSize / (radius * 4)) * (alignment === 'left' ? 1 : -1); - const y = avatarSize - radius; - - const left = - alignment === 'left' - ? x - - (Number(reactionBubbleBackground.width || 0) || styles.reactionBubbleBackground.width) + - radius - : x - radius; - const top = - y - - radius - - (Number(reactionBubbleBackground.height || 0) || styles.reactionBubbleBackground.height); - - return ( - - - - - - - - - - - - - - - - - - - - - - {name} - - - - ); - }; + const renderItem = ({ item }: { item: Reaction }) => ( + + ); const showScreenStyle = useAnimatedStyle( () => ({ @@ -316,33 +210,38 @@ export const OverlayReactions = (props: OverlayReactionsProps) => { ]} > {title} - `${name}_${index}`} - numColumns={numColumns} - renderItem={renderItem} - scrollEnabled={filteredReactions.length / numColumns > 1} - style={[ - styles.flatListContainer, - flatListContainer, - { - // we show the item height plus a little extra to tease for scrolling if there are more than one row - maxHeight: - itemHeight + (filteredReactions.length / numColumns > 1 ? itemHeight / 4 : 8), - }, - ]} - /> + {!loading && ( + `${name}${id}_${index}`} + numColumns={numColumns} + onEndReached={loadNextPage} + renderItem={renderItem} + scrollEnabled={filteredReactions.length / numColumns > 1} + style={[ + styles.flatListContainer, + flatListContainer, + { + // we show the item height plus a little extra to tease for scrolling if there are more than one row + maxHeight: + itemHeight + (filteredReactions.length / numColumns > 1 ? itemHeight / 4 : 8), + }, + ]} + /> + )} {/* The below view is unseen by the user, we use it to compute the height that the item must be */} - { - setItemHeight(layout.height); - }} - style={[styles.unseenItemContainer, styles.flatListContentContainer]} - > - {renderItem({ item: filteredReactions[0] })} - + {!loading && ( + { + setItemHeight(layout.height); + }} + style={[styles.unseenItemContainer, styles.flatListContentContainer]} + > + {renderItem({ item: filteredReactions[0] })} + + )} ); diff --git a/package/src/components/MessageOverlay/OverlayReactionsItem.tsx b/package/src/components/MessageOverlay/OverlayReactionsItem.tsx new file mode 100644 index 0000000000..cad971046f --- /dev/null +++ b/package/src/components/MessageOverlay/OverlayReactionsItem.tsx @@ -0,0 +1,188 @@ +import React from 'react'; + +import { StyleSheet, Text, View } from 'react-native'; +import Svg, { Circle } from 'react-native-svg'; + +import { ReactionResponse } from 'stream-chat'; + +import { Reaction } from './OverlayReactions'; + +import { useChatContext } from '../../contexts/chatContext/ChatContext'; +import type { MessageOverlayContextValue } from '../../contexts/messageOverlayContext/MessageOverlayContext'; +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { Unknown } from '../../icons'; + +import type { DefaultStreamChatGenerics } from '../../types/types'; +import { ReactionData } from '../../utils/utils'; + +export type OverlayReactionsItemProps< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +> = Pick, 'OverlayReactionsAvatar'> & { + reaction: Reaction; + supportedReactions: ReactionData[]; +}; + +type ReactionIconProps< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +> = Pick, 'type'> & { + pathFill: string; + size: number; + supportedReactions: ReactionData[]; +}; + +const ReactionIcon = ({ pathFill, size, supportedReactions, type }: ReactionIconProps) => { + const Icon = supportedReactions.find((reaction) => reaction.type === type)?.Icon || Unknown; + return ; +}; + +export const OverlayReactionsItem = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>({ + OverlayReactionsAvatar, + reaction, + supportedReactions, +}: OverlayReactionsItemProps) => { + const { id, name, type } = reaction; + const { + theme: { + colors: { accent_blue, black, grey_gainsboro, white }, + overlay: { + reactions: { + avatarContainer, + avatarName, + avatarSize, + radius, + reactionBubble, + reactionBubbleBackground, + reactionBubbleBorderRadius, + }, + }, + }, + } = useTheme(); + const { client } = useChatContext(); + const alignment = client.userID && client.userID === id ? 'right' : 'left'; + const x = avatarSize / 2 - (avatarSize / (radius * 4)) * (alignment === 'left' ? 1 : -1); + const y = avatarSize - radius; + + const left = + alignment === 'left' + ? x - + (Number(reactionBubbleBackground.width || 0) || styles.reactionBubbleBackground.width) + + radius + : x - radius; + const top = + y - + radius - + (Number(reactionBubbleBackground.height || 0) || styles.reactionBubbleBackground.height); + + return ( + + + + + + + + + + + + + + + + + + + + + + {name} + + + + ); +}; + +const styles = StyleSheet.create({ + avatarContainer: { + padding: 8, + }, + avatarInnerContainer: { + alignSelf: 'center', + }, + avatarName: { + flex: 1, + fontSize: 12, + fontWeight: '700', + paddingTop: 6, + textAlign: 'center', + }, + avatarNameContainer: { + alignItems: 'center', + flexDirection: 'row', + flexGrow: 1, + }, + reactionBubble: { + alignItems: 'center', + borderRadius: 24, + justifyContent: 'center', + position: 'absolute', + }, + reactionBubbleBackground: { + borderRadius: 24, + height: 24, + position: 'absolute', + width: 24, + }, +}); diff --git a/package/src/components/MessageOverlay/hooks/useFetchReactions.ts b/package/src/components/MessageOverlay/hooks/useFetchReactions.ts new file mode 100644 index 0000000000..62a0290f1b --- /dev/null +++ b/package/src/components/MessageOverlay/hooks/useFetchReactions.ts @@ -0,0 +1,85 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { ReactionResponse, ReactionSort } from 'stream-chat'; + +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; +import { getReactionsForFilterSort } from '../../../store/apis/getReactionsforFilterSort'; +import { DefaultStreamChatGenerics } from '../../../types/types'; + +export type UseFetchReactionParams< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +> = { + limit?: number; + messageId?: string; + reactionType?: string; + sort?: ReactionSort; +}; + +export const useFetchReactions = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>({ + limit = 25, + messageId, + reactionType, + sort, +}: UseFetchReactionParams) => { + const [reactions, setReactions] = useState[]>([]); + const [loading, setLoading] = useState(true); + const [next, setNext] = useState(undefined); + + const { client, enableOfflineSupport } = useChatContext(); + + const sortString = useMemo(() => JSON.stringify(sort), [sort]); + + const fetchReactions = useCallback(async () => { + const loadOfflineReactions = () => { + if (!messageId) return; + const reactionsFromDB = getReactionsForFilterSort({ + currentMessageId: messageId, + filters: reactionType ? { type: reactionType } : {}, + sort, + }); + if (reactionsFromDB) { + setReactions(reactionsFromDB); + setLoading(false); + } + }; + + const loadOnlineReactions = async () => { + if (!messageId) return; + const response = await client.queryReactions( + messageId, + reactionType ? { type: reactionType } : {}, + sort, + { limit, next }, + ); + if (response) { + setNext(response.next); + setReactions((prevReactions) => [...prevReactions, ...response.reactions]); + setLoading(false); + } + }; + + try { + if (enableOfflineSupport) { + loadOfflineReactions(); + } else { + await loadOnlineReactions(); + } + } catch (error) { + console.log('Error fetching reactions: ', error); + } + }, [client, messageId, reactionType, sortString, next, enableOfflineSupport]); + + const loadNextPage = useCallback(async () => { + if (next) { + await fetchReactions(); + } + }, [fetchReactions]); + + useEffect(() => { + fetchReactions(); + }, [messageId, reactionType, sortString]); + + return { loading, loadNextPage, reactions }; +}; diff --git a/package/src/store/apis/getReactions.ts b/package/src/store/apis/getReactions.ts new file mode 100644 index 0000000000..e1cb4cd63a --- /dev/null +++ b/package/src/store/apis/getReactions.ts @@ -0,0 +1,21 @@ +import type { ReactionResponse } from 'stream-chat'; + +import type { DefaultStreamChatGenerics } from '../../types/types'; +import { mapStorableToReaction } from '../mappers/mapStorableToReaction'; +import { QuickSqliteClient } from '../QuickSqliteClient'; +import { TableRowJoinedUser } from '../types'; + +export const getReactions = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>({ + reactions, +}: { + reactions: TableRowJoinedUser<'reactions'>[]; +}): ReactionResponse[] => { + QuickSqliteClient.logger?.('info', 'getReactions', { reactions }); + + // Enrich the channels with state + return reactions.map((reaction) => ({ + ...mapStorableToReaction(reaction), + })); +}; diff --git a/package/src/store/apis/getReactionsforFilterSort.ts b/package/src/store/apis/getReactionsforFilterSort.ts new file mode 100644 index 0000000000..81e0a06a15 --- /dev/null +++ b/package/src/store/apis/getReactionsforFilterSort.ts @@ -0,0 +1,43 @@ +import type { ReactionFilters, ReactionResponse, ReactionSort } from 'stream-chat'; + +import { getReactions } from './getReactions'; +import { selectReactionsForMessages } from './queries/selectReactionsForMessages'; + +import type { DefaultStreamChatGenerics } from '../../types/types'; + +import { QuickSqliteClient } from '../QuickSqliteClient'; + +/** + * Fetches reactions for a message from the database based on the provided filters and sort. + * @param currentMessageId The message ID for which reactions are to be fetched. + * @param filters The filters to be applied while fetching reactions. + * @param sort The sort to be applied while fetching reactions. + */ +export const getReactionsForFilterSort = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>({ + currentMessageId, + filters, + sort, +}: { + currentMessageId: string; + filters?: ReactionFilters; + sort?: ReactionSort; +}): ReactionResponse[] | null => { + if (!filters && !sort) { + console.warn('Please provide the query (filters/sort) to fetch channels from DB'); + return null; + } + + QuickSqliteClient.logger?.('info', 'getReactionsForFilterSort', { filters, sort }); + + const reactions = selectReactionsForMessages([currentMessageId]); + + if (!reactions) return null; + + if (reactions.length === 0) { + return []; + } + + return getReactions({ reactions }); +}; diff --git a/package/src/store/apis/queries/selectReactionsForMessages.ts b/package/src/store/apis/queries/selectReactionsForMessages.ts index 13c48c598d..911193f034 100644 --- a/package/src/store/apis/queries/selectReactionsForMessages.ts +++ b/package/src/store/apis/queries/selectReactionsForMessages.ts @@ -2,6 +2,10 @@ import { QuickSqliteClient } from '../../QuickSqliteClient'; import { tables } from '../../schema'; import type { TableRowJoinedUser } from '../../types'; +/** + * Fetches reactions for a message from the database for messageIds. + * @param messageIds The message IDs for which reactions are to be fetched. + */ export const selectReactionsForMessages = ( messageIds: string[], ): TableRowJoinedUser<'reactions'>[] => { @@ -28,7 +32,7 @@ export const selectReactionsForMessages = ( FROM reactions a LEFT JOIN users b - ON b.id = a.userId + ON b.id = a.userId WHERE a.messageId in (${questionMarks})`, messageIds, ); diff --git a/package/src/store/apis/updateReaction.ts b/package/src/store/apis/updateReaction.ts index d7a797cce7..c8f35d4daf 100644 --- a/package/src/store/apis/updateReaction.ts +++ b/package/src/store/apis/updateReaction.ts @@ -34,8 +34,6 @@ export const updateReaction = ({ }), ); - let updatedReactionCounts: string | undefined; - let updatedReactionGroups: string | undefined; if (message.reaction_groups) { const { reactionGroups } = mapMessageToStorable(message); @@ -47,7 +45,6 @@ export const updateReaction = ({ addedUser: storableUser, flush, updatedReaction: storableReaction, - updatedReactionCounts, updatedReactionGroups, }); diff --git a/package/src/utils/addReactionToLocalState.ts b/package/src/utils/addReactionToLocalState.ts index b3a81dd613..4a6fa22ff3 100644 --- a/package/src/utils/addReactionToLocalState.ts +++ b/package/src/utils/addReactionToLocalState.ts @@ -63,33 +63,6 @@ export const addReactionToLocalState = < message.reaction_groups[currentReaction.type].sum_scores - 1; } - if (!message.reaction_groups) { - message.reaction_groups = { - [reactionType]: { - count: 1, - first_reaction_at: new Date().toISOString(), - last_reaction_at: new Date().toISOString(), - sum_scores: 1, - }, - }; - } else { - if (!message.reaction_groups[reactionType]) { - message.reaction_groups[reactionType] = { - count: 1, - first_reaction_at: new Date().toISOString(), - last_reaction_at: new Date().toISOString(), - sum_scores: 1, - }; - } else { - message.reaction_groups[reactionType] = { - ...message.reaction_groups[reactionType], - count: message.reaction_groups[reactionType].count + 1, - last_reaction_at: new Date().toISOString(), - sum_scores: message.reaction_groups[reactionType].sum_scores + 1, - }; - } - } - } else { if (!message.reaction_groups) { message.reaction_groups = { [reactionType]: {