From 6489942570e741e0ab04af3b64ae70f22f0c0942 Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Wed, 20 Nov 2024 22:54:01 -0500 Subject: [PATCH] feat: Headers and New Conversation Updated new conversation Handling Moved components to be in features branch Updated title components Bumped RN SDK Commented out api header work --- .../Conversation/ConversationTitleDumb.tsx | 68 ---- .../Conversation/GroupConversationTitle.tsx | 94 ++++++ components/Conversation/V3Conversation.tsx | 253 +++------------ .../Conversation/V3ConversationHeader.tsx | 1 - components/Search/NavigationChatButton.tsx | 2 + components/V3DMListItem.tsx | 34 +- components/V3GroupConversationListItem.tsx | 5 +- .../hooks/useMessageIsUnread.ts | 10 +- .../components/ConversationTitleDumb.tsx | 56 ++++ .../components/DmConversationTitle.tsx | 91 ++++++ .../components/GroupConversationTitle.tsx | 95 ++++++ .../components/V3Conversation.tsx | 302 ++++++++++++++++++ .../components/V3ConversationFromPeer.tsx | 57 ++++ .../hooks/useConversationTitleLongPress.ts | 16 + .../hooks/useGroupMembersAvatarData.ts | 59 ++++ ios/Podfile | 2 +- ios/Podfile.lock | 20 +- package.json | 2 +- queries/QueryKeys.ts | 7 + queries/useConversationQuery.ts | 55 +++- queries/useDmPeerAddressQuery.ts | 15 + screens/Conversation.tsx | 26 +- screens/Navigation/ConversationNav.tsx | 3 + utils/xmtpRN/api.ts | 63 ++-- utils/xmtpRN/conversations.ts | 96 +++++- yarn.lock | 8 +- 26 files changed, 1072 insertions(+), 368 deletions(-) delete mode 100644 components/Conversation/ConversationTitleDumb.tsx create mode 100644 components/Conversation/GroupConversationTitle.tsx delete mode 100644 components/Conversation/V3ConversationHeader.tsx create mode 100644 features/conversations/components/ConversationTitleDumb.tsx create mode 100644 features/conversations/components/DmConversationTitle.tsx create mode 100644 features/conversations/components/GroupConversationTitle.tsx create mode 100644 features/conversations/components/V3Conversation.tsx create mode 100644 features/conversations/components/V3ConversationFromPeer.tsx create mode 100644 features/conversations/hooks/useConversationTitleLongPress.ts create mode 100644 features/conversations/hooks/useGroupMembersAvatarData.ts create mode 100644 queries/useDmPeerAddressQuery.ts diff --git a/components/Conversation/ConversationTitleDumb.tsx b/components/Conversation/ConversationTitleDumb.tsx deleted file mode 100644 index dbd2cacc2..000000000 --- a/components/Conversation/ConversationTitleDumb.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { headerTitleStyle, textPrimaryColor } from "@styles/colors"; -import { - Platform, - StyleSheet, - Text, - TouchableOpacity, - useColorScheme, - View, -} from "react-native"; - -import { getTitleFontScale } from "../../utils/str"; - -type ConversationTitleDumbProps = { - title?: string; - avatarComponent?: React.ReactNode; - onLongPress?: () => void; - onPress?: () => void; -}; - -export function ConversationTitleDumb({ - avatarComponent, - title, - onLongPress, - onPress, -}: ConversationTitleDumbProps) { - const styles = useStyles(); - - return ( - - - {avatarComponent} - - {title} - - - - ); -} - -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/components/Conversation/GroupConversationTitle.tsx b/components/Conversation/GroupConversationTitle.tsx new file mode 100644 index 000000000..88e826db6 --- /dev/null +++ b/components/Conversation/GroupConversationTitle.tsx @@ -0,0 +1,94 @@ +import React, { memo, useCallback, useMemo } from "react"; +import { ConversationTitleDumb } from "../../features/conversations/components/ConversationTitleDumb"; +import { useGroupNameQuery } from "@queries/useGroupNameQuery"; +import { ConversationTopic } from "@xmtp/react-native-sdk"; +import { useCurrentAccount } from "@data/store/accountsStore"; +import { NativeStackNavigationProp } from "@react-navigation/native-stack"; +import { NavigationParamList } from "@screens/Navigation/Navigation"; +import { ImageStyle, Platform } from "react-native"; +import { useRouter } from "@navigation/useNavigation"; +import { useGroupPhotoQuery } from "@queries/useGroupPhotoQuery"; +import Avatar from "@components/Avatar"; +import { AvatarSizes } from "@styles/sizes"; +import { ThemedStyle, useAppTheme } from "@theme/useAppTheme"; +import { GroupAvatarDumb } from "@components/GroupAvatar"; +import { useConversationTitleLongPress } from "../../features/conversations/hooks/useConversationTitleLongPress"; +import { useGroupMembersAvatarData } from "../../features/conversations/hooks/useGroupMembersAvatarData"; + +type GroupConversationTitleProps = { + topic: ConversationTopic; +}; + +type UseUserInteractionProps = { + topic: ConversationTopic; + navigation: NativeStackNavigationProp; +}; + +const useUserInteraction = ({ navigation, topic }: UseUserInteractionProps) => { + const onPress = useCallback(() => { + // textInputRef?.current?.blur(); + navigation.push("Group", { topic }); + }, [navigation, topic]); + + const onLongPress = useConversationTitleLongPress(topic); + + return { onPress, onLongPress }; +}; + +export const GroupConversationTitle = memo( + ({ topic }: GroupConversationTitleProps) => { + const currentAccount = useCurrentAccount()!; + + const { data: groupName, isLoading: groupNameLoading } = useGroupNameQuery( + currentAccount, + topic! + ); + + const { data: groupPhoto, isLoading: groupPhotoLoading } = + useGroupPhotoQuery(currentAccount, topic!); + + const { data: memberData } = useGroupMembersAvatarData({ topic }); + + const navigation = useRouter(); + + const { themed } = useAppTheme(); + + const { onPress, onLongPress } = useUserInteraction({ + topic, + navigation, + }); + + const displayAvatar = !groupPhotoLoading && !groupNameLoading; + + const avatarComponent = useMemo(() => { + if (displayAvatar) return null; + return groupPhoto ? ( + + ) : ( + + ); + }, [displayAvatar, groupPhoto, memberData, themed]); + + return ( + + ); + } +); + +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/components/Conversation/V3Conversation.tsx b/components/Conversation/V3Conversation.tsx index 2de970420..a0c1bf3b2 100644 --- a/components/Conversation/V3Conversation.tsx +++ b/components/Conversation/V3Conversation.tsx @@ -1,39 +1,12 @@ -import Avatar from "@components/Avatar"; import { ChatDumb } from "@components/Chat/ChatDumb"; -import { useDebugEnabled } from "@components/DebugButton"; -import { GroupAvatarDumb } from "@components/GroupAvatar"; import { useCurrentAccount } from "@data/store/accountsStore"; -import { useProfilesSocials } from "@hooks/useProfilesSocials"; -import { useGroupMembersConversationScreenQuery } from "@queries/useGroupMembersQuery"; import { useConversationMessages } from "@queries/useConversationMessages"; -import { useGroupNameQuery } from "@queries/useGroupNameQuery"; -import { useGroupPhotoQuery } from "@queries/useGroupPhotoQuery"; import { useConversationScreenQuery } from "@queries/useConversationQuery"; -import Clipboard from "@react-native-clipboard/clipboard"; -import { - NativeStackNavigationProp, - NativeStackScreenProps, -} from "@react-navigation/native-stack"; -import { NavigationParamList } from "@screens/Navigation/Navigation"; import { ListRenderItem } from "@shopify/flash-list"; -import { - backgroundColor, - textPrimaryColor, - textSecondaryColor, -} from "@styles/colors"; -import { AvatarSizes } from "@styles/sizes"; -import { getPreferredAvatar, getPreferredName } from "@utils/profile"; -import { ConversationWithCodecsType } from "@utils/xmtpRN/client"; +import { textPrimaryColor, textSecondaryColor } from "@styles/colors"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - Alert, - Platform, - StyleSheet, - useColorScheme, - View, -} from "react-native"; +import { Platform, useColorScheme, View, ViewStyle } from "react-native"; -import { ConversationTitleDumb } from "./ConversationTitleDumb"; import { GroupChatPlaceholder } from "@components/Chat/ChatPlaceholder/GroupChatPlaceholder"; import { ConversationTopic, @@ -49,10 +22,14 @@ import { getDraftMessage, setDraftMessage, } from "../../features/conversations/utils/textDrafts"; +import { useRouter } from "@navigation/useNavigation"; +import { GroupConversationTitle } from "../../features/conversations/components/GroupConversationTitle"; +import { DmConversationTitle } from "../../features/conversations/components/DmConversationTitle"; +import { ThemedStyle, useAppTheme } from "@theme/useAppTheme"; // import { DmChatPlaceholder } from "@components/Chat/ChatPlaceholder/ChatPlaceholder"; type UseDataProps = { - topic: ConversationTopic; + topic: ConversationTopic | undefined; }; const useData = ({ topic }: UseDataProps) => { @@ -65,16 +42,6 @@ const useData = ({ topic }: UseDataProps) => { const { data: messages, isLoading: messagesLoading } = useConversationMessages(currentAccount, topic!); - const { data: groupName, isLoading: groupNameLoading } = useGroupNameQuery( - currentAccount, - topic! - ); - const { data: groupPhoto, isLoading: groupPhotoLoading } = useGroupPhotoQuery( - currentAccount, - topic! - ); - const { data: members, isLoading: membersLoading } = - useGroupMembersConversationScreenQuery(currentAccount, topic!); useEffect(() => { const checkActive = async () => { @@ -90,127 +57,32 @@ const useData = ({ topic }: UseDataProps) => { checkActive(); }, [conversation]); - const memberAddresses = useMemo(() => { - const addresses: string[] = []; - for (const memberId of members?.ids ?? []) { - const member = members?.byId[memberId]; - if ( - member?.addresses[0] && - member?.addresses[0].toLowerCase() !== currentAccount?.toLowerCase() - ) { - addresses.push(member?.addresses[0]); - } - } - 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], - } - ); - }, [data, memberAddresses]); - - const debugEnabled = useDebugEnabled(); - return { conversation, messages, messagesLoading, - groupName, - groupNameLoading, - groupPhoto, - groupPhotoLoading, - members, - membersLoading, isLoading, isRefetching, - debugEnabled, - memberData, }; }; -const useStyles = () => { - const colorScheme = useColorScheme(); - return useMemo( - () => - StyleSheet.create({ - container: { - flex: 1, - }, - chatContainer: { - flex: 1, - justifyContent: "flex-end", - backgroundColor: backgroundColor(colorScheme), - }, - avatar: { - marginRight: Platform.OS === "android" ? 24 : 7, - marginLeft: Platform.OS === "ios" ? 0 : -9, - }, - }), - [colorScheme] - ); +const $container: ViewStyle = { + flex: 1, }; -type UseDisplayInfoProps = { - conversation: ConversationWithCodecsType | undefined | null; - groupPhotoLoading: boolean; -}; +const $chatContainer: ThemedStyle = ({ colors }) => ({ + flex: 1, + justifyContent: "flex-end", + backgroundColor: colors.background.surface, +}); -const useDisplayInfo = ({ - conversation, - groupPhotoLoading, -}: UseDisplayInfoProps) => { +const useDisplayInfo = () => { const colorScheme = useColorScheme(); const headerTintColor = Platform.OS === "android" ? textSecondaryColor(colorScheme) : textPrimaryColor(colorScheme); - const displayAvatar = !conversation || groupPhotoLoading; - return { headerTintColor, displayAvatar }; -}; - -type UseUserInteractionProps = { - debugEnabled: boolean; - topic: ConversationTopic; - navigation: NativeStackNavigationProp; -}; - -const useUserInteraction = ({ - debugEnabled, - navigation, - topic, -}: UseUserInteractionProps) => { - const onPress = useCallback(() => { - // textInputRef?.current?.blur(); - navigation.push("Group", { topic }); - }, [navigation, topic]); - - const onLongPress = useCallback(() => { - if (!debugEnabled) return; - Clipboard.setString( - JSON.stringify({ - topic: topic || "", - }) - ); - Alert.alert("Conversation details copied"); - }, [debugEnabled, topic]); - - return { onPress, onLongPress }; + return { headerTintColor }; }; const keyExtractor = (item: string) => item; @@ -218,43 +90,25 @@ const getItemTypeCallback = () => { return "MESSAGE"; }; -export const V3Conversation = ({ - route, - navigation, -}: NativeStackScreenProps) => { +type V3ConversationProps = { + topic: ConversationTopic; + textPrefill?: string; +}; + +export const V3Conversation = ({ topic, textPrefill }: V3ConversationProps) => { // TODO Update values + const navigation = useRouter(); const showChatInput = true; - const topic = route.params.topic!; - const { - conversation, - messages, - messagesLoading, - groupName, - groupPhoto, - groupPhotoLoading, - groupNameLoading, - members, - isRefetching, - debugEnabled, - memberData, - isLoading, - } = useData({ - topic, - }); + const { conversation, messages, messagesLoading, isRefetching, isLoading } = + useData({ + topic, + }); const currentAccount = useCurrentAccount()!; - const styles = useStyles(); - const { headerTintColor, displayAvatar } = useDisplayInfo({ - conversation, - groupPhotoLoading, - }); + const { themed } = useAppTheme(); + const { headerTintColor } = useDisplayInfo(); const onReadyToFocus = useCallback(() => {}, []); - const { onPress, onLongPress } = useUserInteraction({ - debugEnabled, - topic, - navigation, - }); const renderItem: ListRenderItem = useCallback( ({ item, index }) => ( @@ -273,43 +127,18 @@ export const V3Conversation = ({ (!conversation && !isLoading); const displayList = !showPlaceholder; - const avatarComponent = useMemo(() => { - if (displayAvatar) return null; - return groupPhoto ? ( - - ) : ( - - ); - }, [displayAvatar, groupPhoto, styles.avatar, memberData]); - useEffect(() => { navigation.setOptions({ - headerTitle: () => ( - - ), + headerTitle: () => { + if (!conversation) return null; + if (conversation.version === ConversationVersion.GROUP) { + return ; + } + return ; + }, headerTintColor, }); - }, [ - groupName, - headerTintColor, - navigation, - onLongPress, - onPress, - avatarComponent, - ]); + }, [headerTintColor, navigation, conversation, topic]); const onSend = useCallback( async ({ @@ -391,8 +220,8 @@ export const V3Conversation = ({ }, [conversation, messages?.ids.length, onSend]); const messageToPrefill = useMemo( - () => route.params?.text ?? getDraftMessage(topic) ?? "", - [route.params?.text, topic] + () => textPrefill ?? getDraftMessage(topic) ?? "", + [textPrefill, topic] ); const textInputRef = useRef(); @@ -421,8 +250,8 @@ export const V3Conversation = ({ return ( - - + + {}; diff --git a/components/Search/NavigationChatButton.tsx b/components/Search/NavigationChatButton.tsx index 1e0d44e0a..ebf806725 100644 --- a/components/Search/NavigationChatButton.tsx +++ b/components/Search/NavigationChatButton.tsx @@ -30,6 +30,7 @@ export function NavigationChatButton({ useShallow((s) => getProfile(address, s.profiles)) ); const preferredName = getPreferredName(profile?.socials, address); + const openChat = useCallback(() => { // On Android the accounts are not in the navigation but in a drawer @@ -40,6 +41,7 @@ export function NavigationChatButton({ navigate("Conversation", { mainConversationWithPeer: address, focus: true, + skipLoading: true, }); }, 300); }, [address, navigation]); diff --git a/components/V3DMListItem.tsx b/components/V3DMListItem.tsx index c87c69924..522dac9c0 100644 --- a/components/V3DMListItem.tsx +++ b/components/V3DMListItem.tsx @@ -45,41 +45,49 @@ const useDisplayInfo = ({ timestamp, isUnread }: UseDisplayInfoProps) => { }; export const V3DMListItem = ({ conversation }: V3DMListItemProps) => { - console.log("conversationMessageDebug111", conversation); const currentAccount = useCurrentAccount()!; + const { name: routeName } = useRoute(); + const isBlockedChatView = routeName === "Blocked"; + const { theme } = useAppTheme(); + const colorScheme = theme.isDark ? "dark" : "light"; + const topic = conversation.topic; + const ref = useRef(null); - const { topicsData, setTopicsData, setPinnedConversations } = useChatStore( - useSelect(["topicsData", "setTopicsData", "setPinnedConversations"]) + + const { setTopicsData, setPinnedConversations } = useChatStore( + useSelect(["setTopicsData", "setPinnedConversations"]) ); + const { data: peer } = useDmPeerInboxOnConversationList( currentAccount!, conversation ); - const [isContextMenuVisible, setIsContextMenuVisible] = useState(false); - const { timeToShow, leftActionIcon } = useDisplayInfo({ - timestamp: conversation.createdAt, - isUnread: false, - }); - const messageText = useMessageText(conversation.lastMessage); - const prefferedName = usePreferredInboxName(peer); - const avatarUri = usePreferredInboxAvatar(peer); + const [isContextMenuVisible, setIsContextMenuVisible] = useState(false); const timestamp = conversation?.lastMessage?.sentNs ?? 0; const isUnread = useConversationIsUnread({ - topicsData, topic, lastMessage: conversation.lastMessage, - conversation, + conversation: conversation, + timestamp, + }); + + const { timeToShow, leftActionIcon } = useDisplayInfo({ timestamp, + isUnread, }); + const messageText = useMessageText(conversation.lastMessage); + const prefferedName = usePreferredInboxName(peer); + const avatarUri = usePreferredInboxAvatar(peer); + const toggleReadStatus = useToggleReadStatus({ setTopicsData, topic, diff --git a/components/V3GroupConversationListItem.tsx b/components/V3GroupConversationListItem.tsx index 744398e20..0c239310b 100644 --- a/components/V3GroupConversationListItem.tsx +++ b/components/V3GroupConversationListItem.tsx @@ -45,8 +45,8 @@ const useData = ({ group }: UseDataProps) => { const isBlockedChatView = routeName === "Blocked"; const colorScheme = useColorScheme(); const currentAccount = useCurrentAccount()!; - const { topicsData, setTopicsData, setPinnedConversations } = useChatStore( - useSelect(["topicsData", "setTopicsData", "setPinnedConversations"]) + const { setTopicsData, setPinnedConversations } = useChatStore( + useSelect(["setTopicsData", "setPinnedConversations"]) ); const [isContextMenuVisible, setIsContextMenuVisible] = useState(false); @@ -59,7 +59,6 @@ const useData = ({ group }: UseDataProps) => { const timestamp = group?.lastMessage?.sentNs ?? 0; const isUnread = useConversationIsUnread({ - topicsData, topic, lastMessage: group.lastMessage, conversation: group, diff --git a/features/conversation-list/hooks/useMessageIsUnread.ts b/features/conversation-list/hooks/useMessageIsUnread.ts index 1ede6abfd..c1e5e37a2 100644 --- a/features/conversation-list/hooks/useMessageIsUnread.ts +++ b/features/conversation-list/hooks/useMessageIsUnread.ts @@ -1,25 +1,29 @@ import { useMemo } from "react"; -import { TopicsData } from "@data/store/chatStore"; +import { ChatStoreType, TopicsData } from "@data/store/chatStore"; import { ConversationWithCodecsType, DecodedMessageWithCodecsType, } from "@utils/xmtpRN/client"; +import { useChatStore } from "@data/store/accountsStore"; +import { useSelect } from "@data/store/storeHelpers"; type UseConversationIsUnreadProps = { - topicsData: TopicsData; topic: string; lastMessage: DecodedMessageWithCodecsType | undefined; conversation: ConversationWithCodecsType; timestamp: number; }; +const chatStoreSelectKeys: (keyof ChatStoreType)[] = ["topicsData"]; + export const useConversationIsUnread = ({ - topicsData, topic, lastMessage, conversation, timestamp, }: UseConversationIsUnreadProps) => { + const { topicsData } = useChatStore(useSelect(chatStoreSelectKeys)); + return useMemo(() => { if (topicsData[topic]?.status === "unread") { return true; diff --git a/features/conversations/components/ConversationTitleDumb.tsx b/features/conversations/components/ConversationTitleDumb.tsx new file mode 100644 index 000000000..39ad0e64d --- /dev/null +++ b/features/conversations/components/ConversationTitleDumb.tsx @@ -0,0 +1,56 @@ +import { Platform, TextStyle, ViewStyle } from "react-native"; + +import { getTitleFontScale } from "@utils/str"; +import { TouchableOpacity } from "@design-system/TouchableOpacity"; +import { AnimatedHStack } from "@design-system/HStack"; +import { Text } from "@design-system/Text"; +import { ThemedStyle, useAppTheme } from "@theme/useAppTheme"; +import { animations } from "@theme/animations"; + +type ConversationTitleDumbProps = { + title?: string; + avatarComponent?: React.ReactNode; + onLongPress?: () => void; + onPress?: () => void; +}; + +export function ConversationTitleDumb({ + avatarComponent, + title, + onLongPress, + onPress, +}: ConversationTitleDumbProps) { + const { themed } = useAppTheme(); + + return ( + // Add an animation since we are loading certain information and makes it feel faster + + + {avatarComponent} + + {title} + + + + ); +} + +const $container: ViewStyle = { flexDirection: "row", flexGrow: 1 }; + +const $touchableContainer: ThemedStyle = ({ spacing }) => ({ + flexDirection: "row", + justifyContent: "flex-start", + left: Platform.OS === "android" ? -36 : spacing.zero, + width: "100%", + alignItems: "center", + paddingRight: spacing.xxl, +}); + +const $title: ThemedStyle = ({ colors }) => ({ + color: colors.text.primary, + fontSize: Platform.OS === "ios" ? 16 * getTitleFontScale() : 18, +}); diff --git a/features/conversations/components/DmConversationTitle.tsx b/features/conversations/components/DmConversationTitle.tsx new file mode 100644 index 000000000..960c016b2 --- /dev/null +++ b/features/conversations/components/DmConversationTitle.tsx @@ -0,0 +1,91 @@ +import { useCallback } from "react"; +import { useConversationTitleLongPress } from "../hooks/useConversationTitleLongPress"; +import { useRouter } from "@navigation/useNavigation"; +import { NativeStackNavigationProp } from "@react-navigation/native-stack"; +import { NavigationParamList } from "@screens/Navigation/Navigation"; +import { useDmPeerAddressQuery } from "@queries/useDmPeerAddressQuery"; +import { useCurrentAccount } from "@data/store/accountsStore"; +import { ConversationTitleDumb } from "./ConversationTitleDumb"; +import { ConversationTopic } from "@xmtp/react-native-sdk"; +import { usePreferredName } from "@hooks/usePreferredName"; +import Avatar from "@components/Avatar"; +import { usePreferredAvatarUri } from "@hooks/usePreferredAvatarUri"; +import { AvatarSizes } from "@styles/sizes"; +import { ThemedStyle, useAppTheme } from "@theme/useAppTheme"; +import { ImageStyle, Platform } from "react-native"; +import { useProfileSocials } from "@hooks/useProfileSocials"; + +type DmConversationTitleProps = { + topic: ConversationTopic; +}; + +type UseUserInteractionProps = { + peerAddress?: string; + navigation: NativeStackNavigationProp; + topic: ConversationTopic; +}; + +const useUserInteraction = ({ + navigation, + peerAddress, + topic, +}: UseUserInteractionProps) => { + const onPress = useCallback(() => { + if (peerAddress) { + navigation.push("Profile", { address: peerAddress }); + } + }, [navigation, peerAddress]); + + const onLongPress = useConversationTitleLongPress(topic); + + return { onPress, onLongPress }; +}; + +export const DmConversationTitle = ({ topic }: DmConversationTitleProps) => { + const account = useCurrentAccount()!; + + const navigation = useRouter(); + + const { themed } = useAppTheme(); + + const { data: peerAddress, isLoading: peerAddressLoading } = + useDmPeerAddressQuery(account, topic); + + const { onPress, onLongPress } = useUserInteraction({ + peerAddress, + navigation, + topic, + }); + + const { isLoading } = useProfileSocials(peerAddress ?? ""); + + const preferredName = usePreferredName(peerAddress ?? ""); + + const preferredAvatarUri = usePreferredAvatarUri(peerAddress ?? ""); + + const displayAvatar = !peerAddressLoading && !isLoading; + + if (!displayAvatar) return null; + + return ( + + ) + } + /> + ); +}; + +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/conversations/components/GroupConversationTitle.tsx b/features/conversations/components/GroupConversationTitle.tsx new file mode 100644 index 000000000..12697578c --- /dev/null +++ b/features/conversations/components/GroupConversationTitle.tsx @@ -0,0 +1,95 @@ +import React, { memo, useCallback, useMemo } from "react"; +import { ConversationTitleDumb } from "./ConversationTitleDumb"; +import { useGroupNameQuery } from "@queries/useGroupNameQuery"; +import { ConversationTopic } from "@xmtp/react-native-sdk"; +import { useCurrentAccount } from "@data/store/accountsStore"; +import { NativeStackNavigationProp } from "@react-navigation/native-stack"; +import { NavigationParamList } from "@screens/Navigation/Navigation"; +import { ImageStyle, Platform } from "react-native"; +import { useRouter } from "@navigation/useNavigation"; +import { useGroupPhotoQuery } from "@queries/useGroupPhotoQuery"; +import Avatar from "@components/Avatar"; +import { AvatarSizes } from "@styles/sizes"; +import { ThemedStyle, useAppTheme } from "@theme/useAppTheme"; +import { GroupAvatarDumb } from "@components/GroupAvatar"; +import { useConversationTitleLongPress } from "../hooks/useConversationTitleLongPress"; +import { useGroupMembersAvatarData } from "../hooks/useGroupMembersAvatarData"; + +type GroupConversationTitleProps = { + topic: ConversationTopic; +}; + +type UseUserInteractionProps = { + topic: ConversationTopic; + navigation: NativeStackNavigationProp; +}; + +const useUserInteraction = ({ navigation, topic }: UseUserInteractionProps) => { + const onPress = useCallback(() => { + // textInputRef?.current?.blur(); + navigation.push("Group", { topic }); + }, [navigation, topic]); + + const onLongPress = useConversationTitleLongPress(topic); + + return { onPress, onLongPress }; +}; + +export const GroupConversationTitle = memo( + ({ topic }: GroupConversationTitleProps) => { + const currentAccount = useCurrentAccount()!; + + const { data: groupName, isLoading: groupNameLoading } = useGroupNameQuery( + currentAccount, + topic! + ); + + const { data: groupPhoto, isLoading: groupPhotoLoading } = + useGroupPhotoQuery(currentAccount, topic!); + + const { data: memberData } = useGroupMembersAvatarData({ topic }); + + const navigation = useRouter(); + + const { themed } = useAppTheme(); + + const { onPress, onLongPress } = useUserInteraction({ + topic, + navigation, + }); + + const displayAvatar = !groupPhotoLoading && !groupNameLoading; + + const avatarComponent = useMemo(() => { + return groupPhoto ? ( + + ) : ( + + ); + }, [groupPhoto, memberData, themed]); + + if (!displayAvatar) return null; + + return ( + + ); + } +); + +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/conversations/components/V3Conversation.tsx b/features/conversations/components/V3Conversation.tsx new file mode 100644 index 000000000..dfc145597 --- /dev/null +++ b/features/conversations/components/V3Conversation.tsx @@ -0,0 +1,302 @@ +import { ChatDumb } from "@components/Chat/ChatDumb"; +import { useCurrentAccount } from "@data/store/accountsStore"; +import { useConversationMessages } from "@queries/useConversationMessages"; +import { + setConversationQueryData, + useConversationScreenQuery, +} from "@queries/useConversationQuery"; +import { ListRenderItem } from "@shopify/flash-list"; +import { textPrimaryColor, textSecondaryColor } from "@styles/colors"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Platform, useColorScheme, View, ViewStyle } from "react-native"; + +import { GroupChatPlaceholder } from "@components/Chat/ChatPlaceholder/GroupChatPlaceholder"; +import { + ConversationTopic, + ConversationVersion, + RemoteAttachmentContent, +} from "@xmtp/react-native-sdk"; +import { ConversationContext } from "@utils/conversation"; +import { TextInputWithValue } from "@utils/str"; +import { MediaPreview } from "@data/store/chatStore"; +import { V3Message } from "@components/Chat/Message/V3Message"; +import { navigate } from "@utils/navigation"; +import { getDraftMessage, setDraftMessage } from "../utils/textDrafts"; +import { useRouter } from "@navigation/useNavigation"; +import { GroupConversationTitle } from "./GroupConversationTitle"; +import { DmConversationTitle } from "./DmConversationTitle"; +import { ThemedStyle, useAppTheme } from "@theme/useAppTheme"; +import { createConversationByAccount } from "@utils/xmtpRN/conversations"; +// import { DmChatPlaceholder } from "@components/Chat/ChatPlaceholder/ChatPlaceholder"; + +type UseDataProps = { + topic: ConversationTopic | undefined; +}; + +const useData = ({ topic }: UseDataProps) => { + const currentAccount = useCurrentAccount()!; + const { + data: conversation, + isLoading, + isRefetching, + } = useConversationScreenQuery(currentAccount, topic!); + + const { data: messages, isLoading: messagesLoading } = + useConversationMessages(currentAccount, topic!); + + useEffect(() => { + const checkActive = async () => { + if (!conversation) return; + if (conversation.version === ConversationVersion.GROUP) { + const isActive = conversation.isGroupActive; + // If not active leave the screen + if (!isActive) { + navigate("Chats"); + } + } + }; + checkActive(); + }, [conversation]); + + return { + conversation, + messages, + messagesLoading, + isLoading, + isRefetching, + }; +}; + +const $container: ViewStyle = { + flex: 1, +}; + +const $chatContainer: ThemedStyle = ({ colors }) => ({ + flex: 1, + justifyContent: "flex-end", + backgroundColor: colors.background.surface, +}); + +const useDisplayInfo = () => { + const colorScheme = useColorScheme(); + const headerTintColor = + Platform.OS === "android" + ? textSecondaryColor(colorScheme) + : textPrimaryColor(colorScheme); + return { headerTintColor }; +}; + +const keyExtractor = (item: string) => item; +const getItemTypeCallback = () => { + return "MESSAGE"; +}; + +type V3ConversationProps = { + topic?: ConversationTopic; + peerAddress?: string; + textPrefill?: string; +}; + +export const V3Conversation = ({ + topic, + peerAddress, + textPrefill, +}: V3ConversationProps) => { + // TODO Update values + const navigation = useRouter(); + const showChatInput = true; + + const { conversation, messages, messagesLoading, isRefetching, isLoading } = + useData({ + topic, + }); + const currentAccount = useCurrentAccount()!; + const { themed } = useAppTheme(); + const { headerTintColor } = useDisplayInfo(); + + const onReadyToFocus = useCallback(() => {}, []); + + const renderItem: ListRenderItem = useCallback( + ({ item, index }) => ( + + ), + [currentAccount, topic] + ); + + const showPlaceholder = + ((messages?.ids.length ?? 0) === 0 && !messagesLoading) || + (!conversation && !isLoading); + const displayList = !showPlaceholder; + + useEffect(() => { + navigation.setOptions({ + headerTitle: () => { + if (!conversation) return null; + if (conversation.version === ConversationVersion.GROUP) { + return ; + } + return ; + }, + headerTintColor, + }); + }, [headerTintColor, navigation, conversation, topic]); + + const handleSend = useCallback( + async (sendContent: SendContent) => { + if (!conversation && peerAddress) { + const conversation = await createConversationByAccount( + currentAccount, + peerAddress + ); + setConversationQueryData( + currentAccount, + conversation.topic, + conversation + ); + navigation.setParams({ topic: conversation.topic }); + await conversation.send(sendContent); + return; + } + await conversation?.send(sendContent); + }, + [conversation, currentAccount, navigation, peerAddress] + ); + + const onSend = useCallback( + async ({ + text, + referencedMessageId, + attachment, + }: { + text?: string; + referencedMessageId?: string; + attachment?: RemoteAttachmentContent; + }) => { + if (referencedMessageId) { + if (attachment) { + await handleSend({ + reply: { + reference: referencedMessageId, + content: { remoteAttachment: attachment }, + }, + }); + } + if (text) { + await handleSend({ + reply: { + reference: referencedMessageId, + content: { text }, + }, + }); + } + return; + } + if (attachment) { + await handleSend({ + remoteAttachment: attachment, + }); + } + if (text) { + await handleSend(text); + } + }, + [handleSend] + ); + + const onLeaveScreen = useCallback(() => { + // useChatStore.getState().setOpenedConversationTopic(null); + setDraftMessage(topic!, textInputRef.current?.currentValue ?? ""); + }, [topic]); + + useEffect(() => { + const unsubscribeBeforeRemove = navigation.addListener( + "beforeRemove", + onLeaveScreen + ); + + return () => { + unsubscribeBeforeRemove(); + }; + }, [navigation, onLeaveScreen]); + + const placeholderComponent = useMemo(() => { + if (!conversation) return null; + if (conversation.version == ConversationVersion.GROUP) { + return ( + + ); + } + // TODO: Add DM placeholder + return null; + // return ( + // + // ); + }, [conversation, messages?.ids.length, onSend]); + + const messageToPrefill = useMemo( + () => textPrefill ?? getDraftMessage(topic!) ?? "", + [textPrefill, topic] + ); + + const textInputRef = useRef(); + const mediaPreviewRef = useRef(); + const [frameTextInputFocused, setFrameTextInputFocused] = useState(false); + const tagsFetchedOnceForMessage = useRef<{ [messageId: string]: boolean }>( + {} + ); + + const conversationContextValue = useMemo( + () => ({ + topic, + conversation: undefined, + messageToPrefill, + inputRef: textInputRef, + mediaPreviewToPrefill: null, + mediaPreviewRef, + isBlockedPeer: false, + onReadyToFocus, + frameTextInputFocused, + setFrameTextInputFocused, + tagsFetchedOnceForMessage, + }), + [topic, messageToPrefill, onReadyToFocus, frameTextInputFocused] + ); + + return ( + + + + + + + + ); +}; diff --git a/features/conversations/components/V3ConversationFromPeer.tsx b/features/conversations/components/V3ConversationFromPeer.tsx new file mode 100644 index 000000000..319ecac8f --- /dev/null +++ b/features/conversations/components/V3ConversationFromPeer.tsx @@ -0,0 +1,57 @@ +import { useConversationWithPeerQuery } from "@queries/useConversationQuery"; +import { V3Conversation } from "./V3Conversation"; +import ActivityIndicator from "@components/ActivityIndicator/ActivityIndicator"; +import { useCurrentAccount } from "@data/store/accountsStore"; +import { memo } from "react"; +import { VStack } from "@design-system/VStack"; +import { ThemedStyle, useAppTheme } from "@theme/useAppTheme"; +import { ViewStyle } from "react-native"; + +type V3ConversationFromPeerProps = { + peer: string; + textPrefill?: string; + skipLoading: boolean; +}; + +/** + * A component that renders a conversation from a peer. + * It is used to render a conversation from a peer. + * This is a wrapper around the V3Conversation component to help load the conversation and abstract some logic. + * If we want the best peformance we should rework this component + */ +export const V3ConversationFromPeer = memo( + ({ peer, textPrefill, skipLoading }: V3ConversationFromPeerProps) => { + const currentAccount = useCurrentAccount()!; + + const { data: conversation, isLoading } = useConversationWithPeerQuery( + currentAccount, + peer, + { + enabled: !skipLoading, + } + ); + + const { themed } = useAppTheme(); + if (isLoading && !skipLoading) { + return ( + + + + ); + } + return ( + + ); + } +); + +const $container: ThemedStyle = (theme) => ({ + flex: 1, + justifyContent: "center", + alignItems: "center", + backgroundColor: theme.colors.background.surface, +}); diff --git a/features/conversations/hooks/useConversationTitleLongPress.ts b/features/conversations/hooks/useConversationTitleLongPress.ts new file mode 100644 index 000000000..af9fe6d32 --- /dev/null +++ b/features/conversations/hooks/useConversationTitleLongPress.ts @@ -0,0 +1,16 @@ +import { useDebugEnabled } from "@components/DebugButton"; +import Clipboard from "@react-native-clipboard/clipboard"; +import { useCallback } from "react"; + +export const useConversationTitleLongPress = (topic: string) => { + const debugEnabled = useDebugEnabled(); + + return useCallback(() => { + if (!debugEnabled) return; + Clipboard.setString( + JSON.stringify({ + topic, + }) + ); + }, [debugEnabled, topic]); +}; diff --git a/features/conversations/hooks/useGroupMembersAvatarData.ts b/features/conversations/hooks/useGroupMembersAvatarData.ts new file mode 100644 index 000000000..f1c3eda33 --- /dev/null +++ b/features/conversations/hooks/useGroupMembersAvatarData.ts @@ -0,0 +1,59 @@ +import { useCurrentAccount } from "@data/store/accountsStore"; +import { useProfilesSocials } from "@hooks/useProfilesSocials"; +import { useGroupMembersConversationScreenQuery } from "@queries/useGroupMembersQuery"; +import { getPreferredAvatar } from "@utils/profile/getPreferredAvatar"; +import { getPreferredName } from "@utils/profile/getPreferredName"; +import { ConversationTopic } from "@xmtp/react-native-sdk"; +import { useMemo } from "react"; + +type UseGroupMembersAvatarDataProps = { + topic: ConversationTopic; +}; + +export const useGroupMembersAvatarData = ({ + topic, +}: UseGroupMembersAvatarDataProps) => { + const currentAccount = useCurrentAccount()!; + const { data: members, ...query } = useGroupMembersConversationScreenQuery( + currentAccount, + topic + ); + + const memberAddresses = useMemo(() => { + const addresses: string[] = []; + for (const memberId of members?.ids ?? []) { + const member = members?.byId[memberId]; + if ( + member?.addresses[0] && + member?.addresses[0].toLowerCase() !== currentAccount?.toLowerCase() + ) { + addresses.push(member?.addresses[0]); + } + } + 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], + } + ); + }, [data, memberAddresses]); + + return { data: memberData, ...query }; +}; diff --git a/ios/Podfile b/ios/Podfile index 0d097aac7..17ee9628c 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -18,7 +18,7 @@ install! 'cocoapods', # Version must match version from XMTP Podspec (matching @xmtp/react-native-sdk from package.json) # https://github.com/xmtp/xmtp-react-native/blob/v2.6.2/ios/XMTPReactNative.podspec#L29 -$xmtpVersion = '3.0.4' +$xmtpVersion = '3.0.6' # Pinning MMKV to 1.3.3 that has included that fix https://github.com/Tencent/MMKV/pull/1222#issuecomment-1905164314 $mmkvVersion = '1.3.3' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 25c9befb2..715763ea9 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -391,7 +391,7 @@ PODS: - libwebp/sharpyuv (1.3.2) - libwebp/webp (1.3.2): - libwebp/sharpyuv - - LibXMTP (3.0.0) + - LibXMTP (3.0.3) - Logging (1.0.0) - MessagePacker (0.4.7) - MMKV (1.3.3): @@ -1838,16 +1838,16 @@ PODS: - GenericJSON (~> 2.0) - Logging (~> 1.0.0) - secp256k1.swift (~> 0.1) - - XMTP (3.0.4): + - XMTP (3.0.6): - Connect-Swift (= 0.12.0) - GzipSwift - - LibXMTP (= 3.0.0) + - LibXMTP (= 3.0.3) - web3.swift - - XMTPReactNative (3.0.2): + - XMTPReactNative (3.0.6): - ExpoModulesCore - MessagePacker - secp256k1.swift - - XMTP (= 3.0.4) + - XMTP (= 3.0.6) - Yoga (0.0.0) DEPENDENCIES: @@ -1987,7 +1987,7 @@ DEPENDENCIES: - Sentry/HybridSDK (= 8.36.0) - SQLite.swift - UMAppLoader (from `../node_modules/unimodules-app-loader/ios`) - - XMTP (= 3.0.4) + - XMTP (= 3.0.6) - "XMTPReactNative (from `../node_modules/@xmtp/react-native-sdk/ios`)" - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) @@ -2346,7 +2346,7 @@ SPEC CHECKSUMS: libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009 - LibXMTP: 4ef99026c3b353bd27195b48580e1bd34d083c3a + LibXMTP: 948d39cf5b978adaa7d0f6ea5c6c0995a0b9e63f Logging: 9ef4ecb546ad3169398d5a723bc9bea1c46bef26 MessagePacker: ab2fe250e86ea7aedd1a9ee47a37083edd41fd02 MMKV: f902fb6719da13c2ab0965233d8963a59416f911 @@ -2447,10 +2447,10 @@ SPEC CHECKSUMS: SwiftProtobuf: 407a385e97fd206c4fbe880cc84123989167e0d1 UMAppLoader: f17a5ee8e85b536ace0fc254b447a37ed198d57e web3.swift: 2263d1e12e121b2c42ffb63a5a7beb1acaf33959 - XMTP: dba23b4f3bcee464ca2f7569e1dc05fd9f4c0148 - XMTPReactNative: ce631cd4fe844631bfa7e1c88893b18eb60ed7bd + XMTP: 48d0c71ef732ac4d79c2942902a132bf71661029 + XMTPReactNative: 080300cc2cb53ffd117d2808c4d9922357ce1d34 Yoga: 1ab23c1835475da69cf14e211a560e73aab24cb0 -PODFILE CHECKSUM: 39eb314bf7be6485679e8b92359907252aa642a5 +PODFILE CHECKSUM: 3f943f557d5555a4dc514bf4a6dd16cb0a9ac507 COCOAPODS: 1.15.2 diff --git a/package.json b/package.json index 088b03204..ca8d5be7b 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "@xmtp/content-type-transaction-reference": "^1.0.3", "@xmtp/frames-client": "^0.5.4", "@xmtp/proto": "^3.60.0", - "@xmtp/react-native-sdk": "^3.0.0", + "@xmtp/react-native-sdk": "^3.0.6", "@xmtp/xmtp-js": "11.5.0", "@yornaath/batshit": "^0.10.1", "alchemy-sdk": "^3.4.4", diff --git a/queries/QueryKeys.ts b/queries/QueryKeys.ts index b8221630c..8d423e8ba 100644 --- a/queries/QueryKeys.ts +++ b/queries/QueryKeys.ts @@ -4,6 +4,7 @@ export enum QueryKeys { // Conversations CONVERSATIONS = "conversations", // When changing the shape of response, update the keys as persistance will break CONVERSATION = "conversation", + CONVERSATION_WITH_PEER = "conversationWithPeer", V3_CONVERSATION_LIST = "v3ConversationList", // Messages @@ -44,6 +45,12 @@ export const conversationQueryKey = ( topic: ConversationTopic ) => [QueryKeys.CONVERSATION, account.toLowerCase(), topic]; +export const conversationWithPeerQueryKey = (account: string, peer: string) => [ + QueryKeys.CONVERSATION_WITH_PEER, + account.toLowerCase(), + peer, +]; + export const conversationMessagesQueryKey = ( account: string, topic: ConversationTopic diff --git a/queries/useConversationQuery.ts b/queries/useConversationQuery.ts index b5b065b4c..84b6f89b2 100644 --- a/queries/useConversationQuery.ts +++ b/queries/useConversationQuery.ts @@ -7,10 +7,16 @@ import { import { getXmtpClient } from "@utils/xmtpRN/sync"; import type { ConversationTopic } from "@xmtp/react-native-sdk"; -import { conversationQueryKey } from "./QueryKeys"; +import { + conversationQueryKey, + conversationWithPeerQueryKey, +} from "./QueryKeys"; import { queryClient } from "./queryClient"; import logger from "@utils/logger"; -import { getConversationByTopicByAccount } from "@utils/xmtpRN/conversations"; +import { + getConversationByPeerByAccount, + getConversationByTopicByAccount, +} from "@utils/xmtpRN/conversations"; export const useConversationQuery = ( account: string, @@ -36,32 +42,55 @@ export const useConversationQuery = ( }); }; +export const useConversationWithPeerQuery = ( + account: string, + peer: string | undefined, + options?: Partial< + UseQueryOptions + > +) => { + return useQuery({ + ...options, + queryKey: conversationWithPeerQueryKey(account, peer!), + queryFn: async () => { + logger.info("[Crash Debug] queryFn fetching conversation with peer"); + if (!peer) { + return null; + } + const conversation = await getConversationByPeerByAccount({ + account, + peer, + includeSync: true, + }); + return conversation; + }, + enabled: !!peer, + }); +}; + export const useConversationScreenQuery = ( account: string, - topic: ConversationTopic, + topic: ConversationTopic | undefined, options?: Partial< UseQueryOptions > ) => { return useQuery({ ...options, - queryKey: conversationQueryKey(account, topic), + queryKey: conversationQueryKey(account, topic!), queryFn: async () => { logger.info("[Crash Debug] queryFn fetching group"); if (!topic) { return null; } - const client = (await getXmtpClient(account)) as ConverseXmtpClientType; - if (!client) { - return null; - } - const conversation = - await client.conversations.findConversationByTopic(topic); - await conversation?.sync(); - + const conversation = await getConversationByTopicByAccount({ + account, + topic, + includeSync: true, + }); return conversation; }, - enabled: isV3Topic(topic), + enabled: !!topic, }); }; diff --git a/queries/useDmPeerAddressQuery.ts b/queries/useDmPeerAddressQuery.ts new file mode 100644 index 000000000..f837b77d6 --- /dev/null +++ b/queries/useDmPeerAddressQuery.ts @@ -0,0 +1,15 @@ +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/screens/Conversation.tsx b/screens/Conversation.tsx index 883bf48d0..4772b1dc1 100644 --- a/screens/Conversation.tsx +++ b/screens/Conversation.tsx @@ -1,21 +1,33 @@ -import { V3Conversation } from "@components/Conversation/V3Conversation"; +import { V3Conversation } from "../features/conversations/components/V3Conversation"; import { NativeStackScreenProps } from "@react-navigation/native-stack"; -import { backgroundColor, headerTitleStyle } from "@styles/colors"; import { isV3Topic } from "@utils/groupUtils/groupId"; import React from "react"; -import { StyleSheet, View, useColorScheme } from "react-native"; import { gestureHandlerRootHOC } from "react-native-gesture-handler"; import { NavigationParamList } from "./Navigation/Navigation"; +import { V3ConversationFromPeer } from "../features/conversations/components/V3ConversationFromPeer"; +import { VStack } from "@design-system/VStack"; const ConversationHoc = ({ route, - navigation, }: NativeStackScreenProps) => { if (route.params?.topic && isV3Topic(route.params.topic)) { - return ; + return ( + + ); } - // TODO: Inform user that the conversation is not available - return ; + if (route.params?.mainConversationWithPeer) { + return ( + + ); + } + return ; }; export default gestureHandlerRootHOC(ConversationHoc); diff --git a/screens/Navigation/ConversationNav.tsx b/screens/Navigation/ConversationNav.tsx index 1ed2f3249..82bdfb4c7 100644 --- a/screens/Navigation/ConversationNav.tsx +++ b/screens/Navigation/ConversationNav.tsx @@ -24,6 +24,7 @@ export type ConversationNavParams = { text?: string; focus?: boolean; mainConversationWithPeer?: string; + skipLoading?: boolean; }; export const ConversationScreenConfig = { @@ -40,10 +41,12 @@ export default function ConversationNav( routeParams?: ConversationNavParams | undefined ) { const colorScheme = useColorScheme(); + const navigationOptions = useCallback( ({ navigation }: { navigation: NavigationProp }) => ({ headerShadowVisible: false, animation: navigationAnimation as StackAnimationTypes, + title: "", headerLeft: () => { const handleBack = () => { navigation.canGoBack() && navigation.goBack(); diff --git a/utils/xmtpRN/api.ts b/utils/xmtpRN/api.ts index a36f3aed2..3cee85d28 100644 --- a/utils/xmtpRN/api.ts +++ b/utils/xmtpRN/api.ts @@ -7,37 +7,38 @@ import { loadXmtpKey } from "../keychain/helpers"; export const xmtpSignatureByAccount: { [account: string]: string } = {}; const getXmtpApiSignature = async (account: string, message: string) => { - const messageToSign = Buffer.from(message); - const base64Key = await loadXmtpKey(account); - if (!base64Key) - throw new Error(`Cannot create signature for ${account}: no key found`); + return ""; + // const messageToSign = Buffer.from(message); + // const base64Key = await loadXmtpKey(account); + // if (!base64Key) + // throw new Error(`Cannot create signature for ${account}: no key found`); - const privateKeyBundle = privateKey.PrivateKeyBundle.decode( - Buffer.from(base64Key, "base64") - ); - const privateKeySecp256k1 = - privateKeyBundle.v1?.identityKey?.secp256k1 || - privateKeyBundle.v2?.identityKey?.secp256k1; - if (!privateKeySecp256k1) - throw new Error("Could not extract private key from private key bundle"); + // const privateKeyBundle = privateKey.PrivateKeyBundle.decode( + // Buffer.from(base64Key, "base64") + // ); + // const privateKeySecp256k1 = + // privateKeyBundle.v1?.identityKey?.secp256k1 || + // privateKeyBundle.v2?.identityKey?.secp256k1; + // if (!privateKeySecp256k1) + // throw new Error("Could not extract private key from private key bundle"); - const [signedBytes, recovery] = await secp.sign( - messageToSign, - privateKeySecp256k1.bytes, - { - recovered: true, - der: false, - } - ); - const signatureProto = signature.Signature.fromPartial({ - ecdsaCompact: { bytes: signedBytes, recovery }, - }); - const encodedSignature = Buffer.from( - signature.Signature.encode(signatureProto).finish() - ).toString("base64"); - const secureMmkv = await getSecureMmkvForAccount(account); - secureMmkv.set("CONVERSE_API_KEY", encodedSignature); - return encodedSignature; + // const [signedBytes, recovery] = await secp.sign( + // messageToSign, + // privateKeySecp256k1.bytes, + // { + // recovered: true, + // der: false, + // } + // ); + // const signatureProto = signature.Signature.fromPartial({ + // ecdsaCompact: { bytes: signedBytes, recovery }, + // }); + // const encodedSignature = Buffer.from( + // signature.Signature.encode(signatureProto).finish() + // ).toString("base64"); + // const secureMmkv = await getSecureMmkvForAccount(account); + // secureMmkv.set("CONVERSE_API_KEY", encodedSignature); + // return encodedSignature; }; export const getXmtpApiHeaders = async (account: string) => { @@ -49,7 +50,7 @@ export const getXmtpApiHeaders = async (account: string) => { const xmtpApiSignature = await getXmtpApiSignature(account, "XMTP_IDENTITY"); xmtpSignatureByAccount[account] = xmtpApiSignature; return { - "xmtp-api-signature": xmtpApiSignature, - "xmtp-api-address": account, + "xmtp-api-signature": "xmtpApiSignature", + "xmtp-api-address": "account", }; }; diff --git a/utils/xmtpRN/conversations.ts b/utils/xmtpRN/conversations.ts index eda9dd822..395150105 100644 --- a/utils/xmtpRN/conversations.ts +++ b/utils/xmtpRN/conversations.ts @@ -3,10 +3,15 @@ import { ConversationOrder, ConversationOptions, ConversationTopic, + ConversationVersion, } from "@xmtp/react-native-sdk"; import { PermissionPolicySet } from "@xmtp/react-native-sdk/build/lib/types/PermissionPolicySet"; -import { ConversationWithCodecsType, ConverseXmtpClientType } from "./client"; +import { + ConversationWithCodecsType, + ConverseXmtpClientType, + DmWithCodecsType, +} from "./client"; import { getXmtpClient } from "./sync"; import { getV3IdFromTopic } from "@utils/groupUtils/groupId"; @@ -169,6 +174,71 @@ export const getConversationByTopic = async ({ return conversation; }; +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(); + let conversation = await client.conversations.findDmByAddress(peer); + 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` + ); + conversation = await client.conversations.findDmByAddress(peer); + } + if (!conversation) { + throw new Error(`Conversation with peer ${peer} not found`); + } + if (includeSync) { + 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] Got conversation by topic in ${ + (end - start) / 1000 + } sec` + ); + return conversation; +}; + +type GetConversationByPeerByAccountParams = { + account: string; + peer: string; + includeSync?: boolean; +}; + +export const getConversationByPeerByAccount = async ({ + account, + peer, + includeSync = false, +}: GetConversationByPeerByAccountParams) => { + const client = (await getXmtpClient(account)) as ConverseXmtpClientType; + return getConversationByPeer({ client, peer, includeSync }); +}; + type GetDmByAddressParams = { client: ConverseXmtpClientType; address: string; @@ -409,3 +479,27 @@ export const refreshProtocolConversationByAccount = async ({ const client = (await getXmtpClient(account)) as ConverseXmtpClientType; return refreshProtocolConversation({ client, topic }); }; + +export const getPeerAddressDm = async (dm: DmWithCodecsType) => { + const peerInboxId = await dm.peerInboxId(); + const peerAddress = (await dm.members()).find( + (member) => member.inboxId === peerInboxId + )?.addresses[0]; + return peerAddress; +}; + +export const getPeerAddressFromTopic = async ( + account: string, + topic: ConversationTopic +) => { + 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"); +}; diff --git a/yarn.lock b/yarn.lock index e7b817b65..7ed3546d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7991,10 +7991,10 @@ rxjs "^7.8.0" undici "^5.8.1" -"@xmtp/react-native-sdk@^3.0.0": - version "3.0.2" - resolved "https://registry.yarnpkg.com/@xmtp/react-native-sdk/-/react-native-sdk-3.0.2.tgz#14c0691ac780d45d48936002bb42ab6ecacbe538" - integrity sha512-7Er5F2sj7L7xJ7o2hAe3Q6UIf64Tyyd+MaKmFmrlS0yeEpGc7E8rr+pgS5Ak3eFFzlBjonm0EmCztfwk450s7g== +"@xmtp/react-native-sdk@^3.0.6": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@xmtp/react-native-sdk/-/react-native-sdk-3.0.6.tgz#7044b6e59dcb4c77ba8c13f494190e182a5eba11" + integrity sha512-EIdy+AhmgFiYnQ9pJPdp7vGp30sAxJJ6rVGiQ/Z7TfbWcaruEgV5Etrn4Ju6BksXf8xw3o1ZgXmez2QZ0yi4XQ== dependencies: "@ethersproject/bytes" "^5.7.0" "@msgpack/msgpack" "^3.0.0-beta2"