From c36902aa97228cc5e76ee3d59621bb4b520e7407 Mon Sep 17 00:00:00 2001 From: Thierry Date: Fri, 13 Dec 2024 14:42:32 -0500 Subject: [PATCH 1/6] wip --- .../conversation-composer-container.tsx | 25 ++ .../conversation-composer-input.tsx | 0 .../conversation-composer-reply-preview.tsx | 19 +- .../conversation-composer.tsx | 59 ++--- .../conversation-keyboard-filler.tsx | 87 ++++++- .../conversation-message-status.tsx | 123 ++++++++++ .../conversation-message-status.utils.ts | 6 + .../conversation-message-reply.tsx | 4 +- ...ersation-message-context-menu-reactors.tsx | 80 ++----- ...ion-message-context-menu.store-context.tsx | 1 - .../conversation-message-context-menu.tsx | 7 +- ...sation-message-reaction-drawer.service..ts | 1 - .../conversation-message-reactions.tsx | 76 +++--- .../conversation-message-status-dumb.tsx | 113 --------- .../conversation-message-status.tsx | 115 ---------- .../conversation-message.store-context.tsx | 8 +- features/conversation/conversation-new-dm.tsx | 8 +- features/conversation/conversation.tsx | 80 ++++--- .../is-latest-message-settled-from-peer.ts | 6 +- hooks/use-current-account-inbox-id.ts | 5 + hooks/use-keyboard-handlers.ts | 26 +++ hooks/use-keyboard-is-shown-ref.ts | 27 +++ hooks/use-keyboard-is-shown.ts | 26 +++ ios/Converse.xcodeproj/project.pbxproj | 4 - ios/Podfile.lock | 15 +- queries/useConversationMessages.ts | 217 +++++++++--------- queries/useConversationQuery.ts | 9 +- utils/xmtpRN/conversations.ts | 1 - 28 files changed, 601 insertions(+), 547 deletions(-) create mode 100644 features/conversation/conversation-composer/conversation-composer-container.tsx create mode 100644 features/conversation/conversation-composer/conversation-composer-input.tsx create mode 100644 features/conversation/conversation-message-status/conversation-message-status.tsx create mode 100644 features/conversation/conversation-message-status/conversation-message-status.utils.ts delete mode 100644 features/conversation/conversation-message/conversation-message-status-dumb.tsx delete mode 100644 features/conversation/conversation-message/conversation-message-status.tsx create mode 100644 hooks/use-keyboard-handlers.ts create mode 100644 hooks/use-keyboard-is-shown-ref.ts create mode 100644 hooks/use-keyboard-is-shown.ts diff --git a/features/conversation/conversation-composer/conversation-composer-container.tsx b/features/conversation/conversation-composer/conversation-composer-container.tsx new file mode 100644 index 000000000..b5eb7ded1 --- /dev/null +++ b/features/conversation/conversation-composer/conversation-composer-container.tsx @@ -0,0 +1,25 @@ +import { IVStackProps, VStack } from "@/design-system/VStack"; +import { memo } from "react"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +export const ConversationComposerContainer = memo(function ( + props: IVStackProps +) { + const { style, ...rest } = props; + + const insets = useSafeAreaInsets(); + + return ( + + ); +}); diff --git a/features/conversation/conversation-composer/conversation-composer-input.tsx b/features/conversation/conversation-composer/conversation-composer-input.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/features/conversation/conversation-composer/conversation-composer-reply-preview.tsx b/features/conversation/conversation-composer/conversation-composer-reply-preview.tsx index 6ecff3643..f579429d4 100644 --- a/features/conversation/conversation-composer/conversation-composer-reply-preview.tsx +++ b/features/conversation/conversation-composer/conversation-composer-reply-preview.tsx @@ -11,7 +11,6 @@ import { useConversationMessageById, } from "@/features/conversation/conversation-message/conversation-message.utils"; import { useCurrentAccountInboxId } from "@/hooks/use-current-account-inbox-id"; -import { useCurrentConversationTopic } from "../conversation.store-context"; import { usePreferredInboxName } from "@/hooks/usePreferredInboxName"; import { HStack } from "@design-system/HStack"; import { Icon } from "@design-system/Icon/Icon"; @@ -26,7 +25,6 @@ import { DecodedMessageWithCodecsType } from "@utils/xmtpRN/client"; import { DecodedMessage, InboxId, - MessageId, RemoteAttachmentCodec, ReplyCodec, StaticAttachmentCodec, @@ -37,6 +35,7 @@ import { useSharedValue, withSpring, } from "react-native-reanimated"; +import { useCurrentConversationTopic } from "../conversation.store-context"; import { useConversationComposerStore, useConversationComposerStoreContext, @@ -47,18 +46,6 @@ export const ReplyPreview = memo(function ReplyPreview() { (state) => state.replyingToMessageId ); - if (!replyingToMessageId) { - return null; - } - - return ; -}); - -const Content = memo(function Content(props: { - replyingToMessageId: MessageId; -}) { - const { replyingToMessageId } = props; - const { theme } = useAppTheme(); const composerStore = useConversationComposerStore(); @@ -67,7 +54,7 @@ const Content = memo(function Content(props: { const topic = useCurrentConversationTopic(); const { message: replyMessage } = useConversationMessageById({ - messageId: replyingToMessageId, + messageId: replyingToMessageId!, // ! because we have enabled in the query topic, }); @@ -115,6 +102,8 @@ const Content = memo(function Content(props: { }, containerAS, ]} + // entering={theme.animation.reanimatedFadeInSpring} + // exiting={theme.animation.reanimatedFadeOutSpring} > {!!replyMessage && ( - - - + - - + - - - - - - - - + + + + + ); }); diff --git a/features/conversation/conversation-keyboard-filler.tsx b/features/conversation/conversation-keyboard-filler.tsx index 02ff9a84d..2081cdd7c 100644 --- a/features/conversation/conversation-keyboard-filler.tsx +++ b/features/conversation/conversation-keyboard-filler.tsx @@ -1,15 +1,86 @@ -import { memo } from "react"; +import { useKeyboardIsShown } from "@/hooks/use-keyboard-is-shown"; import { AnimatedVStack } from "@design-system/VStack"; -import { useAnimatedKeyboard, useAnimatedStyle } from "react-native-reanimated"; +import { memo, useEffect, useRef } from "react"; +import { Keyboard, TextInput } from "react-native"; +import { + useAnimatedKeyboard, + useAnimatedStyle, + useSharedValue, +} from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -export const KeyboardFiller = memo(function KeyboardFiller() { - const { height: keyboardHeightAV } = useAnimatedKeyboard(); +type IKeyboardFillerProps = { + messageContextMenuIsOpen: boolean; +}; + +export const KeyboardFiller = memo(function KeyboardFiller( + props: IKeyboardFillerProps +) { + const { messageContextMenuIsOpen } = props; + const { height: keyboardHeight } = useAnimatedKeyboard(); const insets = useSafeAreaInsets(); + const lastKeyboardHeight = useSharedValue(0); + const textInputRef = useRef(null); + const keyboardWasOpenRef = useRef(false); + const isKeyboardShown = useKeyboardIsShown(); + + useEffect(() => { + console.log("messageContextMenuIsOpen:", messageContextMenuIsOpen); + console.log("keyboardWasOpenRef.current:", keyboardWasOpenRef.current); + // Context menu was hidden + if (!messageContextMenuIsOpen) { + // Reopen keyboard if it was open before context menu was shown + if (keyboardWasOpenRef.current) { + textInputRef.current?.focus(); + } + } + // Context menu is shown + else { + if (isKeyboardShown) { + Keyboard.dismiss(); + lastKeyboardHeight.value = keyboardHeight.value; + keyboardWasOpenRef.current = true; + } else { + keyboardWasOpenRef.current = false; + } + } + }, [ + messageContextMenuIsOpen, + isKeyboardShown, + keyboardHeight, + lastKeyboardHeight, + ]); + + // Reset the last height when context menu is dismissed + // And make sure to wait until the keyboard is open to that the height animates back to what it was before + useEffect(() => { + if (!messageContextMenuIsOpen && isKeyboardShown) { + lastKeyboardHeight.value = 0; + } + }, [ + messageContextMenuIsOpen, + isKeyboardShown, + keyboardHeight, + lastKeyboardHeight, + ]); - const as = useAnimatedStyle(() => ({ - height: Math.max(keyboardHeightAV.value - insets.bottom, 0), - })); + const animatedStyle = useAnimatedStyle(() => { + return { + height: Math.max( + Math.max(lastKeyboardHeight.value, keyboardHeight.value) - + insets.bottom, + 0 + ), + }; + }); - return ; + return ( + <> + + + + ); }); diff --git a/features/conversation/conversation-message-status/conversation-message-status.tsx b/features/conversation/conversation-message-status/conversation-message-status.tsx new file mode 100644 index 000000000..49e24aa93 --- /dev/null +++ b/features/conversation/conversation-message-status/conversation-message-status.tsx @@ -0,0 +1,123 @@ +import { useSelect } from "@/data/store/storeHelpers"; +import { AnimatedText } from "@/design-system/Text"; +import { messageIsSent } from "@/features/conversation/conversation-message-status/conversation-message-status.utils"; +import { useMessageContextStoreContext } from "@/features/conversation/conversation-message/conversation-message.store-context"; +import { translate } from "@/i18n"; +import { useAppTheme } from "@/theme/useAppTheme"; +import { DecodedMessageWithCodecsType } from "@/utils/xmtpRN/client.types"; +import React, { memo, useEffect, useRef, useState } from "react"; +import { View } from "react-native"; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; + +type IConversationMessageStatusProps = { + message: DecodedMessageWithCodecsType; +}; + +const statusMapping: { + [key: string]: string | undefined; +} = { + sent: translate("message_status.sent"), + delivered: translate("message_status.delivered"), + error: translate("message_status.error"), + sending: translate("message_status.sending"), + prepared: translate("message_status.prepared"), + seen: translate("message_status.seen"), +}; + +export const ConversationMessageStatus = memo( + function ConversationMessageStatus({ + message, + }: IConversationMessageStatusProps) { + const { theme } = useAppTheme(); + + const { fromMe, isLatestSettledFromMe } = useMessageContextStoreContext( + useSelect(["fromMe", "isLatestSettledFromMe"]) + ); + + const prevStatusRef = useRef(message.status); + const isSent = messageIsSent(message); + + const [renderText, setRenderText] = useState(false); + const opacity = useSharedValue(isLatestSettledFromMe ? 1 : 0); + const height = useSharedValue(isLatestSettledFromMe ? 22 : 0); + const scale = useSharedValue(isLatestSettledFromMe ? 1 : 0); + + const timingConfig = { + duration: 200, + easing: Easing.inOut(Easing.quad), + }; + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + height: height.value, + transform: [{ scale: scale.value }], + })); + + useEffect( + () => { + const prevStatus = prevStatusRef.current; + prevStatusRef.current = message.status; + + setTimeout(() => { + requestAnimationFrame(() => { + if ( + isSent && + (prevStatus === "sending" || prevStatus === "prepared") + ) { + opacity.value = withTiming(1, timingConfig); + height.value = withTiming(22, timingConfig); + scale.value = withTiming(1, timingConfig); + setRenderText(true); + } else if (isSent && !isLatestSettledFromMe) { + opacity.value = withTiming(0, timingConfig); + height.value = withTiming(0, timingConfig); + scale.value = withTiming(0, timingConfig); + setTimeout(() => setRenderText(false), timingConfig.duration); + } else if (isLatestSettledFromMe) { + opacity.value = 1; + height.value = 22; + scale.value = 1; + setRenderText(true); + } + }); + }, 100); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [isLatestSettledFromMe, isSent] + ); + + if (!isLatestSettledFromMe) { + return null; + } + + if (!fromMe) { + return null; + } + + return ( + + + + {renderText && statusMapping[message.status]} + + + + ); + } +); diff --git a/features/conversation/conversation-message-status/conversation-message-status.utils.ts b/features/conversation/conversation-message-status/conversation-message-status.utils.ts new file mode 100644 index 000000000..e376fb06e --- /dev/null +++ b/features/conversation/conversation-message-status/conversation-message-status.utils.ts @@ -0,0 +1,6 @@ +import { DecodedMessageWithCodecsType } from "@/utils/xmtpRN/client"; +import { MessageDeliveryStatus } from "@xmtp/react-native-sdk"; + +export function messageIsSent(message: DecodedMessageWithCodecsType) { + return message.deliveryStatus === MessageDeliveryStatus.PUBLISHED; +} diff --git a/features/conversation/conversation-message/conversation-message-content-types/conversation-message-reply.tsx b/features/conversation/conversation-message/conversation-message-content-types/conversation-message-reply.tsx index 284ed83d1..ff88203e1 100644 --- a/features/conversation/conversation-message/conversation-message-content-types/conversation-message-reply.tsx +++ b/features/conversation/conversation-message/conversation-message-content-types/conversation-message-reply.tsx @@ -1,11 +1,11 @@ import { useSelect } from "@/data/store/storeHelpers"; import { AttachmentRemoteImage } from "@/features/conversation/conversation-attachment/conversation-attachment-remote-image"; -import { useMessageContextStoreContext } from "@/features/conversation/conversation-message/conversation-message.store-context"; import { BubbleContainer, BubbleContentContainer, } from "@/features/conversation/conversation-message/conversation-message-bubble"; import { MessageText } from "@/features/conversation/conversation-message/conversation-message-text"; +import { useMessageContextStoreContext } from "@/features/conversation/conversation-message/conversation-message.store-context"; import { useCurrentConversationTopic } from "@/features/conversation/conversation.store-context"; import { usePreferredInboxName } from "@/hooks/usePreferredInboxName"; import { getConversationMessages } from "@/queries/useConversationMessages"; @@ -111,8 +111,6 @@ const MessageReplyReference = memo(function MessageReplyReference(props: { const fromMe = useMessageContextStoreContext((s) => s.fromMe); - const currentAccount = useCurrentAccount()!; - const replyMessageReference = useConversationMessageForReplyMessage(referenceMessageId); diff --git a/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-reactors.tsx b/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-reactors.tsx index 0bec9116b..1c0ad21d6 100644 --- a/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-reactors.tsx +++ b/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-reactors.tsx @@ -1,26 +1,16 @@ -import { MESSAGE_CONTEXT_REACTIONS_HEIGHT } from "@/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-constant"; import { AnimatedVStack, VStack } from "@/design-system/VStack"; +import { MESSAGE_CONTEXT_REACTIONS_HEIGHT } from "@/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-constant"; +import { useInboxProfileSocialsQueries } from "@/queries/useInboxProfileSocialsQuery"; import { ObjectTyped } from "@/utils/objectTyped"; +import { getReactionContent } from "@/utils/xmtpRN/reactions"; import GroupAvatar from "@components/GroupAvatar"; -import { useProfilesStore } from "@data/store/accountsStore"; +import { useCurrentAccount } from "@data/store/accountsStore"; import { Text } from "@design-system/Text"; import { useAppTheme } from "@theme/useAppTheme"; -import { - getPreferredAvatar, - getPreferredName, - getProfile, -} from "@utils/profile"; -import { getReactionContent } from "@/utils/xmtpRN/reactions"; +import { getPreferredInboxAvatar, getPreferredInboxName } from "@utils/profile"; import { InboxId, ReactionContent } from "@xmtp/react-native-sdk"; -import React, { FC, useEffect, useMemo } from "react"; +import React, { FC, useMemo } from "react"; import { FlatList } from "react-native-gesture-handler"; -import { - Easing, - useAnimatedStyle, - useSharedValue, - withDelay, - withTiming, -} from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; type MessageContextMenuReactorsProps = { @@ -59,8 +49,9 @@ export const MessageContextMenuReactors: FC< }, [reactors]); return ( - - + ); }; -const INITIAL_DELAY = 0; -const ITEM_DELAY = 200; -const ITEM_ANIMATION_DURATION = 500; - type MessageReactionsItemProps = { content: string; addresses: string[]; @@ -104,45 +91,26 @@ type MessageReactionsItemProps = { const Item: FC = ({ content, addresses, index }) => { const { theme } = useAppTheme(); - const animatedValue = useSharedValue(0); + const currentAccount = useCurrentAccount()!; - const membersSocials = useProfilesStore((s) => - addresses.map((address) => { - const socials = getProfile(address, s.profiles)?.socials; - return { - address, - uri: getPreferredAvatar(socials), - name: getPreferredName(socials, address), - }; - }) - ); + const queriesData = useInboxProfileSocialsQueries(currentAccount, addresses); - useEffect(() => { - animatedValue.value = withDelay( - index * ITEM_DELAY + INITIAL_DELAY, - withTiming(1, { - duration: ITEM_ANIMATION_DURATION, - easing: Easing.out(Easing.exp), - }) - ); - }, [animatedValue, index]); - - const animatedStyle = useAnimatedStyle(() => ({ - opacity: animatedValue.value, - transform: [{ scale: animatedValue.value }], - })); + const membersSocials = queriesData.map(({ data: socials }) => { + return { + address: addresses[index], + uri: getPreferredInboxAvatar(socials), + name: getPreferredInboxName(socials), + }; + }); return ( - {messageComponent} + {/* Put back once we refactor the menu items */} {/* */} diff --git a/features/conversation/conversation-message/conversation-message-reactions/conversation-message-reaction-drawer/conversation-message-reaction-drawer.service..ts b/features/conversation/conversation-message/conversation-message-reactions/conversation-message-reaction-drawer/conversation-message-reaction-drawer.service..ts index 0e86997a7..fe7221bd1 100644 --- a/features/conversation/conversation-message/conversation-message-reactions/conversation-message-reaction-drawer/conversation-message-reaction-drawer.service..ts +++ b/features/conversation/conversation-message/conversation-message-reactions/conversation-message-reaction-drawer/conversation-message-reaction-drawer.service..ts @@ -1,5 +1,4 @@ import { createBottomSheetModalRef } from "@design-system/BottomSheet/BottomSheet.utils"; - import { RolledUpReactions } from "../conversation-message-reactions.types"; import { resetMessageReactionsStore, diff --git a/features/conversation/conversation-message/conversation-message-reactions/conversation-message-reactions.tsx b/features/conversation/conversation-message/conversation-message-reactions/conversation-message-reactions.tsx index dcfafb705..e62690d7e 100644 --- a/features/conversation/conversation-message/conversation-message-reactions/conversation-message-reactions.tsx +++ b/features/conversation/conversation-message/conversation-message-reactions/conversation-message-reactions.tsx @@ -1,15 +1,20 @@ import { useSelect } from "@/data/store/storeHelpers"; import { useMessageContextStoreContext } from "@/features/conversation/conversation-message/conversation-message.store-context"; import { useConversationMessageReactions } from "@/features/conversation/conversation-message/conversation-message.utils"; -import { useCurrentAccount, useProfilesStore } from "@data/store/accountsStore"; +import { + isCurrentUserInboxId, + useCurrentAccountInboxId, +} from "@/hooks/use-current-account-inbox-id"; +import { useInboxProfileSocialsQueries } from "@/queries/useInboxProfileSocialsQuery"; +import { useCurrentAccount } from "@data/store/accountsStore"; import { AnimatedHStack, HStack } from "@design-system/HStack"; import { Text } from "@design-system/Text"; import { VStack } from "@design-system/VStack"; import { ThemedStyle, useAppTheme } from "@theme/useAppTheme"; import { - getPreferredAvatar, - getPreferredName, - getProfile, + getPreferredInboxAddress, + getPreferredInboxAvatar, + getPreferredInboxName, } from "@utils/profile"; import { memo, useCallback, useMemo } from "react"; import { TouchableHighlight, ViewStyle } from "react-native"; @@ -88,27 +93,33 @@ export const ConversationMessageReactions = memo( function useMessageReactionsRolledUp() { const { messageId } = useMessageContextStoreContext(useSelect(["messageId"])); + const { bySender: reactionsBySender } = + useConversationMessageReactions(messageId); + const currentAddress = useCurrentAccount()!; + const { data: currentUserInboxId } = useCurrentAccountInboxId(); - const { bySender: reactions } = useConversationMessageReactions(messageId); - - const userAddress = useCurrentAccount(); - - // Get social details for all unique addresses - const addresses = Array.from( + const inboxIds = Array.from( new Set( - Object.entries(reactions ?? {}).map(([senderAddress]) => senderAddress) + Object.entries(reactionsBySender ?? {}).map( + ([senderInboxId]) => senderInboxId + ) ) ); - const membersSocials = useProfilesStore((s) => - addresses.map((address) => { - const socials = getProfile(address, s.profiles)?.socials; + const inboxProfileSocialsQueries = useInboxProfileSocialsQueries( + currentAddress, + inboxIds + ); + + const membersSocials = inboxProfileSocialsQueries.map( + ({ data: socials }, index) => { return { - address, - uri: getPreferredAvatar(socials), - name: getPreferredName(socials, address), + inboxId: inboxIds[index], + address: getPreferredInboxAddress(socials), + uri: getPreferredInboxAvatar(socials), + name: getPreferredInboxName(socials), }; - }) + } ); return useMemo((): RolledUpReactions => { @@ -117,24 +128,21 @@ function useMessageReactionsRolledUp() { let userReacted = false; // Flatten reactions and track sender addresses - const flatReactions = Object.entries(reactions ?? {}).flatMap( - ([senderAddress, senderReactions]) => - senderReactions.map((reaction) => ({ senderAddress, ...reaction })) + const flatReactions = Object.entries(reactionsBySender ?? {}).flatMap( + ([senderInboxId, senderReactions]) => + senderReactions.map((reaction) => ({ senderInboxId, ...reaction })) ); totalCount = flatReactions.length; - // Create a map to efficiently access social details by address - const socialsMap = new Map( - membersSocials.map((social) => [social.address, social]) - ); - // Track reaction counts for preview const previewCounts = new Map(); flatReactions.forEach((reaction) => { - const isOwnReaction = - reaction.senderAddress.toLowerCase() === userAddress?.toLowerCase(); - if (isOwnReaction) userReacted = true; + const isOwnReaction = isCurrentUserInboxId(reaction.senderInboxId); + + if (isOwnReaction) { + userReacted = true; + } // Count reactions for the preview previewCounts.set( @@ -142,13 +150,15 @@ function useMessageReactionsRolledUp() { (previewCounts.get(reaction.content) || 0) + 1 ); - // Add to detailed array - const socialDetails = socialsMap.get(reaction.senderAddress); + const socialDetails = membersSocials.find( + (social) => social.inboxId === reaction.senderInboxId + ); + detailed.push({ content: reaction.content, isOwnReaction, reactor: { - address: reaction.senderAddress, + address: reaction.senderInboxId, userName: socialDetails?.name, avatar: socialDetails?.uri, }, @@ -170,7 +180,7 @@ function useMessageReactionsRolledUp() { preview, detailed, }; - }, [reactions, userAddress, membersSocials]); + }, [reactionsBySender, membersSocials]); } const $reactionButton: ThemedStyle = ({ diff --git a/features/conversation/conversation-message/conversation-message-status-dumb.tsx b/features/conversation/conversation-message/conversation-message-status-dumb.tsx deleted file mode 100644 index 017bf7b38..000000000 --- a/features/conversation/conversation-message/conversation-message-status-dumb.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { translate } from "@i18n"; -import { textSecondaryColor } from "@styles/colors"; -import React, { useEffect, useRef, useState } from "react"; -import { StyleSheet, useColorScheme, View } from "react-native"; -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, - Easing, -} from "react-native-reanimated"; - -const statusMapping = { - sent: translate("message_status.sent"), - delivered: translate("message_status.delivered"), - error: translate("message_status.error"), - sending: translate("message_status.sending"), - prepared: translate("message_status.prepared"), - seen: translate("message_status.seen"), -}; - -type MessageStatusDumbProps = { - // - shouldDisplay: boolean; - isLatestSettledFromMe: boolean; - status: keyof typeof statusMapping; -}; - -export function MessageStatusDumb({ - shouldDisplay, - isLatestSettledFromMe, - status, -}: MessageStatusDumbProps) { - const styles = useStyles(); - const prevStatusRef = useRef(status); - const isSentOrDelivered = status === "sent" || status === "delivered"; - - const [renderText, setRenderText] = useState(false); - const opacity = useSharedValue(isLatestSettledFromMe ? 1 : 0); - const height = useSharedValue(isLatestSettledFromMe ? 22 : 0); - const scale = useSharedValue(isLatestSettledFromMe ? 1 : 0); - - const timingConfig = { - duration: 200, - easing: Easing.inOut(Easing.quad), - }; - - const animatedStyle = useAnimatedStyle(() => ({ - opacity: opacity.value, - height: height.value, - transform: [{ scale: scale.value }], - })); - - useEffect( - () => { - const prevStatus = prevStatusRef.current; - prevStatusRef.current = status; - - setTimeout(() => { - requestAnimationFrame(() => { - if ( - isSentOrDelivered && - (prevStatus === "sending" || prevStatus === "prepared") - ) { - opacity.value = withTiming(1, timingConfig); - height.value = withTiming(22, timingConfig); - scale.value = withTiming(1, timingConfig); - setRenderText(true); - } else if (isSentOrDelivered && !isLatestSettledFromMe) { - opacity.value = withTiming(0, timingConfig); - height.value = withTiming(0, timingConfig); - scale.value = withTiming(0, timingConfig); - setTimeout(() => setRenderText(false), timingConfig.duration); - } else if (isLatestSettledFromMe) { - opacity.value = 1; - height.value = 22; - scale.value = 1; - setRenderText(true); - } - }); - }, 100); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [isLatestSettledFromMe, isSentOrDelivered] - ); - - return ( - shouldDisplay && ( - - - - {renderText && statusMapping[status]} - - - - ) - ); -} - -const useStyles = () => { - const colorScheme = useColorScheme(); - return StyleSheet.create({ - container: { - overflow: "hidden", - }, - contentContainer: { - paddingTop: 5, - }, - statusText: { - fontSize: 12, - color: textSecondaryColor(colorScheme), - }, - }); -}; diff --git a/features/conversation/conversation-message/conversation-message-status.tsx b/features/conversation/conversation-message/conversation-message-status.tsx deleted file mode 100644 index d1a25540c..000000000 --- a/features/conversation/conversation-message/conversation-message-status.tsx +++ /dev/null @@ -1,115 +0,0 @@ -/** - * TODO - */ -// import { DecodedMessageWithCodecsType } from "@/utils/xmtpRN/client.types"; -// import { textSecondaryColor } from "@styles/colors"; -// import React, { useEffect, useRef, useState } from "react"; -// import { StyleSheet, View, useColorScheme } from "react-native"; -// import Animated, { -// Easing, -// useAnimatedStyle, -// useSharedValue, -// withTiming, -// } from "react-native-reanimated"; - -// type Props = { -// message: DecodedMessageWithCodecsType; -// }; - -// const statusMapping: { -// [key: string]: string | undefined; -// } = { -// sent: "Sent", -// delivered: "Sent", -// error: "Failed", -// sending: "Sending", -// prepared: "Sending", -// seen: "Read", -// }; - -// export default function MessageStatus({ message }: Props) { -// const styles = useStyles(); -// const prevStatusRef = useRef(message.status); -// const isSentOrDelivered = -// message.status === "sent" || message.status === "delivered"; -// const isLatestSettledFromMe = message.isLatestSettledFromMe; - -// const [renderText, setRenderText] = useState(false); -// const opacity = useSharedValue(message.isLatestSettledFromMe ? 1 : 0); -// const height = useSharedValue(message.isLatestSettledFromMe ? 22 : 0); -// const scale = useSharedValue(message.isLatestSettledFromMe ? 1 : 0); - -// const timingConfig = { -// duration: 200, -// easing: Easing.inOut(Easing.quad), -// }; - -// const animatedStyle = useAnimatedStyle(() => ({ -// opacity: opacity.value, -// height: height.value, -// transform: [{ scale: scale.value }], -// })); - -// useEffect( -// () => { -// const prevStatus = prevStatusRef.current; -// prevStatusRef.current = message.status; - -// setTimeout(() => { -// requestAnimationFrame(() => { -// if ( -// isSentOrDelivered && -// (prevStatus === "sending" || prevStatus === "prepared") -// ) { -// opacity.value = withTiming(1, timingConfig); -// height.value = withTiming(22, timingConfig); -// scale.value = withTiming(1, timingConfig); -// setRenderText(true); -// } else if (isSentOrDelivered && !isLatestSettledFromMe) { -// opacity.value = withTiming(0, timingConfig); -// height.value = withTiming(0, timingConfig); -// scale.value = withTiming(0, timingConfig); -// setTimeout(() => setRenderText(false), timingConfig.duration); -// } else if (isLatestSettledFromMe) { -// opacity.value = 1; -// height.value = 22; -// scale.value = 1; -// setRenderText(true); -// } -// }); -// }, 100); -// }, -// // eslint-disable-next-line react-hooks/exhaustive-deps -// [isLatestSettledFromMe, isSentOrDelivered] -// ); - -// return ( -// message.fromMe && -// message.status !== "sending" && -// message.status !== "prepared" && ( -// -// -// -// {renderText && statusMapping[message.status]} -// -// -// -// ) -// ); -// } - -// const useStyles = () => { -// const colorScheme = useColorScheme(); -// return StyleSheet.create({ -// container: { -// overflow: "hidden", -// }, -// contentContainer: { -// paddingTop: 5, -// }, -// statusText: { -// fontSize: 12, -// color: textSecondaryColor(colorScheme), -// }, -// }); -// }; diff --git a/features/conversation/conversation-message/conversation-message.store-context.tsx b/features/conversation/conversation-message/conversation-message.store-context.tsx index 9ea22375f..81cbebf68 100644 --- a/features/conversation/conversation-message/conversation-message.store-context.tsx +++ b/features/conversation/conversation-message/conversation-message.store-context.tsx @@ -2,11 +2,12 @@ * This store/context is to avoid prop drilling in message components. */ -import { convertNanosecondsToMilliseconds } from "@/utils/date"; import { hasNextMessageInSeries } from "@/features/conversation/utils/has-next-message-in-serie"; import { hasPreviousMessageInSeries } from "@/features/conversation/utils/has-previous-message-in-serie"; +import { isLatestMessageSettledFromPeer } from "@/features/conversation/utils/is-latest-message-settled-from-peer"; import { messageIsFromCurrentUserV3 } from "@/features/conversation/utils/message-is-from-current-user"; import { messageShouldShowDateChange } from "@/features/conversation/utils/message-should-show-date-change"; +import { convertNanosecondsToMilliseconds } from "@/utils/date"; import { DecodedMessageWithCodecsType } from "@/utils/xmtpRN/client.types"; import { InboxId, MessageId } from "@xmtp/react-native-sdk"; import { createContext, memo, useContext, useEffect, useRef } from "react"; @@ -21,6 +22,7 @@ type IMessageContextStoreProps = { type IMessageContextStoreState = IMessageContextStoreProps & { messageId: MessageId; + isLatestSettledFromMe: boolean; hasNextMessageInSeries: boolean; hasPreviousMessageInSeries: boolean; fromMe: boolean; @@ -63,6 +65,10 @@ function getStoreStateBasedOnProps(props: IMessageContextStoreProps) { return { ...props, messageId: props.message.id as MessageId, + isLatestSettledFromMe: isLatestMessageSettledFromPeer({ + message: props.message, + nextMessage: props.nextMessage, + }), hasNextMessageInSeries: hasNextMessageInSeries({ currentMessage: props.message, nextMessage: props.nextMessage, diff --git a/features/conversation/conversation-new-dm.tsx b/features/conversation/conversation-new-dm.tsx index be69e4dea..8c091ede1 100644 --- a/features/conversation/conversation-new-dm.tsx +++ b/features/conversation/conversation-new-dm.tsx @@ -1,6 +1,7 @@ import { showSnackbar } from "@/components/Snackbar/Snackbar.service"; import { getCurrentAccount } from "@/data/store/accountsStore"; import { Composer } from "@/features/conversation/conversation-composer/conversation-composer"; +import { ConversationComposerContainer } from "@/features/conversation/conversation-composer/conversation-composer-container"; import { ConversationComposerStoreProvider } from "@/features/conversation/conversation-composer/conversation-composer.store-context"; import { KeyboardFiller } from "@/features/conversation/conversation-keyboard-filler"; import { NewConversationTitle } from "@/features/conversation/conversation-new-dm-header-title"; @@ -47,14 +48,15 @@ export const ConversationNewDm = memo(function ConversationNewDm(props: { storeName={"new-conversation" as ConversationTopic} inputValue={textPrefill} > - {/* TODO: Add empty state */} - - + + + + ); }); diff --git a/features/conversation/conversation.tsx b/features/conversation/conversation.tsx index f7af70326..773a4ca42 100644 --- a/features/conversation/conversation.tsx +++ b/features/conversation/conversation.tsx @@ -5,6 +5,8 @@ import { ExternalWalletPickerContextProvider } from "@/features/ExternalWalletPi import { useConversationIsUnread } from "@/features/conversation-list/hooks/useMessageIsUnread"; import { useToggleReadStatus } from "@/features/conversation-list/hooks/useToggleReadStatus"; import { Composer } from "@/features/conversation/conversation-composer/conversation-composer"; +import { ConversationComposerContainer } from "@/features/conversation/conversation-composer/conversation-composer-container"; +import { ReplyPreview } from "@/features/conversation/conversation-composer/conversation-composer-reply-preview"; import { ConversationComposerStoreProvider, useConversationComposerStore, @@ -14,25 +16,27 @@ import { GroupConsentPopup } from "@/features/conversation/conversation-consent- import { DmConversationTitle } from "@/features/conversation/conversation-dm-header-title"; import { GroupConversationTitle } from "@/features/conversation/conversation-group-header-title"; import { KeyboardFiller } from "@/features/conversation/conversation-keyboard-filler"; -import { - IMessageGesturesOnLongPressArgs, - MessageGestures, -} from "@/features/conversation/conversation-message/conversation-message-gestures"; -import { ConversationMessageTimestamp } from "@/features/conversation/conversation-message/conversation-message-timestamp"; -import { - MessageContextStoreProvider, - useMessageContextStore, -} from "@/features/conversation/conversation-message/conversation-message.store-context"; +import { ConversationMessageStatus } from "@/features/conversation/conversation-message-status/conversation-message-status"; import { ConversationMessage } from "@/features/conversation/conversation-message/conversation-message"; import { MessageContextMenu } from "@/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu"; import { MessageContextMenuStoreProvider, useMessageContextMenuStore, + useMessageContextMenuStoreContext, } from "@/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu.store-context"; +import { + IMessageGesturesOnLongPressArgs, + MessageGestures, +} from "@/features/conversation/conversation-message/conversation-message-gestures"; import { ConversationMessageLayout } from "@/features/conversation/conversation-message/conversation-message-layout"; import { MessageReactionsDrawer } from "@/features/conversation/conversation-message/conversation-message-reactions/conversation-message-reaction-drawer/conversation-message-reaction-drawer"; import { ConversationMessageReactions } from "@/features/conversation/conversation-message/conversation-message-reactions/conversation-message-reactions"; import { ConversationMessageRepliable } from "@/features/conversation/conversation-message/conversation-message-repliable"; +import { ConversationMessageTimestamp } from "@/features/conversation/conversation-message/conversation-message-timestamp"; +import { + MessageContextStoreProvider, + useMessageContextStore, +} from "@/features/conversation/conversation-message/conversation-message.store-context"; import { ConversationMessagesList } from "@/features/conversation/conversation-messages-list"; import { useSendMessage } from "@/features/conversation/hooks/use-send-message"; import { isConversationAllowed } from "@/features/conversation/utils/is-conversation-allowed"; @@ -64,7 +68,7 @@ import { ConversationStoreProvider, useCurrentConversationTopic, } from "./conversation.store-context"; -import { debugBorder } from "@/utils/debug-style"; +import { MessageSimpleText } from "@/features/conversation/conversation-message/conversation-message-content-types/conversation-message-simple-text"; export const Conversation = memo(function Conversation(props: { topic: ConversationTopic; @@ -131,7 +135,7 @@ export const Conversation = memo(function Conversation(props: { > - + @@ -142,6 +146,13 @@ export const Conversation = memo(function Conversation(props: { ); }); +const KeyboardFillerWrapper = memo(function KeyboardFillerWrapper() { + const messageContextMenuData = useMessageContextMenuStoreContext( + (state) => state.messageContextMenuData + ); + return ; +}); + const ComposerWrapper = memo(function ComposerWrapper(props: { conversation: ConversationWithCodecsType; }) { @@ -149,7 +160,13 @@ const ComposerWrapper = memo(function ComposerWrapper(props: { const sendMessage = useSendMessage({ conversation, }); - return ; + + return ( + + + + + ); }); const Messages = memo(function Messages(props: { @@ -245,25 +262,24 @@ const ConversationMessagesListItem = memo( previousMessage={previousMessage} nextMessage={nextMessage} > - + - - - + + + + ); } ); -const WithGestures = memo(function WithGestures({ +const ConversationMessageGestures = memo(function ConversationMessageGestures({ children, }: { children: React.ReactNode; @@ -275,9 +291,9 @@ const WithGestures = memo(function WithGestures({ const handleLongPress = useCallback( (e: IMessageGesturesOnLongPressArgs) => { const messageId = messageStore.getState().messageId; - const message = messageStore.getState().message; - const previousMessage = messageStore.getState().previousMessage; - const nextMessage = messageStore.getState().nextMessage; + // const message = messageStore.getState().message; + // const previousMessage = messageStore.getState().previousMessage; + // const nextMessage = messageStore.getState().nextMessage; messageContextMenuStore.getState().setMessageContextMenuData({ messageId, @@ -289,18 +305,18 @@ const WithGestures = memo(function WithGestures({ // Not the cleanest... // Might want to find another solution later but works for now. // Solution might be to remove the context and just pass props - messageComponent: ( - - {children} - - ), + // messageComponent: ( + // + // {children} + // + // ), }); }, - [messageStore, messageContextMenuStore, children] + [messageContextMenuStore, messageStore] ); const handleTap = useCallback(() => { diff --git a/features/conversation/utils/is-latest-message-settled-from-peer.ts b/features/conversation/utils/is-latest-message-settled-from-peer.ts index 7343c4211..fee0ea6ad 100644 --- a/features/conversation/utils/is-latest-message-settled-from-peer.ts +++ b/features/conversation/utils/is-latest-message-settled-from-peer.ts @@ -2,14 +2,12 @@ import { DecodedMessageWithCodecsType } from "@utils/xmtpRN/client"; import { messageIsFromCurrentUser } from "./message-is-from-current-user"; type IsLatestMessageSettledFromPeerPayload = { - message?: DecodedMessageWithCodecsType; - nextMessage?: DecodedMessageWithCodecsType; - currentAccount: string; + message: DecodedMessageWithCodecsType; + nextMessage: DecodedMessageWithCodecsType | undefined; }; export const isLatestMessageSettledFromPeer = ({ message, - currentAccount, nextMessage, }: IsLatestMessageSettledFromPeerPayload) => { if (!message) return false; diff --git a/hooks/use-current-account-inbox-id.ts b/hooks/use-current-account-inbox-id.ts index c8d5ad380..d88d96825 100644 --- a/hooks/use-current-account-inbox-id.ts +++ b/hooks/use-current-account-inbox-id.ts @@ -22,3 +22,8 @@ export function prefetchCurrentUserAccountInboxId() { const currentAccount = getCurrentAccount()!; return prefetchInboxIdQuery({ account: currentAccount }); } + +export function isCurrentUserInboxId(inboxId: InboxId) { + const currentUserInboxId = getCurrentUserAccountInboxId(); + return currentUserInboxId?.toLowerCase() === inboxId.toLowerCase(); +} diff --git a/hooks/use-keyboard-handlers.ts b/hooks/use-keyboard-handlers.ts new file mode 100644 index 000000000..9f9188283 --- /dev/null +++ b/hooks/use-keyboard-handlers.ts @@ -0,0 +1,26 @@ +import { + runOnJS, + KeyboardState, + useAnimatedReaction, + useAnimatedKeyboard, +} from "react-native-reanimated"; + +type IKeyboardHandlersArgs = { + onKeyboardOpen: () => void; + onKeyboardClose: () => void; +}; + +export function useKeyboardHandlers(args: IKeyboardHandlersArgs) { + const { state } = useAnimatedKeyboard(); + + useAnimatedReaction( + () => state.value, + (state) => { + if (state === KeyboardState.OPEN) { + runOnJS(args.onKeyboardOpen)(); + } else if (state === KeyboardState.CLOSED) { + runOnJS(args.onKeyboardClose)(); + } + } + ); +} diff --git a/hooks/use-keyboard-is-shown-ref.ts b/hooks/use-keyboard-is-shown-ref.ts new file mode 100644 index 000000000..78edef27d --- /dev/null +++ b/hooks/use-keyboard-is-shown-ref.ts @@ -0,0 +1,27 @@ +import { + KeyboardState, + useAnimatedKeyboard, + useAnimatedReaction, + useSharedValue, +} from "react-native-reanimated"; + +export function useKeyboardIsShownRef() { + const isKeyboardShown = useSharedValue(false); + + const { state } = useAnimatedKeyboard(); + + useAnimatedReaction( + () => state.value, + (state) => { + if (state === KeyboardState.OPEN) { + console.log("keyboard is open"); + isKeyboardShown.value = true; + } else if (state === KeyboardState.CLOSED) { + console.log("keyboard is closed"); + isKeyboardShown.value = false; + } + } + ); + + return isKeyboardShown.value; +} diff --git a/hooks/use-keyboard-is-shown.ts b/hooks/use-keyboard-is-shown.ts new file mode 100644 index 000000000..f195880be --- /dev/null +++ b/hooks/use-keyboard-is-shown.ts @@ -0,0 +1,26 @@ +import { useState } from "react"; +import { + KeyboardState, + runOnJS, + useAnimatedKeyboard, + useAnimatedReaction, +} from "react-native-reanimated"; + +export function useKeyboardIsShown() { + const [isKeyboardShown, setIsKeyboardShown] = useState(false); + + const { state } = useAnimatedKeyboard(); + + useAnimatedReaction( + () => state.value, + (state) => { + if (state === KeyboardState.OPEN) { + runOnJS(setIsKeyboardShown)(true); + } else if (state === KeyboardState.CLOSED) { + runOnJS(setIsKeyboardShown)(false); + } + } + ); + + return isKeyboardShown; +} diff --git a/ios/Converse.xcodeproj/project.pbxproj b/ios/Converse.xcodeproj/project.pbxproj index a042fea5d..b7a701b67 100644 --- a/ios/Converse.xcodeproj/project.pbxproj +++ b/ios/Converse.xcodeproj/project.pbxproj @@ -434,7 +434,6 @@ "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoLocalization/ExpoLocalization_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ReachabilitySwift/ReachabilitySwift.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", @@ -446,7 +445,6 @@ "${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle", ); name = "[CP] Copy Pods Resources"; outputPaths = ( @@ -461,7 +459,6 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoLocalization_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ReachabilitySwift.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", @@ -473,7 +470,6 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevMenu.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; diff --git a/ios/Podfile.lock b/ios/Podfile.lock index ee22fbdc6..7b677a0b3 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -410,6 +410,11 @@ PODS: - fmt (= 9.1.0) - glog - RCT-Folly/Default (= 2024.01.01.00) + - RCT-Folly/Default (2024.01.01.00): + - boost + - DoubleConversion + - fmt (= 9.1.0) + - glog - RCT-Folly/Fabric (2024.01.01.00): - boost - DoubleConversion @@ -2699,7 +2704,7 @@ SPEC CHECKSUMS: CryptoSwift: 967f37cea5a3294d9cce358f78861652155be483 CSecp256k1: 2a59c03e52637ded98896a33be4b2649392cb843 DGSwiftUtilities: 1f2722e8b2442dc11c17b66818f62b93780e95fd - DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 + DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5 EASClient: 1509a9a6b48b932ec61667644634daf2562983b8 EXApplication: c08200c34daca7af7fd76ac4b9d606077410e8ad EXConstants: 409690fbfd5afea964e5e9d6c4eb2c2b59222c59 @@ -2740,8 +2745,8 @@ SPEC CHECKSUMS: EXUpdates: 60b8b9b0d6e8cb7699ebd375a4deada231b9f5ca EXUpdatesInterface: 996527fd7d1a5d271eb523258d603f8f92038f24 FBLazyVector: 430e10366de01d1e3d57374500b1b150fe482e6d - fmt: 10c6e61f4be25dc963c36bd73fc7b1705fe975be - glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a + fmt: 4c2741a687cc09f0634a2e2c72a838b99f1ff120 + glog: fdfdfe5479092de0c4bdbebedd9056951f092c4f hermes-engine: ea92f60f37dba025e293cbe4b4a548fd26b610a0 libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f @@ -2753,7 +2758,7 @@ SPEC CHECKSUMS: MMKVCore: d26e4d3edd5cb8588c2569222cbd8be4231374e9 op-sqlite: 7720c6cc59e76c983263edc07420e642b45fa288 OpenSSL-Universal: 6e1ae0555546e604dbc632a2b9a24a9c46c41ef6 - RCT-Folly: bf5c0376ffe4dd2cf438dcf86db385df9fdce648 + RCT-Folly: 02617c592a293bd6d418e0a88ff4ee1f88329b47 RCTDeprecation: 726d24248aeab6d7180dac71a936bbca6a994ed1 RCTRequired: a94e7febda6db0345d207e854323c37e3a31d93b RCTTypeSafety: 28e24a6e44f5cbf912c66dde6ab7e07d1059a205 @@ -2856,7 +2861,7 @@ SPEC CHECKSUMS: UMAppLoader: f17a5ee8e85b536ace0fc254b447a37ed198d57e XMTP: 3b586fa3703640bb5fec8a64daba9e157d9e5fdc XMTPReactNative: f3e1cbf80b7278b817bd42982703a95a9250497d - Yoga: a9ef4f5c2cd79ad812110525ef61048be6a582a4 + Yoga: b05994d1933f507b0a28ceaa4fdb968dc18da178 PODFILE CHECKSUM: 7ed5cefb992e438c67772278d7c473ace4b42753 diff --git a/queries/useConversationMessages.ts b/queries/useConversationMessages.ts index 9c47dfcea..57061ce1e 100644 --- a/queries/useConversationMessages.ts +++ b/queries/useConversationMessages.ts @@ -1,6 +1,6 @@ -import { useQuery } from "@tanstack/react-query"; import { isReactionMessage } from "@/features/conversation/conversation-message/conversation-message.utils"; import { contentTypesPrefixes } from "@/utils/xmtpRN/content-types/content-types"; +import { useQuery } from "@tanstack/react-query"; import logger from "@utils/logger"; import { ConversationWithCodecsType, @@ -17,14 +17,119 @@ import { MessagesOptions, } from "@xmtp/react-native-sdk/build/lib/types"; import { conversationMessagesQueryKey } from "./QueryKeys"; -import { cacheOnlyQueryOptions } from "./cacheOnlyQueryOptions"; import { queryClient } from "./queryClient"; -import { useConversationQuery } from "./useConversationQuery"; +import { getConversationQueryData } from "./useConversationQuery"; export type ConversationMessagesQueryData = Awaited< ReturnType >; +export const conversationMessagesQueryFn = async ( + conversation: ConversationWithCodecsType, + options?: MessagesOptions +) => { + logger.info("[useConversationMessages] queryFn fetching messages"); + + if (!conversation) { + throw new Error("Conversation not found in conversationMessagesQueryFn"); + } + + const messages = await conversation.messages(options); + return processMessages({ messages }); +}; + +const conversationMessagesByTopicQueryFn = async ( + account: string, + topic: ConversationTopic +) => { + logger.info("[useConversationMessages] queryFn fetching messages by topic"); + const conversation = await getConversationByTopicByAccount({ + account, + topic, + }); + return conversationMessagesQueryFn(conversation!); +}; + +export const useConversationMessages = ( + account: string, + topic: ConversationTopic +) => { + return useQuery(getConversationMessagesQueryOptions(account, topic)); +}; + +export const getConversationMessages = ( + account: string, + topic: ConversationTopic +) => { + return queryClient.getQueryData( + getConversationMessagesQueryOptions(account, topic).queryKey + ); +}; + +export function refetchConversationMessages( + account: string, + topic: ConversationTopic +) { + logger.info("[refetchConversationMessages] refetching messages"); + return queryClient.refetchQueries( + getConversationMessagesQueryOptions(account, topic) + ); +} + +export const addConversationMessage = (args: { + account: string; + topic: ConversationTopic; + message: DecodedMessageWithCodecsType; + // isOptimistic?: boolean; +}) => { + const { + account, + topic, + message, + // isOptimistic + } = args; + + // WIP + // if (isOptimistic) { + // addOptimisticMessage(message.id); + // } + + queryClient.setQueryData( + conversationMessagesQueryKey(account, topic), + (previousMessages) => { + const processedMessages = processMessages({ + messages: [message], + existingData: previousMessages, + prependNewMessages: true, + }); + return processedMessages; + } + ); +}; + +export const prefetchConversationMessages = async ( + account: string, + topic: ConversationTopic +) => { + return queryClient.prefetchQuery( + getConversationMessagesQueryOptions(account, topic) + ); +}; + +function getConversationMessagesQueryOptions( + account: string, + topic: ConversationTopic +) { + const conversation = getConversationQueryData(account, topic); + return { + queryKey: conversationMessagesQueryKey(account, topic), + queryFn: () => { + return conversationMessagesByTopicQueryFn(account, topic); + }, + enabled: !!conversation, + }; +} + const ignoredContentTypesPrefixes = [ contentTypesPrefixes.coinbasePayment, contentTypesPrefixes.transactionReference, @@ -159,112 +264,6 @@ function processMessages(args: { return result; } -export const conversationMessagesQueryFn = async ( - conversation: ConversationWithCodecsType, - options?: MessagesOptions -) => { - logger.info("[useConversationMessages] queryFn fetching messages"); - - if (!conversation) { - throw new Error("Conversation not found in conversationMessagesQueryFn"); - } - - const messages = await conversation.messages(options); - return processMessages({ messages }); -}; - -const conversationMessagesByTopicQueryFn = async ( - account: string, - topic: ConversationTopic -) => { - logger.info("[useConversationMessages] queryFn fetching messages by topic"); - const conversation = await getConversationByTopicByAccount({ - account, - topic, - }); - return conversationMessagesQueryFn(conversation!); -}; - -export const useConversationMessages = ( - account: string, - topic: ConversationTopic -) => { - const { data: conversation } = useConversationQuery( - account, - topic, - cacheOnlyQueryOptions - ); - - return useQuery({ - queryKey: conversationMessagesQueryKey(account, topic), - queryFn: async () => { - return conversationMessagesQueryFn(conversation!); - }, - enabled: !!conversation, - }); -}; - -export const getConversationMessages = ( - account: string, - topic: ConversationTopic -) => { - return queryClient.getQueryData( - conversationMessagesQueryKey(account, topic) - ); -}; - -export function refetchConversationMessages( - account: string, - topic: ConversationTopic -) { - return queryClient.refetchQueries({ - queryKey: conversationMessagesQueryKey(account, topic), - }); -} - -export const addConversationMessage = (args: { - account: string; - topic: ConversationTopic; - message: DecodedMessageWithCodecsType; - // isOptimistic?: boolean; -}) => { - const { - account, - topic, - message, - // isOptimistic - } = args; - - // WIP - // if (isOptimistic) { - // addOptimisticMessage(message.id); - // } - - queryClient.setQueryData( - conversationMessagesQueryKey(account, topic), - (previousMessages) => { - return processMessages({ - messages: [message], - existingData: previousMessages, - prependNewMessages: true, - }); - } - ); -}; - -export const prefetchConversationMessages = async ( - account: string, - topic: ConversationTopic -) => { - return queryClient.prefetchQuery({ - queryKey: conversationMessagesQueryKey(account.toLowerCase(), topic), - queryFn: () => { - logger.info("[prefetchConversationMessages] prefetching messages"); - return conversationMessagesByTopicQueryFn(account, topic); - }, - }); -}; - // WIP // type IOptimisticMessage = { // tempId: string; diff --git a/queries/useConversationQuery.ts b/queries/useConversationQuery.ts index b4a12449f..c4e9bce29 100644 --- a/queries/useConversationQuery.ts +++ b/queries/useConversationQuery.ts @@ -31,7 +31,7 @@ export const invalidateConversationQuery = ( account: string, topic: ConversationTopic ) => { - queryClient.invalidateQueries({ + return queryClient.invalidateQueries({ queryKey: conversationQueryKey(account, topic), }); }; @@ -41,7 +41,7 @@ export function updateConversationQueryData( topic: ConversationTopic, conversation: ConversationQueryData ) { - queryClient.setQueryData( + return queryClient.setQueryData( conversationQueryKey(account, topic), conversation ); @@ -70,7 +70,8 @@ export function refetchConversationQuery( export const getConversationQueryData = ( account: string, topic: ConversationTopic -) => - queryClient.getQueryData( +) => { + return queryClient.getQueryData( conversationQueryKey(account, topic) ); +}; diff --git a/utils/xmtpRN/conversations.ts b/utils/xmtpRN/conversations.ts index f338060f2..ddd05f41a 100644 --- a/utils/xmtpRN/conversations.ts +++ b/utils/xmtpRN/conversations.ts @@ -22,7 +22,6 @@ export const streamConversations = async (account: string) => { const client = (await getXmtpClient(account)) as ConverseXmtpClientType; await client.conversations.stream(async (conversation) => { logger.info("[XMTPRN Conversations] GOT A NEW CONVO"); - addConversationToConversationListQuery(account, conversation); }); logger.info("STREAMING CONVOS"); From 26b8bc3231bcd08dcebf1a3e785a8f417d7d750d Mon Sep 17 00:00:00 2001 From: Thierry Date: Mon, 16 Dec 2024 11:29:00 -0500 Subject: [PATCH 2/6] add optimistic update when sinding simple text message --- .../conversation-message-status.tsx | 123 ++++-------------- ...conversation-message-content-container.tsx | 1 + .../conversation-message-context-menu.tsx | 118 +++++++++-------- .../conversation-message-layout.tsx | 9 +- .../conversation-message.store-context.tsx | 5 +- .../conversation-message.types.ts | 1 + .../conversation-message.utils.tsx | 21 ++- features/conversation/conversation.tsx | 32 ++++- .../conversation/hooks/use-send-message.ts | 116 ++++++++++------- hooks/use-current-account-inbox-id.ts | 1 + hooks/use-previous-value.ts | 11 ++ queries/useConversationMessage.ts | 4 + queries/useConversationMessages.ts | 118 ++++++++--------- utils/xmtpRN/messages.ts | 25 +++- 14 files changed, 311 insertions(+), 274 deletions(-) create mode 100644 features/conversation/conversation-message/conversation-message.types.ts create mode 100644 hooks/use-previous-value.ts diff --git a/features/conversation/conversation-message-status/conversation-message-status.tsx b/features/conversation/conversation-message-status/conversation-message-status.tsx index 49e24aa93..ca7031281 100644 --- a/features/conversation/conversation-message-status/conversation-message-status.tsx +++ b/features/conversation/conversation-message-status/conversation-message-status.tsx @@ -1,123 +1,46 @@ -import { useSelect } from "@/data/store/storeHelpers"; +import { AnimatedHStack } from "@/design-system/HStack"; import { AnimatedText } from "@/design-system/Text"; -import { messageIsSent } from "@/features/conversation/conversation-message-status/conversation-message-status.utils"; -import { useMessageContextStoreContext } from "@/features/conversation/conversation-message/conversation-message.store-context"; +import { usePrevious } from "@/hooks/use-previous-value"; import { translate } from "@/i18n"; import { useAppTheme } from "@/theme/useAppTheme"; -import { DecodedMessageWithCodecsType } from "@/utils/xmtpRN/client.types"; -import React, { memo, useEffect, useRef, useState } from "react"; -import { View } from "react-native"; -import Animated, { - Easing, - useAnimatedStyle, - useSharedValue, - withTiming, -} from "react-native-reanimated"; +import { Haptics } from "@/utils/haptics"; +import React, { memo, useEffect } from "react"; +import { IConversationMessageStatus } from "../conversation-message/conversation-message.types"; type IConversationMessageStatusProps = { - message: DecodedMessageWithCodecsType; + status: IConversationMessageStatus; }; -const statusMapping: { - [key: string]: string | undefined; -} = { +const statusMapping: Record = { + // sending: translate("message_status.sending"), + sending: " ", // For now don't show anything for sending, waiting to see what UX we want sent: translate("message_status.sent"), - delivered: translate("message_status.delivered"), error: translate("message_status.error"), - sending: translate("message_status.sending"), - prepared: translate("message_status.prepared"), - seen: translate("message_status.seen"), }; export const ConversationMessageStatus = memo( function ConversationMessageStatus({ - message, + status, }: IConversationMessageStatusProps) { const { theme } = useAppTheme(); - const { fromMe, isLatestSettledFromMe } = useMessageContextStoreContext( - useSelect(["fromMe", "isLatestSettledFromMe"]) - ); - - const prevStatusRef = useRef(message.status); - const isSent = messageIsSent(message); - - const [renderText, setRenderText] = useState(false); - const opacity = useSharedValue(isLatestSettledFromMe ? 1 : 0); - const height = useSharedValue(isLatestSettledFromMe ? 22 : 0); - const scale = useSharedValue(isLatestSettledFromMe ? 1 : 0); - - const timingConfig = { - duration: 200, - easing: Easing.inOut(Easing.quad), - }; - - const animatedStyle = useAnimatedStyle(() => ({ - opacity: opacity.value, - height: height.value, - transform: [{ scale: scale.value }], - })); - - useEffect( - () => { - const prevStatus = prevStatusRef.current; - prevStatusRef.current = message.status; - - setTimeout(() => { - requestAnimationFrame(() => { - if ( - isSent && - (prevStatus === "sending" || prevStatus === "prepared") - ) { - opacity.value = withTiming(1, timingConfig); - height.value = withTiming(22, timingConfig); - scale.value = withTiming(1, timingConfig); - setRenderText(true); - } else if (isSent && !isLatestSettledFromMe) { - opacity.value = withTiming(0, timingConfig); - height.value = withTiming(0, timingConfig); - scale.value = withTiming(0, timingConfig); - setTimeout(() => setRenderText(false), timingConfig.duration); - } else if (isLatestSettledFromMe) { - opacity.value = 1; - height.value = 22; - scale.value = 1; - setRenderText(true); - } - }); - }, 100); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [isLatestSettledFromMe, isSent] - ); - - if (!isLatestSettledFromMe) { - return null; - } + const previousStatus = usePrevious(status); - if (!fromMe) { - return null; - } + useEffect(() => { + if (previousStatus === "sending" && status === "sent") { + Haptics.softImpactAsync(); + } + }, [status, previousStatus]); return ( - - - - {renderText && statusMapping[message.status]} - - - + + {statusMapping[status]} + + ); } ); diff --git a/features/conversation/conversation-message/conversation-message-content-container.tsx b/features/conversation/conversation-message/conversation-message-content-container.tsx index eec932c0a..a5b3d8439 100644 --- a/features/conversation/conversation-message/conversation-message-content-container.tsx +++ b/features/conversation/conversation-message/conversation-message-content-container.tsx @@ -3,6 +3,7 @@ import { useSelect } from "@/data/store/storeHelpers"; import { HStack } from "@/design-system/HStack"; import { useAppTheme } from "@/theme/useAppTheme"; import { memo } from "react"; +import { debugBorder } from "@/utils/debug-style"; export const MessageContentContainer = memo( function MessageContentContainer(props: { children: React.ReactNode }) { diff --git a/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu.tsx b/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu.tsx index bacf76210..58df67f13 100644 --- a/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu.tsx +++ b/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu.tsx @@ -1,3 +1,6 @@ +import { useCurrentAccount } from "@/data/store/accountsStore"; +import { AnimatedVStack, VStack } from "@/design-system/VStack"; +import { ConversationMessage } from "@/features/conversation/conversation-message/conversation-message"; import { MessageContextMenuBackdrop } from "@/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-backdrop"; import { MessageContextMenuEmojiPicker } from "@/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-emoji-picker/conversation-message-context-menu-emoji-picker"; import { openMessageContextMenuEmojiPicker } from "@/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-emoji-picker/conversation-message-context-menu-emoji-picker-utils"; @@ -8,26 +11,27 @@ import { useMessageContextMenuStore, useMessageContextMenuStoreContext, } from "@/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu.store-context"; +import { MessageContextStoreProvider } from "@/features/conversation/conversation-message/conversation-message.store-context"; import { getMessageById, useConversationMessageReactions, } from "@/features/conversation/conversation-message/conversation-message.utils"; -import { useCurrentAccountInboxId } from "@/hooks/use-current-account-inbox-id"; -import { useCurrentAccount } from "@/data/store/accountsStore"; -import { AnimatedVStack, VStack } from "@/design-system/VStack"; -import { useCurrentConversationTopic } from "../../conversation.store-context"; +import { + ConversationStoreProvider, + useCurrentConversationTopic, +} from "@/features/conversation/conversation.store-context"; import { useReactOnMessage } from "@/features/conversation/hooks/use-react-on-message"; import { useRemoveReactionOnMessage } from "@/features/conversation/hooks/use-remove-reaction-on-message"; import { messageIsFromCurrentUserV3 } from "@/features/conversation/utils/message-is-from-current-user"; +import { useCurrentAccountInboxId } from "@/hooks/use-current-account-inbox-id"; import { useConversationQuery } from "@/queries/useConversationQuery"; import { calculateMenuHeight } from "@design-system/ContextMenu/ContextMenu.utils"; import { Portal } from "@gorhom/portal"; -import { memo, useCallback, useEffect } from "react"; -import { Keyboard, StyleSheet } from "react-native"; +import { memo, useCallback } from "react"; +import { StyleSheet } from "react-native"; import { MessageContextMenuAboveMessageReactions } from "./conversation-message-context-menu-above-message-reactions"; import { MessageContextMenuContainer } from "./conversation-message-context-menu-container"; import { useMessageContextMenuItems } from "./conversation-message-context-menu.utils"; -import { ConversationMessage } from "@/features/conversation/conversation-message/conversation-message"; export const MESSAGE_CONTEXT_MENU_SPACE_BETWEEN_ABOVE_MESSAGE_REACTIONS_AND_MESSAGE = 16; @@ -50,14 +54,8 @@ const Content = memo(function Content(props: { }) { const { messageContextMenuData } = props; - const { - messageId, - itemRectX, - itemRectY, - itemRectHeight, - itemRectWidth, - messageComponent, - } = messageContextMenuData; + const { messageId, itemRectX, itemRectY, itemRectHeight, itemRectWidth } = + messageContextMenuData; const account = useCurrentAccount()!; const topic = useCurrentConversationTopic(); @@ -124,49 +122,61 @@ const Content = memo(function Content(props: { return ( <> - - - {!!bySender && } - - + + + + {!!bySender && } + + - {/* Replace with rowGap when we refactored menu items */} - + {/* Replace with rowGap when we refactored menu items and not using rn-paper TableView */} + - + + {/* TODO: maybe make ConversationMessage more dumb to not need any context? */} + + - {/* Put back once we refactor the menu items */} - {/* */} + {/* Put back once we refactor the menu items */} + {/* */} - - - - + + + + + diff --git a/features/conversation/conversation-message/conversation-message-layout.tsx b/features/conversation/conversation-message/conversation-message-layout.tsx index d0acd7919..70ae55b92 100644 --- a/features/conversation/conversation-message/conversation-message-layout.tsx +++ b/features/conversation/conversation-message/conversation-message-layout.tsx @@ -1,10 +1,11 @@ import { useSelect } from "@/data/store/storeHelpers"; -import { VStack } from "@/design-system/VStack"; +import { AnimatedVStack, VStack } from "@/design-system/VStack"; import { MessageContainer } from "@/features/conversation/conversation-message/conversation-message-container"; import { MessageContentContainer } from "@/features/conversation/conversation-message/conversation-message-content-container"; import { V3MessageSenderAvatar } from "@/features/conversation/conversation-message/conversation-message-sender-avatar"; import { useMessageContextStoreContext } from "@/features/conversation/conversation-message/conversation-message.store-context"; import { useAppTheme } from "@/theme/useAppTheme"; +import { debugBorder } from "@/utils/debug-style"; import { ReactNode, memo } from "react"; type IConversationMessageLayoutProps = { @@ -35,14 +36,16 @@ export const ConversationMessageLayout = memo( )} - {children} - + ); diff --git a/features/conversation/conversation-message/conversation-message.store-context.tsx b/features/conversation/conversation-message/conversation-message.store-context.tsx index 81cbebf68..f16b73062 100644 --- a/features/conversation/conversation-message/conversation-message.store-context.tsx +++ b/features/conversation/conversation-message/conversation-message.store-context.tsx @@ -65,10 +65,7 @@ function getStoreStateBasedOnProps(props: IMessageContextStoreProps) { return { ...props, messageId: props.message.id as MessageId, - isLatestSettledFromMe: isLatestMessageSettledFromPeer({ - message: props.message, - nextMessage: props.nextMessage, - }), + isLatestSettledFromMe: true, hasNextMessageInSeries: hasNextMessageInSeries({ currentMessage: props.message, nextMessage: props.nextMessage, diff --git a/features/conversation/conversation-message/conversation-message.types.ts b/features/conversation/conversation-message/conversation-message.types.ts new file mode 100644 index 000000000..646f92342 --- /dev/null +++ b/features/conversation/conversation-message/conversation-message.types.ts @@ -0,0 +1 @@ +export type IConversationMessageStatus = "sending" | "sent" | "error"; diff --git a/features/conversation/conversation-message/conversation-message.utils.tsx b/features/conversation/conversation-message/conversation-message.utils.tsx index f12d535cc..dc3edd0d4 100644 --- a/features/conversation/conversation-message/conversation-message.utils.tsx +++ b/features/conversation/conversation-message/conversation-message.utils.tsx @@ -1,4 +1,3 @@ -import { useCurrentConversationTopic } from "../conversation.store-context"; import { getConversationMessageQueryOptions } from "@/queries/useConversationMessage"; import { getConversationMessages, @@ -21,6 +20,7 @@ import { ConversationTopic, DecodedMessage, GroupUpdatedCodec, + MessageDeliveryStatus, MessageId, ReactionCodec, ReactionContent, @@ -33,6 +33,7 @@ import { StaticAttachmentContent, TextCodec, } from "@xmtp/react-native-sdk"; +import { useCurrentConversationTopic } from "../conversation.store-context"; export function isTextMessage( message: DecodedMessageWithCodecsType @@ -216,3 +217,21 @@ export function useConversationMessageReactions(messageId: MessageId) { byReactionContent: messages?.reactions[messageId]?.byReactionContent, }; } + +export function getConvosMessageStatus(message: DecodedMessageWithCodecsType) { + // @ts-ignore - Custom for optimistic message, we might want to have our custom ConvoMessage + if (message.deliveryStatus === "sending") { + return "sending"; + } + + switch (message.deliveryStatus) { + case MessageDeliveryStatus.UNPUBLISHED: + case MessageDeliveryStatus.FAILED: + return "error"; + case MessageDeliveryStatus.PUBLISHED: + case MessageDeliveryStatus.ALL: + return "sent"; + default: + return message.deliveryStatus satisfies never; + } +} diff --git a/features/conversation/conversation.tsx b/features/conversation/conversation.tsx index 773a4ca42..e5e104b14 100644 --- a/features/conversation/conversation.tsx +++ b/features/conversation/conversation.tsx @@ -37,11 +37,13 @@ import { MessageContextStoreProvider, useMessageContextStore, } from "@/features/conversation/conversation-message/conversation-message.store-context"; +import { getConvosMessageStatus } from "@/features/conversation/conversation-message/conversation-message.utils"; import { ConversationMessagesList } from "@/features/conversation/conversation-messages-list"; import { useSendMessage } from "@/features/conversation/hooks/use-send-message"; import { isConversationAllowed } from "@/features/conversation/utils/is-conversation-allowed"; import { isConversationDm } from "@/features/conversation/utils/is-conversation-dm"; import { isConversationGroup } from "@/features/conversation/utils/is-conversation-group"; +import { useCurrentAccountInboxId } from "@/hooks/use-current-account-inbox-id"; import { useConversationQuery } from "@/queries/useConversationQuery"; import { useGroupNameQuery } from "@/queries/useGroupNameQuery"; import { @@ -62,13 +64,13 @@ import React, { useCallback, useEffect, useLayoutEffect, + useMemo, useRef, } from "react"; import { ConversationStoreProvider, useCurrentConversationTopic, } from "./conversation.store-context"; -import { MessageSimpleText } from "@/features/conversation/conversation-message/conversation-message-content-types/conversation-message-simple-text"; export const Conversation = memo(function Conversation(props: { topic: ConversationTopic; @@ -175,6 +177,7 @@ const Messages = memo(function Messages(props: { const { conversation } = props; const currentAccount = useCurrentAccount()!; + const { data: currentAccountInboxId } = useCurrentAccountInboxId(); const topic = useCurrentConversationTopic()!; const { @@ -184,6 +187,14 @@ const Messages = memo(function Messages(props: { refetch, } = useConversationMessages(currentAccount, topic!); + const latestMessageIdByCurrentUser = useMemo(() => { + if (!messages?.ids) return -1; + return messages.ids.find( + (messageId) => + messages.byId[messageId].senderAddress === currentAccountInboxId + ); + }, [messages?.ids, messages?.byId, currentAccountInboxId]); + const isUnread = useConversationIsUnread({ topic, lastMessage: messages?.byId[messages?.ids[0]], // Get latest message @@ -236,6 +247,9 @@ const Messages = memo(function Messages(props: { message={message} previousMessage={previousMessage} nextMessage={nextMessage} + isLatestMessageSentByCurrentUser={ + latestMessageIdByCurrentUser === messageId + } /> ); }} @@ -248,8 +262,14 @@ const ConversationMessagesListItem = memo( message: DecodedMessageWithCodecsType; previousMessage: DecodedMessageWithCodecsType | undefined; nextMessage: DecodedMessageWithCodecsType | undefined; + isLatestMessageSentByCurrentUser: boolean; }) { - const { message, previousMessage, nextMessage } = props; + const { + message, + previousMessage, + nextMessage, + isLatestMessageSentByCurrentUser, + } = props; const composerStore = useConversationComposerStore(); const handleReply = useCallback(() => { @@ -267,12 +287,16 @@ const ConversationMessagesListItem = memo( - + + {isLatestMessageSentByCurrentUser && ( + + )} - ); diff --git a/features/conversation/hooks/use-send-message.ts b/features/conversation/hooks/use-send-message.ts index 63e2ed904..fdaa706c0 100644 --- a/features/conversation/hooks/use-send-message.ts +++ b/features/conversation/hooks/use-send-message.ts @@ -1,9 +1,24 @@ import { getCurrentAccount } from "@/data/store/accountsStore"; -import { refetchConversationMessages } from "@/queries/useConversationMessages"; +import { IConversationMessageStatus } from "@/features/conversation/conversation-message/conversation-message.types"; +import { getCurrentUserAccountInboxId } from "@/hooks/use-current-account-inbox-id"; +import { fetchMessageByIdQuery } from "@/queries/useConversationMessage"; +import { + addConversationMessage, + refetchConversationMessages, + replaceOptimisticMessageWithReal, +} from "@/queries/useConversationMessages"; import { captureError, captureErrorWithToast } from "@/utils/capture-error"; +import { getTodayNs } from "@/utils/date"; +import { getRandomId } from "@/utils/general"; import { ConversationWithCodecsType } from "@/utils/xmtpRN/client.types"; +import { contentTypesPrefixes } from "@/utils/xmtpRN/content-types/content-types"; import { useMutation } from "@tanstack/react-query"; -import { MessageId, RemoteAttachmentContent } from "@xmtp/react-native-sdk"; +import { + DecodedMessage, + MessageId, + RemoteAttachmentContent, + TextCodec, +} from "@xmtp/react-native-sdk"; import { useCallback } from "react"; export type ISendMessageParams = { @@ -48,52 +63,65 @@ export function useSendMessage(props: { mutationFn: (variables: ISendMessageParams) => sendMessage({ conversation, params: variables }), // WIP - // onMutate: (variables) => { - // const currentAccount = getCurrentAccount()!; - // const currentUserInboxId = getCurrentUserAccountInboxId()!; + onMutate: (variables) => { + const currentAccount = getCurrentAccount()!; + const currentUserInboxId = getCurrentUserAccountInboxId()!; - // // For now only optimistic message for simple text message - // if (variables.content.text && !variables.referencedMessageId) { - // const generatedMessageId = getRandomId(); + // For now only optimistic message for simple text message + if (variables.content.text && !variables.referencedMessageId) { + const generatedMessageId = getRandomId(); - // const textMessage: DecodedMessage = { - // id: generatedMessageId, - // client: conversation.client, - // contentTypeId: variables.content.text - // ? contentTypesPrefixes.text - // : contentTypesPrefixes.remoteAttachment, - // sentNs: getTodayNs(), - // fallback: "new-message", - // deliveryStatus: MessageDeliveryStatus.PUBLISHED, - // topic: conversation.topic, - // senderAddress: currentUserInboxId, - // nativeContent: {}, - // content: () => { - // return variables.content.text!; - // }, - // }; + const textMessage: DecodedMessage = { + id: generatedMessageId, + client: conversation.client, + contentTypeId: variables.content.text + ? contentTypesPrefixes.text + : contentTypesPrefixes.remoteAttachment, + sentNs: getTodayNs(), + fallback: "new-message", + // @ts-ignore we're adding our "own" delivery status because we want to display it in the UI + deliveryStatus: "sending" satisfies IConversationMessageStatus, + topic: conversation.topic, + senderAddress: currentUserInboxId, + nativeContent: {}, + content: () => { + return variables.content.text!; + }, + }; - // addConversationMessage({ - // account: currentAccount, - // topic: conversation.topic, - // message: textMessage, - // // isOptimistic: true, - // }); + addConversationMessage({ + account: currentAccount, + topic: conversation.topic, + message: textMessage, + }); - // return { - // generatedMessageId, - // }; - // } - // }, - // WIP - // onSuccess: (messageId, _, context) => { - // if (context && messageId) { - // updateConversationMessagesOptimisticMessages( - // context.generatedMessageId, - // messageId - // ); - // } - // }, + return { + generatedMessageId, + }; + } + }, + onSuccess: async (messageId, _, context) => { + if (context && messageId) { + // The SDK only returns the messageId + const message = await fetchMessageByIdQuery({ + account: getCurrentAccount()!, + messageId, + }); + + if (!message) { + throw new Error("Message not found"); + } + + if (message) { + replaceOptimisticMessageWithReal({ + tempId: context.generatedMessageId, + topic: conversation.topic, + account: getCurrentAccount()!, + message, + }); + } + } + }, onError: (error) => { captureError(error); const currentAccount = getCurrentAccount()!; diff --git a/hooks/use-current-account-inbox-id.ts b/hooks/use-current-account-inbox-id.ts index d88d96825..cac5a0bed 100644 --- a/hooks/use-current-account-inbox-id.ts +++ b/hooks/use-current-account-inbox-id.ts @@ -7,6 +7,7 @@ import { prefetchInboxIdQuery, useInboxIdQuery, } from "../queries/use-inbox-id-query"; +import { InboxId } from "@xmtp/react-native-sdk"; export function useCurrentAccountInboxId() { const currentAccount = useCurrentAccount()!; diff --git a/hooks/use-previous-value.ts b/hooks/use-previous-value.ts new file mode 100644 index 000000000..af2d3cd35 --- /dev/null +++ b/hooks/use-previous-value.ts @@ -0,0 +1,11 @@ +import { useEffect, useRef } from "react"; + +export function usePrevious(value: T): T | undefined { + const ref = useRef(); + + useEffect(() => { + ref.current = value; + }, [value]); + + return ref.current; +} diff --git a/queries/useConversationMessage.ts b/queries/useConversationMessage.ts index 309d52f76..f33ae04db 100644 --- a/queries/useConversationMessage.ts +++ b/queries/useConversationMessage.ts @@ -45,3 +45,7 @@ export const getConversationMessage = (args: IArgs) => { getConversationMessageQueryOptions(args).queryKey ); }; + +export function fetchMessageByIdQuery(args: IArgs) { + return queryClient.fetchQuery(getConversationMessageQueryOptions(args)); +} diff --git a/queries/useConversationMessages.ts b/queries/useConversationMessages.ts index 57061ce1e..025133ee6 100644 --- a/queries/useConversationMessages.ts +++ b/queries/useConversationMessages.ts @@ -80,19 +80,8 @@ export const addConversationMessage = (args: { account: string; topic: ConversationTopic; message: DecodedMessageWithCodecsType; - // isOptimistic?: boolean; }) => { - const { - account, - topic, - message, - // isOptimistic - } = args; - - // WIP - // if (isOptimistic) { - // addOptimisticMessage(message.id); - // } + const { account, topic, message } = args; queryClient.setQueryData( conversationMessagesQueryKey(account, topic), @@ -177,25 +166,10 @@ function processMessages(args: { if (!isReactionMessage(message)) { const messageId = message.id as MessageId; - // WIP - // Find matching optimistic message using correlationId from message root - // const optimisticMessage = optimisticMessages.find( - // (msg) => msg.messageId === message.id - // ); - - // if (optimisticMessage) { - // // Remove the optimistic message from tracking and from the result - // removeOptimisticMessage(messageId); - // // Remove from the query data - // result.ids = result.ids.filter((id) => id !== optimisticMessage.tempId); - // delete result.byId[optimisticMessage.tempId as MessageId]; - // } - - // Add the new message result.byId[messageId] = message; if (prependNewMessages) { result.ids = [messageId, ...result.ids]; - } else if (!result.ids.includes(messageId)) { + } else { result.ids.push(messageId); } } @@ -265,34 +239,60 @@ function processMessages(args: { } // WIP -// type IOptimisticMessage = { -// tempId: string; -// messageId?: MessageId; -// }; - -// // Keep track of optimistic messages -// let optimisticMessages: IOptimisticMessage[] = []; - -// function addOptimisticMessage(tempId: string) { -// optimisticMessages.push({ -// tempId, -// }); -// } - -// function removeOptimisticMessage(messageId: MessageId) { -// optimisticMessages = optimisticMessages.filter( -// (msg) => msg.messageId !== messageId -// ); -// } - -// export function updateConversationMessagesOptimisticMessages( -// tempId: string, -// messageId: MessageId -// ) { -// const optimisticMessage = optimisticMessages.find( -// (msg) => msg.tempId === tempId -// ); -// if (optimisticMessage) { -// optimisticMessage.messageId = messageId; -// } -// } +type IOptimisticMessage = { + tempId: string; + messageId?: MessageId; +}; + +export function replaceOptimisticMessageWithReal(args: { + tempId: string; + topic: ConversationTopic; + account: string; + message: DecodedMessageWithCodecsType; +}) { + const { tempId, topic, account, message } = args; + logger.info( + "[linkOptimisticMessageToReal] linking optimistic message to real", + { + tempId, + messageId: message.id, + } + ); + + queryClient.setQueryData( + conversationMessagesQueryKey(account, topic), + (previousMessages) => { + if (!previousMessages) { + return { + ids: [message.id as MessageId], + byId: { + [message.id as MessageId]: message, + }, + reactions: {}, + }; + } + + // Find the index of the temporary message + const tempIndex = previousMessages.ids.indexOf(tempId as MessageId); + + if (tempIndex === -1) { + return previousMessages; + } + + // Create new ids array with the real message id replacing the temp id + const newIds = [...previousMessages.ids]; + newIds[tempIndex] = message.id as MessageId; + + // Create new byId object without the temp message and with the real message + const newById = { ...previousMessages.byId }; + delete newById[tempId as MessageId]; + newById[message.id as MessageId] = message; + + return { + ...previousMessages, + ids: newIds, + byId: newById, + }; + } + ); +} diff --git a/utils/xmtpRN/messages.ts b/utils/xmtpRN/messages.ts index ef6ac70cd..d2014ce29 100644 --- a/utils/xmtpRN/messages.ts +++ b/utils/xmtpRN/messages.ts @@ -6,6 +6,11 @@ import { updateMessageToConversationListQuery } from "@/queries/useV3Conversatio import { handleGroupUpdatedMessage } from "@data/helpers/messages/handleGroupUpdatedMessage"; import { addConversationMessage } from "@queries/useConversationMessages"; import type { ConversationTopic } from "@xmtp/react-native-sdk"; +import { + messageIsFromCurrentUser, + messageIsFromCurrentUserV3, +} from "@/features/conversation/utils/message-is-from-current-user"; +import { isTextMessage } from "@/features/conversation/conversation-message/conversation-message.utils"; export const streamAllMessages = async (account: string) => { await stopStreamingAllMessage(account); @@ -24,11 +29,21 @@ export const streamAllMessages = async (account: string) => { message ); } - addConversationMessage({ - account: client.address, - topic: message.topic as ConversationTopic, - message, - }); + + // We already handle text messages from the current user locally via react-query + // We only need to handle messages that are either: + // 1. From other users + // 2. Non-text messages from current user + const isMessageFromOtherUser = !messageIsFromCurrentUserV3({ message }); + const isNonTextMessage = !isTextMessage(message); + if (isMessageFromOtherUser || isNonTextMessage) { + addConversationMessage({ + account: client.address, + topic: message.topic as ConversationTopic, + message, + }); + } + updateMessageToConversationListQuery(client.address, message); return; }); From 88d2de8622847c7ac58e7bc2718818f79a672e55 Mon Sep 17 00:00:00 2001 From: Thierry Date: Mon, 16 Dec 2024 12:31:16 -0500 Subject: [PATCH 3/6] more fixes --- .../conversation-composer-reply-preview.tsx | 2 -- .../conversation-keyboard-filler.tsx | 2 -- .../conversation-message-status.tsx | 10 +++++++ ...ersation-message-context-menu-reactors.tsx | 21 +++++++-------- .../conversation-message.utils.tsx | 8 +++++- features/conversation/conversation.tsx | 6 ++++- hooks/use-keyboard-is-shown-ref.ts | 27 ------------------- queries/useConversationMessages.ts | 7 ++++- 8 files changed, 38 insertions(+), 45 deletions(-) delete mode 100644 hooks/use-keyboard-is-shown-ref.ts diff --git a/features/conversation/conversation-composer/conversation-composer-reply-preview.tsx b/features/conversation/conversation-composer/conversation-composer-reply-preview.tsx index f579429d4..2190af092 100644 --- a/features/conversation/conversation-composer/conversation-composer-reply-preview.tsx +++ b/features/conversation/conversation-composer/conversation-composer-reply-preview.tsx @@ -102,8 +102,6 @@ export const ReplyPreview = memo(function ReplyPreview() { }, containerAS, ]} - // entering={theme.animation.reanimatedFadeInSpring} - // exiting={theme.animation.reanimatedFadeOutSpring} > {!!replyMessage && ( { - console.log("messageContextMenuIsOpen:", messageContextMenuIsOpen); - console.log("keyboardWasOpenRef.current:", keyboardWasOpenRef.current); // Context menu was hidden if (!messageContextMenuIsOpen) { // Reopen keyboard if it was open before context menu was shown diff --git a/features/conversation/conversation-message-status/conversation-message-status.tsx b/features/conversation/conversation-message-status/conversation-message-status.tsx index ca7031281..305d0c947 100644 --- a/features/conversation/conversation-message-status/conversation-message-status.tsx +++ b/features/conversation/conversation-message-status/conversation-message-status.tsx @@ -1,4 +1,5 @@ import { AnimatedHStack } from "@/design-system/HStack"; +import { Icon } from "@/design-system/Icon/Icon"; import { AnimatedText } from "@/design-system/Text"; import { usePrevious } from "@/hooks/use-previous-value"; import { translate } from "@/i18n"; @@ -36,10 +37,19 @@ export const ConversationMessageStatus = memo( {statusMapping[status]} + ); } diff --git a/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-reactors.tsx b/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-reactors.tsx index 1c0ad21d6..50f255370 100644 --- a/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-reactors.tsx +++ b/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-reactors.tsx @@ -32,7 +32,7 @@ export const MessageContextMenuReactors: FC< } const reactionMap: Record = {}; - ObjectTyped.entries(reactors).forEach(([reactorAddress, reactions]) => { + ObjectTyped.entries(reactors).forEach(([reactorInboxId, reactions]) => { if (!reactions || reactions.length === 0) { return; } @@ -41,7 +41,7 @@ export const MessageContextMenuReactors: FC< reactionMap[getReactionContent(reaction)] = []; } reactionMap[getReactionContent(reaction)].push( - reactorAddress as InboxId + reactorInboxId as InboxId ); } }); @@ -71,8 +71,8 @@ export const MessageContextMenuReactors: FC< ( - + renderItem={({ item: [content, inboxIds], index }) => ( + )} keyExtractor={(item) => item[0]} showsHorizontalScrollIndicator={false} @@ -84,20 +84,19 @@ export const MessageContextMenuReactors: FC< type MessageReactionsItemProps = { content: string; - addresses: string[]; - index: number; + inboxIds: InboxId[]; }; -const Item: FC = ({ content, addresses, index }) => { +const Item: FC = ({ content, inboxIds }) => { const { theme } = useAppTheme(); const currentAccount = useCurrentAccount()!; - const queriesData = useInboxProfileSocialsQueries(currentAccount, addresses); + const queriesData = useInboxProfileSocialsQueries(currentAccount, inboxIds); - const membersSocials = queriesData.map(({ data: socials }) => { + const membersSocials = queriesData.map(({ data: socials }, index) => { return { - address: addresses[index], + address: inboxIds[index], uri: getPreferredInboxAvatar(socials), name: getPreferredInboxName(socials), }; @@ -125,7 +124,7 @@ const Item: FC = ({ content, addresses, index }) => { /> - {content} {addresses.length} + {content} {inboxIds.length} ); diff --git a/features/conversation/conversation-message/conversation-message.utils.tsx b/features/conversation/conversation-message/conversation-message.utils.tsx index dc3edd0d4..3348cfbb9 100644 --- a/features/conversation/conversation-message/conversation-message.utils.tsx +++ b/features/conversation/conversation-message/conversation-message.utils.tsx @@ -35,6 +35,12 @@ import { } from "@xmtp/react-native-sdk"; import { useCurrentConversationTopic } from "../conversation.store-context"; +export function isAnActualMessage( + message: DecodedMessageWithCodecsType +): message is DecodedMessage { + return !isReadReceiptMessage(message) && !isGroupUpdatedMessage(message); +} + export function isTextMessage( message: DecodedMessageWithCodecsType ): message is DecodedMessage { @@ -232,6 +238,6 @@ export function getConvosMessageStatus(message: DecodedMessageWithCodecsType) { case MessageDeliveryStatus.ALL: return "sent"; default: - return message.deliveryStatus satisfies never; + throw new Error(`Unhandled delivery status: ${message.deliveryStatus}`); } } diff --git a/features/conversation/conversation.tsx b/features/conversation/conversation.tsx index e5e104b14..1339fba31 100644 --- a/features/conversation/conversation.tsx +++ b/features/conversation/conversation.tsx @@ -37,7 +37,10 @@ import { MessageContextStoreProvider, useMessageContextStore, } from "@/features/conversation/conversation-message/conversation-message.store-context"; -import { getConvosMessageStatus } from "@/features/conversation/conversation-message/conversation-message.utils"; +import { + getConvosMessageStatus, + isAnActualMessage, +} from "@/features/conversation/conversation-message/conversation-message.utils"; import { ConversationMessagesList } from "@/features/conversation/conversation-messages-list"; import { useSendMessage } from "@/features/conversation/hooks/use-send-message"; import { isConversationAllowed } from "@/features/conversation/utils/is-conversation-allowed"; @@ -191,6 +194,7 @@ const Messages = memo(function Messages(props: { if (!messages?.ids) return -1; return messages.ids.find( (messageId) => + isAnActualMessage(messages.byId[messageId]) && messages.byId[messageId].senderAddress === currentAccountInboxId ); }, [messages?.ids, messages?.byId, currentAccountInboxId]); diff --git a/hooks/use-keyboard-is-shown-ref.ts b/hooks/use-keyboard-is-shown-ref.ts deleted file mode 100644 index 78edef27d..000000000 --- a/hooks/use-keyboard-is-shown-ref.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - KeyboardState, - useAnimatedKeyboard, - useAnimatedReaction, - useSharedValue, -} from "react-native-reanimated"; - -export function useKeyboardIsShownRef() { - const isKeyboardShown = useSharedValue(false); - - const { state } = useAnimatedKeyboard(); - - useAnimatedReaction( - () => state.value, - (state) => { - if (state === KeyboardState.OPEN) { - console.log("keyboard is open"); - isKeyboardShown.value = true; - } else if (state === KeyboardState.CLOSED) { - console.log("keyboard is closed"); - isKeyboardShown.value = false; - } - } - ); - - return isKeyboardShown.value; -} diff --git a/queries/useConversationMessages.ts b/queries/useConversationMessages.ts index 025133ee6..2599f3917 100644 --- a/queries/useConversationMessages.ts +++ b/queries/useConversationMessages.ts @@ -47,7 +47,12 @@ const conversationMessagesByTopicQueryFn = async ( account, topic, }); - return conversationMessagesQueryFn(conversation!); + if (!conversation) { + throw new Error( + "Conversation not found in conversationMessagesByTopicQueryFn" + ); + } + return conversationMessagesQueryFn(conversation); }; export const useConversationMessages = ( From 9cc58a2f8242b5e897bb1b5d360eaea6ae8711a6 Mon Sep 17 00:00:00 2001 From: Thierry Date: Wed, 18 Dec 2024 11:34:23 -0500 Subject: [PATCH 4/6] more fixes --- components/Connecting.tsx | 4 +- .../PinnedConversations.tsx | 17 +- .../PinnedV3DMConversation.tsx | 36 +- .../StateHandlers/HydrationStateHandler.tsx | 4 +- components/V3DMListItem.tsx | 54 +- containers/GroupScreenAddition.tsx | 15 +- containers/GroupScreenName.tsx | 16 +- .../handleGroupUpdatedMessage.test.ts | 197 -------- .../messages/handleGroupUpdatedMessage.ts | 56 --- .../joinGroup/JoinGroup.client.ts | 8 +- features/blocked-chats/useV3BlockedChats.ts | 12 +- .../useV3ConversationItems.ts | 12 +- .../useV3RequestItemCount.ts | 12 +- .../useV3RequestItems.tsx | 12 +- .../conversation-consent-popup-dm.tsx | 56 ++- .../conversation-consent-popup-group.tsx | 12 +- .../conversation-dm-header-title.tsx | 21 +- .../conversation-group-header-title.tsx | 102 ++-- .../conversation-message-context-menu.tsx | 5 +- .../conversation-message-layout.tsx | 6 +- .../conversation-message.store-context.tsx | 4 +- .../conversation-new-dm-header-title.tsx | 14 +- features/conversation/conversation-new-dm.tsx | 57 +-- features/conversation/conversation-title.tsx | 65 +-- features/conversation/conversation.screen.tsx | 18 +- features/conversation/conversation.tsx | 61 +-- .../hooks/use-group-name-convos.ts | 6 +- .../conversation/utils/is-conversation-dm.ts | 9 +- .../utils/is-conversation-group.ts | 5 +- .../utils/accountTopicSubscription.ts | 7 +- .../utils/onInteractWithNotification.ts | 2 +- hooks/useConversationListGroupItem.ts | 15 +- hooks/useGroupConsent.ts | 12 +- hooks/useGroupCreator.ts | 20 - hooks/useGroupDescription.ts | 5 +- hooks/useGroupName.ts | 14 +- hooks/useGroupPhoto.ts | 10 +- queries/QueryKeys.test.ts | 138 +++-- queries/QueryKeys.ts | 80 +-- queries/__snapshots__/QueryKeys.test.ts.snap | 11 +- queries/groupConsentMutationUtils.ts | 12 +- queries/useAddToGroupMutation.ts | 11 +- queries/useAllowGroupMutation.ts | 169 +++---- queries/useBlockGroupMutation.ts | 58 +-- queries/useConversationListQuery.ts | 247 +++++++++ queries/useConversationMessages.ts | 2 +- queries/useConversationPreviewMessages.ts | 20 +- queries/useConversationQuery.ts | 126 +++-- queries/useConversationWithPeerQuery.ts | 55 -- queries/useDmConstentStateQuery.ts | 54 -- queries/useDmPeerAddressQuery.ts | 15 - queries/useDmPeerInbox.ts | 25 +- queries/useDmPeerInboxOnConversationList.ts | 29 -- queries/useDmQuery.ts | 128 +++++ queries/useGroupConsentQuery.ts | 69 +-- queries/useGroupCreatorQuery.ts | 22 + queries/useGroupDescriptionMutation.ts | 82 ++- queries/useGroupDescriptionQuery.ts | 62 +-- queries/useGroupIsActive.ts | 55 -- queries/useGroupIsActiveQuery.ts | 16 + queries/useGroupMembersQuery.ts | 4 +- queries/useGroupNameMutation.ts | 72 +-- queries/useGroupNameQuery.ts | 71 +-- queries/useGroupPermissionPolicyQuery.ts | 2 +- queries/useGroupPermissionsQuery.ts | 16 +- queries/useGroupPhotoMutation.ts | 53 +- queries/useGroupPhotoQuery.ts | 74 +-- queries/useGroupPinnedFrameQuery.ts | 2 +- queries/useGroupQuery.ts | 92 +++- queries/usePromoteToAdminMutation.ts | 6 +- queries/usePromoteToSuperAdminMutation.ts | 10 +- queries/useRemoveFromGroupMutation.ts | 6 +- queries/useRevokeAdminMutation.ts | 6 +- queries/useRevokeSuperAdminMutation.ts | 6 +- queries/useV3ConversationListQuery.ts | 291 ----------- screens/ConversationList.tsx | 35 +- screens/ConversationReadOnly.tsx | 5 +- screens/Group.tsx | 5 +- screens/NewConversation/NewConversation.tsx | 25 +- .../handleGroupDescriptionUpdate.ts | 22 - utils/groupUtils/handleGroupImageUpdate.ts | 18 - utils/groupUtils/handleGroupNameUpdate.ts | 18 - utils/mutate-object-properties.ts | 36 ++ utils/xmtpRN/conversations.ts | 474 +++++++----------- utils/xmtpRN/messages.ts | 130 ++++- utils/xmtpRN/sync.ts | 6 +- 86 files changed, 1684 insertions(+), 2268 deletions(-) delete mode 100644 data/helpers/messages/handleGroupUpdatedMessage.test.ts delete mode 100644 data/helpers/messages/handleGroupUpdatedMessage.ts delete mode 100644 hooks/useGroupCreator.ts create mode 100644 queries/useConversationListQuery.ts delete mode 100644 queries/useConversationWithPeerQuery.ts delete mode 100644 queries/useDmConstentStateQuery.ts delete mode 100644 queries/useDmPeerAddressQuery.ts delete mode 100644 queries/useDmPeerInboxOnConversationList.ts create mode 100644 queries/useDmQuery.ts create mode 100644 queries/useGroupCreatorQuery.ts delete mode 100644 queries/useGroupIsActive.ts create mode 100644 queries/useGroupIsActiveQuery.ts delete mode 100644 queries/useV3ConversationListQuery.ts delete mode 100644 utils/groupUtils/handleGroupDescriptionUpdate.ts delete mode 100644 utils/groupUtils/handleGroupImageUpdate.ts delete mode 100644 utils/groupUtils/handleGroupNameUpdate.ts create mode 100644 utils/mutate-object-properties.ts diff --git a/components/Connecting.tsx b/components/Connecting.tsx index c474102f4..c82ec2375 100644 --- a/components/Connecting.tsx +++ b/components/Connecting.tsx @@ -5,7 +5,7 @@ import { useDebugEnabled } from "./DebugButton"; import { useChatStore, useCurrentAccount } from "../data/store/accountsStore"; import { useAppStore } from "../data/store/appStore"; import { useSelect } from "../data/store/storeHelpers"; -import { useV3ConversationListQuery } from "@/queries/useV3ConversationListQuery"; +import { useConversationListQuery } from "@/queries/useConversationListQuery"; export const useShouldShowConnecting = () => { const isInternetReachable = useAppStore((s) => s.isInternetReachable); @@ -67,7 +67,7 @@ export const useShouldShowConnecting = () => { export const useShouldShowConnectingOrSyncing = () => { const currentAccount = useCurrentAccount(); - const { isLoading } = useV3ConversationListQuery(currentAccount!); + const { isLoading } = useConversationListQuery({ account: currentAccount! }); const initialLoadDoneOnce = !isLoading; const shouldShowConnecting = useShouldShowConnecting(); diff --git a/components/PinnedConversations/PinnedConversations.tsx b/components/PinnedConversations/PinnedConversations.tsx index 2b7f29645..32e22500e 100644 --- a/components/PinnedConversations/PinnedConversations.tsx +++ b/components/PinnedConversations/PinnedConversations.tsx @@ -1,9 +1,8 @@ import { View, ViewStyle } from "react-native"; - -import { PinnedV3Conversation } from "./PinnedV3Conversation"; -import { useV3ConversationListQuery } from "@queries/useV3ConversationListQuery"; -import { useCurrentAccount } from "@data/store/accountsStore"; +import { useConversationListQuery } from "@/queries/useConversationListQuery"; import { ThemedStyle, useAppTheme } from "@/theme/useAppTheme"; +import { useCurrentAccount } from "@data/store/accountsStore"; +import { PinnedV3Conversation } from "./PinnedV3Conversation"; type Props = { topics?: string[]; @@ -14,14 +13,14 @@ export const PinnedConversations = ({ topics }: Props) => { const { themed } = useAppTheme(); - const { isLoading } = useV3ConversationListQuery( - currentAccount!, - { + const { isLoading } = useConversationListQuery({ + account: currentAccount!, + context: "PinnedConversations", + queryOptions: { refetchOnWindowFocus: false, refetchOnMount: false, }, - "PinnedConversations" - ); + }); if (isLoading) return null; diff --git a/components/PinnedConversations/PinnedV3DMConversation.tsx b/components/PinnedConversations/PinnedV3DMConversation.tsx index dd19354a0..c60d75e1f 100644 --- a/components/PinnedConversations/PinnedV3DMConversation.tsx +++ b/components/PinnedConversations/PinnedV3DMConversation.tsx @@ -1,25 +1,25 @@ -import { PinnedConversation } from "./PinnedConversation"; -import { useCallback, useMemo } from "react"; -import { navigate } from "@utils/navigation"; -import Avatar from "@components/Avatar"; -import { DmWithCodecsType } from "@utils/xmtpRN/client"; -import { usePreferredInboxName } from "@hooks/usePreferredInboxName"; -import { usePreferredInboxAvatar } from "@hooks/usePreferredInboxAvatar"; -import { useDmPeerInboxOnConversationList } from "@queries/useDmPeerInboxOnConversationList"; -import { useChatStore, useCurrentAccount } from "@data/store/accountsStore"; import { useSelect } from "@/data/store/storeHelpers"; +import { VStack } from "@/design-system/VStack"; import { resetConversationListContextMenuStore, setConversationListContextMenuConversationData, } from "@/features/conversation-list/ConversationListContextMenu.store"; -import { translate } from "@i18n"; -import { useToggleReadStatus } from "@/features/conversation-list/hooks/useToggleReadStatus"; -import { useConversationIsUnread } from "@/features/conversation-list/hooks/useMessageIsUnread"; import { useHandleDeleteDm } from "@/features/conversation-list/hooks/useHandleDeleteDm"; +import { useConversationIsUnread } from "@/features/conversation-list/hooks/useMessageIsUnread"; +import { useToggleReadStatus } from "@/features/conversation-list/hooks/useToggleReadStatus"; +import { useDmPeerInboxId } from "@/queries/useDmPeerInbox"; import { useAppTheme } from "@/theme/useAppTheme"; -import { ContextMenuIcon, ContextMenuItem } from "../ContextMenuItems"; +import Avatar from "@components/Avatar"; +import { useChatStore, useCurrentAccount } from "@data/store/accountsStore"; +import { usePreferredInboxAvatar } from "@hooks/usePreferredInboxAvatar"; +import { usePreferredInboxName } from "@hooks/usePreferredInboxName"; +import { translate } from "@i18n"; +import { navigate } from "@utils/navigation"; +import { DmWithCodecsType } from "@utils/xmtpRN/client"; +import { useCallback, useMemo } from "react"; import { isTextMessage } from "../../features/conversation/conversation-message/conversation-message.utils"; -import { VStack } from "@/design-system/VStack"; +import { ContextMenuIcon, ContextMenuItem } from "../ContextMenuItems"; +import { PinnedConversation } from "./PinnedConversation"; import { PinnedMessagePreview } from "./PinnedMessagePreview"; type PinnedV3DMConversationProps = { @@ -37,10 +37,10 @@ export const PinnedV3DMConversation = ({ const topic = conversation.topic; - const { data: peerInboxId } = useDmPeerInboxOnConversationList( - currentAccount, - conversation - ); + const { data: peerInboxId } = useDmPeerInboxId({ + account: currentAccount!, + topic, + }); const preferredName = usePreferredInboxName(peerInboxId); diff --git a/components/StateHandlers/HydrationStateHandler.tsx b/components/StateHandlers/HydrationStateHandler.tsx index b884d9f8f..44c5d30ad 100644 --- a/components/StateHandlers/HydrationStateHandler.tsx +++ b/components/StateHandlers/HydrationStateHandler.tsx @@ -1,5 +1,5 @@ import { prefetchInboxIdQuery } from "@/queries/use-inbox-id-query"; -import { fetchPersistedConversationListQuery } from "@/queries/useV3ConversationListQuery"; +import { fetchPersistedConversationListQuery } from "@/queries/useConversationListQuery"; import logger from "@utils/logger"; import { useEffect } from "react"; import { getAccountsList } from "@data/store/accountsStore"; @@ -34,7 +34,7 @@ export default function HydrationStateHandler() { const results = await Promise.allSettled([ getXmtpClient(account), - fetchPersistedConversationListQuery(account), + fetchPersistedConversationListQuery({ account }), prefetchInboxIdQuery({ account }), ]); diff --git a/components/V3DMListItem.tsx b/components/V3DMListItem.tsx index 571d5b6aa..df539cab9 100644 --- a/components/V3DMListItem.tsx +++ b/components/V3DMListItem.tsx @@ -1,32 +1,32 @@ -import { DmWithCodecsType } from "@utils/xmtpRN/client"; -import { ConversationListItemDumb } from "./ConversationListItem/ConversationListItemDumb"; -import { useCallback, useMemo, useRef } from "react"; -import Avatar from "./Avatar"; +import { + resetConversationListContextMenuStore, + setConversationListContextMenuConversationData, +} from "@/features/conversation-list/ConversationListContextMenu.store"; +import { useHandleDeleteDm } from "@/features/conversation-list/hooks/useHandleDeleteDm"; +import { useDmPeerInboxId } from "@/queries/useDmPeerInbox"; +import { useAppTheme } from "@/theme/useAppTheme"; import { useChatStore, useCurrentAccount } from "@data/store/accountsStore"; -import { AvatarSizes } from "@styles/sizes"; -import { getMinimalDate } from "@utils/date"; -import { useColorScheme } from "react-native"; +import { useSelect } from "@data/store/storeHelpers"; import { IIconName } from "@design-system/Icon/Icon.types"; -import { useDmPeerInboxOnConversationList } from "@queries/useDmPeerInboxOnConversationList"; -import { usePreferredInboxName } from "@hooks/usePreferredInboxName"; import { usePreferredInboxAvatar } from "@hooks/usePreferredInboxAvatar"; +import { usePreferredInboxName } from "@hooks/usePreferredInboxName"; +import { translate } from "@i18n/index"; +import { useRoute } from "@navigation/useNavigation"; +import { AvatarSizes } from "@styles/sizes"; +import { getMinimalDate } from "@utils/date"; +import { Haptics } from "@utils/haptics"; import { navigate } from "@utils/navigation"; +import { DmWithCodecsType } from "@utils/xmtpRN/client"; +import { useCallback, useMemo, useRef } from "react"; +import { useColorScheme } from "react-native"; import { Swipeable } from "react-native-gesture-handler"; -import { useSelect } from "@data/store/storeHelpers"; -import { Haptics } from "@utils/haptics"; import { runOnJS } from "react-native-reanimated"; -import { translate } from "@i18n/index"; -import { useToggleReadStatus } from "../features/conversation-list/hooks/useToggleReadStatus"; -import { useMessageText } from "../features/conversation-list/hooks/useMessageText"; -import { useRoute } from "@navigation/useNavigation"; import { useConversationIsUnread } from "../features/conversation-list/hooks/useMessageIsUnread"; -import { - resetConversationListContextMenuStore, - setConversationListContextMenuConversationData, -} from "@/features/conversation-list/ConversationListContextMenu.store"; -import { useHandleDeleteDm } from "@/features/conversation-list/hooks/useHandleDeleteDm"; +import { useMessageText } from "../features/conversation-list/hooks/useMessageText"; +import { useToggleReadStatus } from "../features/conversation-list/hooks/useToggleReadStatus"; +import Avatar from "./Avatar"; import { ContextMenuIcon, ContextMenuItem } from "./ContextMenuItems"; -import { useAppTheme } from "@/theme/useAppTheme"; +import { ConversationListItemDumb } from "./ConversationListItem/ConversationListItemDumb"; type V3DMListItemProps = { conversation: DmWithCodecsType; @@ -58,10 +58,10 @@ export const V3DMListItem = ({ conversation }: V3DMListItemProps) => { useSelect(["setPinnedConversations"]) ); - const { data: peer } = useDmPeerInboxOnConversationList( - currentAccount!, - conversation - ); + const { data: peerInboxId } = useDmPeerInboxId({ + account: currentAccount!, + topic, + }); const timestamp = conversation?.lastMessage?.sentNs ?? 0; const timeToShow = getMinimalDate(timestamp); @@ -79,8 +79,8 @@ export const V3DMListItem = ({ conversation }: V3DMListItemProps) => { const { theme } = useAppTheme(); const messageText = useMessageText(conversation.lastMessage); - const preferredName = usePreferredInboxName(peer); - const avatarUri = usePreferredInboxAvatar(peer); + const preferredName = usePreferredInboxName(peerInboxId); + const avatarUri = usePreferredInboxAvatar(peerInboxId); const toggleReadStatus = useToggleReadStatus({ topic, diff --git a/containers/GroupScreenAddition.tsx b/containers/GroupScreenAddition.tsx index 0079d1688..00e5ebee1 100644 --- a/containers/GroupScreenAddition.tsx +++ b/containers/GroupScreenAddition.tsx @@ -1,10 +1,10 @@ +import { useGroupNameQuery } from "@/queries/useGroupNameQuery"; import { useChatStore, useCurrentAccount } from "@data/store/accountsStore"; import { useSelect } from "@data/store/storeHelpers"; import { Icon } from "@design-system/Icon/Icon"; import { useExistingGroupInviteLink } from "@hooks/useExistingGroupInviteLink"; import { useGroupDescription } from "@hooks/useGroupDescription"; import { useGroupMembers } from "@hooks/useGroupMembers"; -import { useGroupName } from "@hooks/useGroupName"; import { useGroupPermissions } from "@hooks/useGroupPermissions"; import { useGroupPhoto } from "@hooks/useGroupPhoto"; import { translate } from "@i18n"; @@ -31,16 +31,16 @@ import { Platform, StyleSheet, TouchableOpacity, - useColorScheme, View, + useColorScheme, } from "react-native"; import { Portal, Snackbar, Text } from "react-native-paper"; import { - saveGroupInviteLink, - deleteGroupInviteLink as deleteLinkFromStore, - saveInviteIdByGroupId, deleteInviteIdByGroupId, + deleteGroupInviteLink as deleteLinkFromStore, getInviteIdByGroupId, + saveGroupInviteLink, + saveInviteIdByGroupId, } from "../features/GroupInvites/groupInvites.utils"; type GroupScreenAdditionProps = { @@ -75,7 +75,10 @@ export const GroupScreenAddition: FC = ({ currentAccountIsAdmin, currentAccountIsSuperAdmin ); - const { groupName } = useGroupName(topic); + const { data: groupName } = useGroupNameQuery({ + account: currentAccount, + topic, + }); const { groupPhoto } = useGroupPhoto(topic); const { groupDescription } = useGroupDescription(topic); diff --git a/containers/GroupScreenName.tsx b/containers/GroupScreenName.tsx index 027fe7cbd..d78ce48f6 100644 --- a/containers/GroupScreenName.tsx +++ b/containers/GroupScreenName.tsx @@ -1,6 +1,6 @@ +import { useGroupName } from "@/hooks/useGroupName"; import { useCurrentAccount } from "@data/store/accountsStore"; import { useGroupMembers } from "@hooks/useGroupMembers"; -import { useGroupName } from "@hooks/useGroupName"; import { useGroupPermissions } from "@hooks/useGroupPermissions"; import { textPrimaryColor } from "@styles/colors"; import { @@ -13,12 +13,12 @@ import { formatGroupName } from "@utils/str"; import type { ConversationTopic } from "@xmtp/react-native-sdk"; import React, { FC, useCallback, useMemo, useState } from "react"; import { + Alert, + Pressable, StyleSheet, + Text, TextInput, useColorScheme, - Text, - Alert, - Pressable, } from "react-native"; type GroupScreenNameProps = { @@ -28,9 +28,9 @@ type GroupScreenNameProps = { export const GroupScreenName: FC = ({ topic }) => { const styles = useStyles(); const { permissions } = useGroupPermissions(topic); - const { groupName, setGroupName } = useGroupName(topic); + const currentAccount = useCurrentAccount()!; + const { updateGroupName, groupName } = useGroupName(topic); const formattedGroupName = formatGroupName(topic, groupName); - const currentAccount = useCurrentAccount() as string; const { members } = useGroupMembers(topic); const { currentAccountIsAdmin, currentAccountIsSuperAdmin } = useMemo( @@ -49,12 +49,12 @@ export const GroupScreenName: FC = ({ topic }) => { const handleNameChange = useCallback(async () => { try { setEditing(false); - await setGroupName(editedName); + await updateGroupName(editedName); } catch (e) { logger.error(e); Alert.alert("An error occurred"); } - }, [editedName, setGroupName]); + }, [editedName, updateGroupName]); const canEditGroupName = memberCanUpdateGroup( permissions?.updateGroupNamePolicy, currentAccountIsAdmin, diff --git a/data/helpers/messages/handleGroupUpdatedMessage.test.ts b/data/helpers/messages/handleGroupUpdatedMessage.test.ts deleted file mode 100644 index 1e4bd3c18..000000000 --- a/data/helpers/messages/handleGroupUpdatedMessage.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { invalidateGroupMembersQuery } from "@queries/useGroupMembersQuery"; -import { setGroupNameQueryData } from "@queries/useGroupNameQuery"; -import { setGroupPhotoQueryData } from "@queries/useGroupPhotoQuery"; -import { - updateGroupNameToConversationListQuery, - updateGroupImageToConversationListQuery, - updateGroupDescriptionToConversationListQuery, -} from "@queries/useV3ConversationListQuery"; - -import { handleGroupUpdatedMessage } from "./handleGroupUpdatedMessage"; -import type { DecodedMessageWithCodecsType } from "@utils/xmtpRN/client"; -import type { ConversationTopic } from "@xmtp/react-native-sdk"; - -jest.mock("@queries/useGroupMembersQuery", () => ({ - invalidateGroupMembersQuery: jest.fn(), -})); - -jest.mock("@utils/xmtpRN/conversations", () => ({ - refreshGroup: jest.fn().mockResolvedValue(""), -})); - -jest.mock("@utils/xmtpRN/sync", () => ({ - getXmtpClient: jest.fn().mockResolvedValue({ - conversations: { - streamAllMessages: jest.fn(() => {}), - cancelStreamAllMessages: jest.fn(() => {}), - cancelStream: jest.fn(() => {}), - }, - exportKeyBundle: jest.fn(() => "keybundle"), - canMessage: jest.fn(() => true), - }), -})); - -jest.mock("../../../utils/xmtpRN/client", () => ({ - DecodedMessageWithCodecsType: jest.fn(), -})); - -jest.mock("@queries/useGroupNameQuery", () => ({ - setGroupNameQueryData: jest.fn(), -})); - -jest.mock("@queries/useGroupPhotoQuery", () => ({ - setGroupPhotoQueryData: jest.fn(), -})); - -jest.mock("@queries/useV3ConversationListQuery", () => ({ - updateGroupNameToConversationListQuery: jest.fn(), - updateGroupImageToConversationListQuery: jest.fn(), - updateGroupDescriptionToConversationListQuery: jest.fn(), -})); - -describe("handleGroupUpdatedMessage", () => { - const account = "testAccount"; - const topic = "testTopic" as ConversationTopic; - - const createMessage = (contentTypeId: string, content: any) => - ({ - contentTypeId, - content: () => content, - }) as unknown as DecodedMessageWithCodecsType; - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should not proceed if contentTypeId does not include "group_updated"', async () => { - const message = createMessage("text", {}); - - await handleGroupUpdatedMessage(account, topic, message); - - expect(invalidateGroupMembersQuery).not.toHaveBeenCalled(); - expect(setGroupNameQueryData).not.toHaveBeenCalled(); - expect(setGroupPhotoQueryData).not.toHaveBeenCalled(); - expect(updateGroupNameToConversationListQuery).not.toHaveBeenCalled(); - expect(updateGroupImageToConversationListQuery).not.toHaveBeenCalled(); - expect( - updateGroupDescriptionToConversationListQuery - ).not.toHaveBeenCalled(); - }); - - it("should invalidate group members query if members are added or removed", async () => { - const content = { - membersAdded: ["member1"], - membersRemoved: [], - metadataFieldsChanged: [], - }; - const message = createMessage("group_updated", content); - - await handleGroupUpdatedMessage(account, topic, message); - - expect(invalidateGroupMembersQuery).toHaveBeenCalledWith(account, topic); - }); - - it("should invalidate group name query if group name is changed", async () => { - const newGroupName = "New Group Name"; - const content = { - membersAdded: [], - membersRemoved: [], - metadataFieldsChanged: [ - { fieldName: "group_name", newValue: newGroupName }, - ], - }; - const message = createMessage("group_updated", content); - - await handleGroupUpdatedMessage(account, topic, message); - - expect(setGroupNameQueryData).toHaveBeenCalledWith( - account, - topic, - newGroupName - ); - expect(updateGroupNameToConversationListQuery).toHaveBeenCalledWith({ - account, - topic, - name: newGroupName, - }); - }); - - it("should invalidate group photo query if group photo is changed", async () => { - const newGroupPhoto = "New Photo URL"; - - const content = { - membersAdded: [], - membersRemoved: [], - metadataFieldsChanged: [ - { fieldName: "group_image_url_square", newValue: newGroupPhoto }, - ], - }; - const message = createMessage("group_updated", content); - - await handleGroupUpdatedMessage(account, topic, message); - - expect(setGroupPhotoQueryData).toHaveBeenCalledWith( - account, - topic, - newGroupPhoto - ); - expect(updateGroupImageToConversationListQuery).toHaveBeenCalledWith({ - account, - topic, - image: newGroupPhoto, - }); - }); - - it("should invalidate all relevant queries if multiple changes occur", async () => { - const newGroupName = "New Group Name"; - const newGroupPhoto = "New Photo URL"; - const content = { - membersAdded: ["member1"], - membersRemoved: ["member2"], - metadataFieldsChanged: [ - { fieldName: "group_name", newValue: newGroupName }, - { fieldName: "group_image_url_square", newValue: newGroupPhoto }, - ], - }; - const message = createMessage("group_updated", content); - - await handleGroupUpdatedMessage(account, topic, message); - - expect(invalidateGroupMembersQuery).toHaveBeenCalledWith(account, topic); - expect(setGroupNameQueryData).toHaveBeenCalledWith( - account, - topic, - newGroupName - ); - expect(setGroupPhotoQueryData).toHaveBeenCalledWith( - account, - topic, - newGroupPhoto - ); - expect(updateGroupNameToConversationListQuery).toHaveBeenCalledWith({ - account, - topic, - name: newGroupName, - }); - }); - - it("should handle empty metadataFieldsChanged array", async () => { - const content = { - membersAdded: [], - membersRemoved: [], - metadataFieldsChanged: [], - }; - const message = createMessage("group_updated", content); - - await handleGroupUpdatedMessage(account, topic, message); - - expect(invalidateGroupMembersQuery).toHaveBeenCalled(); - expect(setGroupNameQueryData).not.toHaveBeenCalled(); - expect(setGroupPhotoQueryData).not.toHaveBeenCalled(); - expect(updateGroupNameToConversationListQuery).not.toHaveBeenCalled(); - expect(updateGroupImageToConversationListQuery).not.toHaveBeenCalled(); - expect( - updateGroupDescriptionToConversationListQuery - ).not.toHaveBeenCalled(); - }); -}); diff --git a/data/helpers/messages/handleGroupUpdatedMessage.ts b/data/helpers/messages/handleGroupUpdatedMessage.ts deleted file mode 100644 index c1e7eda14..000000000 --- a/data/helpers/messages/handleGroupUpdatedMessage.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { handleGroupDescriptionUpdate } from "@/utils/groupUtils/handleGroupDescriptionUpdate"; -import { handleGroupImageUpdate } from "@/utils/groupUtils/handleGroupImageUpdate"; -import { handleGroupNameUpdate } from "@/utils/groupUtils/handleGroupNameUpdate"; -import { invalidateGroupIsActiveQuery } from "@queries/useGroupIsActive"; -import { invalidateGroupMembersQuery } from "@queries/useGroupMembersQuery"; -import { DecodedMessageWithCodecsType } from "@utils/xmtpRN/client"; -import { ConversationTopic, GroupUpdatedContent } from "@xmtp/react-native-sdk"; - -export const handleGroupUpdatedMessage = async ( - account: string, - topic: ConversationTopic, - message: DecodedMessageWithCodecsType -) => { - if (!message.contentTypeId.includes("group_updated")) return; - const content = message.content() as GroupUpdatedContent; - if (content.membersAdded.length > 0 || content.membersRemoved.length > 0) { - // This will refresh members - invalidateGroupMembersQuery(account, topic); - } - if (content.metadataFieldsChanged.length > 0) { - let newGroupName = ""; - let newGroupPhotoUrl = ""; - let newGroupDescription = ""; - for (const field of content.metadataFieldsChanged) { - if (field.fieldName === "group_name") { - newGroupName = field.newValue; - } else if (field.fieldName === "group_image_url_square") { - newGroupPhotoUrl = field.newValue; - } else if (field.fieldName === "description") { - newGroupDescription = field.newValue; - } - } - if (!!newGroupName) { - handleGroupNameUpdate({ account, topic, name: newGroupName }); - } - if (!!newGroupPhotoUrl) { - handleGroupImageUpdate({ account, topic, image: newGroupPhotoUrl }); - } - if (!!newGroupDescription) { - handleGroupDescriptionUpdate({ - account, - topic, - description: newGroupDescription, - }); - } - } - // Admin Update - if ( - content.membersAdded.length === 0 && - content.membersRemoved.length === 0 && - content.metadataFieldsChanged.length === 0 - ) { - invalidateGroupMembersQuery(account, topic); - invalidateGroupIsActiveQuery(account, topic); - } -}; diff --git a/features/GroupInvites/joinGroup/JoinGroup.client.ts b/features/GroupInvites/joinGroup/JoinGroup.client.ts index d2fe92c03..cc452477f 100644 --- a/features/GroupInvites/joinGroup/JoinGroup.client.ts +++ b/features/GroupInvites/joinGroup/JoinGroup.client.ts @@ -21,7 +21,7 @@ import { AxiosInstance } from "axios"; import {} from "../groupInvites.utils"; import { JoinGroupResult } from "./joinGroup.types"; -import { V3ConversationListType } from "@queries/useV3ConversationListQuery"; +import { ConversationListQueryData } from "@/queries/useConversationListQuery"; import { entify } from "@/queries/entify"; import { GroupWithCodecsType } from "@/utils/xmtpRN/client"; @@ -92,11 +92,11 @@ export class JoinGroupClient { account: string ): Promise => { const { fetchConversationListQuery } = await import( - "@queries/useV3ConversationListQuery" + "@/queries/useConversationListQuery" ); - const conversationList: V3ConversationListType = - await fetchConversationListQuery(account); + const conversationList: ConversationListQueryData = + await fetchConversationListQuery({ account }); const conversationEntity: ConversationDataEntity = entify( conversationList, diff --git a/features/blocked-chats/useV3BlockedChats.ts b/features/blocked-chats/useV3BlockedChats.ts index 5f2ca5205..46591a6e3 100644 --- a/features/blocked-chats/useV3BlockedChats.ts +++ b/features/blocked-chats/useV3BlockedChats.ts @@ -1,18 +1,18 @@ import { useCurrentAccount } from "@data/store/accountsStore"; import { useTopicsData } from "@hooks/useTopicsData"; -import { useV3ConversationListQuery } from "@queries/useV3ConversationListQuery"; +import { useConversationListQuery } from "@/queries/useConversationListQuery"; import { useMemo } from "react"; export const useV3BlockedChats = () => { const currentAccount = useCurrentAccount(); const topicsData = useTopicsData(); - const { data, ...rest } = useV3ConversationListQuery( - currentAccount!, - { + const { data, ...rest } = useConversationListQuery({ + account: currentAccount!, + queryOptions: { refetchOnMount: false, }, - "useV3BlockedChats" - ); + context: "useV3BlockedChats", + }); const blockedConversations = useMemo(() => { return data?.filter((convo) => { diff --git a/features/conversation-list/useV3ConversationItems.ts b/features/conversation-list/useV3ConversationItems.ts index ec4592b18..bff909680 100644 --- a/features/conversation-list/useV3ConversationItems.ts +++ b/features/conversation-list/useV3ConversationItems.ts @@ -1,20 +1,20 @@ import { isConversationAllowed } from "@/features/conversation/utils/is-conversation-allowed"; +import { useConversationListQuery } from "@/queries/useConversationListQuery"; import { useChatStore, useCurrentAccount } from "@data/store/accountsStore"; import { useSelect } from "@data/store/storeHelpers"; -import { useV3ConversationListQuery } from "@queries/useV3ConversationListQuery"; import { useMemo } from "react"; export const useV3ConversationItems = () => { const currentAccount = useCurrentAccount(); - const { data: conversations, ...rest } = useV3ConversationListQuery( - currentAccount!, - { + const { data: conversations, ...rest } = useConversationListQuery({ + account: currentAccount!, + queryOptions: { refetchOnWindowFocus: false, refetchOnMount: false, }, - "useV3ConversationItems" - ); + context: "useV3ConversationItems", + }); const { pinnedConversationTopics, topicsData } = useChatStore( useSelect(["pinnedConversationTopics", "topicsData"]) diff --git a/features/conversation-list/useV3RequestItemCount.ts b/features/conversation-list/useV3RequestItemCount.ts index c216fafe2..e1b89512f 100644 --- a/features/conversation-list/useV3RequestItemCount.ts +++ b/features/conversation-list/useV3RequestItemCount.ts @@ -1,17 +1,17 @@ import { useCurrentAccount } from "@data/store/accountsStore"; -import { useV3ConversationListQuery } from "@queries/useV3ConversationListQuery"; +import { useConversationListQuery } from "@/queries/useConversationListQuery"; import { useMemo } from "react"; export const useV3RequestItemCount = () => { const currentAccount = useCurrentAccount(); - const { data: groups } = useV3ConversationListQuery( - currentAccount!, - { + const { data: groups } = useConversationListQuery({ + account: currentAccount!, + queryOptions: { refetchOnWindowFocus: false, refetchOnMount: false, }, - "useV3RequestItemCount" - ); + context: "useV3RequestItemCount", + }); const requestGroupCount = useMemo(() => { return groups?.filter((group) => group.state === "unknown").length ?? 0; diff --git a/features/conversation-requests-list/useV3RequestItems.tsx b/features/conversation-requests-list/useV3RequestItems.tsx index ee32e02c3..9a4adfb48 100644 --- a/features/conversation-requests-list/useV3RequestItems.tsx +++ b/features/conversation-requests-list/useV3RequestItems.tsx @@ -1,21 +1,21 @@ +import { useConversationListQuery } from "@/queries/useConversationListQuery"; import logger from "@/utils/logger"; import { getMessageContentType } from "@/utils/xmtpRN/content-types/content-types"; import { getV3SpamScore } from "@data/helpers/conversations/spamScore"; import { useCurrentAccount } from "@data/store/accountsStore"; -import { useV3ConversationListQuery } from "@queries/useV3ConversationListQuery"; import { ConversationWithCodecsType } from "@utils/xmtpRN/client"; import { useEffect, useState } from "react"; export const useV3RequestItems = () => { const currentAccount = useCurrentAccount(); - const { data, ...rest } = useV3ConversationListQuery( - currentAccount!, - { + const { data, ...rest } = useConversationListQuery({ + account: currentAccount!, + queryOptions: { refetchOnWindowFocus: false, refetchOnMount: false, }, - "useV3RequestItems" - ); + context: "useV3RequestItems", + }); const [likelyNotSpam, setLikelyNotSpam] = useState< ConversationWithCodecsType[] >([]); diff --git a/features/conversation/conversation-consent-popup/conversation-consent-popup-dm.tsx b/features/conversation/conversation-consent-popup/conversation-consent-popup-dm.tsx index 61caf8696..409a89bf6 100644 --- a/features/conversation/conversation-consent-popup/conversation-consent-popup-dm.tsx +++ b/features/conversation/conversation-consent-popup/conversation-consent-popup-dm.tsx @@ -4,16 +4,15 @@ import { getCurrentAccount, useCurrentAccount, } from "@/data/store/accountsStore"; -import { - useConversationCurrentConversationId, - useCurrentConversationTopic, -} from "../conversation.store-context"; import { useRouter } from "@/navigation/useNavigation"; -import { refetchConversationQuery } from "@/queries/useConversationQuery"; +import { getConversationQueryData } from "@/queries/useConversationQuery"; import { useDmPeerInboxId } from "@/queries/useDmPeerInbox"; +import { getDmQueryData, setDmQueryData } from "@/queries/useDmQuery"; import { actionSheetColors } from "@/styles/colors"; import { captureError, captureErrorWithToast } from "@/utils/capture-error"; import { ensureError } from "@/utils/error"; +import { mutateObjectProperties } from "@/utils/mutate-object-properties"; +import { DmWithCodecsType } from "@/utils/xmtpRN/client"; import { consentToGroupsOnProtocolByAccount, consentToInboxIdsOnProtocolByAccount, @@ -22,6 +21,10 @@ import { translate } from "@i18n"; import { useMutation } from "@tanstack/react-query"; import React, { useCallback } from "react"; import { useColorScheme } from "react-native"; +import { + useConversationCurrentConversationId, + useCurrentConversationTopic, +} from "../conversation.store-context"; import { ConsentPopupButton, ConsentPopupButtonsContainer, @@ -34,7 +37,10 @@ export function DmConsentPopup() { const conversationId = useConversationCurrentConversationId(); const currentAccount = useCurrentAccount()!; - const { data: peerInboxId } = useDmPeerInboxId(currentAccount, topic); + const { data: peerInboxId } = useDmPeerInboxId({ + account: currentAccount, + topic, + }); const navigation = useRouter(); @@ -62,8 +68,42 @@ export function DmConsentPopup() { }), ]); }, - onSuccess: () => { - refetchConversationQuery(getCurrentAccount()!, topic); + onMutate: (args) => { + const conversation = getConversationQueryData({ + account: currentAccount, + topic, + }); + if (conversation) { + const updatedDm = mutateObjectProperties(conversation, { + state: args.consent === "allow" ? "allowed" : "denied", + }); + setDmQueryData({ + account: currentAccount, + peer: topic, + dm: updatedDm as DmWithCodecsType, + }); + return { previousDmConsent: conversation.state }; + } + }, + onError: (error, _, context) => { + const { previousDmConsent } = context || {}; + if (previousDmConsent) { + const dm = getDmQueryData({ + account: currentAccount, + peer: topic, + }); + if (!dm) { + return; + } + const updatedDm = mutateObjectProperties(dm, { + state: previousDmConsent, + }); + setDmQueryData({ + account: currentAccount, + peer: topic, + dm: updatedDm, + }); + } }, }); diff --git a/features/conversation/conversation-consent-popup/conversation-consent-popup-group.tsx b/features/conversation/conversation-consent-popup/conversation-consent-popup-group.tsx index 8bb0007ca..7462b0a9d 100644 --- a/features/conversation/conversation-consent-popup/conversation-consent-popup-group.tsx +++ b/features/conversation/conversation-consent-popup/conversation-consent-popup-group.tsx @@ -1,32 +1,32 @@ +import { useCurrentAccount } from "@/data/store/accountsStore"; import { ConsentPopupButton, ConsentPopupButtonsContainer, ConsentPopupContainer, ConsentPopupTitle, } from "@/features/conversation/conversation-consent-popup/conversation-consent-popup.design-system"; -import { useCurrentAccount } from "@/data/store/accountsStore"; -import { useCurrentConversationTopic } from "../conversation.store-context"; import { useRouter } from "@/navigation/useNavigation"; -import { getGroupNameQueryData } from "@/queries/useGroupNameQuery"; +import { useGroupNameQuery } from "@/queries/useGroupNameQuery"; import { captureErrorWithToast } from "@/utils/capture-error"; import { useGroupConsent } from "@hooks/useGroupConsent"; import { translate } from "@i18n"; import { groupRemoveRestoreHandler } from "@utils/groupUtils/groupActionHandlers"; import React, { useCallback } from "react"; import { useColorScheme } from "react-native"; +import { useCurrentConversationTopic } from "../conversation.store-context"; export function GroupConsentPopup() { const topic = useCurrentConversationTopic(); const navigation = useRouter(); - const currentAccount = useCurrentAccount()!; - const colorScheme = useColorScheme(); const { blockGroup, allowGroup } = useGroupConsent(topic); - const groupName = getGroupNameQueryData(currentAccount, topic); + const account = useCurrentAccount()!; + + const { data: groupName } = useGroupNameQuery({ account, topic }); const onBlock = useCallback(async () => { groupRemoveRestoreHandler( diff --git a/features/conversation/conversation-dm-header-title.tsx b/features/conversation/conversation-dm-header-title.tsx index 667f59553..4ef9ac25d 100644 --- a/features/conversation/conversation-dm-header-title.tsx +++ b/features/conversation/conversation-dm-header-title.tsx @@ -1,4 +1,6 @@ import { ConversationTitle } from "@/features/conversation/conversation-title"; +import { usePreferredInboxAddress } from "@/hooks/usePreferredInboxAddress"; +import { useDmPeerInboxId } from "@/queries/useDmPeerInbox"; import { copyToClipboard } from "@/utils/clipboard"; import Avatar from "@components/Avatar"; import { useCurrentAccount } from "@data/store/accountsStore"; @@ -6,12 +8,9 @@ import { usePreferredAvatarUri } from "@hooks/usePreferredAvatarUri"; import { usePreferredName } from "@hooks/usePreferredName"; import { useProfileSocials } from "@hooks/useProfileSocials"; import { useRouter } from "@navigation/useNavigation"; -import { useDmPeerAddressQuery } from "@queries/useDmPeerAddressQuery"; -import { AvatarSizes } from "@styles/sizes"; -import { ThemedStyle, useAppTheme } from "@theme/useAppTheme"; +import { useAppTheme } from "@theme/useAppTheme"; import { ConversationTopic } from "@xmtp/react-native-sdk"; import { useCallback } from "react"; -import { ImageStyle, Platform } from "react-native"; type DmConversationTitleProps = { topic: ConversationTopic; @@ -22,9 +21,11 @@ export const DmConversationTitle = ({ topic }: DmConversationTitleProps) => { const navigation = useRouter(); - const { themed } = useAppTheme(); + const { theme } = useAppTheme(); - const { data: peerAddress } = useDmPeerAddressQuery(account, topic); + const { data: peerInboxId } = useDmPeerInboxId({ account, topic }); + + const peerAddress = usePreferredInboxAddress(peerInboxId!); const onPress = useCallback(() => { if (peerAddress) { @@ -54,8 +55,7 @@ export const DmConversationTitle = ({ topic }: DmConversationTitleProps) => { displayAvatar && ( ) @@ -63,8 +63,3 @@ export const DmConversationTitle = ({ topic }: DmConversationTitleProps) => { /> ); }; - -const $avatar: ThemedStyle = (theme) => ({ - marginRight: Platform.OS === "android" ? theme.spacing.lg : theme.spacing.xxs, - marginLeft: Platform.OS === "ios" ? theme.spacing.zero : -theme.spacing.xxs, -}); diff --git a/features/conversation/conversation-group-header-title.tsx b/features/conversation/conversation-group-header-title.tsx index b5b489a1a..062265b87 100644 --- a/features/conversation/conversation-group-header-title.tsx +++ b/features/conversation/conversation-group-header-title.tsx @@ -15,11 +15,9 @@ import { useCurrentAccount } from "@data/store/accountsStore"; import { translate } from "@i18n"; import { useRouter } from "@navigation/useNavigation"; import { useGroupPhotoQuery } from "@queries/useGroupPhotoQuery"; -import { AvatarSizes } from "@styles/sizes"; -import { ThemedStyle, useAppTheme } from "@theme/useAppTheme"; +import { useAppTheme } from "@theme/useAppTheme"; import { ConversationTopic } from "@xmtp/react-native-sdk"; import React, { memo, useCallback, useMemo } from "react"; -import { ImageStyle, Platform } from "react-native"; type GroupConversationTitleProps = { topic: ConversationTopic; @@ -30,7 +28,10 @@ export const GroupConversationTitle = memo( const currentAccount = useCurrentAccount()!; const { data: groupPhoto, isLoading: groupPhotoLoading } = - useGroupPhotoQuery(currentAccount, topic!); + useGroupPhotoQuery({ + account: currentAccount, + topic, + }); const { data: members } = useGroupMembersQuery(currentAccount, topic!); @@ -43,7 +44,7 @@ export const GroupConversationTitle = memo( const navigation = useRouter(); - const { themed } = useAppTheme(); + const { theme } = useAppTheme(); const onPress = useCallback(() => { navigation.push("Group", { topic }); @@ -55,23 +56,17 @@ export const GroupConversationTitle = memo( const requestsCount = useGroupPendingRequests(topic).length; - const displayAvatar = !groupPhotoLoading && !groupNameLoading; - const avatarComponent = useMemo(() => { return groupPhoto ? ( - + ) : ( - + ); - }, [groupPhoto, memberData, themed]); + }, [groupPhoto, memberData, theme]); + + if (groupPhotoLoading || groupNameLoading) { + return null; + } const memberText = members?.ids.length === 1 @@ -79,8 +74,6 @@ export const GroupConversationTitle = memo( : translate("members_count", { count: members?.ids.length }); const displayMemberText = members?.ids.length; - if (!displayAvatar) return null; - return ( = (theme) => ({ - marginRight: Platform.OS === "android" ? theme.spacing.lg : theme.spacing.xxs, - marginLeft: Platform.OS === "ios" ? theme.spacing.zero : -theme.spacing.xxs, -}); - -type UseGroupMembersAvatarDataProps = { - topic: ConversationTopic; +type IMemberData = { + address: string; + uri?: string; + name?: string; }; -const useGroupMembersAvatarData = ({ - topic, -}: UseGroupMembersAvatarDataProps) => { +const useGroupMembersAvatarData = (args: { topic: ConversationTopic }) => { + const { topic } = args; const currentAccount = useCurrentAccount()!; const { data: members, ...query } = useGroupMembersConversationScreenQuery( currentAccount, @@ -126,39 +115,40 @@ const useGroupMembersAvatarData = ({ ); const memberAddresses = useMemo(() => { - const addresses: string[] = []; - for (const memberId of members?.ids ?? []) { - const member = members?.byId[memberId]; + if (!members?.ids) { + return []; + } + + return members.ids.reduce((addresses, memberId) => { + const memberAddress = members.byId[memberId]?.addresses[0]; if ( - member?.addresses[0] && - member?.addresses[0].toLowerCase() !== currentAccount?.toLowerCase() + memberAddress && + memberAddress.toLowerCase() !== currentAccount?.toLowerCase() ) { - addresses.push(member?.addresses[0]); + addresses.push(memberAddress); } - } - return addresses; + return addresses; + }, []); }, [members, currentAccount]); const data = useProfilesSocials(memberAddresses); - const memberData: { - address: string; - uri?: string; - name?: string; - }[] = useMemo(() => { - return data.map(({ data: socials }, index) => - socials - ? { - address: memberAddresses[index], - uri: getPreferredAvatar(socials), - name: getPreferredName(socials, memberAddresses[index]), - } - : { - address: memberAddresses[index], - uri: undefined, - name: memberAddresses[index], - } - ); + const memberData = useMemo(() => { + return data.map(({ data: socials }, index) => { + const address = memberAddresses[index]; + if (!socials) { + return { + address, + name: address, + }; + } + + return { + address, + uri: getPreferredAvatar(socials), + name: getPreferredName(socials, address), + }; + }); }, [data, memberAddresses]); return { data: memberData, ...query }; diff --git a/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu.tsx b/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu.tsx index 58df67f13..fb4cbfefd 100644 --- a/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu.tsx +++ b/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu.tsx @@ -60,7 +60,10 @@ const Content = memo(function Content(props: { const account = useCurrentAccount()!; const topic = useCurrentConversationTopic(); const messageContextMenuStore = useMessageContextMenuStore(); - const { data: conversation } = useConversationQuery(account, topic); + const { data: conversation } = useConversationQuery({ + account, + topic, + }); const { data: currentUserInboxId } = useCurrentAccountInboxId(); const { bySender } = useConversationMessageReactions(messageId!); diff --git a/features/conversation/conversation-message/conversation-message-layout.tsx b/features/conversation/conversation-message/conversation-message-layout.tsx index 70ae55b92..a4733a307 100644 --- a/features/conversation/conversation-message/conversation-message-layout.tsx +++ b/features/conversation/conversation-message/conversation-message-layout.tsx @@ -18,9 +18,9 @@ export const ConversationMessageLayout = memo( }: IConversationMessageLayoutProps) { const { theme } = useAppTheme(); - const { senderAddress, fromMe, hasNextMessageInSeries } = + const { senderInboxId, fromMe, hasNextMessageInSeries } = useMessageContextStoreContext( - useSelect(["senderAddress", "fromMe", "hasNextMessageInSeries"]) + useSelect(["senderInboxId", "fromMe", "hasNextMessageInSeries"]) ); return ( @@ -29,7 +29,7 @@ export const ConversationMessageLayout = memo( {!fromMe && ( <> {!hasNextMessageInSeries ? ( - + ) : ( )} diff --git a/features/conversation/conversation-message/conversation-message.store-context.tsx b/features/conversation/conversation-message/conversation-message.store-context.tsx index f16b73062..6a14e6b20 100644 --- a/features/conversation/conversation-message/conversation-message.store-context.tsx +++ b/features/conversation/conversation-message/conversation-message.store-context.tsx @@ -28,7 +28,7 @@ type IMessageContextStoreState = IMessageContextStoreProps & { fromMe: boolean; sentAt: number; showDateChange: boolean; - senderAddress: InboxId; + senderInboxId: InboxId; isHighlighted: boolean; isShowingTime: boolean; }; @@ -82,7 +82,7 @@ function getStoreStateBasedOnProps(props: IMessageContextStoreProps) { previousMessage: props.previousMessage, }), sentAt: convertNanosecondsToMilliseconds(props.message.sentNs), - senderAddress: props.message.senderAddress, + senderInboxId: props.message.senderAddress, isHighlighted: false, isShowingTime: false, }; diff --git a/features/conversation/conversation-new-dm-header-title.tsx b/features/conversation/conversation-new-dm-header-title.tsx index 579de38c8..d44973300 100644 --- a/features/conversation/conversation-new-dm-header-title.tsx +++ b/features/conversation/conversation-new-dm-header-title.tsx @@ -4,10 +4,8 @@ import { usePreferredAvatarUri } from "@hooks/usePreferredAvatarUri"; import { usePreferredName } from "@hooks/usePreferredName"; import { useProfileSocials } from "@hooks/useProfileSocials"; import { useRouter } from "@navigation/useNavigation"; -import { AvatarSizes } from "@styles/sizes"; -import { ThemedStyle, useAppTheme } from "@theme/useAppTheme"; +import { useAppTheme } from "@theme/useAppTheme"; import { useCallback } from "react"; -import { ImageStyle, Platform } from "react-native"; type NewConversationTitleProps = { peerAddress: string; @@ -18,7 +16,7 @@ export const NewConversationTitle = ({ }: NewConversationTitleProps) => { const navigation = useRouter(); - const { themed } = useAppTheme(); + const { theme } = useAppTheme(); const onPress = useCallback(() => { if (peerAddress) { @@ -44,16 +42,10 @@ export const NewConversationTitle = ({ displayAvatar && ( ) } /> ); }; - -const $avatar: ThemedStyle = (theme) => ({ - marginRight: Platform.OS === "android" ? theme.spacing.lg : theme.spacing.xxs, - marginLeft: Platform.OS === "ios" ? theme.spacing.zero : -theme.spacing.xxs, -}); diff --git a/features/conversation/conversation-new-dm.tsx b/features/conversation/conversation-new-dm.tsx index 8c091ede1..88895a22d 100644 --- a/features/conversation/conversation-new-dm.tsx +++ b/features/conversation/conversation-new-dm.tsx @@ -11,9 +11,9 @@ import { sendMessage, } from "@/features/conversation/hooks/use-send-message"; import { useRouter } from "@/navigation/useNavigation"; -import { updateConversationQueryData } from "@/queries/useConversationQuery"; -import { updateConversationWithPeerQueryData } from "@/queries/useConversationWithPeerQuery"; -import { updateConversationDataToConversationListQuery } from "@/queries/useV3ConversationListQuery"; +import { addConversationToConversationListQuery } from "@/queries/useConversationListQuery"; +import { setDmQueryData } from "@/queries/useDmQuery"; +import { captureError } from "@/utils/capture-error"; import { sentryTrackError } from "@/utils/sentry"; import { createConversationByAccount } from "@/utils/xmtpRN/conversations"; import { useMutation } from "@tanstack/react-query"; @@ -72,27 +72,19 @@ function useSendFirstConversationMessage(peerAddress: string) { }, onSuccess: (newConversation) => { const currentAccount = getCurrentAccount()!; - - // Update the conversation with peer query - updateConversationWithPeerQueryData( - currentAccount, - peerAddress, - newConversation - ); - - // Update the list of conversations - updateConversationDataToConversationListQuery( - currentAccount, - newConversation.topic, - newConversation - ); - - // Update conversation by topic - updateConversationQueryData( - currentAccount, - newConversation.topic, - newConversation - ); + try { + addConversationToConversationListQuery({ + account: currentAccount, + conversation: newConversation, + }); + setDmQueryData({ + account: currentAccount, + peer: peerAddress, + dm: newConversation, + }); + } catch (error) { + captureError(error); + } }, // TODO: Add this for optimistic update and faster UX // onMutate: (peerAddress) => { @@ -128,13 +120,18 @@ function useSendFirstConversationMessage(peerAddress: string) { try { // First, create the conversation const conversation = await createNewConversationAsync(peerAddress); - // Then, send the message - await sendMessageAsync({ - conversation, - params: args, - }); + try { + // Then, send the message + await sendMessageAsync({ + conversation, + params: args, + }); + } catch (error) { + showSnackbar({ message: "Failed to send message" }); + sentryTrackError(error); + } } catch (error) { - showSnackbar({ message: "Failed to send message" }); + showSnackbar({ message: "Failed to create conversation" }); sentryTrackError(error); } }, diff --git a/features/conversation/conversation-title.tsx b/features/conversation/conversation-title.tsx index 55090c698..31a91351c 100644 --- a/features/conversation/conversation-title.tsx +++ b/features/conversation/conversation-title.tsx @@ -1,14 +1,8 @@ -import { headerTitleStyle, textPrimaryColor } from "@styles/colors"; -import { - Platform, - StyleSheet, - Text, - TouchableOpacity, - useColorScheme, -} from "react-native"; -import { getTitleFontScale } from "@utils/str"; +import { Pressable } from "@/design-system/Pressable"; import { VStack } from "@/design-system/VStack"; +import { useAppTheme } from "@/theme/useAppTheme"; import { HStack } from "@design-system/HStack"; +import { Text } from "react-native"; type ConversationTitleDumbProps = { title?: string; @@ -25,49 +19,36 @@ export function ConversationTitle({ onLongPress, onPress, }: ConversationTitleDumbProps) { - const styles = useStyles(); + const { theme } = useAppTheme(); return ( - - + - {avatarComponent} + + {avatarComponent} + - + {title} {subtitle} - + ); } - -const useStyles = () => { - const colorScheme = useColorScheme(); - return StyleSheet.create({ - avatar: { - marginRight: Platform.OS === "android" ? 24 : 7, - marginLeft: Platform.OS === "ios" ? 0 : -9, - }, - container: { flexDirection: "row", flexGrow: 1 }, - touchableContainer: { - flexDirection: "row", - justifyContent: "flex-start", - left: Platform.OS === "android" ? -36 : 0, - width: "100%", - alignItems: "center", - paddingRight: 40, - }, - title: { - color: textPrimaryColor(colorScheme), - fontSize: - Platform.OS === "ios" - ? 16 * getTitleFontScale() - : headerTitleStyle(colorScheme).fontSize, - }, - }); -}; diff --git a/features/conversation/conversation.screen.tsx b/features/conversation/conversation.screen.tsx index b767bb277..135f3bb8c 100644 --- a/features/conversation/conversation.screen.tsx +++ b/features/conversation/conversation.screen.tsx @@ -4,7 +4,8 @@ import { Center } from "@/design-system/Center"; import { Loader } from "@/design-system/loader"; import { Conversation } from "@/features/conversation/conversation"; import { ConversationNewDm } from "@/features/conversation/conversation-new-dm"; -import { useConversationWithPeerQuery } from "@/queries/useConversationWithPeerQuery"; +import { useDmQuery } from "@/queries/useDmQuery"; +import { $globalStyles } from "@/theme/styles"; import { captureError } from "@/utils/capture-error"; import { VStack } from "@design-system/VStack"; import { NativeStackScreenProps } from "@react-navigation/native-stack"; @@ -51,22 +52,23 @@ const PeerAddressFlow = memo(function PeerAddressFlow( ) { const { peerAddress, textPrefill } = args; const currentAccount = useCurrentAccount()!; - const { data: conversation, isLoading } = useConversationWithPeerQuery( - currentAccount, - peerAddress - ); + + const { data: dmConversation, isLoading } = useDmQuery({ + account: currentAccount, + peer: peerAddress, + }); if (isLoading) { return ( -
+
); } - if (conversation?.topic) { + if (dmConversation?.topic) { return ( - + ); } diff --git a/features/conversation/conversation.tsx b/features/conversation/conversation.tsx index 1339fba31..ae797bc36 100644 --- a/features/conversation/conversation.tsx +++ b/features/conversation/conversation.tsx @@ -48,19 +48,16 @@ import { isConversationDm } from "@/features/conversation/utils/is-conversation- import { isConversationGroup } from "@/features/conversation/utils/is-conversation-group"; import { useCurrentAccountInboxId } from "@/hooks/use-current-account-inbox-id"; import { useConversationQuery } from "@/queries/useConversationQuery"; -import { useGroupNameQuery } from "@/queries/useGroupNameQuery"; import { ConversationWithCodecsType, DecodedMessageWithCodecsType, } from "@/utils/xmtpRN/client.types"; import { useCurrentAccount } from "@data/store/accountsStore"; -import { Button } from "@design-system/Button/Button"; import { Center } from "@design-system/Center"; import { Text } from "@design-system/Text"; import { translate } from "@i18n/translate"; import { useRouter } from "@navigation/useNavigation"; import { useConversationMessages } from "@queries/useConversationMessages"; -import { useAppTheme } from "@theme/useAppTheme"; import { ConversationTopic, MessageId } from "@xmtp/react-native-sdk"; import React, { memo, @@ -86,7 +83,10 @@ export const Conversation = memo(function Conversation(props: { const navigation = useRouter(); const { data: conversation, isLoading: isLoadingConversation } = - useConversationQuery(currentAccount, topic); + useConversationQuery({ + account: currentAccount, + topic, + }); useLayoutEffect(() => { if (!conversation) { @@ -362,58 +362,11 @@ const ConversationMessageGestures = memo(function ConversationMessageGestures({ }); const DmConversationEmpty = memo(function DmConversationEmpty() { + // Will never really be empty anyway because to create the DM conversation the user has to send a first message return null; }); const GroupConversationEmpty = memo(() => { - const { theme } = useAppTheme(); - - const currentAccount = useCurrentAccount()!; - const topic = useCurrentConversationTopic(); - - const { data: groupName } = useGroupNameQuery(currentAccount, topic); - - const { data: conversation } = useConversationQuery(currentAccount, topic); - - const sendMessage = useSendMessage({ - conversation: conversation!, - }); - - const handleSend = useCallback(() => { - sendMessage({ - content: { - text: "👋", - }, - }); - }, [sendMessage]); - - return ( -
- - {translate("group_placeholder.placeholder_text", { - groupName, - })} - - -
- ); + // Will never really be empty anyway becaue we have group updates + return null; }); diff --git a/features/conversation/hooks/use-group-name-convos.ts b/features/conversation/hooks/use-group-name-convos.ts index a426aa957..d70051ca4 100644 --- a/features/conversation/hooks/use-group-name-convos.ts +++ b/features/conversation/hooks/use-group-name-convos.ts @@ -14,10 +14,10 @@ export function useGroupNameConvos(args: { }) { const { topic, account } = args; - const { data: groupName, isLoading: groupNameLoading } = useGroupNameQuery( + const { data: groupName, isLoading: groupNameLoading } = useGroupNameQuery({ account, - topic - ); + topic, + }); const { data: members, isLoading: membersLoading } = useGroupMembersQuery( account, diff --git a/features/conversation/utils/is-conversation-dm.ts b/features/conversation/utils/is-conversation-dm.ts index 288e3cbf4..484e54b91 100644 --- a/features/conversation/utils/is-conversation-dm.ts +++ b/features/conversation/utils/is-conversation-dm.ts @@ -1,6 +1,11 @@ -import { ConversationWithCodecsType } from "@/utils/xmtpRN/client"; +import { + ConversationWithCodecsType, + DmWithCodecsType, +} from "@/utils/xmtpRN/client"; import { ConversationVersion } from "@xmtp/react-native-sdk"; -export function isConversationDm(conversation: ConversationWithCodecsType) { +export function isConversationDm( + conversation: ConversationWithCodecsType +): conversation is DmWithCodecsType { return conversation.version === ConversationVersion.DM; } diff --git a/features/conversation/utils/is-conversation-group.ts b/features/conversation/utils/is-conversation-group.ts index 6eca52322..40f9c1929 100644 --- a/features/conversation/utils/is-conversation-group.ts +++ b/features/conversation/utils/is-conversation-group.ts @@ -1,6 +1,9 @@ import { ConversationWithCodecsType } from "@/utils/xmtpRN/client"; +import { GroupWithCodecsType } from "@/utils/xmtpRN/client.types"; import { ConversationVersion } from "@xmtp/react-native-sdk"; -export function isConversationGroup(conversation: ConversationWithCodecsType) { +export function isConversationGroup( + conversation: ConversationWithCodecsType +): conversation is GroupWithCodecsType { return conversation.version === ConversationVersion.GROUP; } diff --git a/features/notifications/utils/accountTopicSubscription.ts b/features/notifications/utils/accountTopicSubscription.ts index 60875932f..1987540b7 100644 --- a/features/notifications/utils/accountTopicSubscription.ts +++ b/features/notifications/utils/accountTopicSubscription.ts @@ -1,4 +1,4 @@ -import { createV3ConversationListQueryObserver } from "@/queries/useV3ConversationListQuery"; +import { createConversationListQueryObserver } from "@/queries/useConversationListQuery"; import { subscribeToNotifications } from "./subscribeToNotifications"; import logger from "@/utils/logger"; @@ -14,7 +14,10 @@ export const setupAccountTopicSubscription = (account: string) => { logger.info( `[setupAccountTopicSubscription] subscribing to account ${account}` ); - const observer = createV3ConversationListQueryObserver(account, "sync"); + const observer = createConversationListQueryObserver({ + account, + context: "sync", + }); let previous: number | undefined; const unsubscribe = observer.subscribe((conversationList) => { if (conversationList.data && conversationList.dataUpdatedAt !== previous) { diff --git a/features/notifications/utils/onInteractWithNotification.ts b/features/notifications/utils/onInteractWithNotification.ts index 76cf734bd..daf4f2cd7 100644 --- a/features/notifications/utils/onInteractWithNotification.ts +++ b/features/notifications/utils/onInteractWithNotification.ts @@ -7,7 +7,7 @@ import { import { getTopicFromV3Id } from "@utils/groupUtils/groupId"; import { useAccountsStore } from "@data/store/accountsStore"; import type { ConversationId, ConversationTopic } from "@xmtp/react-native-sdk"; -import { fetchPersistedConversationListQuery } from "@/queries/useV3ConversationListQuery"; +import { fetchPersistedConversationListQuery } from "@/queries/useConversationListQuery"; import { resetNotifications } from "./resetNotifications"; export const onInteractWithNotification = async ( diff --git a/hooks/useConversationListGroupItem.ts b/hooks/useConversationListGroupItem.ts index f96a9c8f5..f82f829c4 100644 --- a/hooks/useConversationListGroupItem.ts +++ b/hooks/useConversationListGroupItem.ts @@ -1,18 +1,19 @@ +import { useConversationListQuery } from "@/queries/useConversationListQuery"; import { useCurrentAccount } from "@data/store/accountsStore"; -import { useV3ConversationListQuery } from "@queries/useV3ConversationListQuery"; -import { useMemo } from "react"; import type { ConversationTopic } from "@xmtp/react-native-sdk"; +import { useMemo } from "react"; export const useConversationListGroupItem = (topic: ConversationTopic) => { const account = useCurrentAccount(); - const { data } = useV3ConversationListQuery( - account!, - { + const { data } = useConversationListQuery({ + account: account!, + queryOptions: { refetchOnWindowFocus: false, refetchOnMount: false, }, - "useConversationListGroupItem" - ); + + context: "useConversationListGroupItem", + }); return useMemo( () => data?.find((group) => group.topic === topic), diff --git a/hooks/useGroupConsent.ts b/hooks/useGroupConsent.ts index 649c06f55..461cada6a 100644 --- a/hooks/useGroupConsent.ts +++ b/hooks/useGroupConsent.ts @@ -8,7 +8,7 @@ import { useGroupQuery } from "@queries/useGroupQuery"; import { consentToInboxIdsOnProtocolByAccount } from "@utils/xmtpRN/contacts"; import { ConversationTopic, InboxId } from "@xmtp/react-native-sdk"; import { useCallback } from "react"; -import { useGroupCreator } from "./useGroupCreator"; +import { useGroupCreatorQuery } from "../queries/useGroupCreatorQuery"; export type IGroupConsentOptions = { includeCreator?: boolean; @@ -18,19 +18,19 @@ export type IGroupConsentOptions = { export const useGroupConsent = (topic: ConversationTopic) => { const account = currentAccount(); - const { data: group, isLoading: isGroupLoading } = useGroupQuery( + const { data: group, isLoading: isGroupLoading } = useGroupQuery({ account, - topic - ); + topic, + }); const { data: groupCreator, isLoading: isGroupCreatorLoading } = - useGroupCreator(topic); + useGroupCreatorQuery(topic); const { data: groupConsent, isLoading: isGroupConsentLoading, isError, - } = useGroupConsentQuery(account, topic); + } = useGroupConsentQuery({ account, topic }); const { mutateAsync: allowGroupMutation, isPending: isAllowingGroup } = useAllowGroupMutation(account, topic); diff --git a/hooks/useGroupCreator.ts b/hooks/useGroupCreator.ts deleted file mode 100644 index 6a6f2dc94..000000000 --- a/hooks/useGroupCreator.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import type { ConversationTopic } from "@xmtp/react-native-sdk"; -import { currentAccount } from "../data/store/accountsStore"; -import { useGroupQuery } from "../queries/useGroupQuery"; - -export const useGroupCreator = (topic: ConversationTopic) => { - const account = currentAccount(); - const { data } = useGroupQuery(account, topic); - - return useQuery({ - queryKey: [account, "groupCreator", topic], - queryFn: () => { - if (!data) return null; - return data.creatorInboxId(); - }, - enabled: !!topic && !!data, - refetchOnWindowFocus: false, - refetchOnMount: false, - }); -}; diff --git a/hooks/useGroupDescription.ts b/hooks/useGroupDescription.ts index 4f197c9c0..401696364 100644 --- a/hooks/useGroupDescription.ts +++ b/hooks/useGroupDescription.ts @@ -5,7 +5,10 @@ import type { ConversationTopic } from "@xmtp/react-native-sdk"; export const useGroupDescription = (topic: ConversationTopic) => { const account = currentAccount(); - const { data, isLoading, isError } = useGroupDescriptionQuery(account, topic); + const { data, isLoading, isError } = useGroupDescriptionQuery({ + account, + topic, + }); const { mutateAsync } = useGroupDescriptionMutation(account, topic); return { diff --git a/hooks/useGroupName.ts b/hooks/useGroupName.ts index 4a7543bed..5a0fb65aa 100644 --- a/hooks/useGroupName.ts +++ b/hooks/useGroupName.ts @@ -1,17 +1,23 @@ +import type { ConversationTopic } from "@xmtp/react-native-sdk"; import { currentAccount } from "../data/store/accountsStore"; import { useGroupNameMutation } from "../queries/useGroupNameMutation"; import { useGroupNameQuery } from "../queries/useGroupNameQuery"; -import type { ConversationTopic } from "@xmtp/react-native-sdk"; export const useGroupName = (topic: ConversationTopic | undefined) => { const account = currentAccount(); - const { data, isLoading, isError } = useGroupNameQuery(account, topic!); - const { mutateAsync } = useGroupNameMutation(account, topic!); + const { data, isLoading, isError } = useGroupNameQuery({ + account, + topic: topic!, + }); + const { mutateAsync } = useGroupNameMutation({ + account, + topic: topic!, + }); return { groupName: data, isLoading, isError, - setGroupName: mutateAsync, + updateGroupName: mutateAsync, }; }; diff --git a/hooks/useGroupPhoto.ts b/hooks/useGroupPhoto.ts index a0e48a756..3dc4ae92c 100644 --- a/hooks/useGroupPhoto.ts +++ b/hooks/useGroupPhoto.ts @@ -5,8 +5,14 @@ import { useGroupPhotoQuery } from "../queries/useGroupPhotoQuery"; export const useGroupPhoto = (topic: ConversationTopic) => { const account = useCurrentAccount(); - const { data, isLoading, isError } = useGroupPhotoQuery(account ?? "", topic); - const { mutateAsync } = useGroupPhotoMutation(account ?? "", topic); + const { data, isLoading, isError } = useGroupPhotoQuery({ + account: account ?? "", + topic, + }); + const { mutateAsync } = useGroupPhotoMutation({ + account: account ?? "", + topic, + }); return { groupPhoto: data, diff --git a/queries/QueryKeys.test.ts b/queries/QueryKeys.test.ts index f5fd0fa06..d4fd4ff37 100644 --- a/queries/QueryKeys.test.ts +++ b/queries/QueryKeys.test.ts @@ -1,14 +1,20 @@ import type { ConversationTopic } from "@xmtp/react-native-sdk"; import { QueryKeys, - groupDescriptionQueryKey, + conversationsQueryKey, + conversationQueryKey, + dmQueryKey, + conversationMessageQueryKey, + conversationMessagesQueryKey, + conversationPreviewMessagesQueryKey, groupMembersQueryKey, - groupNameQueryKey, - groupPermissionsQueryKey, - groupPhotoQueryKey, groupPinnedFrameQueryKey, - conversationQueryKey, - conversationsQueryKey, + groupPermissionPolicyQueryKey, + groupCreatorQueryKey, + groupPermissionsQueryKey, + groupInviteQueryKey, + groupJoinRequestQueryKey, + pendingJoinRequestsQueryKey, } from "./QueryKeys"; describe("QueryKeys", () => { @@ -18,74 +24,134 @@ describe("QueryKeys", () => { }); describe("Query Key Functions", () => { - const account = "testAccount"; + const account = "TestAccount"; const topic = "testTopic" as ConversationTopic; + const peer = "testPeer"; + const messageId = "testMessageId"; + const inviteId = "testInviteId"; + const requestId = "testRequestId"; - it("conversationsQueryKey should return the correct array", () => { - const result = conversationsQueryKey(account); - expect(result).toEqual([QueryKeys.CONVERSATIONS, account.toLowerCase()]); + // Conversations + it("conversationsQueryKey should return correct array", () => { + expect(conversationsQueryKey(account)).toEqual([ + QueryKeys.CONVERSATIONS, + account.toLowerCase(), + ]); }); - it("conversationQueryKey should return the correct array", () => { - const result = conversationQueryKey(account, topic); - expect(result).toEqual([ + it("conversationQueryKey should return correct array", () => { + expect(conversationQueryKey(account, topic)).toEqual([ QueryKeys.CONVERSATION, account.toLowerCase(), topic, ]); }); - it("groupMembersQueryKey should return the correct array", () => { - const result = groupMembersQueryKey(account, topic); - expect(result).toEqual([ - QueryKeys.GROUP_MEMBERS, + it("dmQueryKey should return correct array", () => { + expect(dmQueryKey(account, peer)).toEqual([ + QueryKeys.CONVERSATION_DM, account.toLowerCase(), - topic, + peer, ]); }); - it("groupNameQueryKey should return the correct array", () => { - const result = groupNameQueryKey(account, topic); - expect(result).toEqual([ - QueryKeys.GROUP_NAME, + // Messages + it("conversationMessageQueryKey should return correct array", () => { + expect(conversationMessageQueryKey(account, messageId)).toEqual([ + QueryKeys.CONVERSATION_MESSAGE, + account.toLowerCase(), + messageId, + ]); + }); + + it("conversationMessagesQueryKey should return correct array", () => { + expect(conversationMessagesQueryKey(account, topic)).toEqual([ + QueryKeys.CONVERSATION_MESSAGES, account.toLowerCase(), topic, ]); }); - it("groupDescriptionQueryKey should return the correct array", () => { - const result = groupDescriptionQueryKey(account, topic); - expect(result).toEqual([ - QueryKeys.GROUP_DESCRIPTION, + it("conversationPreviewMessagesQueryKey should return correct array", () => { + expect(conversationPreviewMessagesQueryKey(account, topic)).toEqual([ + QueryKeys.CONVERSATION_MESSAGES, account.toLowerCase(), topic, ]); }); - it("groupPhotoQueryKey should return the correct array", () => { - const result = groupPhotoQueryKey(account, topic); - expect(result).toEqual([ - QueryKeys.GROUP_PHOTO, + // Members + it("groupMembersQueryKey should return correct array", () => { + expect(groupMembersQueryKey(account, topic)).toEqual([ + QueryKeys.GROUP_MEMBERS, account.toLowerCase(), topic, ]); }); - it("groupPinnedFrameQueryKey should return the correct array", () => { - const result = groupPinnedFrameQueryKey(account, topic); - expect(result).toEqual([ + // Group Mutable Metadata + it("groupPinnedFrameQueryKey should return correct array", () => { + expect(groupPinnedFrameQueryKey(account, topic)).toEqual([ QueryKeys.PINNED_FRAME, account.toLowerCase(), topic, ]); }); - it("groupPermissionsQueryKey should return the correct array", () => { - const result = groupPermissionsQueryKey(account, topic); - expect(result).toEqual([ + it("groupPermissionPolicyQueryKey should return correct array", () => { + expect(groupPermissionPolicyQueryKey(account, topic)).toEqual([ + QueryKeys.GROUP_PERMISSION_POLICY, + account.toLowerCase(), + topic, + ]); + }); + + it("groupCreatorQueryKey should return correct array", () => { + expect(groupCreatorQueryKey(account, topic)).toEqual([ + QueryKeys.GROUP_CREATOR, + account.toLowerCase(), + topic, + ]); + }); + + // Permissions + it("groupPermissionsQueryKey should return correct array", () => { + expect(groupPermissionsQueryKey(account, topic)).toEqual([ QueryKeys.GROUP_PERMISSIONS, account.toLowerCase(), topic, ]); }); + + // Group Invites + it("groupInviteQueryKey should return correct array", () => { + expect(groupInviteQueryKey(account, inviteId)).toEqual([ + QueryKeys.GROUP_INVITE, + account.toLowerCase(), + inviteId, + ]); + }); + + it("groupJoinRequestQueryKey should return correct array", () => { + expect(groupJoinRequestQueryKey(account, requestId)).toEqual([ + QueryKeys.GROUP_JOIN_REQUEST, + account.toLowerCase(), + requestId, + ]); + }); + + it("pendingJoinRequestsQueryKey should return correct array", () => { + expect(pendingJoinRequestsQueryKey(account)).toEqual([ + QueryKeys.PENDING_JOIN_REQUESTS, + account.toLowerCase(), + ]); + }); + + // Case sensitivity tests + it("should convert account to lowercase consistently", () => { + const mixedCaseAccount = "MiXeDcAsE"; + expect(conversationsQueryKey(mixedCaseAccount)[1]).toBe( + mixedCaseAccount.toLowerCase() + ); + }); }); diff --git a/queries/QueryKeys.ts b/queries/QueryKeys.ts index 87e93cd14..1b750be2b 100644 --- a/queries/QueryKeys.ts +++ b/queries/QueryKeys.ts @@ -2,10 +2,9 @@ import type { ConversationTopic } from "@xmtp/react-native-sdk"; export enum QueryKeys { // Conversations - CONVERSATIONS = "conversations", // When changing the shape of response, update the keys as persistance will break + CONVERSATIONS = "conversations", CONVERSATION = "conversation", - CONVERSATION_WITH_PEER = "conversationWithPeer", - V3_CONVERSATION_LIST = "v3ConversationList", + CONVERSATION_DM = "conversationDM", // Messages CONVERSATION_MESSAGE = "conversationMessage", @@ -13,36 +12,22 @@ export enum QueryKeys { // Members GROUP_MEMBERS = "groupMembersv2", - ADDED_BY = "addedBy", // Group Mutable Metadata - GROUP_NAME = "groupName", - GROUP_DESCRIPTION = "groupDescription", - GROUP_PHOTO = "groupPhoto", PINNED_FRAME = "pinnedFrame", GROUP_PERMISSION_POLICY = "groupPermissionPolicy", + GROUP_CREATOR = "groupCreator", // Permissions GROUP_PERMISSIONS = "groupPermissions", - // Group Consent - GROUP_CONSENT = "groupConsent", - DM_CONSENT = "dmConsent", - - // Group info - GROUP_ACTIVE = "groupActive", - // Group Invites GROUP_INVITE = "groupInvite", GROUP_JOIN_REQUEST = "groupJoinRequest", PENDING_JOIN_REQUESTS = "pendingJoinRequests", } -export const conversationMessageQueryKey = ( - account: string, - messageId: string -) => [QueryKeys.CONVERSATION_MESSAGE, account.toLowerCase(), messageId]; - +// Conversations export const conversationsQueryKey = (account: string) => [ QueryKeys.CONVERSATIONS, account.toLowerCase(), @@ -53,12 +38,18 @@ export const conversationQueryKey = ( topic: ConversationTopic ) => [QueryKeys.CONVERSATION, account.toLowerCase(), topic]; -export const conversationWithPeerQueryKey = (account: string, peer: string) => [ - QueryKeys.CONVERSATION_WITH_PEER, +export const dmQueryKey = (account: string, peer: string) => [ + QueryKeys.CONVERSATION_DM, account.toLowerCase(), peer, ]; +// Messages +export const conversationMessageQueryKey = ( + account: string, + messageId: string +) => [QueryKeys.CONVERSATION_MESSAGE, account.toLowerCase(), messageId]; + export const conversationMessagesQueryKey = ( account: string, topic: ConversationTopic @@ -69,57 +60,35 @@ export const conversationPreviewMessagesQueryKey = ( topic: ConversationTopic ) => [QueryKeys.CONVERSATION_MESSAGES, account.toLowerCase(), topic]; +// Members export const groupMembersQueryKey = ( account: string, topic: ConversationTopic ) => [QueryKeys.GROUP_MEMBERS, account.toLowerCase(), topic]; -export const groupNameQueryKey = ( - account: string, - topic: ConversationTopic -) => [QueryKeys.GROUP_NAME, account.toLowerCase(), topic]; - -export const groupDescriptionQueryKey = ( - account: string, - topic: ConversationTopic -) => [QueryKeys.GROUP_DESCRIPTION, account.toLowerCase(), topic]; - -export const groupPhotoQueryKey = ( - account: string, - topic: ConversationTopic -) => [QueryKeys.GROUP_PHOTO, account.toLowerCase(), topic]; - +// Group Mutable Metadata export const groupPinnedFrameQueryKey = ( account: string, topic: ConversationTopic ) => [QueryKeys.PINNED_FRAME, account.toLowerCase(), topic]; -export const groupPermissionsQueryKey = ( - account: string, - topic: ConversationTopic -) => [QueryKeys.GROUP_PERMISSIONS, account.toLowerCase(), topic]; - -export const groupConsentQueryKey = ( +export const groupPermissionPolicyQueryKey = ( account: string, topic: ConversationTopic -) => [QueryKeys.GROUP_CONSENT, account.toLowerCase(), topic]; +) => [QueryKeys.GROUP_PERMISSION_POLICY, account.toLowerCase(), topic]; -export const dmConsentQueryKey = ( +export const groupCreatorQueryKey = ( account: string, topic: ConversationTopic -) => [QueryKeys.DM_CONSENT, account.toLowerCase(), topic]; +) => [QueryKeys.GROUP_CREATOR, account.toLowerCase(), topic]; -export const addedByQueryKey = (account: string, topic: ConversationTopic) => [ - QueryKeys.ADDED_BY, - account.toLowerCase(), - topic, -]; - -export const groupIsActiveQueryKey = ( +// Permissions +export const groupPermissionsQueryKey = ( account: string, topic: ConversationTopic -) => [QueryKeys.GROUP_ACTIVE, account.toLowerCase(), topic]; +) => [QueryKeys.GROUP_PERMISSIONS, account.toLowerCase(), topic]; +// Group Invites export const groupInviteQueryKey = (account: string, inviteId: string) => [ QueryKeys.GROUP_INVITE, account.toLowerCase(), @@ -135,8 +104,3 @@ export const pendingJoinRequestsQueryKey = (account: string) => [ QueryKeys.PENDING_JOIN_REQUESTS, account.toLowerCase(), ]; - -export const groupPermissionPolicyQueryKey = ( - account: string, - topic: ConversationTopic -) => [QueryKeys.GROUP_PERMISSION_POLICY, account.toLowerCase(), topic]; diff --git a/queries/__snapshots__/QueryKeys.test.ts.snap b/queries/__snapshots__/QueryKeys.test.ts.snap index 1733530d6..b12201dd4 100644 --- a/queries/__snapshots__/QueryKeys.test.ts.snap +++ b/queries/__snapshots__/QueryKeys.test.ts.snap @@ -2,25 +2,18 @@ exports[`QueryKeys should match the snapshot 1`] = ` { - "ADDED_BY": "addedBy", "CONVERSATION": "conversation", "CONVERSATIONS": "conversations", + "CONVERSATION_DM": "conversationDM", "CONVERSATION_MESSAGE": "conversationMessage", "CONVERSATION_MESSAGES": "conversationMessages", - "CONVERSATION_WITH_PEER": "conversationWithPeer", - "DM_CONSENT": "dmConsent", - "GROUP_ACTIVE": "groupActive", - "GROUP_CONSENT": "groupConsent", - "GROUP_DESCRIPTION": "groupDescription", + "GROUP_CREATOR": "groupCreator", "GROUP_INVITE": "groupInvite", "GROUP_JOIN_REQUEST": "groupJoinRequest", "GROUP_MEMBERS": "groupMembersv2", - "GROUP_NAME": "groupName", "GROUP_PERMISSIONS": "groupPermissions", "GROUP_PERMISSION_POLICY": "groupPermissionPolicy", - "GROUP_PHOTO": "groupPhoto", "PENDING_JOIN_REQUESTS": "pendingJoinRequests", "PINNED_FRAME": "pinnedFrame", - "V3_CONVERSATION_LIST": "v3ConversationList", } `; diff --git a/queries/groupConsentMutationUtils.ts b/queries/groupConsentMutationUtils.ts index a2cb1647d..8c4f2ac19 100644 --- a/queries/groupConsentMutationUtils.ts +++ b/queries/groupConsentMutationUtils.ts @@ -2,17 +2,15 @@ import { MutationObserver, QueryClient } from "@tanstack/react-query"; import logger from "@utils/logger"; import { sentryTrackError } from "@utils/sentry"; import { consentToGroupsOnProtocolByAccount } from "@utils/xmtpRN/contacts"; - -import { - cancelGroupConsentQuery, - getGroupConsentQueryData, - setGroupConsentQueryData, -} from "./useGroupConsentQuery"; import { ConsentState, ConversationId, ConversationTopic, } from "@xmtp/react-native-sdk"; +import { + getGroupConsentQueryData, + setGroupConsentQueryData, +} from "./useGroupConsentQuery"; export type GroupConsentAction = "allow" | "deny"; @@ -41,7 +39,6 @@ export const createGroupConsentMutationObserver = ( return consentStatus; }, onMutate: async () => { - await cancelGroupConsentQuery(account, topic); const previousConsent = getGroupConsentQueryData(account, topic); setGroupConsentQueryData(account, topic, consentStatus); return { previousConsent }; @@ -89,7 +86,6 @@ export const getGroupConsentMutationOptions = ({ return consentStatus; }, onMutate: async () => { - await cancelGroupConsentQuery(account, topic); const previousConsent = getGroupConsentQueryData(account, topic); setGroupConsentQueryData(account, topic, consentStatus); return { previousConsent }; diff --git a/queries/useAddToGroupMutation.ts b/queries/useAddToGroupMutation.ts index 477364244..b2b40a476 100644 --- a/queries/useAddToGroupMutation.ts +++ b/queries/useAddToGroupMutation.ts @@ -1,21 +1,21 @@ import { useMutation } from "@tanstack/react-query"; import logger from "@utils/logger"; -import { sentryTrackError } from "@utils/sentry"; +import { captureError } from "@/utils/capture-error"; +import { useGroupQuery } from "@queries/useGroupQuery"; +import type { ConversationTopic } from "@xmtp/react-native-sdk"; import { addMemberMutationKey } from "./MutationKeys"; import { cancelGroupMembersQuery, invalidateGroupMembersQuery, } from "./useGroupMembersQuery"; -import { useGroupQuery } from "@queries/useGroupQuery"; -import type { ConversationTopic } from "@xmtp/react-native-sdk"; // import { refreshGroup } from "../utils/xmtpRN/conversations"; export const useAddToGroupMutation = ( account: string, topic: ConversationTopic ) => { - const { data: group } = useGroupQuery(account, topic); + const { data: group } = useGroupQuery({ account, topic }); return useMutation({ mutationKey: addMemberMutationKey(account, topic!), @@ -33,8 +33,7 @@ export const useAddToGroupMutation = ( await cancelGroupMembersQuery(account, topic); }, onError: (error, _variables, _context) => { - logger.warn("onError useAddToGroupMutation"); - sentryTrackError(error); + captureError(error); }, onSuccess: (_data, _variables, _context) => { logger.debug("onSuccess useAddToGroupMutation"); diff --git a/queries/useAllowGroupMutation.ts b/queries/useAllowGroupMutation.ts index 7274e779d..1ea163c2f 100644 --- a/queries/useAllowGroupMutation.ts +++ b/queries/useAllowGroupMutation.ts @@ -1,19 +1,15 @@ import { queryClient } from "@queries/queryClient"; import { - useMutation, MutationObserver, MutationOptions, + useMutation, } from "@tanstack/react-query"; import { consentToGroupsOnProtocolByAccount, consentToInboxIdsOnProtocolByAccount, } from "@utils/xmtpRN/contacts"; - -import { - cancelGroupConsentQuery, - getGroupConsentQueryData, - setGroupConsentQueryData, -} from "./useGroupConsentQuery"; +import { captureError } from "@/utils/capture-error"; +import { GroupWithCodecsType } from "@/utils/xmtpRN/client"; import { getV3IdFromTopic } from "@utils/groupUtils/groupId"; import { ConsentState, @@ -21,14 +17,11 @@ import { ConversationTopic, InboxId, } from "@xmtp/react-native-sdk"; -import { GroupWithCodecsType } from "@/utils/xmtpRN/client"; -import { - ConversationQueryData, - getConversationQueryData, - setConversationQueryData, -} from "./useConversationQuery"; -import { captureError } from "@/utils/capture-error"; import { MutationKeys } from "./MutationKeys"; +import { + getGroupConsentQueryData, + setGroupConsentQueryData, +} from "./useGroupConsentQuery"; export type AllowGroupMutationProps = { account: string; @@ -49,102 +42,76 @@ export type IUseAllowGroupMutationOptions = MutationOptions< AllowGroupMutationVariables, { previousConsent: ConsentState | undefined; - previousConversation: ConversationQueryData | undefined; } >; -const allowGroupMutationFn = async (args: AllowGroupMutationVariables) => { - const { includeAddedBy, includeCreator, group, account } = args; - - const groupTopic = group.topic; - const groupCreator = await group.creatorInboxId(); - - const inboxIdsToAllow: InboxId[] = []; - if (includeAddedBy && group?.addedByInboxId) { - inboxIdsToAllow.push(group.addedByInboxId); - } - - if (includeCreator && groupCreator) { - inboxIdsToAllow.push(groupCreator); - } - - await Promise.all([ - consentToGroupsOnProtocolByAccount({ - account, - groupIds: [getV3IdFromTopic(groupTopic)], - consent: "allow", - }), - ...(inboxIdsToAllow.length > 0 - ? [ - consentToInboxIdsOnProtocolByAccount({ - account, - inboxIds: inboxIdsToAllow, - consent: "allow", - }), - ] - : []), - ]); - - return "allowed"; -}; - -const onMutateAllowGroupMutation = async ( - args: AllowGroupMutationVariables -): Promise<{ - previousConsent: ConsentState | undefined; - previousConversation: ConversationQueryData | undefined; -}> => { - const { account, group } = args; - - const topic = group.topic; - - await cancelGroupConsentQuery(account, topic); - setGroupConsentQueryData(account, topic, "allowed"); - const previousConsent = getGroupConsentQueryData(account, topic); - - const previousConversation = getConversationQueryData(account, topic); - - if (previousConversation) { - previousConversation.state = "allowed"; - setConversationQueryData(account, topic, previousConversation); - } - return { previousConsent, previousConversation }; -}; - -const onErrorAllowGroupMutation = ( - error: unknown, - variables: AllowGroupMutationVariables, - context?: { - previousConsent: ConsentState | undefined; - previousConversation: ConversationQueryData | undefined; - } -) => { - const { account, group } = variables; - - const topic = group.topic; - captureError(error); - if (!context) { - return; - } - - if (context.previousConsent) { - setGroupConsentQueryData(account, topic, context.previousConsent); - } - - if (context.previousConversation) { - setConversationQueryData(account, topic, context.previousConversation); - } -}; - export const getAllowGroupMutationOptions = ( account: string, topic: ConversationTopic ): IUseAllowGroupMutationOptions => { return { mutationKey: [MutationKeys.ALLOW_GROUP, account, topic], - mutationFn: allowGroupMutationFn, - onMutate: onMutateAllowGroupMutation, - onError: onErrorAllowGroupMutation, + mutationFn: async (args: AllowGroupMutationVariables) => { + const { includeAddedBy, includeCreator, group, account } = args; + + const groupTopic = group.topic; + const groupCreator = await group.creatorInboxId(); + + const inboxIdsToAllow: InboxId[] = []; + if (includeAddedBy && group?.addedByInboxId) { + inboxIdsToAllow.push(group.addedByInboxId); + } + + if (includeCreator && groupCreator) { + inboxIdsToAllow.push(groupCreator); + } + + await Promise.all([ + consentToGroupsOnProtocolByAccount({ + account, + groupIds: [getV3IdFromTopic(groupTopic)], + consent: "allow", + }), + ...(inboxIdsToAllow.length > 0 + ? [ + consentToInboxIdsOnProtocolByAccount({ + account, + inboxIds: inboxIdsToAllow, + consent: "allow", + }), + ] + : []), + ]); + + return "allowed"; + }, + onMutate: (args: AllowGroupMutationVariables) => { + const { account, group } = args; + const previousConsent = getGroupConsentQueryData(account, group.topic); + setGroupConsentQueryData(account, group.topic, "allowed"); + return { + previousConsent: previousConsent ?? undefined, + }; + }, + onError: ( + error: unknown, + variables: AllowGroupMutationVariables, + context?: { + previousConsent: ConsentState | undefined; + } + ) => { + const { account, group } = variables; + + captureError(error); + + if (!context) { + return; + } + + if (context.previousConsent) { + setGroupConsentQueryData(account, group.topic, context.previousConsent); + } + }, }; }; diff --git a/queries/useBlockGroupMutation.ts b/queries/useBlockGroupMutation.ts index 4d873efbf..9ccce1110 100644 --- a/queries/useBlockGroupMutation.ts +++ b/queries/useBlockGroupMutation.ts @@ -1,60 +1,14 @@ -import { queryClient } from "@queries/queryClient"; -import { useMutation, MutationObserver } from "@tanstack/react-query"; +import { captureError } from "@/utils/capture-error"; +import { useMutation } from "@tanstack/react-query"; +import { getV3IdFromTopic } from "@utils/groupUtils/groupId"; import logger from "@utils/logger"; -import { sentryTrackError } from "@utils/sentry"; import { consentToGroupsOnProtocolByAccount } from "@utils/xmtpRN/contacts"; - +import type { ConversationTopic } from "@xmtp/react-native-sdk"; import { blockGroupMutationKey } from "./MutationKeys"; import { - cancelGroupConsentQuery, getGroupConsentQueryData, setGroupConsentQueryData, } from "./useGroupConsentQuery"; -import type { ConversationId, ConversationTopic } from "@xmtp/react-native-sdk"; -import { getV3IdFromTopic } from "@utils/groupUtils/groupId"; -import { useConversationQuery } from "./useConversationQuery"; - -export type BlockGroupMutationProps = { - account: string; - topic: ConversationTopic; - groupId: ConversationId; -}; - -const createBlockGroupMutationObserver = ({ - account, - topic, - groupId, -}: BlockGroupMutationProps) => { - const blockGroupMutationObserver = new MutationObserver(queryClient, { - mutationKey: blockGroupMutationKey(account, topic), - mutationFn: async () => { - await consentToGroupsOnProtocolByAccount({ - account, - groupIds: [groupId], - consent: "deny", - }); - return "denied"; - }, - onMutate: async () => { - await cancelGroupConsentQuery(account, topic); - const previousConsent = getGroupConsentQueryData(account, topic); - setGroupConsentQueryData(account, topic, "denied"); - return { previousConsent }; - }, - onError: (error, _variables, context) => { - logger.warn("onError useBlockGroupMutation"); - sentryTrackError(error); - if (context?.previousConsent === undefined) { - return; - } - setGroupConsentQueryData(account, topic, context.previousConsent); - }, - onSuccess: () => { - logger.debug("onSuccess useBlockGroupMutation"); - }, - }); - return blockGroupMutationObserver; -}; export const useBlockGroupMutation = ( account: string, @@ -74,14 +28,12 @@ export const useBlockGroupMutation = ( return "denied"; }, onMutate: async () => { - await cancelGroupConsentQuery(account, topic!); const previousConsent = getGroupConsentQueryData(account, topic!); setGroupConsentQueryData(account, topic!, "denied"); return { previousConsent }; }, onError: (error, _variables, context) => { - logger.warn("onError useBlockGroupMutation"); - sentryTrackError(error); + captureError(error); if (context?.previousConsent === undefined) { return; } diff --git a/queries/useConversationListQuery.ts b/queries/useConversationListQuery.ts new file mode 100644 index 000000000..6315ea11c --- /dev/null +++ b/queries/useConversationListQuery.ts @@ -0,0 +1,247 @@ +import { setConversationQueryData } from "@/queries/useConversationQuery"; +import { mutateObjectProperties } from "@/utils/mutate-object-properties"; +import { QueryKeys } from "@queries/QueryKeys"; +import { + QueryObserver, + UseQueryOptions, + useQuery, +} from "@tanstack/react-query"; +import logger from "@utils/logger"; +import { + ConversationWithCodecsType, + ConverseXmtpClientType, +} from "@utils/xmtpRN/client"; +import { getXmtpClient } from "@utils/xmtpRN/sync"; +import { ConversationTopic } from "@xmtp/react-native-sdk"; +import { queryClient } from "./queryClient"; + +export type ConversationListQueryData = Awaited< + ReturnType +>; + +export const createConversationListQueryObserver = (args: { + account: string; + context: string; + includeSync?: boolean; +}) => { + return new QueryObserver(queryClient, conversationListQueryConfig(args)); +}; + +export const useConversationListQuery = (args: { + account: string; + queryOptions?: Partial>; + context?: string; +}) => { + const { account, queryOptions, context } = args; + return useQuery({ + ...conversationListQueryConfig({ account, context: context ?? "" }), + ...queryOptions, + }); +}; + +export const fetchPersistedConversationListQuery = (args: { + account: string; +}) => { + const { account } = args; + return queryClient.fetchQuery( + conversationListQueryConfig({ + account, + context: "fetchPersistedConversationListQuery", + includeSync: false, + }) + ); +}; + +export const fetchConversationListQuery = (args: { account: string }) => { + const { account } = args; + return queryClient.fetchQuery( + conversationListQueryConfig({ + account, + context: "fetchConversationListQuery", + }) + ); +}; + +export const prefetchConversationListQuery = (args: { account: string }) => { + const { account } = args; + return queryClient.prefetchQuery( + conversationListQueryConfig({ + account, + context: "prefetchConversationListQuery", + }) + ); +}; + +export function refetchConversationListQuery(args: { account: string }) { + const { account } = args; + return queryClient.refetchQueries({ + queryKey: conversationListQueryConfig({ + account, + context: "refetchConversationListQuery", + }).queryKey, + }); +} + +export const addConversationToConversationListQuery = (args: { + account: string; + conversation: ConversationWithCodecsType; +}) => { + const { account, conversation } = args; + const previousConversationsData = getConversationListQueryData({ account }); + if (!previousConversationsData) { + setConversationListQueryData({ account, conversations: [conversation] }); + return; + } + + const conversationExists = previousConversationsData.some( + (c) => c.topic === conversation.topic + ); + + if (conversationExists) { + return; + } + + setConversationListQueryData({ + account, + conversations: [conversation, ...previousConversationsData], + }); +}; + +export const updateConversationInConversationListQuery = (args: { + account: string; + topic: ConversationTopic; + conversationUpdate: Partial; +}) => { + const { account, topic, conversationUpdate } = args; + const previousConversationsData = getConversationListQueryData({ account }); + if (!previousConversationsData) { + return; + } + const newConversations = previousConversationsData.map((c) => { + if (c.topic === topic) { + // Need to mutate otherwise some methods on the conversation object change to "undefined"... + return mutateObjectProperties(c, conversationUpdate); + } + return c; + }); + setConversationListQueryData({ account, conversations: newConversations }); +}; + +export function replaceConversationInConversationListQuery(args: { + account: string; + topic: ConversationTopic; + conversation: ConversationWithCodecsType; +}) { + const { account, topic, conversation } = args; + const previousConversationsData = getConversationListQueryData({ account }); + if (!previousConversationsData) { + return; + } + const newConversations = previousConversationsData.map((c) => { + if (c.topic === topic) { + return conversation; + } + return c; + }); + setConversationListQueryData({ account, conversations: newConversations }); +} + +export const getConversationListQueryData = (args: { account: string }) => { + const { account } = args; + return queryClient.getQueryData( + conversationListQueryConfig({ + account, + context: "getConversationListQueryData", + }).queryKey + ); +}; + +export const setConversationListQueryData = (args: { + account: string; + conversations: ConversationListQueryData; +}) => { + const { account, conversations } = args; + return queryClient.setQueryData( + conversationListQueryConfig({ + account, + context: "setConversationListQueryData", + }).queryKey, + conversations + ); +}; + +const getConversationList = async (args: { + account: string; + context: string; + includeSync?: boolean; +}) => { + const { account, context, includeSync = true } = args; + try { + logger.debug( + `[ConversationListQuery] Fetching conversation list from network ${context}` + ); + + const client = (await getXmtpClient(account)) as ConverseXmtpClientType; + + const beforeSync = new Date().getTime(); + + if (includeSync) { + await client.conversations.sync(); + await client.conversations.syncAllConversations(); + } + + const afterSync = new Date().getTime(); + + logger.debug( + `[ConversationListQuery] Fetching conversation list from network took ${ + (afterSync - beforeSync) / 1000 + } sec` + ); + + const conversations = await client.conversations.list( + { + isActive: true, + addedByInboxId: true, + name: true, + imageUrlSquare: true, + consentState: true, + lastMessage: true, + description: true, + }, + "lastMessage" // Order by last message + ); + + for (const conversation of conversations) { + setConversationQueryData({ + account, + topic: conversation.topic, + conversation, + }); + } + + return conversations; + } catch (error) { + logger.error( + `[ConversationListQuery] Error fetching conversation list from network ${context}`, + error + ); + throw error; + } +}; + +export const conversationListQueryConfig = (args: { + account: string; + context: string; + includeSync?: boolean; +}) => { + const { account, context, includeSync = true } = args; + return { + queryKey: [ + QueryKeys.CONVERSATIONS, + account?.toLowerCase(), // All queries are case sensitive, sometimes we use checksum, but the SDK use lowercase, always use lowercase + ], + queryFn: () => getConversationList({ account, context, includeSync }), + staleTime: 2000, + enabled: !!account, + }; +}; diff --git a/queries/useConversationMessages.ts b/queries/useConversationMessages.ts index 2599f3917..ab1c47cde 100644 --- a/queries/useConversationMessages.ts +++ b/queries/useConversationMessages.ts @@ -114,7 +114,7 @@ function getConversationMessagesQueryOptions( account: string, topic: ConversationTopic ) { - const conversation = getConversationQueryData(account, topic); + const conversation = getConversationQueryData({ account, topic }); return { queryKey: conversationMessagesQueryKey(account, topic), queryFn: () => { diff --git a/queries/useConversationPreviewMessages.ts b/queries/useConversationPreviewMessages.ts index 2ae9eb74c..c1b5c956a 100644 --- a/queries/useConversationPreviewMessages.ts +++ b/queries/useConversationPreviewMessages.ts @@ -1,16 +1,16 @@ +import logger from "@/utils/logger"; +import { ConversationWithCodecsType } from "@/utils/xmtpRN/client"; +import { getConversationByTopicByAccount } from "@/utils/xmtpRN/conversations"; import { useQuery, UseQueryOptions } from "@tanstack/react-query"; -import { useConversationQuery } from "./useConversationQuery"; +import { ConversationTopic } from "@xmtp/react-native-sdk"; import { cacheOnlyQueryOptions } from "./cacheOnlyQueryOptions"; +import { queryClient } from "./queryClient"; import { conversationPreviewMessagesQueryKey } from "./QueryKeys"; import { - conversationMessagesQueryFn, ConversationMessagesQueryData, + conversationMessagesQueryFn, } from "./useConversationMessages"; -import { ConversationTopic } from "@xmtp/react-native-sdk"; -import logger from "@/utils/logger"; -import { ConversationWithCodecsType } from "@/utils/xmtpRN/client"; -import { queryClient } from "./queryClient"; -import { getConversationByTopicByAccount } from "@/utils/xmtpRN/conversations"; +import { useConversationQuery } from "./useConversationQuery"; const conversationPreviewMessagesQueryFn = async ( conversation: ConversationWithCodecsType @@ -42,11 +42,11 @@ export const useConversationPreviewMessages = ( topic: ConversationTopic, options?: Partial> ) => { - const { data: conversation } = useConversationQuery( + const { data: conversation } = useConversationQuery({ account, topic, - cacheOnlyQueryOptions - ); + queryOptions: cacheOnlyQueryOptions, + }); return useQuery({ queryKey: conversationPreviewMessagesQueryKey(account, topic), diff --git a/queries/useConversationQuery.ts b/queries/useConversationQuery.ts index c4e9bce29..5efe1f07e 100644 --- a/queries/useConversationQuery.ts +++ b/queries/useConversationQuery.ts @@ -1,76 +1,118 @@ -import { useQuery, UseQueryOptions } from "@tanstack/react-query"; +import { + conversationListQueryConfig, + replaceConversationInConversationListQuery, +} from "@/queries/useConversationListQuery"; +import { + QueryObserver, + UseQueryOptions, + useQuery, +} from "@tanstack/react-query"; import { getConversationByTopicByAccount } from "@utils/xmtpRN/conversations"; import type { ConversationTopic } from "@xmtp/react-native-sdk"; -import { queryClient } from "./queryClient"; +import React from "react"; import { conversationQueryKey } from "./QueryKeys"; +import { queryClient } from "./queryClient"; export type ConversationQueryData = Awaited>; -function getConversation(account: string, topic: ConversationTopic) { +type IArgs = { + account: string; + topic: ConversationTopic; +}; + +function getConversation(args: IArgs) { + const { account, topic } = args; return getConversationByTopicByAccount({ account, topic, - includeSync: false, + includeSync: true, }); } export const useConversationQuery = ( - account: string, - topic: ConversationTopic | undefined, - options?: Partial> + args: IArgs & { + queryOptions?: Partial>; + } ) => { - return useQuery({ - ...options, - queryKey: conversationQueryKey(account, topic!), - queryFn: () => getConversation(account, topic!), - enabled: !!topic, + const { account, topic, queryOptions } = args; + + // Individual conversation query + const query = useQuery({ + ...getConversationQueryOptions({ account, topic }), + ...queryOptions, }); + + // Keep in sync with conversation list + React.useEffect(() => { + const observer = new QueryObserver( + queryClient, + conversationListQueryConfig({ account, context: "useConversationQuery" }) + ); + + observer.subscribe(({ data: conversations }) => { + const currentConversation = + queryClient.getQueryData( + conversationQueryKey(account, topic) + ); + + const listConversation = conversations?.find( + (c) => c.topic === currentConversation?.topic + ); + + if (listConversation) { + // List is source of truth + queryClient.setQueryData( + conversationQueryKey(account, topic), + listConversation + ); + } + }); + + return () => observer.destroy(); + }, [account, topic]); + + return query; }; -export const invalidateConversationQuery = ( - account: string, - topic: ConversationTopic -) => { +export function getConversationQueryOptions(args: IArgs) { + const { account, topic } = args; + return { + queryKey: conversationQueryKey(account, topic), + queryFn: () => getConversation({ account, topic: topic! }), + enabled: !!topic, + }; +} + +export const invalidateConversationQuery = (args: IArgs) => { + const { account, topic } = args; return queryClient.invalidateQueries({ queryKey: conversationQueryKey(account, topic), }); }; -export function updateConversationQueryData( - account: string, - topic: ConversationTopic, - conversation: ConversationQueryData -) { - return queryClient.setQueryData( - conversationQueryKey(account, topic), - conversation - ); -} - export const setConversationQueryData = ( - account: string, - topic: ConversationTopic, - conversation: ConversationQueryData + args: IArgs & { + conversation: ConversationQueryData; + } ) => { - queryClient.setQueryData( - conversationQueryKey(account, topic), - conversation - ); + const { account, topic, conversation } = args; + // Source of truth for now + replaceConversationInConversationListQuery({ + account, + topic, + conversation, + }); }; -export function refetchConversationQuery( - account: string, - topic: ConversationTopic -) { +export function refetchConversationQuery(args: IArgs) { + const { account, topic } = args; return queryClient.refetchQueries({ queryKey: conversationQueryKey(account, topic), }); } -export const getConversationQueryData = ( - account: string, - topic: ConversationTopic -) => { +export const getConversationQueryData = (args: IArgs) => { + const { account, topic } = args; return queryClient.getQueryData( conversationQueryKey(account, topic) ); diff --git a/queries/useConversationWithPeerQuery.ts b/queries/useConversationWithPeerQuery.ts deleted file mode 100644 index 03bbdea4a..000000000 --- a/queries/useConversationWithPeerQuery.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { queryClient } from "@/queries/queryClient"; -import { setConversationQueryData } from "@/queries/useConversationQuery"; -import { useQuery } from "@tanstack/react-query"; -import logger from "@utils/logger"; -import { DmWithCodecsType } from "@utils/xmtpRN/client"; -import { getConversationByPeerByAccount } from "@utils/xmtpRN/conversations"; -import { conversationWithPeerQueryKey } from "./QueryKeys"; - -type ConversationWithPeerQueryData = Awaited< - ReturnType ->; - -async function fetchConversationWithPeer(args: { - account: string; - peer: string; -}) { - const { account, peer } = args; - - logger.info("[Crash Debug] queryFn fetching conversation with peer"); - if (!peer) { - return null; - } - - const conversation = await getConversationByPeerByAccount({ - account, - peer, - includeSync: true, - }); - - if (!conversation) { - return null; - } - - setConversationQueryData(account, conversation.topic, conversation); - return conversation; -} - -export const useConversationWithPeerQuery = (account: string, peer: string) => { - return useQuery({ - queryKey: conversationWithPeerQueryKey(account, peer!), - queryFn: () => fetchConversationWithPeer({ account, peer }), - enabled: !!peer, - }); -}; - -export function updateConversationWithPeerQueryData( - account: string, - peer: string, - newConversation: DmWithCodecsType -) { - queryClient.setQueryData( - conversationWithPeerQueryKey(account, peer), - () => newConversation - ); -} diff --git a/queries/useDmConstentStateQuery.ts b/queries/useDmConstentStateQuery.ts deleted file mode 100644 index 89e93b948..000000000 --- a/queries/useDmConstentStateQuery.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { useConversationQuery } from "@/queries/useConversationQuery"; -import { useQuery } from "@tanstack/react-query"; -import { ConsentState, type ConversationTopic } from "@xmtp/react-native-sdk"; -import { dmConsentQueryKey } from "./QueryKeys"; -import { queryClient } from "./queryClient"; - -type DmConsentQueryData = ConsentState; - -export const useDmConsentQuery = (args: { - account: string; - topic: ConversationTopic | undefined; -}) => { - const { account, topic } = args; - const { data: dmConversation } = useConversationQuery(account, topic!); - - return useQuery({ - queryKey: dmConsentQueryKey(account, topic!), - queryFn: () => dmConversation!.consentState(), - enabled: !!dmConversation && !!topic, - initialData: dmConversation?.state, - }); -}; - -export const getDmConsentQueryData = (args: { - account: string; - topic: ConversationTopic; -}) => { - const { account, topic } = args; - return queryClient.getQueryData( - dmConsentQueryKey(account, topic) - ); -}; - -export const setDmConsentQueryData = (args: { - account: string; - topic: ConversationTopic; - consent: DmConsentQueryData; -}) => { - const { account, topic, consent } = args; - queryClient.setQueryData( - dmConsentQueryKey(account, topic), - consent - ); -}; - -export const cancelDmConsentQuery = async (args: { - account: string; - topic: ConversationTopic; -}) => { - const { account, topic } = args; - await queryClient.cancelQueries({ - queryKey: dmConsentQueryKey(account, topic), - }); -}; diff --git a/queries/useDmPeerAddressQuery.ts b/queries/useDmPeerAddressQuery.ts deleted file mode 100644 index f837b77d6..000000000 --- a/queries/useDmPeerAddressQuery.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { getPeerAddressFromTopic } from "@utils/xmtpRN/conversations"; -import { ConversationTopic } from "@xmtp/react-native-sdk"; -import { cacheOnlyQueryOptions } from "./cacheOnlyQueryOptions"; - -export const useDmPeerAddressQuery = ( - account: string, - topic: ConversationTopic -) => { - return useQuery({ - queryKey: ["dmPeerAddress", account, topic], - queryFn: () => getPeerAddressFromTopic(account, topic), - ...cacheOnlyQueryOptions, - }); -}; diff --git a/queries/useDmPeerInbox.ts b/queries/useDmPeerInbox.ts index 3fd4fed81..6b2f3364e 100644 --- a/queries/useDmPeerInbox.ts +++ b/queries/useDmPeerInbox.ts @@ -1,9 +1,6 @@ +import { isConversationDm } from "@/features/conversation/utils/is-conversation-dm"; import { useQuery } from "@tanstack/react-query"; -import { getDmPeerInbox } from "@utils/xmtpRN/contacts"; -import { - ConversationVersion, - type ConversationTopic, -} from "@xmtp/react-native-sdk"; +import { type ConversationTopic } from "@xmtp/react-native-sdk"; import { useConversationQuery } from "./useConversationQuery"; export const dmPeerInboxIdQueryKey = ( @@ -11,19 +8,27 @@ export const dmPeerInboxIdQueryKey = ( topic: ConversationTopic ) => ["dmPeerInboxId", account, topic]; -export const useDmPeerInboxId = (account: string, topic: ConversationTopic) => { - const { data: conversation } = useConversationQuery(account, topic); +export const useDmPeerInboxId = (args: { + account: string; + topic: ConversationTopic; +}) => { + const { account, topic } = args; + const { data: conversation } = useConversationQuery({ + account, + topic, + }); + return useQuery({ queryKey: dmPeerInboxIdQueryKey(account, topic), queryFn: () => { if (!conversation) { throw new Error("Conversation not found"); } - if (conversation.version !== ConversationVersion.DM) { + if (!isConversationDm(conversation)) { throw new Error("Conversation is not a DM"); } - return getDmPeerInbox(conversation); + return conversation.peerInboxId(); }, - enabled: !!conversation && conversation.version === ConversationVersion.DM, + enabled: !!conversation && isConversationDm(conversation), }); }; diff --git a/queries/useDmPeerInboxOnConversationList.ts b/queries/useDmPeerInboxOnConversationList.ts deleted file mode 100644 index 921a58b34..000000000 --- a/queries/useDmPeerInboxOnConversationList.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { ConversationVersion } from "@xmtp/react-native-sdk"; -import { useQuery } from "@tanstack/react-query"; -import { getDmPeerInbox } from "@utils/xmtpRN/contacts"; -import { DmWithCodecsType } from "@utils/xmtpRN/client"; -import type { ConversationTopic } from "@xmtp/react-native-sdk"; - -export const dmPeerInboxQueryKey = ( - account: string, - topic: ConversationTopic | undefined -) => ["dmPeerInbox", account, topic]; - -export const useDmPeerInboxOnConversationList = ( - account: string, - dm: DmWithCodecsType -) => { - return useQuery({ - queryKey: dmPeerInboxQueryKey(account, dm.topic), - queryFn: () => { - if (!dm) { - throw new Error("Conversation not found"); - } - if (dm.version !== ConversationVersion.DM) { - throw new Error("Conversation is not a DM"); - } - return getDmPeerInbox(dm); - }, - enabled: !!dm && dm.version === ConversationVersion.DM, - }); -}; diff --git a/queries/useDmQuery.ts b/queries/useDmQuery.ts new file mode 100644 index 000000000..2f87d3d5d --- /dev/null +++ b/queries/useDmQuery.ts @@ -0,0 +1,128 @@ +/** + * TODO: Maybe delete this and just use the conversation query instead and add a "peer" argument? + */ +import { isConversationDm } from "@/features/conversation/utils/is-conversation-dm"; +import { queryClient } from "@/queries/queryClient"; +import { conversationListQueryConfig } from "@/queries/useConversationListQuery"; +import { captureError } from "@/utils/capture-error"; +import { DmWithCodecsType } from "@/utils/xmtpRN/client"; +import { QueryObserver, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + getConversationByPeerByAccount, + getPeerAddressDm, +} from "@utils/xmtpRN/conversations"; +import { useEffect } from "react"; +import { dmQueryKey } from "./QueryKeys"; +import { setConversationQueryData } from "./useConversationQuery"; + +type IDmQueryArgs = { + account: string; + peer: string; +}; + +type IDmQueryData = Awaited>; + +async function getDm(args: IDmQueryArgs) { + const { account, peer } = args; + + const conversation = await getConversationByPeerByAccount({ + account, + peer, + includeSync: true, + }); + + // Update the main conversation query because it's a 1-1 + setConversationQueryData({ + account, + topic: conversation.topic, + conversation, + }); + + return conversation; +} + +export function useDmQuery(args: IDmQueryArgs) { + const { account, peer } = args; + const queryClient = useQueryClient(); + + const query = useQuery({ + queryKey: dmQueryKey(account, peer), + queryFn: () => getDm(args), + enabled: !!peer, + }); + + // Keep in sync with conversation list + useEffect(() => { + const observer = new QueryObserver( + queryClient, + conversationListQueryConfig({ account, context: "useDmQuery" }) + ); + + observer.subscribe(async ({ data: conversations }) => { + try { + const currentConversation = queryClient.getQueryData( + dmQueryKey(account, peer) + ); + + // If we have the conversation in cache, sync with list + if (currentConversation) { + const listConversation = conversations?.find( + (c): c is DmWithCodecsType => + c.topic === currentConversation.topic && isConversationDm(c) + ); + + if (listConversation) { + queryClient.setQueryData( + dmQueryKey(account, peer), + listConversation + ); + } + return; + } + + // Try to find conversation by peer address in list + const dmConversations = conversations?.filter(isConversationDm); + + if (!dmConversations?.length) { + return; + } + + const peerAddresses = await Promise.all( + dmConversations.map(getPeerAddressDm) + ); + + const matchingConversationIndex = peerAddresses.findIndex( + (address) => address === peer + ); + + if (matchingConversationIndex !== -1) { + queryClient.setQueryData( + dmQueryKey(account, peer), + dmConversations[matchingConversationIndex] + ); + } + } catch (error) { + captureError(error); + } + }); + + return () => observer.destroy(); + }, [queryClient, account, peer]); + + return query; +} + +export function setDmQueryData(args: IDmQueryArgs & { dm: IDmQueryData }) { + const { account, peer, dm } = args; + queryClient.setQueryData(dmQueryKey(account, peer), dm); + setConversationQueryData({ + account, + topic: dm.topic, + conversation: dm, + }); +} + +export function getDmQueryData(args: IDmQueryArgs) { + const { account, peer } = args; + return queryClient.getQueryData(dmQueryKey(account, peer)); +} diff --git a/queries/useGroupConsentQuery.ts b/queries/useGroupConsentQuery.ts index 4889edf4a..1ae83e111 100644 --- a/queries/useGroupConsentQuery.ts +++ b/queries/useGroupConsentQuery.ts @@ -1,62 +1,43 @@ -import { useGroupQuery } from "@queries/useGroupQuery"; -import { QueryObserverOptions, useQuery } from "@tanstack/react-query"; +import { + getGroupQueryData, + getGroupQueryOptions, + setGroupQueryData, +} from "@/queries/useGroupQuery"; +import { GroupWithCodecsType } from "@/utils/xmtpRN/client.types"; +import { useQuery } from "@tanstack/react-query"; import type { ConsentState, ConversationTopic } from "@xmtp/react-native-sdk"; -import { groupConsentQueryKey } from "./QueryKeys"; -import { queryClient } from "./queryClient"; -type GroupConsentQueryData = ConsentState; +type IGroupConsentQueryArgs = { + account: string; + topic: ConversationTopic; +}; -export const useGroupConsentQuery = ( - account: string, - topic: ConversationTopic, - queryOptions?: Partial> -) => { - const { data: group } = useGroupQuery(account, topic); +export const useGroupConsentQuery = (args: IGroupConsentQueryArgs) => { + const { account, topic } = args; return useQuery({ - queryKey: groupConsentQueryKey(account, topic!), - queryFn: async () => { - const consent = await group!.consentState(); - return consent; - }, - enabled: !!group && !!topic, - initialData: group?.state, - ...queryOptions, + ...getGroupQueryOptions({ account, topic }), + select: (group) => group?.state, }); }; export const getGroupConsentQueryData = ( account: string, topic: ConversationTopic -) => - queryClient.getQueryData( - groupConsentQueryKey(account, topic) - ); +) => { + const group = getGroupQueryData({ account, topic }); + return group?.state; +}; export const setGroupConsentQueryData = ( account: string, topic: ConversationTopic, consent: ConsentState ) => { - queryClient.setQueryData( - groupConsentQueryKey(account, topic), - consent - ); -}; - -export const cancelGroupConsentQuery = async ( - account: string, - topic: ConversationTopic -) => { - await queryClient.cancelQueries({ - queryKey: groupConsentQueryKey(account, topic), - }); -}; - -export const invalidateGroupConsentQuery = async ( - account: string, - topic: ConversationTopic -) => { - return queryClient.invalidateQueries({ - queryKey: groupConsentQueryKey(account, topic), + const currentGroup = getGroupQueryData({ account, topic }); + if (!currentGroup) return; + setGroupQueryData({ + account, + topic, + group: { ...currentGroup, state: consent } as GroupWithCodecsType, }); }; diff --git a/queries/useGroupCreatorQuery.ts b/queries/useGroupCreatorQuery.ts new file mode 100644 index 000000000..a03b3b1cc --- /dev/null +++ b/queries/useGroupCreatorQuery.ts @@ -0,0 +1,22 @@ +import { groupCreatorQueryKey } from "@/queries/QueryKeys"; +import { useQuery } from "@tanstack/react-query"; +import type { ConversationTopic } from "@xmtp/react-native-sdk"; +import { currentAccount } from "../data/store/accountsStore"; +import { useGroupQuery } from "./useGroupQuery"; + +export const useGroupCreatorQuery = (topic: ConversationTopic) => { + const account = currentAccount(); + + const { data: group } = useGroupQuery({ account, topic }); + + return useQuery({ + queryKey: groupCreatorQueryKey(account, topic), + queryFn: () => { + if (!group) return null; + return group.creatorInboxId(); + }, + enabled: !!topic && !!group, + refetchOnWindowFocus: false, + refetchOnMount: false, + }); +}; diff --git a/queries/useGroupDescriptionMutation.ts b/queries/useGroupDescriptionMutation.ts index 150cd1adc..324a65afa 100644 --- a/queries/useGroupDescriptionMutation.ts +++ b/queries/useGroupDescriptionMutation.ts @@ -1,64 +1,56 @@ +import { captureError } from "@/utils/capture-error"; +import { + getGroupQueryData, + updateGroupQueryData, + useGroupQuery, +} from "@queries/useGroupQuery"; import { useMutation } from "@tanstack/react-query"; -import logger from "@utils/logger"; -import { sentryTrackError } from "@utils/sentry"; - -import { useGroupQuery } from "@queries/useGroupQuery"; import type { ConversationTopic } from "@xmtp/react-native-sdk"; import { setGroupDescriptionMutationKey } from "./MutationKeys"; -import { - cancelGroupDescriptionQuery, - getGroupDescriptionQueryData, -} from "./useGroupDescriptionQuery"; -import { handleGroupDescriptionUpdate } from "@/utils/groupUtils/handleGroupDescriptionUpdate"; export const useGroupDescriptionMutation = ( account: string, topic: ConversationTopic ) => { - const { data: group } = useGroupQuery(account, topic); + const { data: group } = useGroupQuery({ account, topic }); return useMutation({ - mutationKey: setGroupDescriptionMutationKey(account, topic!), - mutationFn: async (groupDescription: string) => { + mutationKey: setGroupDescriptionMutationKey(account, topic), + mutationFn: async (description: string) => { if (!group || !account || !topic) { - return; + throw new Error( + "Missing group, account, or topic in useGroupDescriptionMutation" + ); } - await group.updateGroupDescription(groupDescription); - return groupDescription; + group.updateGroupDescription(description); + return description; }, - onMutate: async (groupDescription: string) => { - if (!topic) { - return; + onMutate: async (description: string) => { + const previousGroup = getGroupQueryData({ account, topic }); + if (previousGroup) { + updateGroupQueryData({ + account, + topic, + updates: { + description, + }, + }); } - await cancelGroupDescriptionQuery(account, topic); - const previousGroupDescription = getGroupDescriptionQueryData( - account, - topic - ); - handleGroupDescriptionUpdate({ - account, - topic, - description: groupDescription, - }); - return { previousGroupDescription }; + + return { previousGroupDescription: previousGroup?.description }; }, onError: (error, _variables, context) => { - logger.warn("onError useGroupDescriptionMutation"); - sentryTrackError(error); - if (context?.previousGroupDescription === undefined) { - return; - } - if (!topic) { - return; + captureError(error); + const { previousGroupDescription } = context || {}; + + if (previousGroupDescription) { + updateGroupQueryData({ + account, + topic, + updates: { + description: previousGroupDescription, + }, + }); } - handleGroupDescriptionUpdate({ - account, - topic, - description: context.previousGroupDescription, - }); - }, - onSuccess: (data, variables, context) => { - logger.debug("onSuccess useGroupDescriptionMutation"); - // refreshGroup(account, topic); }, }); }; diff --git a/queries/useGroupDescriptionQuery.ts b/queries/useGroupDescriptionQuery.ts index 0cae3f763..c09757b10 100644 --- a/queries/useGroupDescriptionQuery.ts +++ b/queries/useGroupDescriptionQuery.ts @@ -1,58 +1,16 @@ +import { isConversationGroup } from "@/features/conversation/utils/is-conversation-group"; +import { getGroupQueryOptions } from "@/queries/useGroupQuery"; import { useQuery } from "@tanstack/react-query"; - -import { groupDescriptionQueryKey } from "./QueryKeys"; -import { queryClient } from "./queryClient"; -import { useGroupQuery } from "@queries/useGroupQuery"; import type { ConversationTopic } from "@xmtp/react-native-sdk"; -export const useGroupDescriptionQuery = ( - account: string, - topic: ConversationTopic -) => { - const { data: group } = useGroupQuery(account, topic); +export const useGroupDescriptionQuery = (args: { + account: string; + topic: ConversationTopic; +}) => { + const { account, topic } = args; return useQuery({ - queryKey: groupDescriptionQueryKey(account, topic), - queryFn: async () => { - if (!group) { - return; - } - return group.groupDescription(); - }, - enabled: !!group, - }); -}; - -export const getGroupDescriptionQueryData = ( - account: string, - topic: ConversationTopic -): string | undefined => - queryClient.getQueryData(groupDescriptionQueryKey(account, topic)); - -export const setGroupDescriptionQueryData = ( - account: string, - topic: ConversationTopic, - groupDescription: string -) => { - queryClient.setQueryData( - groupDescriptionQueryKey(account, topic), - groupDescription - ); -}; - -export const cancelGroupDescriptionQuery = async ( - account: string, - topic: ConversationTopic -) => { - await queryClient.cancelQueries({ - queryKey: groupDescriptionQueryKey(account, topic), - }); -}; - -export const invalidateGroupDescriptionQuery = async ( - account: string, - topic: ConversationTopic -) => { - return queryClient.invalidateQueries({ - queryKey: groupDescriptionQueryKey(account, topic), + ...getGroupQueryOptions({ account, topic }), + select: (group) => + isConversationGroup(group) ? group.description : undefined, }); }; diff --git a/queries/useGroupIsActive.ts b/queries/useGroupIsActive.ts deleted file mode 100644 index 4983cb509..000000000 --- a/queries/useGroupIsActive.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; - -import { groupIsActiveQueryKey } from "./QueryKeys"; -import { queryClient } from "./queryClient"; -import { useGroupQuery } from "@queries/useGroupQuery"; -import type { ConversationTopic } from "@xmtp/react-native-sdk"; - -export const useGroupIsActiveQuery = ( - account: string, - topic: ConversationTopic -) => { - const { data: group } = useGroupQuery(account, topic); - return useQuery({ - queryKey: groupIsActiveQueryKey(account, topic), - queryFn: async () => { - if (!group) { - return; - } - return group.isActive(); - }, - enabled: !!group, - }); -}; - -export const getGroupIsActiveQueryData = ( - account: string, - topic: ConversationTopic -): boolean | undefined => - queryClient.getQueryData(groupIsActiveQueryKey(account, topic)); - -export const setGroupIsActiveQueryData = ( - account: string, - topic: ConversationTopic, - isActive: boolean -) => { - queryClient.setQueryData(groupIsActiveQueryKey(account, topic), isActive); -}; - -export const cancelGroupIsActiveQuery = async ( - account: string, - topic: ConversationTopic -) => { - await queryClient.cancelQueries({ - queryKey: groupIsActiveQueryKey(account, topic), - }); -}; - -export const invalidateGroupIsActiveQuery = async ( - account: string, - topic: ConversationTopic -) => { - return queryClient.invalidateQueries({ - queryKey: groupIsActiveQueryKey(account, topic), - }); -}; diff --git a/queries/useGroupIsActiveQuery.ts b/queries/useGroupIsActiveQuery.ts new file mode 100644 index 000000000..593202411 --- /dev/null +++ b/queries/useGroupIsActiveQuery.ts @@ -0,0 +1,16 @@ +import { isConversationGroup } from "@/features/conversation/utils/is-conversation-group"; +import { getGroupQueryOptions } from "@/queries/useGroupQuery"; +import { useQuery } from "@tanstack/react-query"; +import type { ConversationTopic } from "@xmtp/react-native-sdk"; + +export const useGroupIsActiveQuery = (args: { + account: string; + topic: ConversationTopic; +}) => { + const { account, topic } = args; + return useQuery({ + ...getGroupQueryOptions({ account, topic }), + select: (group) => + isConversationGroup(group) ? group.isGroupActive : undefined, + }); +}; diff --git a/queries/useGroupMembersQuery.ts b/queries/useGroupMembersQuery.ts index 6c63697ad..19a967c6e 100644 --- a/queries/useGroupMembersQuery.ts +++ b/queries/useGroupMembersQuery.ts @@ -48,7 +48,7 @@ export const useGroupMembersQuery = ( account: string, topic: ConversationTopic ) => { - const { data: group } = useGroupQuery(account, topic); + const { data: group } = useGroupQuery({ account, topic }); const enabled = !!group && !!topic; return useQuery( groupMembersQueryConfig(account, group, enabled) @@ -59,7 +59,7 @@ export const useGroupMembersConversationScreenQuery = ( account: string, topic: ConversationTopic ) => { - const { data: group } = useGroupQuery(account, topic); + const { data: group } = useGroupQuery({ account, topic }); const enabled = !!group && !!topic; return useQuery( groupMembersQueryConfig(account, group, enabled) diff --git a/queries/useGroupNameMutation.ts b/queries/useGroupNameMutation.ts index c8e23d538..a9f521ba1 100644 --- a/queries/useGroupNameMutation.ts +++ b/queries/useGroupNameMutation.ts @@ -1,51 +1,55 @@ -import { useMutation } from "@tanstack/react-query"; -import logger from "@utils/logger"; -import { sentryTrackError } from "@utils/sentry"; - -import { setGroupNameMutationKey } from "./MutationKeys"; +import { captureError } from "@/utils/capture-error"; import { - cancelGroupNameQuery, - getGroupNameQueryData, -} from "./useGroupNameQuery"; -import { useGroupQuery } from "@queries/useGroupQuery"; + getGroupQueryData, + updateGroupQueryData, + useGroupQuery, +} from "@queries/useGroupQuery"; +import { useMutation } from "@tanstack/react-query"; import type { ConversationTopic } from "@xmtp/react-native-sdk"; -import { handleGroupNameUpdate } from "@/utils/groupUtils/handleGroupNameUpdate"; +import { setGroupNameMutationKey } from "./MutationKeys"; -export const useGroupNameMutation = ( - account: string, - topic: ConversationTopic -) => { - const { data: group } = useGroupQuery(account, topic); +export const useGroupNameMutation = (args: { + account: string; + topic: ConversationTopic; +}) => { + const { account, topic } = args; + const { data: group } = useGroupQuery({ account, topic }); return useMutation({ mutationKey: setGroupNameMutationKey(account, topic), mutationFn: async (groupName: string) => { if (!group || !account || !topic) { - return; + throw new Error( + "Missing group, account, or topic in useGroupNameMutation" + ); } - await group.updateGroupName(groupName); + group.updateGroupName(groupName); return groupName; }, onMutate: async (groupName: string) => { - await cancelGroupNameQuery(account, topic); - const previousGroupName = getGroupNameQueryData(account, topic); - handleGroupNameUpdate({ account, topic, name: groupName }); - return { previousGroupName }; + const previousGroup = getGroupQueryData({ account, topic }); + if (previousGroup) { + updateGroupQueryData({ + account, + topic, + updates: { + name: groupName, + }, + }); + } + return { previousGroupName: previousGroup?.name }; }, onError: (error, _variables, context) => { - logger.warn("onError useGroupNameMutation"); - sentryTrackError(error); - if (context?.previousGroupName === undefined) { - return; + captureError(error); + const { previousGroupName } = context || {}; + if (previousGroupName) { + updateGroupQueryData({ + account, + topic, + updates: { + name: previousGroupName, + }, + }); } - handleGroupNameUpdate({ - account, - topic, - name: context.previousGroupName, - }); - }, - onSuccess: (data, variables, context) => { - logger.debug("onSuccess useGroupNameMutation"); - // refreshGroup(account, topic); }, }); }; diff --git a/queries/useGroupNameQuery.ts b/queries/useGroupNameQuery.ts index 93bf434f2..67fd3400d 100644 --- a/queries/useGroupNameQuery.ts +++ b/queries/useGroupNameQuery.ts @@ -1,62 +1,15 @@ -import { SetDataOptions, useQuery } from "@tanstack/react-query"; -import { ConversationTopic, ConversationVersion } from "@xmtp/react-native-sdk"; -import { groupNameQueryKey } from "./QueryKeys"; -import { queryClient } from "./queryClient"; -import { useConversationQuery } from "./useConversationQuery"; - -export const useGroupNameQuery = ( - account: string, - topic: ConversationTopic -) => { - const { data: conversation } = useConversationQuery(account, topic); +import { isConversationGroup } from "@/features/conversation/utils/is-conversation-group"; +import { getGroupQueryOptions } from "@/queries/useGroupQuery"; +import { useQuery } from "@tanstack/react-query"; +import { ConversationTopic } from "@xmtp/react-native-sdk"; + +export const useGroupNameQuery = (args: { + account: string; + topic: ConversationTopic; +}) => { + const { account, topic } = args; return useQuery({ - queryKey: groupNameQueryKey(account, topic), - queryFn: async () => { - if (!conversation || conversation.version !== ConversationVersion.GROUP) { - return undefined; - } - return conversation.groupName(); - }, - enabled: - !!conversation && - conversation.version === ConversationVersion.GROUP && - !!account, - }); -}; - -export const getGroupNameQueryData = ( - account: string, - topic: ConversationTopic -): string | undefined => - queryClient.getQueryData(groupNameQueryKey(account, topic)); - -export const setGroupNameQueryData = ( - account: string, - topic: ConversationTopic, - groupName: string, - options?: SetDataOptions -) => { - queryClient.setQueryData( - groupNameQueryKey(account, topic), - groupName, - options - ); -}; - -export const cancelGroupNameQuery = async ( - account: string, - topic: ConversationTopic -) => { - await queryClient.cancelQueries({ - queryKey: groupNameQueryKey(account, topic), - }); -}; - -export const invalidateGroupNameQuery = async ( - account: string, - topic: ConversationTopic -) => { - return queryClient.invalidateQueries({ - queryKey: groupNameQueryKey(account, topic), + ...getGroupQueryOptions({ account, topic }), + select: (group) => (isConversationGroup(group) ? group.name : undefined), }); }; diff --git a/queries/useGroupPermissionPolicyQuery.ts b/queries/useGroupPermissionPolicyQuery.ts index e49e53933..c5306fa22 100644 --- a/queries/useGroupPermissionPolicyQuery.ts +++ b/queries/useGroupPermissionPolicyQuery.ts @@ -7,7 +7,7 @@ export const useGroupPermissionPolicyQuery = ( account: string, topic: ConversationTopic ) => { - const { data: group } = useGroupQuery(account, topic); + const { data: group } = useGroupQuery({ account, topic }); return useQuery({ queryKey: groupPermissionPolicyQueryKey(account, topic!), queryFn: () => { diff --git a/queries/useGroupPermissionsQuery.ts b/queries/useGroupPermissionsQuery.ts index 8b97b8472..2fe018ee0 100644 --- a/queries/useGroupPermissionsQuery.ts +++ b/queries/useGroupPermissionsQuery.ts @@ -1,4 +1,3 @@ -import { queryClient } from "@/queries/queryClient"; import { GroupWithCodecsType } from "@/utils/xmtpRN/client"; import { useGroupQuery } from "@queries/useGroupQuery"; import { useQuery } from "@tanstack/react-query"; @@ -18,11 +17,11 @@ export const useGroupPermissionsQuery = ( account: string, topic: ConversationTopic ) => { - const { data: group } = useGroupQuery(account, topic); + const { data: group } = useGroupQuery({ account, topic }); return useQuery(getGroupPermissionsQueryOptions({ account, topic, group })); }; -export function getGroupPermissionsQueryOptions(args: { +function getGroupPermissionsQueryOptions(args: { account: string; topic: ConversationTopic; group: GroupWithCodecsType | undefined | null; @@ -37,14 +36,3 @@ export function getGroupPermissionsQueryOptions(args: { queryKey: groupPermissionsQueryKey(account, topic), }; } - -export function getGroupPermissionLevel(args: { - account: string; - topic: ConversationTopic; - group: GroupWithCodecsType | undefined | null; -}) { - const { account, topic, group } = args; - return queryClient.getQueryData( - getGroupPermissionsQueryOptions({ account, topic, group }).queryKey - ); -} diff --git a/queries/useGroupPhotoMutation.ts b/queries/useGroupPhotoMutation.ts index 1db64fb01..a85fd2c92 100644 --- a/queries/useGroupPhotoMutation.ts +++ b/queries/useGroupPhotoMutation.ts @@ -1,21 +1,21 @@ import { useMutation } from "@tanstack/react-query"; -import logger from "@utils/logger"; -import { sentryTrackError } from "@utils/sentry"; -import { setGroupPhotoMutationKey } from "./MutationKeys"; +import { captureError } from "@/utils/capture-error"; +import { GroupWithCodecsType } from "@/utils/xmtpRN/client.types"; import { - cancelGroupPhotoQuery, - getGroupPhotoQueryData, -} from "./useGroupPhotoQuery"; -import { useGroupQuery } from "@queries/useGroupQuery"; + getGroupQueryData, + setGroupQueryData, + useGroupQuery, +} from "@queries/useGroupQuery"; import type { ConversationTopic } from "@xmtp/react-native-sdk"; -import { handleGroupImageUpdate } from "@/utils/groupUtils/handleGroupImageUpdate"; +import { setGroupPhotoMutationKey } from "./MutationKeys"; -export const useGroupPhotoMutation = ( - account: string, - topic: ConversationTopic -) => { - const { data: group } = useGroupQuery(account, topic); +export const useGroupPhotoMutation = (args: { + account: string; + topic: ConversationTopic; +}) => { + const { account, topic } = args; + const { data: group } = useGroupQuery({ account, topic }); return useMutation({ mutationKey: setGroupPhotoMutationKey(account, topic), mutationFn: async (groupPhoto: string) => { @@ -26,26 +26,27 @@ export const useGroupPhotoMutation = ( return groupPhoto; }, onMutate: async (groupPhoto: string) => { - await cancelGroupPhotoQuery(account, topic); - const previousGroupPhoto = getGroupPhotoQueryData(account, topic); - handleGroupImageUpdate({ account, topic, image: groupPhoto }); - return { previousGroupPhoto }; + const previousGroup = getGroupQueryData({ account, topic }); + setGroupQueryData({ + account, + topic, + group: { + ...group, + imageUrlSquare: groupPhoto, + } as GroupWithCodecsType, + }); + return { previousGroup }; }, onError: (error, _variables, context) => { - logger.warn("onError useGroupPhotoMutation"); - sentryTrackError(error); - if (context?.previousGroupPhoto === undefined) { + captureError(error); + if (context?.previousGroup === undefined) { return; } - handleGroupImageUpdate({ + setGroupQueryData({ account, topic, - image: context.previousGroupPhoto, + group: context.previousGroup, }); }, - onSuccess: (data, variables, context) => { - logger.debug("onSuccess useGroupPhotoMutation"); - // refreshGroup(account, topic); - }, }); }; diff --git a/queries/useGroupPhotoQuery.ts b/queries/useGroupPhotoQuery.ts index d88dbf74e..a78696a2e 100644 --- a/queries/useGroupPhotoQuery.ts +++ b/queries/useGroupPhotoQuery.ts @@ -1,68 +1,16 @@ -import { - SetDataOptions, - useQuery, - UseQueryOptions, -} from "@tanstack/react-query"; - +import { isConversationGroup } from "@/features/conversation/utils/is-conversation-group"; +import { getGroupQueryOptions } from "@/queries/useGroupQuery"; +import { useQuery } from "@tanstack/react-query"; import type { ConversationTopic } from "@xmtp/react-native-sdk"; -import { queryClient } from "./queryClient"; -import { groupPhotoQueryKey } from "./QueryKeys"; -import { useGroupQuery } from "./useGroupQuery"; -export const useGroupPhotoQuery = ( - account: string, - topic: ConversationTopic, - queryOptions?: Partial< - UseQueryOptions - > -) => { - const { data: group } = useGroupQuery(account, topic); +export const useGroupPhotoQuery = (args: { + account: string; + topic: ConversationTopic; +}) => { + const { account, topic } = args; return useQuery({ - queryKey: groupPhotoQueryKey(account, topic), - queryFn: async () => { - if (!group) { - return; - } - return group.groupImageUrlSquare(); - }, - enabled: !!group, - ...queryOptions, - }); -}; - -export const getGroupPhotoQueryData = ( - account: string, - topic: ConversationTopic -): string | undefined => - queryClient.getQueryData(groupPhotoQueryKey(account, topic)); - -export const setGroupPhotoQueryData = ( - account: string, - topic: ConversationTopic, - groupPhoto: string, - options?: SetDataOptions -) => { - queryClient.setQueryData( - groupPhotoQueryKey(account, topic), - groupPhoto, - options - ); -}; - -export const cancelGroupPhotoQuery = async ( - account: string, - topic: ConversationTopic -) => { - await queryClient.cancelQueries({ - queryKey: groupPhotoQueryKey(account, topic), - }); -}; - -export const invalidateGroupPhotoQuery = async ( - account: string, - topic: ConversationTopic -) => { - return queryClient.invalidateQueries({ - queryKey: groupPhotoQueryKey(account, topic), + ...getGroupQueryOptions({ account, topic }), + select: (group) => + isConversationGroup(group) ? group.imageUrlSquare : undefined, }); }; diff --git a/queries/useGroupPinnedFrameQuery.ts b/queries/useGroupPinnedFrameQuery.ts index 572daeb7a..b44638d88 100644 --- a/queries/useGroupPinnedFrameQuery.ts +++ b/queries/useGroupPinnedFrameQuery.ts @@ -8,7 +8,7 @@ export const useGroupPinnedFrameQuery = ( account: string, topic: ConversationTopic ) => { - const { data: group } = useGroupQuery(account, topic); + const { data: group } = useGroupQuery({ account, topic }); return useQuery({ queryKey: groupPinnedFrameQueryKey(account, topic!), queryFn: async () => { diff --git a/queries/useGroupQuery.ts b/queries/useGroupQuery.ts index 86c714ca6..0a29f64cf 100644 --- a/queries/useGroupQuery.ts +++ b/queries/useGroupQuery.ts @@ -1,28 +1,72 @@ -import { useQuery, UseQueryOptions } from "@tanstack/react-query"; +/** + * useGroupQuery is derived from useConversationQuery + */ +import { + getConversationQueryData, + getConversationQueryOptions, + setConversationQueryData, + useConversationQuery, +} from "@/queries/useConversationQuery"; +import { mutateObjectProperties } from "@/utils/mutate-object-properties"; +import { UseQueryResult } from "@tanstack/react-query"; import { GroupWithCodecsType } from "@utils/xmtpRN/client"; -import { getGroupByTopicByAccount } from "@utils/xmtpRN/conversations"; import type { ConversationTopic } from "@xmtp/react-native-sdk"; -import { conversationQueryKey } from "./QueryKeys"; -export const useGroupQuery = ( - account: string, - topic: ConversationTopic, - includeSync = false, - options?: Partial> -) => { - return useQuery({ - ...options, - queryKey: conversationQueryKey(account, topic!), - queryFn: async () => { - if (!topic) { - return null; - } - return getGroupByTopicByAccount({ - account, - topic, - includeSync, - }); - }, - enabled: !!topic, +export function useGroupQuery(args: { + account: string; + topic: ConversationTopic; +}) { + const { account, topic } = args; + return useConversationQuery({ + account, + topic, + }) as UseQueryResult; +} + +export function getGroupQueryData(args: { + account: string; + topic: ConversationTopic; +}) { + const { account, topic } = args; + return getConversationQueryData({ account, topic }) as + | GroupWithCodecsType + | undefined; +} + +export function setGroupQueryData(args: { + account: string; + topic: ConversationTopic; + group: GroupWithCodecsType; +}) { + const { account, topic, group } = args; + setConversationQueryData({ + account, + topic, + conversation: group, + }); +} + +export function getGroupQueryOptions(args: { + account: string; + topic: ConversationTopic; +}) { + const { account, topic } = args; + return getConversationQueryOptions({ account, topic }); +} + +export function updateGroupQueryData(args: { + account: string; + topic: ConversationTopic; + updates: Partial; +}) { + const { account, topic, updates } = args; + const previousGroupData = getGroupQueryData({ account, topic }); + if (!previousGroupData) { + return; + } + setGroupQueryData({ + account, + topic, + group: mutateObjectProperties(previousGroupData, updates), }); -}; +} diff --git a/queries/usePromoteToAdminMutation.ts b/queries/usePromoteToAdminMutation.ts index b12aee561..02b8b4690 100644 --- a/queries/usePromoteToAdminMutation.ts +++ b/queries/usePromoteToAdminMutation.ts @@ -12,12 +12,13 @@ import { import { useGroupQuery } from "@queries/useGroupQuery"; // import { refreshGroup } from "../utils/xmtpRN/conversations"; import type { ConversationTopic } from "@xmtp/react-native-sdk"; +import { captureError } from "@/utils/capture-error"; export const usePromoteToAdminMutation = ( account: string, topic: ConversationTopic ) => { - const { data: group } = useGroupQuery(account, topic); + const { data: group } = useGroupQuery({ account, topic }); return useMutation({ mutationKey: promoteAdminMutationKey(account, topic!), @@ -53,8 +54,7 @@ export const usePromoteToAdminMutation = ( }, // Use onError to revert the cache if the mutation fails onError: (error, _variables, context) => { - logger.warn("onError usePromoteToAdminMutation"); - sentryTrackError(error); + captureError(error); if (context?.previousGroupMembers === undefined) { return; } diff --git a/queries/usePromoteToSuperAdminMutation.ts b/queries/usePromoteToSuperAdminMutation.ts index 75745dc40..302b5d298 100644 --- a/queries/usePromoteToSuperAdminMutation.ts +++ b/queries/usePromoteToSuperAdminMutation.ts @@ -1,9 +1,8 @@ +import { captureError } from "@/utils/capture-error"; import { useMutation } from "@tanstack/react-query"; import logger from "@utils/logger"; -import { sentryTrackError } from "@utils/sentry"; -import { InboxId } from "@xmtp/react-native-sdk/build/lib/Client"; - import type { ConversationTopic } from "@xmtp/react-native-sdk"; +import { InboxId } from "@xmtp/react-native-sdk/build/lib/Client"; import { promoteSuperAdminMutationKey } from "./MutationKeys"; import { cancelGroupMembersQuery, @@ -17,7 +16,7 @@ export const usePromoteToSuperAdminMutation = ( account: string, topic: ConversationTopic ) => { - const { data: group } = useGroupQuery(account, topic); + const { data: group } = useGroupQuery({ account, topic }); return useMutation({ mutationKey: promoteSuperAdminMutationKey(account, topic!), @@ -48,8 +47,7 @@ export const usePromoteToSuperAdminMutation = ( return { previousGroupMembers }; }, onError: (error, _variables, context) => { - logger.warn("onError usePromoteToSuperAdminMutation"); - sentryTrackError(error); + captureError(error); if (context?.previousGroupMembers === undefined) { return; } diff --git a/queries/useRemoveFromGroupMutation.ts b/queries/useRemoveFromGroupMutation.ts index 63bc6954b..917590c97 100644 --- a/queries/useRemoveFromGroupMutation.ts +++ b/queries/useRemoveFromGroupMutation.ts @@ -12,12 +12,13 @@ import { import { useGroupQuery } from "@queries/useGroupQuery"; // import { refreshGroup } from "../utils/xmtpRN/conversations"; import type { ConversationTopic } from "@xmtp/react-native-sdk"; +import { captureError } from "@/utils/capture-error"; export const useRemoveFromGroupMutation = ( account: string, topic: ConversationTopic ) => { - const { data: group } = useGroupQuery(account, topic); + const { data: group } = useGroupQuery({ account, topic }); return useMutation({ mutationKey: removeMemberMutationKey(account, topic!), @@ -51,8 +52,7 @@ export const useRemoveFromGroupMutation = ( return { previousGroupMembers }; }, onError: (error, _variables, context) => { - logger.warn("onError useRemoveFromGroupMutation"); - sentryTrackError(error); + captureError(error); if (context?.previousGroupMembers === undefined) { return; } diff --git a/queries/useRevokeAdminMutation.ts b/queries/useRevokeAdminMutation.ts index b9b046def..48f1142c2 100644 --- a/queries/useRevokeAdminMutation.ts +++ b/queries/useRevokeAdminMutation.ts @@ -11,12 +11,13 @@ import { } from "./useGroupMembersQuery"; import { useGroupQuery } from "./useGroupQuery"; import type { ConversationTopic } from "@xmtp/react-native-sdk"; +import { captureError } from "@/utils/capture-error"; export const useRevokeAdminMutation = ( account: string, topic: ConversationTopic ) => { - const { data: group } = useGroupQuery(account, topic); + const { data: group } = useGroupQuery({ account, topic }); return useMutation({ mutationKey: revokeAdminMutationKey(account, topic!), @@ -47,8 +48,7 @@ export const useRevokeAdminMutation = ( return { previousGroupMembers }; }, onError: (error, _variables, context) => { - logger.warn("onError useRevokeAdminMutation"); - sentryTrackError(error); + captureError(error); if (context?.previousGroupMembers === undefined) { return; } diff --git a/queries/useRevokeSuperAdminMutation.ts b/queries/useRevokeSuperAdminMutation.ts index 313b0c189..b31b1fdd5 100644 --- a/queries/useRevokeSuperAdminMutation.ts +++ b/queries/useRevokeSuperAdminMutation.ts @@ -11,13 +11,14 @@ import { } from "./useGroupMembersQuery"; import { useGroupQuery } from "@queries/useGroupQuery"; import type { ConversationTopic } from "@xmtp/react-native-sdk"; +import { captureError } from "@/utils/capture-error"; // import { refreshGroup } from "../utils/xmtpRN/conversations"; export const useRevokeSuperAdminMutation = ( account: string, topic: ConversationTopic ) => { - const { data: group } = useGroupQuery(account, topic); + const { data: group } = useGroupQuery({ account, topic }); return useMutation({ mutationKey: revokeSuperAdminMutationKey(account, topic!), @@ -48,8 +49,7 @@ export const useRevokeSuperAdminMutation = ( return { previousGroupMembers }; }, onError: (error, _variables, context) => { - logger.warn("onError useRevokeSuperAdminMutation"); - sentryTrackError(error); + captureError(error); if (!topic) { return; } diff --git a/queries/useV3ConversationListQuery.ts b/queries/useV3ConversationListQuery.ts deleted file mode 100644 index 253aff9f3..000000000 --- a/queries/useV3ConversationListQuery.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { QueryKeys } from "@queries/QueryKeys"; -import { - useQuery, - UseQueryOptions, - QueryObserver, -} from "@tanstack/react-query"; -import logger from "@utils/logger"; -import { - ConversationWithCodecsType, - ConverseXmtpClientType, - DecodedMessageWithCodecsType, - GroupWithCodecsType, -} from "@utils/xmtpRN/client"; -import { getXmtpClient } from "@utils/xmtpRN/sync"; - -import { queryClient } from "./queryClient"; -import { setGroupIsActiveQueryData } from "./useGroupIsActive"; -import { setGroupNameQueryData } from "./useGroupNameQuery"; -import { setGroupPhotoQueryData } from "./useGroupPhotoQuery"; -import { ConversationTopic, ConversationVersion } from "@xmtp/react-native-sdk"; - -export const conversationListKey = (account: string) => [ - QueryKeys.V3_CONVERSATION_LIST, - account?.toLowerCase(), // All queries are case sensitive, sometimes we use checksum, but the SDK use lowercase, always use lowercase -]; - -export type V3ConversationListType = ConversationWithCodecsType[]; - -const v3ConversationListQueryFn = async ( - account: string, - context: string, - includeSync: boolean = true -): Promise => { - try { - logger.debug( - `[ConversationListQuery] Fetching conversation list from network ${context}` - ); - const client = (await getXmtpClient(account)) as ConverseXmtpClientType; - const beforeSync = new Date().getTime(); - if (includeSync) { - await client.conversations.sync(); - await client.conversations.syncAllConversations(); - } - const afterSync = new Date().getTime(); - logger.debug( - `[ConversationListQuery] Fetching conversation list from network took ${ - (afterSync - beforeSync) / 1000 - } sec` - ); - const conversations = await client.conversations.list( - { - isActive: true, - addedByInboxId: true, - name: true, - imageUrlSquare: true, - consentState: true, - lastMessage: true, - }, - "lastMessage" - ); - for (const conversation of conversations) { - if (conversation.version === ConversationVersion.GROUP) { - setGroupNameQueryData(account, conversation.topic, conversation.name); - setGroupPhotoQueryData( - account, - conversation.topic, - conversation.imageUrlSquare - ); - setGroupIsActiveQueryData( - account, - conversation.topic, - conversation.isGroupActive - ); - } - } - return conversations; - } catch (error) { - logger.error( - `[ConversationListQuery] Error fetching conversation list from network ${context}`, - error - ); - throw error; - } -}; - -const v3ConversationListQueryConfig = ( - account: string, - context: string, - includeSync: boolean = true -) => ({ - queryKey: conversationListKey(account), - queryFn: () => v3ConversationListQueryFn(account, context, includeSync), - staleTime: 2000, - enabled: !!account, -}); - -export const createV3ConversationListQueryObserver = ( - account: string, - context: string, - includeSync: boolean = true -) => { - return new QueryObserver( - queryClient, - v3ConversationListQueryConfig(account, context, includeSync) - ); -}; - -export const useV3ConversationListQuery = ( - account: string, - queryOptions?: Partial>, - context?: string -) => { - return useQuery({ - ...v3ConversationListQueryConfig(account, context ?? ""), - ...queryOptions, - }); -}; - -export const fetchPersistedConversationListQuery = (account: string) => { - return queryClient.fetchQuery( - v3ConversationListQueryConfig( - account, - "fetchPersistedConversationListQuery", - false - ) - ); -}; - -export const fetchConversationListQuery = (account: string) => { - return queryClient.fetchQuery( - v3ConversationListQueryConfig(account, "fetchConversationListQuery") - ); -}; - -export const prefetchConversationListQuery = (account: string) => { - return queryClient.prefetchQuery( - v3ConversationListQueryConfig(account, "prefetchConversationListQuery") - ); -}; - -export const invalidateGroupsConversationListQuery = (account: string) => { - return queryClient.invalidateQueries({ - queryKey: v3ConversationListQueryConfig( - account, - "invalidateGroupsConversationListQuery" - ).queryKey, - }); -}; - -const getConversationListQueryData = ( - account: string -): V3ConversationListType | undefined => { - return queryClient.getQueryData( - v3ConversationListQueryConfig(account, "getConversationListQueryData") - .queryKey - ); -}; - -const setConversationListQueryData = ( - account: string, - conversations: V3ConversationListType -) => { - return queryClient.setQueryData( - v3ConversationListQueryConfig(account, "setConversationListQueryData") - .queryKey, - conversations - ); -}; - -export const addConversationToConversationListQuery = ( - account: string, - conversation: ConversationWithCodecsType -) => { - const previousConversationsData = getConversationListQueryData(account); - if (!previousConversationsData) { - setConversationListQueryData(account, [conversation]); - return; - } - setConversationListQueryData(account, [ - conversation, - ...previousConversationsData, - ]); -}; - -export const updateConversationDataToConversationListQuery = ( - account: string, - topic: ConversationTopic, - conversation: Partial -) => { - const previousConversationsData = getConversationListQueryData(account); - - if (!previousConversationsData) return; - const newConversations: V3ConversationListType = - previousConversationsData.map((c) => { - if (c.topic === topic) { - return { - ...c, - ...conversation, - } as ConversationWithCodecsType; - } - return c; - }); - setConversationListQueryData(account, newConversations); -}; - -export const updateMessageToConversationListQuery = ( - account: string, - message: DecodedMessageWithCodecsType -) => { - updateConversationDataToConversationListQuery( - account, - message.topic as ConversationTopic, - { - lastMessage: message, - } - ); -}; - -type UpdateGroupMetadataToConversationListQueryParams = { - account: string; - topic: ConversationTopic; - groupMetadata: Partial; -}; - -export const updateGroupMetadataToConversationListQuery = ({ - account, - topic, - groupMetadata, -}: UpdateGroupMetadataToConversationListQueryParams) => { - const previousConversationsData = getConversationListQueryData(account); - if (!previousConversationsData) return; - const newConversations: V3ConversationListType = - previousConversationsData.map((c) => { - if (c.topic === topic && c.version === ConversationVersion.GROUP) { - return { - ...c, - ...groupMetadata, - } as GroupWithCodecsType; - } - return c; - }); - setConversationListQueryData(account, newConversations); -}; - -type UpdateGroupImageToConversationListQueryParams = { - account: string; - topic: ConversationTopic; - image: string; -}; - -export const updateGroupImageToConversationListQuery = ({ - account, - topic, - image, -}: UpdateGroupImageToConversationListQueryParams) => { - return updateConversationDataToConversationListQuery(account, topic, { - imageUrlSquare: image, - }); -}; - -type UpdateGroupNameToConversationListQueryParams = { - account: string; - topic: ConversationTopic; - name: string; -}; - -export const updateGroupNameToConversationListQuery = ({ - account, - topic, - name, -}: UpdateGroupNameToConversationListQueryParams) => { - return updateConversationDataToConversationListQuery(account, topic, { - name, - }); -}; - -type UpdateGroupDescriptionToConversationListQueryParams = { - account: string; - topic: ConversationTopic; - description: string; -}; - -export const updateGroupDescriptionToConversationListQuery = ({ - account, - topic, - description, -}: UpdateGroupDescriptionToConversationListQueryParams) => { - return updateConversationDataToConversationListQuery(account, topic, { - description, - }); -}; diff --git a/screens/ConversationList.tsx b/screens/ConversationList.tsx index 7f94c2c16..d01e1271b 100644 --- a/screens/ConversationList.tsx +++ b/screens/ConversationList.tsx @@ -17,17 +17,23 @@ import { import { gestureHandlerRootHOC } from "react-native-gesture-handler"; import { SearchBarCommands } from "react-native-screens"; -import ChatNullState from "../components/ConversationList/ChatNullState"; +import { ConversationContextMenu } from "@/components/ConversationContextMenu"; +import { + dmMatchesSearchQuery, + groupMatchesSearchQuery, +} from "@/features/conversation/utils/search"; +import { translate } from "@/i18n"; +import NoResult from "@search/components/NoResult"; +import { ConversationWithCodecsType } from "@utils/xmtpRN/client"; +import { ConversationVersion } from "@xmtp/react-native-sdk"; import ConversationFlashList from "../components/ConversationFlashList"; +import ChatNullState from "../components/ConversationList/ChatNullState"; import NewConversationButton from "../components/ConversationList/NewConversationButton"; import RequestsButton from "../components/ConversationList/RequestsButton"; import EphemeralAccountBanner from "../components/EphemeralAccountBanner"; import InitialLoad from "../components/InitialLoad"; -import { useHeaderSearchBar } from "./Navigation/ConversationListNav"; -import { NavigationParamList } from "./Navigation/Navigation"; import { PinnedConversations } from "../components/PinnedConversations/PinnedConversations"; import Recommendations from "../components/Recommendations/Recommendations"; -import NoResult from "@search/components/NoResult"; import { useChatStore, useCurrentAccount, @@ -35,19 +41,13 @@ import { useSettingsStore, } from "../data/store/accountsStore"; import { useSelect } from "../data/store/storeHelpers"; +import { useConversationListItems } from "../features/conversation-list/useConversationListItems"; +import { useConversationListRequestCount } from "../features/conversation-list/useConversationListRequestCount"; +import { useIsSharingMode } from "../features/conversation-list/useIsSharingMode"; import { ConversationFlatListItem } from "../utils/conversation"; import { converseEventEmitter } from "../utils/events"; -import { useIsSharingMode } from "../features/conversation-list/useIsSharingMode"; -import { useConversationListRequestCount } from "../features/conversation-list/useConversationListRequestCount"; -import { useConversationListItems } from "../features/conversation-list/useConversationListItems"; -import { ConversationWithCodecsType } from "@utils/xmtpRN/client"; -import { ConversationContextMenu } from "@/components/ConversationContextMenu"; -import { ConversationVersion } from "@xmtp/react-native-sdk"; -import { - dmMatchesSearchQuery, - groupMatchesSearchQuery, -} from "@/features/conversation/utils/search"; -import { translate } from "@/i18n"; +import { useHeaderSearchBar } from "./Navigation/ConversationListNav"; +import { NavigationParamList } from "./Navigation/Navigation"; type Props = { searchBarRef: @@ -105,7 +105,10 @@ function ConversationList({ navigation, route, searchBarRef }: Props) { const requestsCount = useConversationListRequestCount(); const showChatNullState = - items?.length === 0 && !searchQuery && !showInitialLoad; + items?.length === 0 && + !searchQuery && + !showInitialLoad && + requestsCount === 0; useEffect(() => { if (!initialLoadDoneOnce) { diff --git a/screens/ConversationReadOnly.tsx b/screens/ConversationReadOnly.tsx index ac3085d34..7d43aa1f2 100644 --- a/screens/ConversationReadOnly.tsx +++ b/screens/ConversationReadOnly.tsx @@ -27,7 +27,10 @@ export const ConversationReadOnly = ({ topic }: ConversationReadOnlyProps) => { useConversationPreviewMessages(currentAccount, topic!); const { data: conversation, isLoading: isLoadingConversation } = - useConversationQuery(currentAccount, topic); + useConversationQuery({ + account: currentAccount, + topic, + }); const isLoading = isLoadingMessages || isLoadingConversation; diff --git a/screens/Group.tsx b/screens/Group.tsx index 7fd66e36d..ff820e41f 100644 --- a/screens/Group.tsx +++ b/screens/Group.tsx @@ -32,7 +32,10 @@ export default function GroupScreen({ const currentAccount = useCurrentAccount() as string; const topic = route.params.topic; - const { data: group } = useGroupQuery(currentAccount, topic, true); + const { data: group } = useGroupQuery({ + account: currentAccount, + topic, + }); const insets = useSafeAreaInsets(); return ( diff --git a/screens/NewConversation/NewConversation.tsx b/screens/NewConversation/NewConversation.tsx index 16dd1c9de..a0424e65d 100644 --- a/screens/NewConversation/NewConversation.tsx +++ b/screens/NewConversation/NewConversation.tsx @@ -19,19 +19,22 @@ import { useColorScheme, } from "react-native"; -import { NewConversationModalParams } from "./NewConversationModal"; +import { translate } from "@/i18n"; +import { getCleanAddress } from "@/utils/evm/getCleanAddress"; +import { useGroupQuery } from "@queries/useGroupQuery"; +import SearchBar from "@search/components/SearchBar"; +import ProfileSearch from "@search/screens/ProfileSearch"; +import { canMessageByAccount } from "@utils/xmtpRN/contacts"; +import { InboxId } from "@xmtp/react-native-sdk"; import ActivityIndicator from "../../components/ActivityIndicator/ActivityIndicator"; import AndroidBackAction from "../../components/AndroidBackAction"; -import SearchBar from "@search/components/SearchBar"; import Recommendations from "../../components/Recommendations/Recommendations"; -import ProfileSearch from "@search/screens/ProfileSearch"; import TableView from "../../components/TableView/TableView"; import { TableViewPicto } from "../../components/TableView/TableViewImage"; import config from "../../config"; import { currentAccount, getProfilesStore, - useChatStore, useRecommendationsStore, } from "../../data/store/accountsStore"; import { ProfileSocials } from "../../data/store/profilesStore"; @@ -42,11 +45,7 @@ import { getAddressForPeer, isSupportedPeer } from "../../utils/evm/address"; import { navigate } from "../../utils/navigation"; import { isEmptyObject } from "../../utils/objects"; import { getPreferredName } from "../../utils/profile"; -import { canMessageByAccount } from "@utils/xmtpRN/contacts"; -import { useGroupQuery } from "@queries/useGroupQuery"; -import { InboxId } from "@xmtp/react-native-sdk"; -import { getCleanAddress } from "@/utils/evm/getCleanAddress"; -import { translate } from "@/i18n"; +import { NewConversationModalParams } from "./NewConversationModal"; export default function NewConversation({ route, @@ -56,10 +55,10 @@ export default function NewConversation({ "NewConversationScreen" >) { const colorScheme = useColorScheme(); - const { data: existingGroup } = useGroupQuery( - currentAccount(), - route.params?.addingToGroupTopic! - ); + const { data: existingGroup } = useGroupQuery({ + account: currentAccount(), + topic: route.params?.addingToGroupTopic!, + }); const [group, setGroup] = useState({ enabled: !!route.params?.addingToGroupTopic, members: [] as (ProfileSocials & { address: string })[], diff --git a/utils/groupUtils/handleGroupDescriptionUpdate.ts b/utils/groupUtils/handleGroupDescriptionUpdate.ts deleted file mode 100644 index 4d9993c0c..000000000 --- a/utils/groupUtils/handleGroupDescriptionUpdate.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { setGroupDescriptionQueryData } from "@/queries/useGroupDescriptionQuery"; -import { updateGroupDescriptionToConversationListQuery } from "@/queries/useV3ConversationListQuery"; -import type { ConversationTopic } from "@xmtp/react-native-sdk"; - -type HandleGroupDescriptionUpdateParams = { - account: string; - topic: ConversationTopic; - description: string; -}; - -export const handleGroupDescriptionUpdate = ({ - account, - topic, - description, -}: HandleGroupDescriptionUpdateParams) => { - setGroupDescriptionQueryData(account, topic, description); - updateGroupDescriptionToConversationListQuery({ - account, - topic, - description, - }); -}; diff --git a/utils/groupUtils/handleGroupImageUpdate.ts b/utils/groupUtils/handleGroupImageUpdate.ts deleted file mode 100644 index a86c832f0..000000000 --- a/utils/groupUtils/handleGroupImageUpdate.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { setGroupPhotoQueryData } from "@/queries/useGroupPhotoQuery"; -import { updateGroupImageToConversationListQuery } from "@/queries/useV3ConversationListQuery"; -import type { ConversationTopic } from "@xmtp/react-native-sdk"; - -type HandleGroupImageUpdateParams = { - account: string; - topic: ConversationTopic; - image: string; -}; - -export const handleGroupImageUpdate = ({ - account, - topic, - image, -}: HandleGroupImageUpdateParams) => { - setGroupPhotoQueryData(account, topic, image); - updateGroupImageToConversationListQuery({ account, topic, image }); -}; diff --git a/utils/groupUtils/handleGroupNameUpdate.ts b/utils/groupUtils/handleGroupNameUpdate.ts deleted file mode 100644 index 81ac81a11..000000000 --- a/utils/groupUtils/handleGroupNameUpdate.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { setGroupNameQueryData } from "@/queries/useGroupNameQuery"; -import { updateGroupNameToConversationListQuery } from "@/queries/useV3ConversationListQuery"; -import type { ConversationTopic } from "@xmtp/react-native-sdk"; - -type HandleGroupNameUpdateParams = { - account: string; - topic: ConversationTopic; - name: string; -}; - -export const handleGroupNameUpdate = ({ - account, - topic, - name, -}: HandleGroupNameUpdateParams) => { - setGroupNameQueryData(account, topic, name); - updateGroupNameToConversationListQuery({ account, topic, name }); -}; diff --git a/utils/mutate-object-properties.ts b/utils/mutate-object-properties.ts new file mode 100644 index 000000000..b7cb76fee --- /dev/null +++ b/utils/mutate-object-properties.ts @@ -0,0 +1,36 @@ +type Object = Record; + +function isPlainObject(value: unknown): value is Object { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function mutateObjectProperties( + object: T, + properties: Partial, + options?: { + level: number; + } +): T { + const level = options?.level ?? 0; // By default only update level 0 because otherwise we have maximum recursion problem becaue of client from the SDK etc... + for (const [key, newValue] of Object.entries(properties) as [ + keyof T, + T[keyof T], + ][]) { + if (isPlainObject(newValue) && level > 0) { + const existingValue = object[key]; + if (isPlainObject(existingValue)) { + mutateObjectProperties( + existingValue as Object, + newValue as Partial, + { level: level - 1 } + ); + } else { + object[key] = newValue as T[keyof T]; + } + } else { + object[key] = newValue; + } + } + + return object; +} diff --git a/utils/xmtpRN/conversations.ts b/utils/xmtpRN/conversations.ts index ddd05f41a..e28c052a9 100644 --- a/utils/xmtpRN/conversations.ts +++ b/utils/xmtpRN/conversations.ts @@ -1,3 +1,5 @@ +import { addConversationToConversationListQuery } from "@/queries/useConversationListQuery"; +import { getV3IdFromTopic } from "@utils/groupUtils/groupId"; import logger from "@utils/logger"; import { ConversationOptions, @@ -6,23 +8,20 @@ import { ConversationVersion, } from "@xmtp/react-native-sdk"; import { PermissionPolicySet } from "@xmtp/react-native-sdk/build/lib/types/PermissionPolicySet"; - -import { getV3IdFromTopic } from "@utils/groupUtils/groupId"; import { ConversationWithCodecsType, ConverseXmtpClientType, DmWithCodecsType, } from "./client"; -import { getXmtpClient } from "./sync"; -import { addConversationToConversationListQuery } from "@/queries/useV3ConversationListQuery"; import { streamAllMessages } from "./messages"; +import { getXmtpClient } from "./sync"; export const streamConversations = async (account: string) => { await stopStreamingConversations(account); const client = (await getXmtpClient(account)) as ConverseXmtpClientType; await client.conversations.stream(async (conversation) => { logger.info("[XMTPRN Conversations] GOT A NEW CONVO"); - addConversationToConversationListQuery(account, conversation); + addConversationToConversationListQuery({ account, conversation }); }); logger.info("STREAMING CONVOS"); }; @@ -32,21 +31,14 @@ export const stopStreamingConversations = async (account: string) => { return client.conversations.cancelStream(); }; -type ListConversationsParams = { +export const listConversations = async (args: { client: ConverseXmtpClientType; includeSync?: boolean; order?: ConversationOrder; limit?: number; opts?: ConversationOptions; -}; - -export const listConversations = async ({ - client, - includeSync = false, - order, - limit, - opts, -}: ListConversationsParams) => { +}) => { + const { client, includeSync = false, order, limit, opts } = args; logger.debug("[XMTPRN Conversations] Listing conversations"); const start = new Date().getTime(); if (includeSync) { @@ -68,21 +60,14 @@ export const listConversations = async ({ return conversations; }; -type ListConversationsByAccountParams = { +export const listConversationsByAccount = async (args: { account: string; includeSync?: boolean; order?: ConversationOrder; limit?: number; opts?: ConversationOptions; -}; - -export const listConversationsByAccount = async ({ - account, - includeSync = false, - order, - limit, - opts, -}: ListConversationsByAccountParams) => { +}) => { + const { account, includeSync = false, order, limit, opts } = args; logger.debug("[XMTPRN Conversations] Listing conversations by account"); const start = new Date().getTime(); const client = (await getXmtpClient(account)) as ConverseXmtpClientType; @@ -103,110 +88,87 @@ export const listConversationsByAccount = async ({ return conversations; }; -type GetConversationByTopicByAccountParams = { - account: string; - topic: ConversationTopic; - includeSync?: boolean; -}; - -export const getConversationByTopicByAccount = async ({ - account, - topic, - includeSync = false, -}: GetConversationByTopicByAccountParams): Promise => { - const client = (await getXmtpClient(account)) as ConverseXmtpClientType; - if (!client) { - throw new Error("Client not found"); - } - return getConversationByTopic({ client, topic, includeSync }); -}; - -type GetConversationByTopicParams = { +async function findGroup(args: { client: ConverseXmtpClientType; topic: ConversationTopic; includeSync?: boolean; -}; +}) { + const { client, topic, includeSync = false } = args; + logger.debug(`[XMTPRN Conversations] Getting group by ${topic}`); + const start = new Date().getTime(); -export const getConversationByTopic = async ({ - client, - topic, - includeSync = false, -}: GetConversationByTopicParams): Promise => { - if (!topic) { - throw new Error("Topic is required to get conversation by topic"); - } + const lookupStart = new Date().getTime(); + let group = await client.conversations.findGroup(getV3IdFromTopic(topic)); + const lookupEnd = new Date().getTime(); logger.debug( - `[XMTPRN Conversations] Getting conversation by topic: ${topic}` + `[XMTPRN Conversations] Initial lookup took ${(lookupEnd - lookupStart) / 1000} sec` ); - const start = new Date().getTime(); - let conversation = await client.conversations.findConversationByTopic(topic); - if (!conversation) { + + if (!group) { logger.debug( - `[XMTPRN Conversations] Conversation ${topic} not found, syncing conversations` + `[XMTPRN Conversations] Group not found, syncing conversations` ); const syncStart = new Date().getTime(); await client.conversations.sync(); const syncEnd = new Date().getTime(); logger.debug( - `[XMTPRN Conversations] Synced conversations in ${ - (syncEnd - syncStart) / 1000 - } sec` + `[XMTPRN Conversations] Synced conversations in ${(syncEnd - syncStart) / 1000} sec` ); - conversation = await client.conversations.findConversationByTopic(topic); - } - if (!conversation) { - throw new Error(`Conversation ${topic} not found`); + + group = await client.conversations.findGroup(getV3IdFromTopic(topic)); + if (!group) { + throw new Error(`Group ${topic} not found`); + } } + if (includeSync) { const syncStart = new Date().getTime(); - await conversation.sync(); + await group.sync(); const syncEnd = new Date().getTime(); logger.debug( - `[XMTPRN Conversations] Synced conversation in ${ - (syncEnd - syncStart) / 1000 - } sec` + `[XMTPRN Conversations] Synced group in ${(syncEnd - syncStart) / 1000} sec` ); } + const end = new Date().getTime(); logger.debug( - `[XMTPRN Conversations] Got conversation by topic in ${ - (end - start) / 1000 - } sec` + `[XMTPRN Conversations] Total time to get group: ${(end - start) / 1000} sec` ); - return conversation; -}; -type GetDmByAddressParams = { + return group; +} + +async function findDm(args: { client: ConverseXmtpClientType; - address: string; + peer: string; includeSync?: boolean; -}; - -export const getDmByAddress = async ({ - client, - address, - includeSync = false, -}: GetDmByAddressParams) => { - logger.debug(`[XMTPRN Conversations] Getting Dm by address: ${address}`); +}) { + const { client, peer, includeSync = false } = args; + logger.debug(`[XMTPRN Conversations] Getting DM by ${peer}`); const start = new Date().getTime(); - let dm = await client.conversations.findDmByAddress(address); + + const lookupStart = new Date().getTime(); + let dm = await client.conversations.findDmByAddress(peer); + const lookupEnd = new Date().getTime(); + logger.debug( + `[XMTPRN Conversations] Initial lookup took ${(lookupEnd - lookupStart) / 1000} sec` + ); + if (!dm) { - logger.debug( - `[XMTPRN Conversations] Dm ${address} not found, syncing conversations` - ); + logger.debug(`[XMTPRN Conversations] DM not found, syncing conversations`); const syncStart = new Date().getTime(); await client.conversations.sync(); const syncEnd = new Date().getTime(); logger.debug( - `[XMTPRN Conversations] Synced conversations in ${ - (syncEnd - syncStart) / 1000 - } sec` + `[XMTPRN Conversations] Synced conversations in ${(syncEnd - syncStart) / 1000} sec` ); - dm = await client.conversations.findDmByAddress(address); - } - if (!dm) { - throw new Error(`Dm ${address} not found`); + + dm = await client.conversations.findDmByAddress(peer); + if (!dm) { + throw new Error(`DM with peer ${peer} not found`); + } } + if (includeSync) { const syncStart = new Date().getTime(); await dm.sync(); @@ -215,101 +177,133 @@ export const getDmByAddress = async ({ `[XMTPRN Conversations] Synced DM in ${(syncEnd - syncStart) / 1000} sec` ); } + const end = new Date().getTime(); logger.debug( - `[XMTPRN Conversations] Got dm by address in ${(end - start) / 1000} sec` + `[XMTPRN Conversations] Total time to get DM: ${(end - start) / 1000} sec` ); - return dm; -}; -type GetDmByAddressByAccountParams = { - account: string; - address: string; - includeSync?: boolean; -}; - -export const getDmByAddressByAccount = async ({ - account, - address, - includeSync = false, -}: GetDmByAddressByAccountParams) => { - const client = (await getXmtpClient(account)) as ConverseXmtpClientType; - if (!client) { - throw new Error("Client not found"); - } - return getDmByAddress({ client, address, includeSync }); -}; + return dm; +} -type GetGroupByTopicParams = { +async function findConversation(args: { client: ConverseXmtpClientType; topic: ConversationTopic; includeSync?: boolean; -}; - -export const getGroupByTopic = async ({ - client, - topic, - includeSync = false, -}: GetGroupByTopicParams) => { - logger.debug(`[XMTPRN Conversations] Getting group by topic: ${topic}`); +}) { + const { client, topic, includeSync = false } = args; + logger.debug(`[XMTPRN Conversations] Getting conversation by ${topic}`); const start = new Date().getTime(); - let group = await client.conversations.findGroup(getV3IdFromTopic(topic)); - if (!group) { + + const lookupStart = new Date().getTime(); + let conversation = await client.conversations.findConversationByTopic(topic); + const lookupEnd = new Date().getTime(); + logger.debug( + `[XMTPRN Conversations] Initial lookup took ${(lookupEnd - lookupStart) / 1000} sec` + ); + + if (!conversation) { logger.debug( - `[XMTPRN Conversations] Group ${topic} not found, syncing conversations` + `[XMTPRN Conversations] Conversation not found, syncing conversations` ); const syncStart = new Date().getTime(); await client.conversations.sync(); const syncEnd = new Date().getTime(); logger.debug( - `[XMTPRN Conversations] Synced conversations in ${ - (syncEnd - syncStart) / 1000 - } sec` + `[XMTPRN Conversations] Synced conversations in ${(syncEnd - syncStart) / 1000} sec` ); - group = await client.conversations.findGroup(getV3IdFromTopic(topic)); - } - if (!group) { - throw new Error(`Group ${topic} not found`); + + conversation = await client.conversations.findConversationByTopic(topic); + if (!conversation) { + throw new Error(`Conversation ${topic} not found`); + } } + if (includeSync) { const syncStart = new Date().getTime(); - await group.sync(); + await conversation.sync(); const syncEnd = new Date().getTime(); logger.debug( - `[XMTPRN Conversations] Synced group in ${(syncEnd - syncStart) / 1000} sec` + `[XMTPRN Conversations] Synced conversation in ${(syncEnd - syncStart) / 1000} sec` ); } + const end = new Date().getTime(); logger.debug( - `[XMTPRN Conversations] Got dm by address in ${(end - start) / 1000} sec` + `[XMTPRN Conversations] Total time to get conversation: ${(end - start) / 1000} sec` ); - return group; + + return conversation; +} + +export const getGroupByTopic = async (args: { + client: ConverseXmtpClientType; + topic: ConversationTopic; + includeSync?: boolean; +}) => { + const { client, topic, includeSync = false } = args; + return findGroup({ + client, + topic, + includeSync, + }); }; -type GetGroupByTopicByAccountParams = { +export async function getGroupByTopicByAccount(args: { account: string; topic: ConversationTopic; includeSync?: boolean; +}) { + const { account, topic, includeSync = false } = args; + const client = (await getXmtpClient(account)) as ConverseXmtpClientType; + return getGroupByTopic({ + client, + topic, + includeSync, + }); +} + +export const getConversationByPeer = async (args: { + client: ConverseXmtpClientType; + peer: string; + includeSync?: boolean; +}) => { + const { client, peer, includeSync = false } = args; + return findDm({ + client, + peer, + includeSync, + }); +}; + +export const getConversationByTopic = async (args: { + client: ConverseXmtpClientType; + topic: ConversationTopic; + includeSync?: boolean; +}) => { + const { client, topic, includeSync = false } = args; + return findConversation({ + client, + topic, + includeSync, + }); }; -export const getGroupByTopicByAccount = async ({ - account, - topic, - includeSync = false, -}: GetGroupByTopicByAccountParams) => { +export const getConversationByTopicByAccount = async (args: { + account: string; + topic: ConversationTopic; + includeSync?: boolean; +}) => { + const { account, topic, includeSync = false } = args; const client = (await getXmtpClient(account)) as ConverseXmtpClientType; - return getGroupByTopic({ client, topic, includeSync }); + return getConversationByTopic({ client, topic, includeSync }); }; -type CreateConversationParams = { +export const createConversation = async (args: { client: ConverseXmtpClientType; peerAddress: string; -}; - -export const createConversation = async ({ - client, - peerAddress, -}: CreateConversationParams) => { +}) => { + const { client, peerAddress } = args; logger.info(`[XMTP] Creating a conversation with peer ${peerAddress}`); const conversation = await client.conversations.findOrCreateDm(peerAddress); await handleNewConversationCreation(client, conversation); @@ -327,23 +321,22 @@ export const createConversationByAccount = async ( return createConversation({ client, peerAddress }); }; -type CreateGroupParams = { +export const createGroup = async (args: { client: ConverseXmtpClientType; peers: string[]; permissionPolicySet: PermissionPolicySet; groupName?: string; groupPhoto?: string; groupDescription?: string; -}; - -export const createGroup = async ({ - client, - peers, - permissionPolicySet, - groupName, - groupPhoto, - groupDescription, -}: CreateGroupParams) => { +}) => { + const { + client, + peers, + permissionPolicySet, + groupName, + groupPhoto, + groupDescription, + } = args; const group = await client.conversations.newGroupCustomPermissions( peers, permissionPolicySet, @@ -361,23 +354,22 @@ export const createGroup = async ({ return group; }; -type CreateGroupByAccountParams = { +export const createGroupByAccount = async (args: { account: string; peers: string[]; permissionPolicySet: PermissionPolicySet; groupName?: string; groupPhoto?: string; groupDescription?: string; -}; - -export const createGroupByAccount = async ({ - account, - peers, - permissionPolicySet, - groupName, - groupPhoto, - groupDescription, -}: CreateGroupByAccountParams) => { +}) => { + const { + account, + peers, + permissionPolicySet, + groupName, + groupPhoto, + groupDescription, + } = args; const client = (await getXmtpClient(account)) as ConverseXmtpClientType; return createGroup({ client, @@ -387,131 +379,31 @@ export const createGroupByAccount = async ({ groupPhoto, groupDescription, }); - - // if (groupName) { - // setGroupNameQueryData(account, group.topic, groupName); - // } - // if (groupPhoto) { - // setGroupPhotoQueryData(account, group.topic, groupPhoto); - // } - // if (groupDescription) { - // setGroupDescriptionQueryData(account, group.topic, groupDescription); - // } - // await handleNewConversation(client, group); }; -type RefreshProtocolConversationParams = { +export const refreshProtocolConversation = async (args: { client: ConverseXmtpClientType; topic: ConversationTopic; -}; - -export const refreshProtocolConversation = async ({ - client, - topic, -}: RefreshProtocolConversationParams) => { +}) => { + const { client, topic } = args; return getConversationByTopic({ client, topic, includeSync: true }); }; -type RefreshProtocolConversationByAccountParams = { +export const refreshProtocolConversationByAccount = async (args: { account: string; topic: ConversationTopic; -}; - -export const refreshProtocolConversationByAccount = async ({ - account, - topic, -}: RefreshProtocolConversationByAccountParams) => { +}) => { + const { account, topic } = args; const client = (await getXmtpClient(account)) as ConverseXmtpClientType; return refreshProtocolConversation({ client, topic }); }; -type GetConversationByPeerParams = { - client: ConverseXmtpClientType; - peer: string; - includeSync?: boolean; -}; - -export const getConversationByPeer = async ({ - client, - peer, - includeSync = false, -}: GetConversationByPeerParams) => { - logger.debug(`[XMTPRN Conversations] Getting conversation by peer: ${peer}`); - const start = new Date().getTime(); - - logger.debug(`[XMTPRN Conversations] Finding DM by address: ${peer}`); - const findStart = new Date().getTime(); - let conversation = await client.conversations.findDmByAddress(peer); - const findEnd = new Date().getTime(); - logger.debug( - `[XMTPRN Conversations] Find DM took ${(findEnd - findStart) / 1000} sec` - ); - - if (!conversation) { - logger.debug( - `[XMTPRN Conversations] Conversation ${peer} not found, syncing conversations` - ); - const syncStart = new Date().getTime(); - await client.conversations.sync(); - const syncEnd = new Date().getTime(); - logger.debug( - `[XMTPRN Conversations] Synced conversations in ${ - (syncEnd - syncStart) / 1000 - } sec` - ); - - logger.debug(`[XMTPRN Conversations] Retrying find DM by address: ${peer}`); - const retryFindStart = new Date().getTime(); - conversation = await client.conversations.findDmByAddress(peer); - const retryFindEnd = new Date().getTime(); - logger.debug( - `[XMTPRN Conversations] Retry find DM took ${ - (retryFindEnd - retryFindStart) / 1000 - } sec` - ); - } - - if (!conversation) { - logger.error( - `[XMTPRN Conversations] Conversation with peer ${peer} not found after sync` - ); - throw new Error(`Conversation with peer ${peer} not found`); - } - - if (includeSync) { - logger.debug( - `[XMTPRN Conversations] Syncing conversation with peer ${peer}` - ); - const syncStart = new Date().getTime(); - await conversation.sync(); - const syncEnd = new Date().getTime(); - logger.debug( - `[XMTPRN Conversations] Synced conversation in ${ - (syncEnd - syncStart) / 1000 - } sec` - ); - } - - const end = new Date().getTime(); - logger.debug( - `[XMTPRN Conversations] Total time to get conversation by peer: ${ - (end - start) / 1000 - } sec` - ); - return conversation; -}; - -type GetConversationByPeerByAccountParams = { +export const getConversationByPeerByAccount = async (args: { account: string; peer: string; includeSync?: boolean; -}; - -export const getConversationByPeerByAccount = async ({ - account, - peer, - includeSync = false, -}: GetConversationByPeerByAccountParams) => { +}) => { + const { account, peer, includeSync = false } = args; const client = (await getXmtpClient(account)) as ConverseXmtpClientType; return getConversationByPeer({ client, peer, includeSync }); }; @@ -526,22 +418,6 @@ export const getPeerAddressDm = async ( return peerAddress; }; -export const getPeerAddressFromTopic = async ( - account: string, - topic: ConversationTopic -): Promise => { - const dm = await getConversationByTopicByAccount({ - account, - topic, - includeSync: false, - }); - if (dm.version === ConversationVersion.DM) { - const peerAddress = await getPeerAddressDm(dm); - return peerAddress; - } - throw new Error("Conversation is not a DM"); -}; - // TODO: This is a temporary function to handle new conversation creation // This is a temporary workaround related to https://github.com/xmtp/xmtp-react-native/issues/560 const handleNewConversationCreation = async ( diff --git a/utils/xmtpRN/messages.ts b/utils/xmtpRN/messages.ts index d2014ce29..a5c3840e1 100644 --- a/utils/xmtpRN/messages.ts +++ b/utils/xmtpRN/messages.ts @@ -1,51 +1,74 @@ +import { isTextMessage } from "@/features/conversation/conversation-message/conversation-message.utils"; +import { messageIsFromCurrentUserV3 } from "@/features/conversation/utils/message-is-from-current-user"; +import { updateConversationInConversationListQuery } from "@/queries/useConversationListQuery"; +import { invalidateGroupMembersQuery } from "@/queries/useGroupMembersQuery"; +import { captureError } from "@/utils/capture-error"; +import { addConversationMessage } from "@queries/useConversationMessages"; import logger from "@utils/logger"; -import { ConverseXmtpClientType } from "./client"; -import { getXmtpClient } from "./sync"; +import type { + ConversationTopic, + GroupUpdatedContent, +} from "@xmtp/react-native-sdk"; import config from "../../config"; -import { updateMessageToConversationListQuery } from "@/queries/useV3ConversationListQuery"; -import { handleGroupUpdatedMessage } from "@data/helpers/messages/handleGroupUpdatedMessage"; -import { addConversationMessage } from "@queries/useConversationMessages"; -import type { ConversationTopic } from "@xmtp/react-native-sdk"; -import { - messageIsFromCurrentUser, - messageIsFromCurrentUserV3, -} from "@/features/conversation/utils/message-is-from-current-user"; -import { isTextMessage } from "@/features/conversation/conversation-message/conversation-message.utils"; +import { ConverseXmtpClientType, DecodedMessageWithCodecsType } from "./client"; +import { getXmtpClient } from "./sync"; export const streamAllMessages = async (account: string) => { await stopStreamingAllMessage(account); + const client = (await getXmtpClient(account)) as ConverseXmtpClientType; + logger.info(`[XmtpRN] Streaming messages for ${client.address}`); + await client.conversations.streamAllMessages(async (message) => { logger.info(`[XmtpRN] Received a message for ${client.address}`, { id: message.id, text: config.env === "prod" ? "Redacted" : message.nativeContent.text, topic: message.topic, }); + if (message.contentTypeId.includes("group_updated")) { - handleGroupUpdatedMessage( - client.address, - message.topic as ConversationTopic, - message - ); + try { + await handleGroupUpdatedMessage( + client.address, + message.topic as ConversationTopic, + message + ); + } catch (error) { + captureError(error); + } } - // We already handle text messages from the current user locally via react-query + // We already handle text messages from the current user locally via react-query. + // Doing this for optimistic updates. // We only need to handle messages that are either: // 1. From other users // 2. Non-text messages from current user const isMessageFromOtherUser = !messageIsFromCurrentUserV3({ message }); const isNonTextMessage = !isTextMessage(message); if (isMessageFromOtherUser || isNonTextMessage) { - addConversationMessage({ + try { + addConversationMessage({ + account: client.address, + topic: message.topic as ConversationTopic, + message, + }); + } catch (error) { + captureError(error); + } + } + + try { + updateConversationInConversationListQuery({ account: client.address, topic: message.topic as ConversationTopic, - message, + conversationUpdate: { + lastMessage: message, + }, }); + } catch (error) { + captureError(error); } - - updateMessageToConversationListQuery(client.address, message); - return; }); }; @@ -55,7 +78,64 @@ export const stopStreamingAllMessage = async (account: string) => { await client.conversations.cancelStreamAllMessages(); }; -export const getUrlToRender = (url: string) => { - const fullUrl = new URL(url); - return fullUrl.hostname; +export const handleGroupUpdatedMessage = async ( + account: string, + topic: ConversationTopic, + message: DecodedMessageWithCodecsType +) => { + if (!message.contentTypeId.includes("group_updated")) return; + const content = message.content() as GroupUpdatedContent; + if (content.membersAdded.length > 0 || content.membersRemoved.length > 0) { + // This will refresh members + invalidateGroupMembersQuery(account, topic); + } + if (content.metadataFieldsChanged.length > 0) { + let newGroupName = ""; + let newGroupPhotoUrl = ""; + let newGroupDescription = ""; + for (const field of content.metadataFieldsChanged) { + if (field.fieldName === "group_name") { + newGroupName = field.newValue; + } else if (field.fieldName === "group_image_url_square") { + newGroupPhotoUrl = field.newValue; + } else if (field.fieldName === "description") { + newGroupDescription = field.newValue; + } + } + if (!!newGroupName) { + updateConversationInConversationListQuery({ + account, + topic, + conversationUpdate: { + name: newGroupName, + }, + }); + } + if (!!newGroupPhotoUrl) { + updateConversationInConversationListQuery({ + account, + topic, + conversationUpdate: { + imageUrlSquare: newGroupPhotoUrl, + }, + }); + } + if (!!newGroupDescription) { + updateConversationInConversationListQuery({ + account, + topic, + conversationUpdate: { + description: newGroupDescription, + }, + }); + } + } + // Admin Update + if ( + content.membersAdded.length === 0 && + content.membersRemoved.length === 0 && + content.metadataFieldsChanged.length === 0 + ) { + invalidateGroupMembersQuery(account, topic); + } }; diff --git a/utils/xmtpRN/sync.ts b/utils/xmtpRN/sync.ts index 67d85d64d..936d644ae 100644 --- a/utils/xmtpRN/sync.ts +++ b/utils/xmtpRN/sync.ts @@ -17,7 +17,7 @@ import { stopStreamingAllMessage, streamAllMessages } from "./messages"; import { fetchPersistedConversationListQuery, prefetchConversationListQuery, -} from "@queries/useV3ConversationListQuery"; +} from "@/queries/useConversationListQuery"; import { setupAccountTopicSubscription } from "@/features/notifications/utils/accountTopicSubscription"; const instantiatingClientForAccount: { @@ -97,7 +97,7 @@ const streamingAccounts: { [account: string]: boolean } = {}; const syncClientConversationList = async (account: string) => { try { // Load the persisted conversation list - await fetchPersistedConversationListQuery(account); + await fetchPersistedConversationListQuery({ account }); // Streaming conversations await retryWithBackoff({ fn: () => streamConversations(account), @@ -129,7 +129,7 @@ const syncClientConversationList = async (account: string) => { // Prefetch the conversation list so when we land on the conversation list // we have it ready, this will include syncing all groups setupAccountTopicSubscription(account); - await prefetchConversationListQuery(account); + await prefetchConversationListQuery({ account }); } catch (e) { logger.error(e, { context: `Failed to fetch persisted conversation list for ${account}`, From 232f07dd37d52f3c294d412573b78f192688da2e Mon Sep 17 00:00:00 2001 From: Thierry Date: Wed, 18 Dec 2024 18:27:19 -0500 Subject: [PATCH 5/6] moreee fixes --- .../conversation-consent-popup-dm.tsx | 75 +----------- .../conversation-message-status.tsx | 12 +- .../conversation-message-bubble.tsx | 1 + .../conversation-message-container.tsx | 28 ----- ...conversation-message-content-container.tsx | 30 ----- .../conversation-message-layout.tsx | 58 +++++++++- .../conversation-message-sender-avatar.tsx | 109 +++--------------- .../conversation-message-sender.tsx | 36 ++---- queries/useAllowGroupMutation.ts | 27 +++-- queries/useBlockGroupMutation.ts | 19 ++- queries/useConversationListQuery.ts | 19 --- queries/useConversationMessages.ts | 2 + queries/useConversationQuery.ts | 79 ++++--------- queries/useDmConsentMutation.ts | 101 ++++++++++++++++ queries/useDmQuery.ts | 76 +----------- queries/useGroupConsentQuery.ts | 2 +- queries/useGroupDescriptionMutation.ts | 61 +++++----- queries/useGroupNameMutation.ts | 62 +++++----- queries/useGroupPhotoMutation.ts | 53 +++++---- queries/useGroupQuery.ts | 2 +- .../OnboardingUserProfileScreen.tsx | 11 +- screens/UserProfileScreen.tsx | 22 ++-- utils/xmtpRN/conversations.ts | 1 - utils/xmtpRN/messages.ts | 34 ++++++ 24 files changed, 415 insertions(+), 505 deletions(-) delete mode 100644 features/conversation/conversation-message/conversation-message-container.tsx delete mode 100644 features/conversation/conversation-message/conversation-message-content-container.tsx create mode 100644 queries/useDmConsentMutation.ts diff --git a/features/conversation/conversation-consent-popup/conversation-consent-popup-dm.tsx b/features/conversation/conversation-consent-popup/conversation-consent-popup-dm.tsx index 409a89bf6..6e70e6da9 100644 --- a/features/conversation/conversation-consent-popup/conversation-consent-popup-dm.tsx +++ b/features/conversation/conversation-consent-popup/conversation-consent-popup-dm.tsx @@ -1,24 +1,13 @@ import { showSnackbar } from "@/components/Snackbar/Snackbar.service"; import { showActionSheetWithOptions } from "@/components/StateHandlers/ActionSheetStateHandler"; -import { - getCurrentAccount, - useCurrentAccount, -} from "@/data/store/accountsStore"; +import { useCurrentAccount } from "@/data/store/accountsStore"; import { useRouter } from "@/navigation/useNavigation"; -import { getConversationQueryData } from "@/queries/useConversationQuery"; +import { useDmConsentMutation } from "@/queries/useDmConsentMutation"; import { useDmPeerInboxId } from "@/queries/useDmPeerInbox"; -import { getDmQueryData, setDmQueryData } from "@/queries/useDmQuery"; import { actionSheetColors } from "@/styles/colors"; import { captureError, captureErrorWithToast } from "@/utils/capture-error"; import { ensureError } from "@/utils/error"; -import { mutateObjectProperties } from "@/utils/mutate-object-properties"; -import { DmWithCodecsType } from "@/utils/xmtpRN/client"; -import { - consentToGroupsOnProtocolByAccount, - consentToInboxIdsOnProtocolByAccount, -} from "@/utils/xmtpRN/contacts"; import { translate } from "@i18n"; -import { useMutation } from "@tanstack/react-query"; import React, { useCallback } from "react"; import { useColorScheme } from "react-native"; import { @@ -49,62 +38,10 @@ export function DmConsentPopup() { const { mutateAsync: consentToInboxIdsOnProtocolByAccountAsync, status: consentToInboxIdsOnProtocolByAccountStatus, - } = useMutation({ - mutationFn: async (args: { consent: "allow" | "deny" }) => { - if (!peerInboxId) { - throw new Error("Peer inbox id not found"); - } - const currentAccount = getCurrentAccount()!; - await Promise.all([ - consentToGroupsOnProtocolByAccount({ - account: currentAccount, - groupIds: [conversationId], - consent: args.consent, - }), - consentToInboxIdsOnProtocolByAccount({ - account: currentAccount, - inboxIds: [peerInboxId], - consent: args.consent, - }), - ]); - }, - onMutate: (args) => { - const conversation = getConversationQueryData({ - account: currentAccount, - topic, - }); - if (conversation) { - const updatedDm = mutateObjectProperties(conversation, { - state: args.consent === "allow" ? "allowed" : "denied", - }); - setDmQueryData({ - account: currentAccount, - peer: topic, - dm: updatedDm as DmWithCodecsType, - }); - return { previousDmConsent: conversation.state }; - } - }, - onError: (error, _, context) => { - const { previousDmConsent } = context || {}; - if (previousDmConsent) { - const dm = getDmQueryData({ - account: currentAccount, - peer: topic, - }); - if (!dm) { - return; - } - const updatedDm = mutateObjectProperties(dm, { - state: previousDmConsent, - }); - setDmQueryData({ - account: currentAccount, - peer: topic, - dm: updatedDm, - }); - } - }, + } = useDmConsentMutation({ + peerInboxId: peerInboxId!, + conversationId: conversationId!, + topic: topic!, }); const handleBlock = useCallback(async () => { diff --git a/features/conversation/conversation-message-status/conversation-message-status.tsx b/features/conversation/conversation-message-status/conversation-message-status.tsx index 305d0c947..4ac193561 100644 --- a/features/conversation/conversation-message-status/conversation-message-status.tsx +++ b/features/conversation/conversation-message-status/conversation-message-status.tsx @@ -45,11 +45,13 @@ export const ConversationMessageStatus = memo( {statusMapping[status]} - + {status === "sent" && ( + + )} ); } diff --git a/features/conversation/conversation-message/conversation-message-bubble.tsx b/features/conversation/conversation-message/conversation-message-bubble.tsx index f2e26ac8b..98c12c752 100644 --- a/features/conversation/conversation-message/conversation-message-bubble.tsx +++ b/features/conversation/conversation-message/conversation-message-bubble.tsx @@ -41,6 +41,7 @@ export const BubbleContentContainer = (args: IBubbleContentContainerProps) => { borderRadius: theme.borderRadius.sm, paddingHorizontal: theme.spacing.xs, paddingVertical: theme.spacing.xxs, + maxWidth: theme.layout.screen.width * 0.7, }; if (!hasNextMessageInSeries) { diff --git a/features/conversation/conversation-message/conversation-message-container.tsx b/features/conversation/conversation-message/conversation-message-container.tsx deleted file mode 100644 index 775068fc5..000000000 --- a/features/conversation/conversation-message/conversation-message-container.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useSelect } from "@/data/store/storeHelpers"; -import { HStack } from "@/design-system/HStack"; -import { useMessageContextStoreContext } from "@/features/conversation/conversation-message/conversation-message.store-context"; -import { debugBorder } from "@/utils/debug-style"; -import { memo } from "react"; - -export const MessageContainer = memo(function MessageContainer(props: { - children: React.ReactNode; -}) { - const { children } = props; - - const { fromMe } = useMessageContextStoreContext(useSelect(["fromMe"])); - - return ( - - {children} - - ); -}); diff --git a/features/conversation/conversation-message/conversation-message-content-container.tsx b/features/conversation/conversation-message/conversation-message-content-container.tsx deleted file mode 100644 index a5b3d8439..000000000 --- a/features/conversation/conversation-message/conversation-message-content-container.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useMessageContextStoreContext } from "@/features/conversation/conversation-message/conversation-message.store-context"; -import { useSelect } from "@/data/store/storeHelpers"; -import { HStack } from "@/design-system/HStack"; -import { useAppTheme } from "@/theme/useAppTheme"; -import { memo } from "react"; -import { debugBorder } from "@/utils/debug-style"; - -export const MessageContentContainer = memo( - function MessageContentContainer(props: { children: React.ReactNode }) { - const { children } = props; - - const { theme } = useAppTheme(); - - const { fromMe } = useMessageContextStoreContext(useSelect(["fromMe"])); - - return ( - - {children} - - ); - } -); diff --git a/features/conversation/conversation-message/conversation-message-layout.tsx b/features/conversation/conversation-message/conversation-message-layout.tsx index a4733a307..9c8ae4379 100644 --- a/features/conversation/conversation-message/conversation-message-layout.tsx +++ b/features/conversation/conversation-message/conversation-message-layout.tsx @@ -1,8 +1,8 @@ import { useSelect } from "@/data/store/storeHelpers"; +import { HStack } from "@/design-system/HStack"; import { AnimatedVStack, VStack } from "@/design-system/VStack"; -import { MessageContainer } from "@/features/conversation/conversation-message/conversation-message-container"; -import { MessageContentContainer } from "@/features/conversation/conversation-message/conversation-message-content-container"; -import { V3MessageSenderAvatar } from "@/features/conversation/conversation-message/conversation-message-sender-avatar"; +import { ConversationMessageSender } from "@/features/conversation/conversation-message/conversation-message-sender"; +import { ConversationSenderAvatar } from "@/features/conversation/conversation-message/conversation-message-sender-avatar"; import { useMessageContextStoreContext } from "@/features/conversation/conversation-message/conversation-message.store-context"; import { useAppTheme } from "@/theme/useAppTheme"; import { debugBorder } from "@/utils/debug-style"; @@ -29,7 +29,7 @@ export const ConversationMessageLayout = memo( {!fromMe && ( <> {!hasNextMessageInSeries ? ( - + ) : ( )} @@ -44,6 +44,9 @@ export const ConversationMessageLayout = memo( alignItems: fromMe ? "flex-end" : "flex-start", }} > + {!fromMe && !hasNextMessageInSeries && ( + + )} {children} @@ -51,3 +54,50 @@ export const ConversationMessageLayout = memo( ); } ); + +const MessageContentContainer = memo(function MessageContentContainer(props: { + children: React.ReactNode; +}) { + const { children } = props; + + const { theme } = useAppTheme(); + + const { fromMe } = useMessageContextStoreContext(useSelect(["fromMe"])); + + return ( + + {children} + + ); +}); + +const MessageContainer = memo(function MessageContainer(props: { + children: React.ReactNode; +}) { + const { children } = props; + + const { fromMe } = useMessageContextStoreContext(useSelect(["fromMe"])); + + return ( + + {children} + + ); +}); diff --git a/features/conversation/conversation-message/conversation-message-sender-avatar.tsx b/features/conversation/conversation-message/conversation-message-sender-avatar.tsx index 7d4af0198..0a6a9f255 100644 --- a/features/conversation/conversation-message/conversation-message-sender-avatar.tsx +++ b/features/conversation/conversation-message/conversation-message-sender-avatar.tsx @@ -1,83 +1,23 @@ +import { useCurrentAccount } from "@/data/store/accountsStore"; +import { navigate } from "@/utils/navigation"; +import { getPreferredInboxAvatar } from "@/utils/profile"; import Avatar from "@components/Avatar"; -import { useCallback, useMemo } from "react"; -import { StyleSheet, TouchableOpacity, View } from "react-native"; - import { usePreferredInboxAddress } from "@hooks/usePreferredInboxAddress"; import { usePreferredInboxName } from "@hooks/usePreferredInboxName"; import { useInboxProfileSocialsQuery } from "@queries/useInboxProfileSocialsQuery"; import { useAppTheme } from "@theme/useAppTheme"; import { InboxId } from "@xmtp/react-native-sdk"; -import { useCurrentAccount } from "../../../data/store/accountsStore"; -import { navigate } from "../../../utils/navigation"; -import { getPreferredInboxAvatar } from "../../../utils/profile"; - -type MessageSenderAvatarDumbProps = { - // hasNextMessageInSeries: boolean; - onPress: () => void; - avatarUri: string | undefined; - avatarName: string; -}; - -export const MessageSenderAvatarDumb = ({ - // hasNextMessageInSeries, - onPress, - avatarUri, - avatarName, -}: MessageSenderAvatarDumbProps) => { - const styles = useStyles(); - const { theme } = useAppTheme(); - - return ( - - {/* {!hasNextMessageInSeries ? ( */} - - - - {/* ) : ( - - )} */} - - ); -}; - -// type MessageSenderAvatarProps = { -// senderAddress: string; -// hasNextMessageInSeries: boolean; -// }; +import { useCallback } from "react"; +import { TouchableOpacity } from "react-native"; -// export const MessageSenderAvatar = ({ -// senderAddress, -// // hasNextMessageInSeries, -// }: MessageSenderAvatarProps) => { -// const senderSocials = useProfilesStore( -// (s) => getProfile(senderAddress, s.profiles)?.socials -// ); - -// const openProfile = useCallback(() => { -// navigate("Profile", { address: senderAddress }); -// }, [senderAddress]); - -// return ( -// -// ); -// }; - -type V3MessageSenderAvatarProps = { +type IConversationSenderAvatarProps = { inboxId: InboxId; }; -export const V3MessageSenderAvatar = ({ +export function ConversationSenderAvatar({ inboxId, -}: V3MessageSenderAvatarProps) => { +}: IConversationSenderAvatarProps) { + const { theme } = useAppTheme(); const currentAccount = useCurrentAccount(); const { data: senderSocials } = useInboxProfileSocialsQuery( currentAccount!, @@ -94,27 +34,12 @@ export const V3MessageSenderAvatar = ({ }, [address]); return ( - + + + ); -}; - -const useStyles = () => { - const { theme } = useAppTheme(); - return useMemo( - () => - StyleSheet.create({ - groupSenderAvatarWrapper: { - // marginRight: 6, - }, - avatarPlaceholder: { - width: theme.avatarSize.sm, - height: theme.avatarSize.sm, - }, - }), - [theme] - ); -}; +} diff --git a/features/conversation/conversation-message/conversation-message-sender.tsx b/features/conversation/conversation-message/conversation-message-sender.tsx index 58af6532a..553566126 100644 --- a/features/conversation/conversation-message/conversation-message-sender.tsx +++ b/features/conversation/conversation-message/conversation-message-sender.tsx @@ -1,19 +1,22 @@ -import { useProfilesStore } from "@data/store/accountsStore"; import { Text } from "@design-system/Text"; import { VStack } from "@design-system/VStack"; import { usePreferredInboxName } from "@hooks/usePreferredInboxName"; import { useAppTheme } from "@theme/useAppTheme"; -import { getPreferredName, getProfile } from "@utils/profile"; import { InboxId } from "@xmtp/react-native-sdk"; import { useMemo } from "react"; import { StyleSheet } from "react-native"; -type MessageSenderDumbProps = { - name: string; +type IConversationMessageSenderProps = { + inboxId: InboxId; }; -export const MessageSenderDumb = ({ name }: MessageSenderDumbProps) => { +export function ConversationMessageSender( + args: IConversationMessageSenderProps +) { + const { inboxId } = args; const styles = useStyles(); + const name = usePreferredInboxName(inboxId); + return ( @@ -21,28 +24,7 @@ export const MessageSenderDumb = ({ name }: MessageSenderDumbProps) => { ); -}; - -type MessageSenderProps = { - senderAddress: string; -}; - -export const MessageSender = ({ senderAddress }: MessageSenderProps) => { - const senderSocials = useProfilesStore( - (s) => getProfile(senderAddress, s.profiles)?.socials - ); - const name = getPreferredName(senderSocials, senderAddress); - return ; -}; - -type V3MessageSenderProps = { - inboxId: InboxId; -}; - -export const V3MessageSender = ({ inboxId }: V3MessageSenderProps) => { - const name = usePreferredInboxName(inboxId); - return ; -}; +} const useStyles = () => { const { theme } = useAppTheme(); diff --git a/queries/useAllowGroupMutation.ts b/queries/useAllowGroupMutation.ts index 1ea163c2f..bc2297ab8 100644 --- a/queries/useAllowGroupMutation.ts +++ b/queries/useAllowGroupMutation.ts @@ -1,16 +1,17 @@ +import { updateConversationInConversationListQuery } from "@/queries/useConversationListQuery"; +import { captureError } from "@/utils/capture-error"; +import { GroupWithCodecsType } from "@/utils/xmtpRN/client"; import { queryClient } from "@queries/queryClient"; import { MutationObserver, MutationOptions, useMutation, } from "@tanstack/react-query"; +import { getV3IdFromTopic } from "@utils/groupUtils/groupId"; import { consentToGroupsOnProtocolByAccount, consentToInboxIdsOnProtocolByAccount, } from "@utils/xmtpRN/contacts"; -import { captureError } from "@/utils/capture-error"; -import { GroupWithCodecsType } from "@/utils/xmtpRN/client"; -import { getV3IdFromTopic } from "@utils/groupUtils/groupId"; import { ConsentState, ConversationId, @@ -89,8 +90,15 @@ export const getAllowGroupMutationOptions = ( const { account, group } = args; const previousConsent = getGroupConsentQueryData(account, group.topic); setGroupConsentQueryData(account, group.topic, "allowed"); + updateConversationInConversationListQuery({ + account, + topic: group.topic, + conversationUpdate: { + state: "allowed", + }, + }); return { - previousConsent: previousConsent ?? undefined, + previousConsent, }; }, onError: ( @@ -108,9 +116,14 @@ export const getAllowGroupMutationOptions = ( return; } - if (context.previousConsent) { - setGroupConsentQueryData(account, group.topic, context.previousConsent); - } + setGroupConsentQueryData(account, group.topic, context.previousConsent); + updateConversationInConversationListQuery({ + account, + topic: group.topic, + conversationUpdate: { + state: context.previousConsent, + }, + }); }, }; }; diff --git a/queries/useBlockGroupMutation.ts b/queries/useBlockGroupMutation.ts index 9ccce1110..5e593db56 100644 --- a/queries/useBlockGroupMutation.ts +++ b/queries/useBlockGroupMutation.ts @@ -1,3 +1,4 @@ +import { updateConversationInConversationListQuery } from "@/queries/useConversationListQuery"; import { captureError } from "@/utils/capture-error"; import { useMutation } from "@tanstack/react-query"; import { getV3IdFromTopic } from "@utils/groupUtils/groupId"; @@ -30,14 +31,30 @@ export const useBlockGroupMutation = ( onMutate: async () => { const previousConsent = getGroupConsentQueryData(account, topic!); setGroupConsentQueryData(account, topic!, "denied"); + updateConversationInConversationListQuery({ + account, + topic: topic!, + conversationUpdate: { + state: "denied", + }, + }); return { previousConsent }; }, onError: (error, _variables, context) => { captureError(error); - if (context?.previousConsent === undefined) { + + if (!context) { return; } + setGroupConsentQueryData(account, topic!, context.previousConsent); + updateConversationInConversationListQuery({ + account, + topic: topic!, + conversationUpdate: { + state: context.previousConsent, + }, + }); }, onSuccess: () => { logger.debug("onSuccess useBlockGroupMutation"); diff --git a/queries/useConversationListQuery.ts b/queries/useConversationListQuery.ts index 6315ea11c..6895278a1 100644 --- a/queries/useConversationListQuery.ts +++ b/queries/useConversationListQuery.ts @@ -127,25 +127,6 @@ export const updateConversationInConversationListQuery = (args: { setConversationListQueryData({ account, conversations: newConversations }); }; -export function replaceConversationInConversationListQuery(args: { - account: string; - topic: ConversationTopic; - conversation: ConversationWithCodecsType; -}) { - const { account, topic, conversation } = args; - const previousConversationsData = getConversationListQueryData({ account }); - if (!previousConversationsData) { - return; - } - const newConversations = previousConversationsData.map((c) => { - if (c.topic === topic) { - return conversation; - } - return c; - }); - setConversationListQueryData({ account, conversations: newConversations }); -} - export const getConversationListQueryData = (args: { account: string }) => { const { account } = args; return queryClient.getQueryData( diff --git a/queries/useConversationMessages.ts b/queries/useConversationMessages.ts index ab1c47cde..633a5588c 100644 --- a/queries/useConversationMessages.ts +++ b/queries/useConversationMessages.ts @@ -46,6 +46,7 @@ const conversationMessagesByTopicQueryFn = async ( const conversation = await getConversationByTopicByAccount({ account, topic, + includeSync: true, }); if (!conversation) { throw new Error( @@ -121,6 +122,7 @@ function getConversationMessagesQueryOptions( return conversationMessagesByTopicQueryFn(account, topic); }, enabled: !!conversation, + refetchOnMount: true, // Just for now because messages are very important and we want to make sure we have all of them }; } diff --git a/queries/useConversationQuery.ts b/queries/useConversationQuery.ts index 5efe1f07e..9d8b9d77f 100644 --- a/queries/useConversationQuery.ts +++ b/queries/useConversationQuery.ts @@ -1,17 +1,9 @@ -import { - conversationListQueryConfig, - replaceConversationInConversationListQuery, -} from "@/queries/useConversationListQuery"; -import { - QueryObserver, - UseQueryOptions, - useQuery, -} from "@tanstack/react-query"; +import { UseQueryOptions, useQuery } from "@tanstack/react-query"; import { getConversationByTopicByAccount } from "@utils/xmtpRN/conversations"; import type { ConversationTopic } from "@xmtp/react-native-sdk"; -import React from "react"; import { conversationQueryKey } from "./QueryKeys"; import { queryClient } from "./queryClient"; +import { mutateObjectProperties } from "@/utils/mutate-object-properties"; export type ConversationQueryData = Awaited>; @@ -35,43 +27,10 @@ export const useConversationQuery = ( } ) => { const { account, topic, queryOptions } = args; - - // Individual conversation query - const query = useQuery({ + return useQuery({ ...getConversationQueryOptions({ account, topic }), ...queryOptions, }); - - // Keep in sync with conversation list - React.useEffect(() => { - const observer = new QueryObserver( - queryClient, - conversationListQueryConfig({ account, context: "useConversationQuery" }) - ); - - observer.subscribe(({ data: conversations }) => { - const currentConversation = - queryClient.getQueryData( - conversationQueryKey(account, topic) - ); - - const listConversation = conversations?.find( - (c) => c.topic === currentConversation?.topic - ); - - if (listConversation) { - // List is source of truth - queryClient.setQueryData( - conversationQueryKey(account, topic), - listConversation - ); - } - }); - - return () => observer.destroy(); - }, [account, topic]); - - return query; }; export function getConversationQueryOptions(args: IArgs) { @@ -83,27 +42,33 @@ export function getConversationQueryOptions(args: IArgs) { }; } -export const invalidateConversationQuery = (args: IArgs) => { - const { account, topic } = args; - return queryClient.invalidateQueries({ - queryKey: conversationQueryKey(account, topic), - }); -}; - export const setConversationQueryData = ( args: IArgs & { conversation: ConversationQueryData; } ) => { const { account, topic, conversation } = args; - // Source of truth for now - replaceConversationInConversationListQuery({ - account, - topic, - conversation, - }); + queryClient.setQueryData( + conversationQueryKey(account, topic), + conversation + ); }; +export function updateConversationQueryData( + args: IArgs & { conversationUpdate: Partial } +) { + const { account, topic, conversationUpdate } = args; + queryClient.setQueryData( + conversationQueryKey(account, topic), + (previousConversation) => { + if (!previousConversation) { + return undefined; + } + return mutateObjectProperties(previousConversation, conversationUpdate); + } + ); +} + export function refetchConversationQuery(args: IArgs) { const { account, topic } = args; return queryClient.refetchQueries({ diff --git a/queries/useDmConsentMutation.ts b/queries/useDmConsentMutation.ts new file mode 100644 index 000000000..14754f5b7 --- /dev/null +++ b/queries/useDmConsentMutation.ts @@ -0,0 +1,101 @@ +import { + getCurrentAccount, + useCurrentAccount, +} from "@/data/store/accountsStore"; +import { updateConversationInConversationListQuery } from "@/queries/useConversationListQuery"; +import { getConversationQueryData } from "@/queries/useConversationQuery"; +import { getDmQueryData, setDmQueryData } from "@/queries/useDmQuery"; +import { mutateObjectProperties } from "@/utils/mutate-object-properties"; +import { DmWithCodecsType } from "@/utils/xmtpRN/client"; +import { + consentToGroupsOnProtocolByAccount, + consentToInboxIdsOnProtocolByAccount, +} from "@/utils/xmtpRN/contacts"; +import { useMutation } from "@tanstack/react-query"; +import { + ConversationId, + ConversationTopic, + InboxId, +} from "@xmtp/react-native-sdk"; + +export function useDmConsentMutation(args: { + peerInboxId: InboxId; + conversationId: ConversationId; + topic: ConversationTopic; +}) { + const { peerInboxId, conversationId, topic } = args; + + const currentAccount = useCurrentAccount()!; + + return useMutation({ + mutationFn: async (args: { consent: "allow" | "deny" }) => { + if (!peerInboxId) { + throw new Error("Peer inbox id not found"); + } + const currentAccount = getCurrentAccount()!; + await Promise.all([ + consentToGroupsOnProtocolByAccount({ + account: currentAccount, + groupIds: [conversationId], + consent: args.consent, + }), + consentToInboxIdsOnProtocolByAccount({ + account: currentAccount, + inboxIds: [peerInboxId], + consent: args.consent, + }), + ]); + }, + onMutate: (args) => { + const conversation = getConversationQueryData({ + account: currentAccount, + topic, + }); + if (conversation) { + const updatedDm = mutateObjectProperties(conversation, { + state: args.consent === "allow" ? "allowed" : "denied", + }); + setDmQueryData({ + account: currentAccount, + peer: topic, + dm: updatedDm as DmWithCodecsType, + }); + updateConversationInConversationListQuery({ + account: currentAccount, + topic, + conversationUpdate: { + state: args.consent === "allow" ? "allowed" : "denied", + }, + }); + return { previousDmConsent: conversation.state }; + } + }, + onError: (error, _, context) => { + const { previousDmConsent } = context || {}; + if (previousDmConsent) { + const dm = getDmQueryData({ + account: currentAccount, + peer: topic, + }); + if (!dm) { + return; + } + const updatedDm = mutateObjectProperties(dm, { + state: previousDmConsent, + }); + setDmQueryData({ + account: currentAccount, + peer: topic, + dm: updatedDm, + }); + updateConversationInConversationListQuery({ + account: currentAccount, + topic, + conversationUpdate: { + state: previousDmConsent, + }, + }); + } + }, + }); +} diff --git a/queries/useDmQuery.ts b/queries/useDmQuery.ts index 2f87d3d5d..5a7626e00 100644 --- a/queries/useDmQuery.ts +++ b/queries/useDmQuery.ts @@ -1,17 +1,9 @@ /** * TODO: Maybe delete this and just use the conversation query instead and add a "peer" argument? */ -import { isConversationDm } from "@/features/conversation/utils/is-conversation-dm"; import { queryClient } from "@/queries/queryClient"; -import { conversationListQueryConfig } from "@/queries/useConversationListQuery"; -import { captureError } from "@/utils/capture-error"; -import { DmWithCodecsType } from "@/utils/xmtpRN/client"; -import { QueryObserver, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - getConversationByPeerByAccount, - getPeerAddressDm, -} from "@utils/xmtpRN/conversations"; -import { useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { getConversationByPeerByAccount } from "@utils/xmtpRN/conversations"; import { dmQueryKey } from "./QueryKeys"; import { setConversationQueryData } from "./useConversationQuery"; @@ -43,78 +35,18 @@ async function getDm(args: IDmQueryArgs) { export function useDmQuery(args: IDmQueryArgs) { const { account, peer } = args; - const queryClient = useQueryClient(); - const query = useQuery({ + return useQuery({ queryKey: dmQueryKey(account, peer), queryFn: () => getDm(args), enabled: !!peer, }); - - // Keep in sync with conversation list - useEffect(() => { - const observer = new QueryObserver( - queryClient, - conversationListQueryConfig({ account, context: "useDmQuery" }) - ); - - observer.subscribe(async ({ data: conversations }) => { - try { - const currentConversation = queryClient.getQueryData( - dmQueryKey(account, peer) - ); - - // If we have the conversation in cache, sync with list - if (currentConversation) { - const listConversation = conversations?.find( - (c): c is DmWithCodecsType => - c.topic === currentConversation.topic && isConversationDm(c) - ); - - if (listConversation) { - queryClient.setQueryData( - dmQueryKey(account, peer), - listConversation - ); - } - return; - } - - // Try to find conversation by peer address in list - const dmConversations = conversations?.filter(isConversationDm); - - if (!dmConversations?.length) { - return; - } - - const peerAddresses = await Promise.all( - dmConversations.map(getPeerAddressDm) - ); - - const matchingConversationIndex = peerAddresses.findIndex( - (address) => address === peer - ); - - if (matchingConversationIndex !== -1) { - queryClient.setQueryData( - dmQueryKey(account, peer), - dmConversations[matchingConversationIndex] - ); - } - } catch (error) { - captureError(error); - } - }); - - return () => observer.destroy(); - }, [queryClient, account, peer]); - - return query; } export function setDmQueryData(args: IDmQueryArgs & { dm: IDmQueryData }) { const { account, peer, dm } = args; queryClient.setQueryData(dmQueryKey(account, peer), dm); + // Also set there because it's a 1-1 setConversationQueryData({ account, topic: dm.topic, diff --git a/queries/useGroupConsentQuery.ts b/queries/useGroupConsentQuery.ts index 1ae83e111..7f106305d 100644 --- a/queries/useGroupConsentQuery.ts +++ b/queries/useGroupConsentQuery.ts @@ -31,7 +31,7 @@ export const getGroupConsentQueryData = ( export const setGroupConsentQueryData = ( account: string, topic: ConversationTopic, - consent: ConsentState + consent: ConsentState | undefined ) => { const currentGroup = getGroupQueryData({ account, topic }); if (!currentGroup) return; diff --git a/queries/useGroupDescriptionMutation.ts b/queries/useGroupDescriptionMutation.ts index 324a65afa..8d66618a0 100644 --- a/queries/useGroupDescriptionMutation.ts +++ b/queries/useGroupDescriptionMutation.ts @@ -7,50 +7,55 @@ import { import { useMutation } from "@tanstack/react-query"; import type { ConversationTopic } from "@xmtp/react-native-sdk"; import { setGroupDescriptionMutationKey } from "./MutationKeys"; +import { updateConversationInConversationListQuery } from "@/queries/useConversationListQuery"; -export const useGroupDescriptionMutation = ( - account: string, - topic: ConversationTopic -) => { +type IArgs = { + account: string; + topic: ConversationTopic; +}; + +export function useGroupDescriptionMutation(args: IArgs) { + const { account, topic } = args; const { data: group } = useGroupQuery({ account, topic }); + return useMutation({ mutationKey: setGroupDescriptionMutationKey(account, topic), mutationFn: async (description: string) => { if (!group || !account || !topic) { - throw new Error( - "Missing group, account, or topic in useGroupDescriptionMutation" - ); + throw new Error("Missing required data in useGroupDescriptionMutation"); } - group.updateGroupDescription(description); + + await group.updateGroupDescription(description); return description; }, onMutate: async (description: string) => { const previousGroup = getGroupQueryData({ account, topic }); + const updates = { description }; + if (previousGroup) { - updateGroupQueryData({ - account, - topic, - updates: { - description, - }, - }); + updateGroupQueryData({ account, topic, updates }); } - return { previousGroupDescription: previousGroup?.description }; + updateConversationInConversationListQuery({ + account, + topic, + conversationUpdate: updates, + }); + + return { previousGroup }; }, onError: (error, _variables, context) => { captureError(error); - const { previousGroupDescription } = context || {}; - - if (previousGroupDescription) { - updateGroupQueryData({ - account, - topic, - updates: { - description: previousGroupDescription, - }, - }); - } + + const { previousGroup } = context || {}; + + const updates = { description: previousGroup?.description ?? "" }; + updateGroupQueryData({ account, topic, updates }); + updateConversationInConversationListQuery({ + account, + topic, + conversationUpdate: updates, + }); }, }); -}; +} diff --git a/queries/useGroupNameMutation.ts b/queries/useGroupNameMutation.ts index a9f521ba1..f22d9e166 100644 --- a/queries/useGroupNameMutation.ts +++ b/queries/useGroupNameMutation.ts @@ -7,49 +7,55 @@ import { import { useMutation } from "@tanstack/react-query"; import type { ConversationTopic } from "@xmtp/react-native-sdk"; import { setGroupNameMutationKey } from "./MutationKeys"; +import { updateConversationInConversationListQuery } from "@/queries/useConversationListQuery"; -export const useGroupNameMutation = (args: { +type IArgs = { account: string; topic: ConversationTopic; -}) => { +}; + +export function useGroupNameMutation(args: IArgs) { const { account, topic } = args; const { data: group } = useGroupQuery({ account, topic }); + return useMutation({ mutationKey: setGroupNameMutationKey(account, topic), - mutationFn: async (groupName: string) => { + mutationFn: async (name: string) => { if (!group || !account || !topic) { - throw new Error( - "Missing group, account, or topic in useGroupNameMutation" - ); + throw new Error("Missing required data in useGroupNameMutation"); } - group.updateGroupName(groupName); - return groupName; + + await group.updateGroupName(name); + return name; }, - onMutate: async (groupName: string) => { + onMutate: async (name: string) => { const previousGroup = getGroupQueryData({ account, topic }); + const updates = { name }; + if (previousGroup) { - updateGroupQueryData({ - account, - topic, - updates: { - name: groupName, - }, - }); + updateGroupQueryData({ account, topic, updates }); } - return { previousGroupName: previousGroup?.name }; + + updateConversationInConversationListQuery({ + account, + topic, + conversationUpdate: updates, + }); + + return { previousGroup }; }, onError: (error, _variables, context) => { captureError(error); - const { previousGroupName } = context || {}; - if (previousGroupName) { - updateGroupQueryData({ - account, - topic, - updates: { - name: previousGroupName, - }, - }); - } + + const { previousGroup } = context || {}; + + const updates = { name: previousGroup?.name ?? "" }; + updateGroupQueryData({ account, topic, updates }); + updateConversationInConversationListQuery({ + account, + topic, + conversationUpdate: updates, + }); }, }); -}; +} diff --git a/queries/useGroupPhotoMutation.ts b/queries/useGroupPhotoMutation.ts index a85fd2c92..83825ac9c 100644 --- a/queries/useGroupPhotoMutation.ts +++ b/queries/useGroupPhotoMutation.ts @@ -1,52 +1,61 @@ -import { useMutation } from "@tanstack/react-query"; - import { captureError } from "@/utils/capture-error"; -import { GroupWithCodecsType } from "@/utils/xmtpRN/client.types"; import { getGroupQueryData, - setGroupQueryData, + updateGroupQueryData, useGroupQuery, } from "@queries/useGroupQuery"; +import { useMutation } from "@tanstack/react-query"; import type { ConversationTopic } from "@xmtp/react-native-sdk"; import { setGroupPhotoMutationKey } from "./MutationKeys"; +import { updateConversationInConversationListQuery } from "@/queries/useConversationListQuery"; -export const useGroupPhotoMutation = (args: { +type IArgs = { account: string; topic: ConversationTopic; -}) => { +}; + +export function useGroupPhotoMutation(args: IArgs) { const { account, topic } = args; const { data: group } = useGroupQuery({ account, topic }); + return useMutation({ mutationKey: setGroupPhotoMutationKey(account, topic), - mutationFn: async (groupPhoto: string) => { + mutationFn: async (imageUrlSquare: string) => { if (!group || !account || !topic) { - return; + throw new Error("Missing required data in useGroupPhotoMutation"); } - await group.updateGroupImageUrlSquare(groupPhoto); - return groupPhoto; + + await group.updateGroupImageUrlSquare(imageUrlSquare); + return imageUrlSquare; }, - onMutate: async (groupPhoto: string) => { + onMutate: async (imageUrlSquare: string) => { const previousGroup = getGroupQueryData({ account, topic }); - setGroupQueryData({ + const updates = { imageUrlSquare }; + + if (previousGroup) { + updateGroupQueryData({ account, topic, updates }); + } + + updateConversationInConversationListQuery({ account, topic, - group: { - ...group, - imageUrlSquare: groupPhoto, - } as GroupWithCodecsType, + conversationUpdate: updates, }); + return { previousGroup }; }, onError: (error, _variables, context) => { captureError(error); - if (context?.previousGroup === undefined) { - return; - } - setGroupQueryData({ + + const { previousGroup } = context || {}; + + const updates = { imageUrlSquare: previousGroup?.imageUrlSquare ?? "" }; + updateGroupQueryData({ account, topic, updates }); + updateConversationInConversationListQuery({ account, topic, - group: context.previousGroup, + conversationUpdate: updates, }); }, }); -}; +} diff --git a/queries/useGroupQuery.ts b/queries/useGroupQuery.ts index 0a29f64cf..d1110b53e 100644 --- a/queries/useGroupQuery.ts +++ b/queries/useGroupQuery.ts @@ -1,5 +1,5 @@ /** - * useGroupQuery is derived from useConversationQuery + * useGroupQuery is derived from useConversationQuery. Like useDmQuery, maybe worth considering if we should just use useConversationQuery instead. */ import { getConversationQueryData, diff --git a/screens/Onboarding/OnboardingUserProfileScreen.tsx b/screens/Onboarding/OnboardingUserProfileScreen.tsx index accf31071..038688290 100644 --- a/screens/Onboarding/OnboardingUserProfileScreen.tsx +++ b/screens/Onboarding/OnboardingUserProfileScreen.tsx @@ -21,6 +21,7 @@ import { } from "react-native"; import { useAppTheme } from "@theme/useAppTheme"; +import { uploadFile } from "@utils/attachment/uploadFile"; import Avatar from "../../components/Avatar"; import Button from "../../components/Button/Button"; import { OnboardingPictoTitleSubtitle } from "../../components/Onboarding/OnboardingPictoTitleSubtitle"; @@ -52,7 +53,6 @@ import { } from "../../utils/str"; import { NavigationParamList } from "../Navigation/Navigation"; import { needToShowNotificationsPermissions } from "./Onboarding.utils"; -import { uploadFile } from "@utils/attachment/uploadFile"; export type ProfileType = { avatar?: string; @@ -185,12 +185,12 @@ export const OnboardingUserProfileScreen = ( }; export function useProfile() { - const address = useCurrentAccount()!; // We assume if someone goes to this screen we have address + const currentAccount = useCurrentAccount()!; // We assume if someone goes to this screen we have address const profiles = useProfilesStore((state) => state.profiles); const currentUserUsername = getProfile( - address, + currentAccount, profiles )?.socials?.userNames?.find((u) => u.isPrimary); @@ -204,11 +204,11 @@ export function useProfile() { ); const defaultEphemeralUsername = formatEphemeralUsername( - address, + currentAccount, usernameWithoutSuffix ); const defaultEphemeralDisplayName = formatEphemeralDisplayName( - address, + currentAccount, currentUserUsername?.displayName ); @@ -225,6 +225,7 @@ export function useProfile() { return { profile, setProfile }; } +// TODO: Put somewhere else export function useAddPfp() { const [asset, setAsset] = useState(); diff --git a/screens/UserProfileScreen.tsx b/screens/UserProfileScreen.tsx index 28d984833..dfbf6894b 100644 --- a/screens/UserProfileScreen.tsx +++ b/screens/UserProfileScreen.tsx @@ -1,15 +1,10 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack"; import { memo, useCallback, useEffect, useRef } from "react"; -import { TextInput, useColorScheme, View } from "react-native"; +import { TextInput, View, useColorScheme } from "react-native"; import Avatar from "../components/Avatar"; -import { - useAddPfp, - useCreateOrUpdateProfileInfo, - useProfile, - useUserProfileStyles, -} from "./Onboarding/OnboardingUserProfileScreen"; import Button from "../components/Button/Button"; +import { Screen } from "../components/Screen/ScreenComp/Screen"; import { ScreenHeaderButton } from "../components/Screen/ScreenHeaderButton/ScreenHeaderButton"; import { Pressable } from "../design-system/Pressable"; import { Text } from "../design-system/Text"; @@ -17,7 +12,12 @@ import { translate } from "../i18n"; import { textSecondaryColor } from "../styles/colors"; import { sentryTrackError } from "../utils/sentry"; import { NavigationParamList } from "./Navigation/Navigation"; -import { Screen } from "../components/Screen/ScreenComp/Screen"; +import { + useAddPfp, + useCreateOrUpdateProfileInfo, + useProfile, + useUserProfileStyles, +} from "./Onboarding/OnboardingUserProfileScreen"; export const UserProfileScreen = memo(function UserProfileScreen( props: NativeStackScreenProps @@ -64,6 +64,12 @@ export const UserProfileScreen = memo(function UserProfileScreen( const { addPFP, asset } = useAddPfp(); + useEffect(() => { + if (asset) { + setProfile((prevProfile) => ({ ...prevProfile, avatar: asset.uri })); + } + }, [asset, setProfile]); + return ( { } } + try { + updateConversationQueryData({ + account: client.address, + topic: message.topic as ConversationTopic, + conversationUpdate: { + lastMessage: message, + }, + }); + } catch (error) { + captureError(error); + } + try { updateConversationInConversationListQuery({ account: client.address, @@ -103,6 +116,13 @@ export const handleGroupUpdatedMessage = async ( } } if (!!newGroupName) { + updateConversationQueryData({ + account, + topic, + conversationUpdate: { + name: newGroupName, + }, + }); updateConversationInConversationListQuery({ account, topic, @@ -112,6 +132,13 @@ export const handleGroupUpdatedMessage = async ( }); } if (!!newGroupPhotoUrl) { + updateConversationQueryData({ + account, + topic, + conversationUpdate: { + imageUrlSquare: newGroupPhotoUrl, + }, + }); updateConversationInConversationListQuery({ account, topic, @@ -121,6 +148,13 @@ export const handleGroupUpdatedMessage = async ( }); } if (!!newGroupDescription) { + updateConversationQueryData({ + account, + topic, + conversationUpdate: { + description: newGroupDescription, + }, + }); updateConversationInConversationListQuery({ account, topic, From 8468ff7dda3a45dc21a84a01f1e84c7dba1c5355 Mon Sep 17 00:00:00 2001 From: Thierry Date: Wed, 18 Dec 2024 18:28:14 -0500 Subject: [PATCH 6/6] fix tsc --- hooks/useGroupDescription.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/hooks/useGroupDescription.ts b/hooks/useGroupDescription.ts index 401696364..22c0e52db 100644 --- a/hooks/useGroupDescription.ts +++ b/hooks/useGroupDescription.ts @@ -1,7 +1,7 @@ +import type { ConversationTopic } from "@xmtp/react-native-sdk"; import { currentAccount } from "../data/store/accountsStore"; import { useGroupDescriptionMutation } from "../queries/useGroupDescriptionMutation"; import { useGroupDescriptionQuery } from "../queries/useGroupDescriptionQuery"; -import type { ConversationTopic } from "@xmtp/react-native-sdk"; export const useGroupDescription = (topic: ConversationTopic) => { const account = currentAccount(); @@ -9,7 +9,10 @@ export const useGroupDescription = (topic: ConversationTopic) => { account, topic, }); - const { mutateAsync } = useGroupDescriptionMutation(account, topic); + const { mutateAsync } = useGroupDescriptionMutation({ + account, + topic, + }); return { groupDescription: data,