diff --git a/components/Chat/Chat.tsx b/components/Chat/Chat.tsx index fc46d0ec9..556ec532c 100644 --- a/components/Chat/Chat.tsx +++ b/components/Chat/Chat.tsx @@ -52,6 +52,7 @@ import { getProfile, getProfileData } from "../../utils/profile"; import { UUID_REGEX } from "../../utils/regex"; import { isContentType } from "../../utils/xmtpRN/contentTypes"; import { Recommendation } from "../Recommendations/Recommendation"; +import { MessageReactionsDrawer } from "./Message/MessageReactions/MessageReactionsDrawer/MessageReactionsDrawer"; const usePeerSocials = () => { const conversation = useConversationContext("conversation"); @@ -430,81 +431,86 @@ export function Chat() { }, [onReadyToFocus]); return ( - - - {conversation && listArray.length > 0 && !isBlockedPeer && ( - { - if (r) { - messageListRef.current = r; + <> + + + {conversation && listArray.length > 0 && !isBlockedPeer && ( + { + if (r) { + messageListRef.current = r; + } + }} + keyboardDismissMode="interactive" + automaticallyAdjustContentInsets={false} + contentInsetAdjustmentBehavior="never" + // Causes a glitch on Android, no sure we need it for now + // maintainVisibleContentPosition={{ + // minIndexForVisible: 0, + // autoscrollToTopThreshold: 100, + // }} + estimatedListSize={ + isSplitScreen ? undefined : Dimensions.get("screen") } - }} - keyboardDismissMode="interactive" - automaticallyAdjustContentInsets={false} - contentInsetAdjustmentBehavior="never" - // Causes a glitch on Android, no sure we need it for now - // maintainVisibleContentPosition={{ - // minIndexForVisible: 0, - // autoscrollToTopThreshold: 100, - // }} - estimatedListSize={ - isSplitScreen ? undefined : Dimensions.get("screen") - } - inverted - keyExtractor={keyExtractor} - getItemType={getItemType(framesStore)} - keyboardShouldPersistTaps="handled" - estimatedItemSize={80} - // Size glitch on Android - showsVerticalScrollIndicator={Platform.OS === "ios"} - pointerEvents="auto" - ListFooterComponent={ListFooterComponent} - /> + inverted + keyExtractor={keyExtractor} + getItemType={getItemType(framesStore)} + keyboardShouldPersistTaps="handled" + estimatedItemSize={80} + // Size glitch on Android + showsVerticalScrollIndicator={Platform.OS === "ios"} + pointerEvents="auto" + ListFooterComponent={ListFooterComponent} + /> + )} + {showPlaceholder && !conversation?.isGroup && ( + + )} + {showPlaceholder && conversation?.isGroup && ( + + )} + {conversation?.isGroup ? : } + + {showChatInput && ( + <> + + {!transactionMode && } + {transactionMode && } + + + )} - {showPlaceholder && !conversation?.isGroup && ( - - )} - {showPlaceholder && conversation?.isGroup && ( - - )} - {conversation?.isGroup ? : } - - {showChatInput && ( - <> - - {!transactionMode && } - {transactionMode && } - - - - )} - + + + ); } diff --git a/components/Chat/Message/Message.tsx b/components/Chat/Message/Message.tsx index 82979ac00..e498045f2 100644 --- a/components/Chat/Message/Message.tsx +++ b/components/Chat/Message/Message.tsx @@ -29,7 +29,7 @@ import Animated, { } from "react-native-reanimated"; import ChatMessageActions from "./MessageActions"; -import { ChatMessageReactions } from "./MessageReactions"; +import { ChatMessageReactions } from "./MessageReactions/MessageReactions"; import MessageStatus from "./MessageStatus"; import { currentAccount, diff --git a/components/Chat/Message/MessageReactions.tsx b/components/Chat/Message/MessageReactions.tsx deleted file mode 100644 index 3c6364835..000000000 --- a/components/Chat/Message/MessageReactions.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import { useCurrentAccount } from "@data/store/accountsStore"; -import { createBottomSheetModalRef } from "@design-system/BottomSheet/BottomSheet.utils"; -import { BottomSheetContentContainer } from "@design-system/BottomSheet/BottomSheetContentContainer"; -import { BottomSheetHeader } from "@design-system/BottomSheet/BottomSheetHeader"; -import { BottomSheetModal } from "@design-system/BottomSheet/BottomSheetModal"; -import { HStack } from "@design-system/HStack"; -import { ScrollView } from "@design-system/ScrollView"; -import { Text } from "@design-system/Text"; -import { VStack } from "@design-system/VStack"; -import { - BottomSheetBackdrop, - BottomSheetScrollView, -} from "@gorhom/bottom-sheet"; -import { BottomSheetDefaultBackdropProps } from "@gorhom/bottom-sheet/lib/typescript/components/bottomSheetBackdrop/types"; -import { useAppTheme } from "@theme/useAppTheme"; -import { MessageReaction } from "@utils/reactions"; -import { memo, useCallback, useEffect, useMemo, useState } from "react"; -import { StyleSheet, TouchableHighlight } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; - -import { MessageToDisplay } from "./Message"; - -const MAX_REACTION_EMOJIS_SHOWN = 3; - -type Props = { - message: MessageToDisplay; - reactions: { - [senderAddress: string]: MessageReaction[]; - }; -}; - -type ReactionDetails = { - content: string; - count: number; - userReacted: boolean; - reactors: string[]; - firstReactionTime: number; -}; - -type RolledUpReactions = { - emojis: string[]; - totalReactions: number; - userReacted: boolean; - details: { [content: string]: ReactionDetails }; -}; - -export const ChatMessageReactions = memo( - ({ message, reactions }: Props) => { - const styles = useStyles(); - const { top } = useSafeAreaInsets(); - const { theme } = useAppTheme(); - const userAddress = useCurrentAccount(); - const bottomSheetModalRef = createBottomSheetModalRef(); - const [isBottomSheetVisible, setBottomSheetVisible] = useState(false); - - const openReactionsDrawer = () => { - setBottomSheetVisible(true); - }; - - const onReactionsDrawerDismiss = () => { - setBottomSheetVisible(false); - }; - - const onReactionDrawerChange = useCallback( - (index: number) => { - // Dismiss when index 0 (open but not visible) - if (index === 0) { - setBottomSheetVisible(false); - bottomSheetModalRef.current?.dismiss(); - } - }, - [bottomSheetModalRef] - ); - - useEffect(() => { - if (isBottomSheetVisible) { - bottomSheetModalRef.current?.present(); - } - }, [isBottomSheetVisible, bottomSheetModalRef]); - - const renderBackdrop = useCallback( - (props: BottomSheetDefaultBackdropProps) => ( - - ), - [theme] - ); - - const rolledUpReactions: RolledUpReactions = useMemo(() => { - const details: { [content: string]: ReactionDetails } = {}; - let totalReactions = 0; - let userReacted = false; - - Object.values(reactions).forEach((reactionArray) => { - reactionArray.forEach((reaction) => { - if (!details[reaction.content]) { - details[reaction.content] = { - content: reaction.content, - count: 0, - userReacted: false, - reactors: [], - firstReactionTime: reaction.sent, - }; - } - details[reaction.content].count++; - details[reaction.content].reactors.push(reaction.senderAddress); - if ( - reaction.senderAddress.toLowerCase() === userAddress?.toLowerCase() - ) { - details[reaction.content].userReacted = true; - userReacted = true; - } - // Keep track of the earliest reaction time for this emoji - details[reaction.content].firstReactionTime = Math.min( - details[reaction.content].firstReactionTime, - reaction.sent - ); - totalReactions++; - }); - }); - - // Sort by the number of reactors in descending order - const sortedReactions = Object.values(details) - .sort((a, b) => b.reactors.length - a.reactors.length) - .slice(0, MAX_REACTION_EMOJIS_SHOWN) - .map((reaction) => reaction.content); - - return { emojis: sortedReactions, totalReactions, userReacted, details }; - }, [reactions, userAddress]); - - if (rolledUpReactions.totalReactions === 0) return null; - - return ( - - - - - {rolledUpReactions.emojis.map((emoji, index) => ( - {emoji} - ))} - - {rolledUpReactions.totalReactions > 1 && ( - - {rolledUpReactions.totalReactions} - - )} - - - {isBottomSheetVisible && ( - - - - - - All {rolledUpReactions.totalReactions} - {Object.entries(rolledUpReactions.details).map( - ([emoji, details]) => ( - - - {emoji} - {details.count} - - - ) - )} - - - - - - )} - - ); - }, - (prevProps, nextProps) => { - if (prevProps.message.id !== nextProps.message.id) { - return false; - } - if (prevProps.message.lastUpdateAt !== nextProps.message.lastUpdateAt) { - return false; - } - return true; - } -); - -const useStyles = () => { - const { theme } = useAppTheme(); - - return StyleSheet.create({ - reactionsWrapper: { - flexDirection: "row", - flexWrap: "wrap", - }, - reactionButton: { - flexDirection: "row", - alignItems: "center", - paddingHorizontal: theme.spacing.xs, - paddingVertical: theme.spacing.xxs, - borderRadius: theme.borderRadius.sm, - borderWidth: theme.borderWidth.sm, - borderColor: theme.colors.border.subtle, - }, - emojiContainer: { - flexDirection: "row", - flexWrap: "wrap", - gap: theme.spacing.xxxs, - }, - reactorCount: { - marginLeft: theme.spacing.xxxs, - color: theme.colors.text.secondary, - }, - }); -}; diff --git a/components/Chat/Message/MessageReactions/MessageReactions.tsx b/components/Chat/Message/MessageReactions/MessageReactions.tsx new file mode 100644 index 000000000..8fd7290f5 --- /dev/null +++ b/components/Chat/Message/MessageReactions/MessageReactions.tsx @@ -0,0 +1,169 @@ +import { useCurrentAccount } from "@data/store/accountsStore"; +import { HStack } from "@design-system/HStack"; +import { Text } from "@design-system/Text"; +import { VStack } from "@design-system/VStack"; +import { useAppTheme } from "@theme/useAppTheme"; +import { memo, useCallback, useMemo } from "react"; +import { StyleSheet, TouchableHighlight } from "react-native"; + +import { MessageToDisplay } from "../Message"; +import { + MessageReactions, + ReactionDetails, + RolledUpReactions, +} from "./MessageReactions.types"; +import { openMessageReactionsDrawer } from "./MessageReactionsDrawer/MessageReactionsDrawer.service"; + +type Props = { + message: MessageToDisplay; + reactions: MessageReactions; +}; + +export const ChatMessageReactions = memo( + ({ message, reactions }: Props) => { + const styles = useStyles(); + const { theme } = useAppTheme(); + const userAddress = useCurrentAccount(); + + const rolledUpReactions = useMessageReactionsRolledUp({ + reactions, + userAddress: userAddress!, // ! If we are here, the user is logged in + }); + + const handlePress = useCallback(() => { + openMessageReactionsDrawer(rolledUpReactions); + }, [rolledUpReactions]); + + if (rolledUpReactions.totalReactions === 0) return null; + + return ( + + + + + {rolledUpReactions.emojis.map((emoji, index) => ( + {emoji} + ))} + + {rolledUpReactions.totalReactions > 1 && ( + + {rolledUpReactions.totalReactions} + + )} + + + + ); + }, + (prevProps, nextProps) => { + if (prevProps.message.id !== nextProps.message.id) { + return false; + } + if (prevProps.message.lastUpdateAt !== nextProps.message.lastUpdateAt) { + return false; + } + // Compare reactions to ensure updates are not missed + if (prevProps.reactions !== nextProps.reactions) { + return false; + } + return true; + } +); + +const useStyles = () => { + const { theme } = useAppTheme(); + + return StyleSheet.create({ + reactionsWrapper: { + flexDirection: "row", + flexWrap: "wrap", + }, + reactionButton: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: theme.spacing.xs, + paddingVertical: theme.spacing.xxs, + borderRadius: theme.borderRadius.sm, + borderWidth: theme.borderWidth.sm, + borderColor: theme.colors.border.subtle, + }, + emojiContainer: { + flexDirection: "row", + flexWrap: "wrap", + gap: theme.spacing.xxxs, + }, + reactorCount: { + marginLeft: theme.spacing.xxxs, + color: theme.colors.text.secondary, + }, + }); +}; + +const MAX_REACTION_EMOJIS_SHOWN = 3; + +function useMessageReactionsRolledUp(arg: { + reactions: MessageReactions; + userAddress: string; +}) { + const { reactions, userAddress } = arg; + + return useMemo((): RolledUpReactions => { + const details: { [content: string]: ReactionDetails } = {}; + let totalReactions = 0; + let userReacted = false; + + Object.values(reactions).forEach((reactionArray) => { + reactionArray.forEach((reaction) => { + if (!details[reaction.content]) { + details[reaction.content] = { + content: reaction.content, + count: 0, + userReacted: false, + reactors: [], + firstReactionTime: reaction.sent, + }; + } + details[reaction.content].count++; + details[reaction.content].reactors.push(reaction.senderAddress); + if ( + reaction.senderAddress.toLowerCase() === userAddress?.toLowerCase() + ) { + details[reaction.content].userReacted = true; + userReacted = true; + } + // Keep track of the earliest reaction time for this emoji + details[reaction.content].firstReactionTime = Math.min( + details[reaction.content].firstReactionTime, + reaction.sent + ); + totalReactions++; + }); + }); + + // Sort by the number of reactors in descending order + const sortedReactions = Object.values(details) + .sort((a, b) => b.reactors.length - a.reactors.length) + .slice(0, MAX_REACTION_EMOJIS_SHOWN) + .map((reaction) => reaction.content); + + return { emojis: sortedReactions, totalReactions, userReacted, details }; + }, [reactions, userAddress]); +} diff --git a/components/Chat/Message/MessageReactions/MessageReactions.types.ts b/components/Chat/Message/MessageReactions/MessageReactions.types.ts new file mode 100644 index 000000000..e521a3d7f --- /dev/null +++ b/components/Chat/Message/MessageReactions/MessageReactions.types.ts @@ -0,0 +1,26 @@ +import { MessageReaction } from "@utils/reactions"; + +export type MessageReactions = { + [senderAddress: string]: MessageReaction[]; +}; + +/** + * Aggregated reaction data including top emojis, total count, and detailed breakdown. + */ +export type RolledUpReactions = { + emojis: string[]; + totalReactions: number; + userReacted: boolean; + details: Record; +}; + +/** + * Details for a specific reaction emoji, including count, reactors, and timing. + */ +export type ReactionDetails = { + content: string; + count: number; + userReacted: boolean; + reactors: string[]; + firstReactionTime: number; +}; diff --git a/components/Chat/Message/MessageReactions/MessageReactionsDrawer/MessageReactions.store.tsx b/components/Chat/Message/MessageReactions/MessageReactionsDrawer/MessageReactions.store.tsx new file mode 100644 index 000000000..26367515d --- /dev/null +++ b/components/Chat/Message/MessageReactions/MessageReactionsDrawer/MessageReactions.store.tsx @@ -0,0 +1,31 @@ +import { create } from "zustand"; + +import { RolledUpReactions } from "../MessageReactions.types"; + +const initialMessageReactionsState: RolledUpReactions = { + emojis: [], + totalReactions: 0, + userReacted: false, + details: {}, +}; + +export interface IMessageReactionsStore { + rolledUpReactions: RolledUpReactions; + setRolledUpReactions: (reactions: RolledUpReactions) => void; + + // TODO: update state when new reactions come up and drawer is open + // updateReactions: (updates: Partial) => void; +} + +export const useMessageReactionsStore = create( + (set) => ({ + rolledUpReactions: initialMessageReactionsState, + setRolledUpReactions: (reactions) => set({ rolledUpReactions: reactions }), + }) +); + +export const resetMessageReactionsStore = () => { + useMessageReactionsStore + .getState() + .setRolledUpReactions(initialMessageReactionsState); +}; diff --git a/components/Chat/Message/MessageReactions/MessageReactionsDrawer/MessageReactionsDrawer.service.ts b/components/Chat/Message/MessageReactions/MessageReactionsDrawer/MessageReactionsDrawer.service.ts new file mode 100644 index 000000000..e3276f750 --- /dev/null +++ b/components/Chat/Message/MessageReactions/MessageReactionsDrawer/MessageReactionsDrawer.service.ts @@ -0,0 +1,40 @@ +import { createBottomSheetModalRef } from "@design-system/BottomSheet/BottomSheet.utils"; + +import { RolledUpReactions } from "../MessageReactions.types"; +import { + resetMessageReactionsStore, + useMessageReactionsStore, +} from "./MessageReactions.store"; + +export const bottomSheetModalRef = createBottomSheetModalRef(); + +export function openMessageReactionsDrawer( + rolledUpReactions: RolledUpReactions +) { + try { + if (!bottomSheetModalRef.current) { + throw new Error("Modal reference not initialized"); + } + useMessageReactionsStore.getState().setRolledUpReactions(rolledUpReactions); + bottomSheetModalRef.current.present(); + } catch (error) { + console.error("Failed to open message reactions drawer:", error); + resetMessageReactionsDrawer(); + } +} + +export function closeMessageReactionsDrawer(arg?: { resetStore?: boolean }) { + const { resetStore = true } = arg ?? {}; + bottomSheetModalRef.current?.dismiss(); + if (resetStore) { + resetMessageReactionsDrawer(); + } +} + +export function resetMessageReactionsDrawer() { + resetMessageReactionsStore(); +} + +export function useMessageReactionsRolledUpReactions() { + return useMessageReactionsStore((state) => state.rolledUpReactions); +} diff --git a/components/Chat/Message/MessageReactions/MessageReactionsDrawer/MessageReactionsDrawer.tsx b/components/Chat/Message/MessageReactions/MessageReactionsDrawer/MessageReactionsDrawer.tsx new file mode 100644 index 000000000..32e54a4dc --- /dev/null +++ b/components/Chat/Message/MessageReactions/MessageReactionsDrawer/MessageReactionsDrawer.tsx @@ -0,0 +1,79 @@ +import { BottomSheetContentContainer } from "@design-system/BottomSheet/BottomSheetContentContainer"; +import { BottomSheetHeader } from "@design-system/BottomSheet/BottomSheetHeader"; +import { BottomSheetModal } from "@design-system/BottomSheet/BottomSheetModal"; +import { HStack } from "@design-system/HStack"; +import { ScrollView } from "@design-system/ScrollView"; +import { Text } from "@design-system/Text"; +import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; +import { useAppTheme } from "@theme/useAppTheme"; +import { memo, useCallback } from "react"; +import { TouchableHighlight } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { + bottomSheetModalRef, + resetMessageReactionsDrawer, + useMessageReactionsRolledUpReactions, +} from "./MessageReactionsDrawer.service"; + +export const MessageReactionsDrawer = memo(function MessageReactionsDrawer() { + const { theme } = useAppTheme(); + + const insets = useSafeAreaInsets(); + + const rolledUpReactions = useMessageReactionsRolledUpReactions(); + + const handleDismiss = useCallback(() => { + resetMessageReactionsDrawer(); + }, []); + + return ( + + + + + + All {rolledUpReactions.totalReactions} + {Object.entries(rolledUpReactions.details).map( + ([emoji, details]) => ( + + + {emoji} + {details.count} + + + ) + )} + + + + + + ); +}); diff --git a/design-system/BottomSheet/BottomSheetBackdropOpacity.tsx b/design-system/BottomSheet/BottomSheetBackdropOpacity.tsx index 891b7d536..b79120584 100644 --- a/design-system/BottomSheet/BottomSheetBackdropOpacity.tsx +++ b/design-system/BottomSheet/BottomSheetBackdropOpacity.tsx @@ -4,15 +4,29 @@ import { } from "@gorhom/bottom-sheet"; import { memo } from "react"; +import { useAppTheme } from "../../theme/useAppTheme"; + export const BottomSheetBackdropOpacity = memo(function BackdropOpacity( props: GorhomBottomSheetBackdropProps ) { + const { style, animatedIndex, animatedPosition, ...rest } = props; + + const { theme } = useAppTheme(); + return ( ); }); diff --git a/design-system/BottomSheet/BottomSheetModal.tsx b/design-system/BottomSheet/BottomSheetModal.tsx index 34bcbf70c..c7488d161 100644 --- a/design-system/BottomSheet/BottomSheetModal.tsx +++ b/design-system/BottomSheet/BottomSheetModal.tsx @@ -10,6 +10,7 @@ import { FullWindowOverlay } from "react-native-screens"; import { BottomSheetBackdropOpacity } from "./BottomSheetBackdropOpacity"; import { BottomSheetHandleBar } from "./BottomSheetHandleBar"; +import { useAppTheme } from "../../theme/useAppTheme"; export type IBottomSheetModalProps = GorhomBottomSheetModalProps & { absoluteHandleBar?: boolean; @@ -18,7 +19,9 @@ export type IBottomSheetModalProps = GorhomBottomSheetModalProps & { export const BottomSheetModal = memo( forwardRef( function BottomSheetModal(props, ref) { - const { absoluteHandleBar = true, ...rest } = props; + const { absoluteHandleBar = true, backgroundStyle, ...rest } = props; + + const { theme } = useAppTheme(); // https://github.com/gorhom/react-native-bottom-sheet/issues/1644#issuecomment-1949019839 const renderContainerComponent = useCallback((props: any) => { @@ -40,8 +43,15 @@ export const BottomSheetModal = memo( containerComponent={ Platform.OS === "ios" ? renderContainerComponent : undefined } + enableDynamicSizing={false} // By default we don't want enable dynamic sizing backdropComponent={BottomSheetBackdropOpacity} handleComponent={renderHandleComponent} + backgroundStyle={[ + { + backgroundColor: theme.colors.background.raised, + }, + backgroundStyle, + ]} {...rest} /> );