diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 1278871..d9ee0d9 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1222,7 +1222,7 @@ PODS: - GzipSwift - LibXMTP (= 0.4.1-beta2) - web3.swift - - XMTPReactNative (1.27.0-beta.6): + - XMTPReactNative (1.27.0-beta.8): - ExpoModulesCore - MessagePacker - secp256k1.swift @@ -1636,7 +1636,7 @@ SPEC CHECKSUMS: WatermelonDB: 842d22ba555425aa9f3ce551239a001200c539bc web3.swift: 2263d1e12e121b2c42ffb63a5a7beb1acaf33959 XMTP: b70e7b864e38d430d2b55e813f33eec775ed0f0d - XMTPReactNative: ca17e3be61be4744a89182c372228c4a776a4693 + XMTPReactNative: 2b9f6fce13a1cda76d600edc76bb7967fdef845f Yoga: e64aa65de36c0832d04e8c7bd614396c77a80047 PODFILE CHECKSUM: c765268d8eab018a5f4619e1d00ca4dab437bc4f diff --git a/package.json b/package.json index 5a3bcca..1909bf8 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "@tanstack/react-query": "^5.17.19", "@thirdweb-dev/react-native": "^0.5.4", "@thirdweb-dev/react-native-compat": "^0.5.4", - "@xmtp/react-native-sdk": "1.27.0-beta.6", + "@xmtp/react-native-sdk": "1.27.0-beta.8", "aws-sdk": "^2.1540.0", "ethers": "^5", "expo": ">=50.0.0-0 <51.0.0", diff --git a/src/components/Blockie.tsx b/src/components/Blockie.tsx index ad5cd20..ce34699 100644 --- a/src/components/Blockie.tsx +++ b/src/components/Blockie.tsx @@ -96,7 +96,10 @@ function createImageData(size: number): number[] { export const Blockie: FC = ({address, size: propSize}) => { const {size, scale, bgcolor, color, spotcolor} = useMemo(() => { - return buildBlockieOpts({seed: address, size: propSize}); + return buildBlockieOpts({ + seed: address.toLocaleLowerCase(), + size: propSize, + }); }, [address, propSize]); const imageData = useMemo(() => { diff --git a/src/components/ConversationMessageContent.tsx b/src/components/ConversationMessageContent.tsx index e8f122e..f85e8f9 100644 --- a/src/components/ConversationMessageContent.tsx +++ b/src/components/ConversationMessageContent.tsx @@ -1,14 +1,14 @@ import {DecodedMessage, RemoteAttachmentContent} from '@xmtp/react-native-sdk'; import {Container} from 'native-base'; import React, {FC} from 'react'; -import {ContentTypes} from '../consts/ContentTypes'; +import {ContentTypes, SupportedContentTypes} from '../consts/ContentTypes'; import {translate} from '../i18n'; import {colors} from '../theme/colors'; import {ImageMessage} from './ImageMessage'; import {Text} from './common/Text'; interface ConversationMessageContentProps { - message: DecodedMessage; + message: DecodedMessage; isMe: boolean; } diff --git a/src/components/GroupAvatarStack.tsx b/src/components/GroupAvatarStack.tsx index cca22c7..e993857 100644 --- a/src/components/GroupAvatarStack.tsx +++ b/src/components/GroupAvatarStack.tsx @@ -1,28 +1,40 @@ import {Box, HStack} from 'native-base'; import React, {FC} from 'react'; import {StyleSheet, ViewStyle} from 'react-native'; +import {useContactInfo} from '../hooks/useContactInfo'; import {AvatarWithFallback} from './AvatarWithFallback'; interface GroupAvatarStackProps { - data: { - avatarUrl: string | null; - address: string; - }[]; + addresses: string[]; style?: ViewStyle; } -export const GroupAvatarStack: FC = ({data, style}) => { +const AvatarWithLoader = ({address}: {address: string}) => { + const {avatarUrl, loading} = useContactInfo(address); + if (loading) { + return ( + + ); + } + return ( + + ); +}; + +export const GroupAvatarStack: FC = ({ + addresses, + style, +}) => { return ( - {data.slice(0, 4).map(({avatarUrl, address}) => ( - + {addresses.slice(0, 4).map(address => ( + ))} diff --git a/src/components/GroupHeader.tsx b/src/components/GroupHeader.tsx index 3d87760..218cd99 100644 --- a/src/components/GroupHeader.tsx +++ b/src/components/GroupHeader.tsx @@ -2,7 +2,7 @@ import {BlurView} from '@react-native-community/blur'; import {Box, HStack, VStack} from 'native-base'; import React, {FC, PropsWithChildren} from 'react'; import {Platform, Pressable, StyleSheet} from 'react-native'; -import {useGroupContactInfo} from '../hooks/useGroupContactInfo'; +import {useGroupName} from '../hooks/useGroupName'; import {useTypedNavigation} from '../hooks/useTypedNavigation'; import {colors} from '../theme/colors'; import {GroupAvatarStack} from './GroupAvatarStack'; @@ -34,7 +34,7 @@ export const GroupHeader: FC = ({ onGroupPress, }) => { const {goBack} = useTypedNavigation(); - const {data, groupDisplayName} = useGroupContactInfo(peerAddresses); + const groupDisplayName = useGroupName(peerAddresses); return ( @@ -61,7 +61,10 @@ export const GroupHeader: FC = ({ */} - + diff --git a/src/components/GroupListItem.tsx b/src/components/GroupListItem.tsx index 96fb109..6b26de9 100644 --- a/src/components/GroupListItem.tsx +++ b/src/components/GroupListItem.tsx @@ -2,7 +2,8 @@ import {Group} from '@xmtp/react-native-sdk/build/lib/Group'; import {Box, HStack, VStack} from 'native-base'; import React, {FC} from 'react'; import {Pressable} from 'react-native'; -import {useGroupContactInfo} from '../hooks/useGroupContactInfo'; +import {SupportedContentTypes} from '../consts/ContentTypes'; +import {useGroupName} from '../hooks/useGroupName'; import {useTypedNavigation} from '../hooks/useTypedNavigation'; import {ScreenNames} from '../navigation/ScreenNames'; import {useGroupParticipantsQuery} from '../queries/useGroupParticipantsQuery'; @@ -11,7 +12,7 @@ import {GroupAvatarStack} from './GroupAvatarStack'; import {Text} from './common/Text'; interface GroupListItemProps { - group: Group; + group: Group; display: string; lastMessageTime: number; } @@ -23,7 +24,7 @@ export const GroupListItem: FC = ({ }) => { const {data: addresses} = useGroupParticipantsQuery(group?.id); const {navigate} = useTypedNavigation(); - const {data, groupDisplayName} = useGroupContactInfo(addresses ?? []); + const groupName = useGroupName(addresses ?? []); return ( = ({ }}> - + - {groupDisplayName} + {groupName} {display} diff --git a/src/components/modals/AddGroupParticipantModal.tsx b/src/components/modals/AddGroupParticipantModal.tsx index bd892d4..ffa191b 100644 --- a/src/components/modals/AddGroupParticipantModal.tsx +++ b/src/components/modals/AddGroupParticipantModal.tsx @@ -1,7 +1,12 @@ import {Group} from '@xmtp/react-native-sdk/build/lib/Group'; import {Box, FlatList, HStack, Input, Pressable, VStack} from 'native-base'; import React, {FC, useCallback, useMemo, useState} from 'react'; -import {ListRenderItem, useWindowDimensions} from 'react-native'; +import { + DeviceEventEmitter, + ListRenderItem, + useWindowDimensions, +} from 'react-native'; +import {EventEmitterEvents} from '../../consts/EventEmitters'; import {TestIds} from '../../consts/TestIds'; import {useContactInfo} from '../../hooks/useContactInfo'; import {useContacts} from '../../hooks/useContacts'; @@ -69,6 +74,7 @@ export const AddGroupParticipantModal: FC = ({ const onAdd = useCallback(async () => { await group.addMembers(participants); + DeviceEventEmitter.emit(`${EventEmitterEvents.GROUP_CHANGED}_${group.id}`); hide(); }, [participants, group, hide]); diff --git a/src/components/modals/GroupInfoModal.tsx b/src/components/modals/GroupInfoModal.tsx index bf3ec81..0d6c38b 100644 --- a/src/components/modals/GroupInfoModal.tsx +++ b/src/components/modals/GroupInfoModal.tsx @@ -1,7 +1,9 @@ import {Group} from '@xmtp/react-native-sdk/build/lib/Group'; import {HStack, Pressable, VStack} from 'native-base'; import React, {FC, useCallback} from 'react'; +import {DeviceEventEmitter} from 'react-native'; import {AppConfig} from '../../consts/AppConfig'; +import {EventEmitterEvents} from '../../consts/EventEmitters'; import {useContactInfo} from '../../hooks/useContactInfo'; import {translate} from '../../i18n'; import {colors} from '../../theme/colors'; @@ -78,6 +80,9 @@ export const GroupInfoModal: FC = ({ const onRemovePress = useCallback( async (address: string) => { await group.removeMembers([address]); + DeviceEventEmitter.emit( + `${EventEmitterEvents.GROUP_CHANGED}_${group.id}`, + ); hide(); }, [group, hide], diff --git a/src/consts/ContentTypes.ts b/src/consts/ContentTypes.ts index fa37319..82180ad 100644 --- a/src/consts/ContentTypes.ts +++ b/src/consts/ContentTypes.ts @@ -1,5 +1,45 @@ +import { + ContentTypeId, + NativeContentCodec, + NativeMessageContent, + RemoteAttachmentCodec, +} from '@xmtp/react-native-sdk'; + +type GroupChangeContent = unknown; + +class GroupChangeCodec implements NativeContentCodec { + contentKey: 'groupChange' = 'groupChange'; + contentType: ContentTypeId = { + authorityId: 'xmtp.org', + typeId: 'group_membership_change', + versionMajor: 1, + versionMinor: 0, + }; + + encode(): NativeMessageContent { + return { + // remoteAttachment: content, + }; + } + + decode(nativeContent: NativeMessageContent): GroupChangeContent { + return nativeContent.text!; + } + + fallback(): string | undefined { + return 'This app doesn’t support attachments.'; + } +} + export const ContentTypes = { Text: 'xmtp.org/text:1.0', RemoteStaticAttachment: 'xmtp.org/remoteStaticAttachment:1.0', GroupMembershipChange: 'xmtp.org/group_membership_change:1.0', }; + +export const supportedContentTypes = [ + new RemoteAttachmentCodec(), + new GroupChangeCodec(), +]; + +export type SupportedContentTypes = typeof supportedContentTypes; diff --git a/src/consts/EventEmitters.ts b/src/consts/EventEmitters.ts index 221a357..d3845ec 100644 --- a/src/consts/EventEmitters.ts +++ b/src/consts/EventEmitters.ts @@ -1,4 +1,7 @@ export enum EventEmitterEvents { CREATE_IDENTITY = 'CREATE_IDENTITY', ENABLE_IDENTITY = 'ENABLE_IDENTITY', + + // Group Participants + GROUP_CHANGED = 'GROUP_CHANGED', } diff --git a/src/context/ClientContext.tsx b/src/context/ClientContext.tsx index beb60da..6e0e118 100644 --- a/src/context/ClientContext.tsx +++ b/src/context/ClientContext.tsx @@ -1,5 +1,5 @@ import {useAddress, useConnectionStatus} from '@thirdweb-dev/react-native'; -import {Client, RemoteAttachmentCodec} from '@xmtp/react-native-sdk'; +import {Client} from '@xmtp/react-native-sdk'; import React, { FC, PropsWithChildren, @@ -8,11 +8,17 @@ import React, { useState, } from 'react'; import {AppConfig} from '../consts/AppConfig'; +import { + SupportedContentTypes, + supportedContentTypes, +} from '../consts/ContentTypes'; import {clearClientKeys, getClientKeys} from '../services/encryptedStorage'; interface ClientContextValue { - client: Client | null; - setClient: React.Dispatch | null>>; + client: Client | null; + setClient: React.Dispatch< + React.SetStateAction | null> + >; loading: boolean; } @@ -25,7 +31,9 @@ export const ClientContext = createContext({ }); export const ClientProvider: FC = ({children}) => { - const [client, setClient] = useState | null>(null); + const [client, setClient] = useState | null>( + null, + ); const [loading, setLoading] = useState(true); const address = useAddress(); const status = useConnectionStatus(); @@ -46,13 +54,13 @@ export const ClientProvider: FC = ({children}) => { if (!keys) { return setLoading(false); } - Client.createFromKeyBundle(keys, { - codecs: [new RemoteAttachmentCodec()], + Client.createFromKeyBundle(keys, { + codecs: supportedContentTypes, enableAlphaMls: true, env: AppConfig.XMTP_ENV, }) .then(newClient => { - setClient(newClient); + setClient(newClient as Client); setLoading(false); }) .catch(() => { diff --git a/src/hooks/useConversation.ts b/src/hooks/useConversation.ts index 47cd52b..5efffe3 100644 --- a/src/hooks/useConversation.ts +++ b/src/hooks/useConversation.ts @@ -1,5 +1,6 @@ import {Conversation} from '@xmtp/react-native-sdk'; import {useEffect, useState} from 'react'; +import {SupportedContentTypes} from '../consts/ContentTypes'; import {useClient} from './useClient'; export const useConversation = (topic: string) => { @@ -8,7 +9,7 @@ export const useConversation = (topic: string) => { } const {client} = useClient(); const [conversation, setConversation] = - useState | null>(null); + useState | null>(null); useEffect(() => { const getConversation = async () => { diff --git a/src/hooks/useConversationMessages.ts b/src/hooks/useConversationMessages.ts index 2d66982..f26d740 100644 --- a/src/hooks/useConversationMessages.ts +++ b/src/hooks/useConversationMessages.ts @@ -1,9 +1,12 @@ import {DecodedMessage} from '@xmtp/react-native-sdk'; import {useEffect, useState} from 'react'; +import {SupportedContentTypes} from '../consts/ContentTypes'; import {useConversation} from './useConversation'; export const useConversationMessages = (topic: string) => { - const [messages, setMessages] = useState[]>([]); + const [messages, setMessages] = useState< + DecodedMessage[] + >([]); const {conversation} = useConversation(topic); useEffect(() => { diff --git a/src/hooks/useGroup.ts b/src/hooks/useGroup.ts index b0c4ce3..c35c4d5 100644 --- a/src/hooks/useGroup.ts +++ b/src/hooks/useGroup.ts @@ -1,5 +1,6 @@ import {Group} from '@xmtp/react-native-sdk/build/lib/Group'; import {useEffect, useState} from 'react'; +import {SupportedContentTypes} from '../consts/ContentTypes'; import {useClient} from './useClient'; export const useGroup = (id: string) => { @@ -7,7 +8,7 @@ export const useGroup = (id: string) => { throw new Error('useGroup requires an id'); } const {client} = useClient(); - const [group, setGroup] = useState | null>(null); + const [group, setGroup] = useState | null>(null); useEffect(() => { const getGroup = async () => { diff --git a/src/hooks/useGroupContactInfo.ts b/src/hooks/useGroupContactInfo.ts deleted file mode 100644 index 236f2d8..0000000 --- a/src/hooks/useGroupContactInfo.ts +++ /dev/null @@ -1,93 +0,0 @@ -import {useSupportedChains, useWalletContext} from '@thirdweb-dev/react-native'; -import {useEffect, useMemo, useState} from 'react'; -import { - getEnsAvatar, - getEnsName, - saveEnsAvatar, - saveEnsName, -} from '../services/mmkvStorage'; -import {formatAddress} from '../utils/formatAddress'; -import {getEnsInfo} from '../utils/getEnsInfo'; -import {useClient} from './useClient'; - -type GroupContactInfoState = Record< - string, - { - address: string; - avatarUrl: string | null; - displayName: string | null; - } ->; - -export const useGroupContactInfo = (addresses: string[]) => { - const [state, setState] = useState({}); - const supportedChains = useSupportedChains(); - const {clientId} = useWalletContext(); - const {client} = useClient(); - - useEffect(() => { - addresses.forEach(address => { - const cachedName = getEnsName(address); - const cachedAvatar = getEnsAvatar(address); - - getEnsInfo(address, supportedChains, clientId) - .then(({ens, avatarUrl}) => { - if (ens) { - saveEnsName(address, ens); - if (avatarUrl) { - saveEnsAvatar(address, avatarUrl); - } - setState(prev => ({ - ...prev, - [address]: { - address, - displayName: ens, - avatarUrl, - }, - })); - } else { - setState(prev => ({ - ...prev, - [address]: { - address, - displayName: formatAddress(address), - avatarUrl: null, - }, - })); - } - }) - .catch(() => { - setState(prev => ({ - ...prev, - [address]: { - address, - displayName: cachedName ?? formatAddress(address), - avatarUrl: cachedAvatar ?? null, - }, - })); - }); - }); - }, [addresses, supportedChains, clientId]); - - const data = useMemo(() => { - const arr: { - address: string; - avatarUrl: string | null; - displayName: string | null; - }[] = []; - - let groupDisplayName = ''; - - Object.values(state).forEach((it, index) => { - arr.push(it); - if (it.address === client?.address) { - return; - } - groupDisplayName += - it.displayName + - (index === Object.values(state).length - 1 ? '' : ', '); - }); - return {groupDisplayName, data: arr}; - }, [client?.address, state]); - return data; -}; diff --git a/src/hooks/useGroupMessages.ts b/src/hooks/useGroupMessages.ts index 3cdb54e..6b0ff85 100644 --- a/src/hooks/useGroupMessages.ts +++ b/src/hooks/useGroupMessages.ts @@ -1,7 +1,7 @@ import {useQueryClient} from '@tanstack/react-query'; import {DecodedMessage} from '@xmtp/react-native-sdk'; import {useEffect} from 'react'; -import {ContentTypes} from '../consts/ContentTypes'; +import {ContentTypes, SupportedContentTypes} from '../consts/ContentTypes'; import {QueryKeys} from '../queries/QueryKeys'; import {useGroupMessagesQuery} from '../queries/useGroupMessagesQuery'; import {useGroup} from './useGroup'; @@ -12,7 +12,7 @@ export const useGroupMessages = (id: string) => { useEffect(() => { const cancelStream = group?.streamGroupMessages(async message => { - queryClient.setQueryData[]>( + queryClient.setQueryData[]>( [QueryKeys.GroupMessages, id], prevMessages => [message, ...(prevMessages ?? [])], ); diff --git a/src/hooks/useGroupName.ts b/src/hooks/useGroupName.ts new file mode 100644 index 0000000..5d5879c --- /dev/null +++ b/src/hooks/useGroupName.ts @@ -0,0 +1,10 @@ +import {useMemo} from 'react'; +import {formatAddress} from '../utils/formatAddress'; + +export const useGroupName = (addresses: string[]) => { + const data = useMemo(() => { + const groupDisplayName = addresses.map(formatAddress).join(', '); + return groupDisplayName; + }, [addresses]); + return data; +}; diff --git a/src/hooks/useListMessages.ts b/src/hooks/useListMessages.ts index ceee9ce..ee5bd90 100644 --- a/src/hooks/useListMessages.ts +++ b/src/hooks/useListMessages.ts @@ -1,6 +1,13 @@ import {useQueryClient} from '@tanstack/react-query'; import {useEffect} from 'react'; -import {ListConversation, ListMessages} from '../models/ListMessages'; +import {DeviceEventEmitter} from 'react-native'; +import {ContentTypes} from '../consts/ContentTypes'; +import {EventEmitterEvents} from '../consts/EventEmitters'; +import { + ListConversation, + ListGroup, + ListMessages, +} from '../models/ListMessages'; import {QueryKeys} from '../queries/QueryKeys'; import {useListQuery} from '../queries/useListQuery'; import {useClient} from './useClient'; @@ -43,7 +50,35 @@ export const useListMessages = () => { }); return data; } else { - return prev; + const existingGroup = prev?.find(it => { + return 'group' in it && it.group.id === topic; + }); + if (existingGroup) { + if ( + message.contentTypeId === ContentTypes.GroupMembershipChange + ) { + DeviceEventEmitter.emit( + `${EventEmitterEvents.GROUP_CHANGED}_${topic}`, + ); + } + const data = prev?.map(it => { + if ('group' in it && it.group.id === topic) { + if ('group' in existingGroup) { + const newIt: ListGroup = { + group: existingGroup.group, + display: messageStringContent, + lastMessageTime: message.sent, + isRequest: it.isRequest, + }; + return newIt; + } + } + return it; + }); + return data; + } else { + return prev; + } } }, ); @@ -89,37 +124,35 @@ export const useListMessages = () => { }; }, [client, queryClient]); - // useEffect(() => { - // const startStream = () => { - // if (!client) { - // return; - // } - // client.conversations.streamGroups(async newGroup => { - // const messages = await newGroup.messages(1); - // const message = messagesJson[0], client); - // queryClient.setQueryData( - // [QueryKeys.List, client?.address], - // prev => { - // return [ - // { - // group: newGroup, - // display: '', - // lastMessageTime: message.sent, - // isRequest: false, - // }, - // ...(prev ?? []), - // ]; - // }, - // ); - // }); - // }; + useEffect(() => { + const startStream = () => { + if (!client) { + return; + } + client.conversations.streamGroups(async newGroup => { + queryClient.setQueryData( + [QueryKeys.List, client?.address], + prev => { + return [ + { + group: newGroup, + display: '', + lastMessageTime: Date.now(), + isRequest: false, + }, + ...(prev ?? []), + ]; + }, + ); + }); + }; - // startStream(); + startStream(); - // return () => { - // client?.conversations.cancelGroupStream(); - // }; - // }, [client, queryClient]); + return () => { + client?.conversations.cancelStreamGroups(); + }; + }, [client, queryClient]); return useListQuery(); }; diff --git a/src/queries/useGroupMessagesQuery.ts b/src/queries/useGroupMessagesQuery.ts index 095e9d7..f70026e 100644 --- a/src/queries/useGroupMessagesQuery.ts +++ b/src/queries/useGroupMessagesQuery.ts @@ -1,13 +1,16 @@ import {useQuery} from '@tanstack/react-query'; +import {DecodedMessage} from '@xmtp/react-native-sdk'; +import {SupportedContentTypes} from '../consts/ContentTypes'; import {useGroup} from '../hooks/useGroup'; import {QueryKeys} from './QueryKeys'; export const useGroupMessagesQuery = (id: string) => { const {group} = useGroup(id); - return useQuery({ + return useQuery[]>({ queryKey: [QueryKeys.GroupMessages, id], - queryFn: () => group?.messages(), + queryFn: () => + group?.messages() as Promise[]>, enabled: !!group, }); }; diff --git a/src/queries/useGroupParticipantsQuery.ts b/src/queries/useGroupParticipantsQuery.ts index 7ca4c9e..c902c97 100644 --- a/src/queries/useGroupParticipantsQuery.ts +++ b/src/queries/useGroupParticipantsQuery.ts @@ -1,9 +1,29 @@ -import {useQuery} from '@tanstack/react-query'; +import {useQuery, useQueryClient} from '@tanstack/react-query'; +import {useEffect} from 'react'; +import {DeviceEventEmitter} from 'react-native'; +import {EventEmitterEvents} from '../consts/EventEmitters'; import {useGroup} from '../hooks/useGroup'; +import {withRequestLogger} from '../utils/logger'; import {QueryKeys} from './QueryKeys'; export const useGroupParticipantsQuery = (id: string) => { const {group} = useGroup(id); + const queryClient = useQueryClient(); + + useEffect(() => { + const groupChangeSubscription = DeviceEventEmitter.addListener( + `${EventEmitterEvents.GROUP_CHANGED}_${id}`, + () => { + queryClient.refetchQueries({ + queryKey: [QueryKeys.GroupParticipants, id], + }); + }, + ); + + return () => { + groupChangeSubscription.remove(); + }; + }, [id, queryClient]); return useQuery({ queryKey: [QueryKeys.GroupParticipants, group?.id], @@ -11,8 +31,10 @@ export const useGroupParticipantsQuery = (id: string) => { if (!group) { return []; } - await group.sync(); - return group.memberAddresses(); + await withRequestLogger(group.sync(), {name: 'group_sync'}); + return withRequestLogger(group.memberAddresses(), { + name: 'group_members', + }); }, }); }; diff --git a/src/queries/useListQuery.ts b/src/queries/useListQuery.ts index 206586c..36d9050 100644 --- a/src/queries/useListQuery.ts +++ b/src/queries/useListQuery.ts @@ -1,13 +1,17 @@ import {useQuery} from '@tanstack/react-query'; import {useClient} from '../hooks/useClient'; import {getAllListMessages} from '../utils/getAllListMessages'; +import {withRequestLogger} from '../utils/logger'; import {QueryKeys} from './QueryKeys'; export const useListQuery = () => { const {client} = useClient(); return useQuery({ queryKey: [QueryKeys.List, client?.address], - queryFn: () => getAllListMessages(client), + queryFn: () => + withRequestLogger(getAllListMessages(client), { + name: 'all_messages_list', + }), enabled: Boolean(client), }); }; diff --git a/src/screens/ConversationScreen.tsx b/src/screens/ConversationScreen.tsx index b7fa3cf..cd9a1fd 100644 --- a/src/screens/ConversationScreen.tsx +++ b/src/screens/ConversationScreen.tsx @@ -1,4 +1,5 @@ import {useRoute} from '@react-navigation/native'; +import {useQueryClient} from '@tanstack/react-query'; import {DecodedMessage, RemoteAttachmentContent} from '@xmtp/react-native-sdk'; import {Box, FlatList, HStack, Pressable, VStack} from 'native-base'; import React, {useCallback, useEffect, useState} from 'react'; @@ -13,11 +14,13 @@ import {Icon} from '../components/common/Icon'; import {Modal} from '../components/common/Modal'; import {Screen} from '../components/common/Screen'; import {Text} from '../components/common/Text'; +import {SupportedContentTypes} from '../consts/ContentTypes'; import {useClient} from '../hooks/useClient'; import {useContactInfo} from '../hooks/useContactInfo'; import {useConversation} from '../hooks/useConversation'; import {useConversationMessages} from '../hooks/useConversationMessages'; import {translate} from '../i18n'; +import {QueryKeys} from '../queries/QueryKeys'; import { getConsent, getTopicAddresses, @@ -36,7 +39,10 @@ const getTimestamp = (timestamp: number) => { date.getMonth() === now.getMonth() && date.getFullYear() === now.getFullYear() ) { - return `${date.getHours()}:${date.getMinutes()}`; + return date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); } return date.toLocaleDateString(); }; @@ -88,6 +94,7 @@ export const ConversationScreen = () => { const [consent, setConsent] = useState<'allowed' | 'denied' | 'unknown'>( getInitialConsentState(myAddress ?? '', address ?? ''), ); + const queryClient = useQueryClient(); useEffect(() => { if (!conversation) { @@ -138,7 +145,9 @@ export const ConversationScreen = () => { [client, conversation], ); - const renderItem: ListRenderItem> = ({item}) => { + const renderItem: ListRenderItem> = ({ + item, + }) => { const isMe = item.senderAddress === myAddress; return ( @@ -165,7 +174,10 @@ export const ConversationScreen = () => { } setConsent('allowed'); saveConsent(myAddress ?? '', address ?? '', true); - }, [address, client?.contacts, myAddress]); + queryClient.invalidateQueries({ + queryKey: [QueryKeys.List, client?.address], + }); + }, [address, client?.address, client?.contacts, myAddress, queryClient]); const onBlock = useCallback(() => { if (address) { diff --git a/src/screens/GroupScreen.tsx b/src/screens/GroupScreen.tsx index ba08370..7ef0a9d 100644 --- a/src/screens/GroupScreen.tsx +++ b/src/screens/GroupScreen.tsx @@ -13,6 +13,7 @@ import {Screen} from '../components/common/Screen'; import {Text} from '../components/common/Text'; import {AddGroupParticipantModal} from '../components/modals/AddGroupParticipantModal'; import {GroupInfoModal} from '../components/modals/GroupInfoModal'; +import {SupportedContentTypes} from '../consts/ContentTypes'; import {useClient} from '../hooks/useClient'; import {useGroup} from '../hooks/useGroup'; import {useGroupMessages} from '../hooks/useGroupMessages'; @@ -40,7 +41,7 @@ const getTimestamp = (timestamp: number) => { }; const useData = (id: string) => { - const {data: messages} = useGroupMessages(id); + const {data: messages, refetch, isRefetching} = useGroupMessages(id); const {data: addresses} = useGroupParticipantsQuery(id); const {client} = useClient(); const {group} = useGroup(id); @@ -49,6 +50,8 @@ const useData = (id: string) => { name: group?.id, myAddress: client?.address, messages, + refetch, + isRefetching, group, client, addresses, @@ -73,7 +76,8 @@ const getInitialConsentState = ( export const GroupScreen = () => { const {params} = useRoute(); const {id} = params as {id: string}; - const {myAddress, messages, addresses, group, client} = useData(id); + const {myAddress, messages, addresses, group, client, refetch, isRefetching} = + useData(id); const [showReply, setShowReply] = useState(false); const [showGroupModal, setShowGroupModal] = useState(false); const [showAddModal, setShowAddModal] = useState(false); @@ -125,7 +129,9 @@ export const GroupScreen = () => { [client, group], ); - const renderItem: ListRenderItem> = ({item}) => { + const renderItem: ListRenderItem> = ({ + item, + }) => { const isMe = item.senderAddress?.toLocaleLowerCase() === myAddress?.toLocaleLowerCase(); @@ -186,6 +192,8 @@ export const GroupScreen = () => { data={messages} renderItem={renderItem} ListFooterComponent={} + onRefresh={refetch} + refreshing={isRefetching} /> {consent !== 'unknown' ? ( diff --git a/src/screens/NewConversationScreen.tsx b/src/screens/NewConversationScreen.tsx index e369dfc..82dcb60 100644 --- a/src/screens/NewConversationScreen.tsx +++ b/src/screens/NewConversationScreen.tsx @@ -1,4 +1,5 @@ import {useRoute} from '@react-navigation/native'; +import {useQueryClient} from '@tanstack/react-query'; import {Box} from 'native-base'; import React, {useCallback} from 'react'; import {Asset} from 'react-native-image-picker'; @@ -8,7 +9,9 @@ import {GroupHeader} from '../components/GroupHeader'; import {Screen} from '../components/common/Screen'; import {useClient} from '../hooks/useClient'; import {useTypedNavigation} from '../hooks/useTypedNavigation'; +import {ListMessages} from '../models/ListMessages'; import {ScreenNames} from '../navigation/ScreenNames'; +import {QueryKeys} from '../queries/QueryKeys'; import {saveConsent} from '../services/mmkvStorage'; export const NewConversationScreen = () => { @@ -16,6 +19,7 @@ export const NewConversationScreen = () => { const {params} = useRoute(); const {addresses} = params as {addresses: string[]}; const {client} = useClient(); + const queryClient = useQueryClient(); const onSend = useCallback( async (message: {text?: string; asset?: Asset}) => { @@ -24,7 +28,23 @@ export const NewConversationScreen = () => { client?.conversations ?.newGroup(addresses) .then(group => { - group.send(message); + // The client is not notified of a group they create, so we add it to the list here + group.send(message as {text: string}).then(() => { + queryClient.setQueryData( + [QueryKeys.List, client?.address], + prev => { + return [ + { + group, + display: message.text ?? 'Image', + lastMessageTime: Date.now(), + isRequest: false, + }, + ...(prev ?? []), + ]; + }, + ); + }); replace(ScreenNames.Group, {id: group.id}); }) .catch(err => { @@ -35,12 +55,12 @@ export const NewConversationScreen = () => { ?.newConversation(addresses[0]) .then(conversation => { saveConsent(client?.address, addresses[0], true); - conversation.send(message); + conversation.send(message as {text: string}); replace(ScreenNames.Conversation, {topic: conversation.topic}); }); } }, - [addresses, client?.address, client?.conversations, replace], + [addresses, client?.address, client?.conversations, queryClient, replace], ); return ( diff --git a/src/screens/OnboardingEnableIdentityScreen.tsx b/src/screens/OnboardingEnableIdentityScreen.tsx index 0579082..621dcf7 100644 --- a/src/screens/OnboardingEnableIdentityScreen.tsx +++ b/src/screens/OnboardingEnableIdentityScreen.tsx @@ -1,5 +1,5 @@ import {useDisconnect, useSigner} from '@thirdweb-dev/react-native'; -import {Client, RemoteAttachmentCodec} from '@xmtp/react-native-sdk'; +import {Client} from '@xmtp/react-native-sdk'; import {VStack} from 'native-base'; import React, {useCallback, useEffect, useState} from 'react'; import {DeviceEventEmitter, Image} from 'react-native'; @@ -8,6 +8,7 @@ import {Icon} from '../components/common/Icon'; import {Screen} from '../components/common/Screen'; import {Text} from '../components/common/Text'; import {AppConfig} from '../consts/AppConfig'; +import {supportedContentTypes} from '../consts/ContentTypes'; import {EventEmitterEvents} from '../consts/EventEmitters'; import {useClientContext} from '../context/ClientContext'; import {useTypedNavigation} from '../hooks/useTypedNavigation'; @@ -62,7 +63,7 @@ export const OnboardingEnableIdentityScreen = () => { preCreateIdentityCallback: async () => { await createIdentityPromise(); }, - codecs: [new RemoteAttachmentCodec()], + codecs: supportedContentTypes, }); const keys = await client.exportKeyBundle(); const address = await signer.getAddress(); diff --git a/src/utils/getAllListMessages.ts b/src/utils/getAllListMessages.ts index dbed8e5..d018d1a 100644 --- a/src/utils/getAllListMessages.ts +++ b/src/utils/getAllListMessages.ts @@ -5,70 +5,99 @@ import { ListMessages, } from '../models/ListMessages'; import {saveConsent} from '../services/mmkvStorage'; +import {withRequestLogger} from './logger'; export const getAllListMessages = async (client?: Client | null) => { - if (!client) { - return []; - } - - const consentList = await client.contacts.refreshConsentList(); - - consentList.forEach(async item => { - saveConsent(client.address, item.value, item.permissionType === 'allowed'); - }); - await client.conversations.syncGroups(); + try { + if (!client) { + return []; + } + const consentList = await withRequestLogger( + client.contacts.refreshConsentList(), + {name: 'consent'}, + ); - const [convos, groups] = await Promise.all([ - client.conversations.list(), - client.conversations.listGroups(), - ]); + consentList.forEach(async item => { + saveConsent( + client.address, + item.value, + item.permissionType === 'allowed', + ); + }); + await withRequestLogger(client.conversations.syncGroups(), { + name: 'group_sync', + }); - const allMessages: PromiseSettledResult[] = - await Promise.allSettled([ - ...convos.map(async conversation => { - const [messages, consent] = await Promise.all([ - conversation.messages(1), - conversation.consentState(), - ]); - const content = messages[0].content(); - return { - conversation, - display: - typeof content === 'string' ? content : messages[0].fallback ?? '', - lastMessageTime: messages[0].sent, - isRequest: consent !== 'allowed', - }; - }), - ...groups.map(async group => { - await group.sync(); - const messages = await group.messages(); - const content = messages?.[0].content(); - const display = - typeof content === 'string' ? content : messages?.[0].fallback ?? ''; - return { - group, - display, - lastMessageTime: messages[0].sent, - isRequest: false, - }; - }), + const [convos, groups] = await Promise.all([ + withRequestLogger(client.conversations.list(), {name: 'list'}), + withRequestLogger(client.conversations.listGroups(), {name: 'groups'}), ]); - // Remove the rejected promises and return the list of messages using .reduce - const allMessagesFiltered = allMessages.reduce((acc, curr) => { - if (curr.status === 'fulfilled' && curr.value) { - if ('group' in curr.value) { - acc.push(curr.value); - } else if ('conversation' in curr.value) { - acc.push(curr.value); - } - } - return acc; - }, []); + const allMessages: PromiseSettledResult[] = + await Promise.allSettled([ + ...convos.map(async conversation => { + const [messages, consent] = await Promise.all([ + withRequestLogger(conversation.messages(1), { + name: 'conversation_messages', + }), + withRequestLogger(conversation.consentState(), { + name: 'conversation_consent', + }), + ]); + const content = messages[0].content(); + return { + conversation, + display: + typeof content === 'string' + ? content + : messages[0].fallback ?? '', + lastMessageTime: messages[0].sent, + isRequest: consent !== 'allowed', + }; + }), + ...groups.map(async group => { + await group.sync(); + const messages = await withRequestLogger(group.messages(), { + name: 'group_messages', + }); + const content = messages?.[0].content(); + const display = + typeof content === 'string' + ? content + : messages?.[0].fallback ?? ''; + return { + group, + display, + lastMessageTime: messages[0].sent, + isRequest: false, + }; + }), + ]); + + // Remove the rejected promises and return the list of messages using .reduce + const allMessagesFiltered = allMessages.reduce( + (acc, curr) => { + if (curr.status === 'fulfilled' && curr.value) { + if ('group' in curr.value) { + acc.push(curr.value); + } else if ('conversation' in curr.value) { + acc.push(curr.value); + } + } else { + console.log('Error fetching messages', curr); + } + return acc; + }, + [], + ); - const sorted = allMessagesFiltered.sort((a, b) => { - return b.lastMessageTime - a.lastMessageTime; - }); + const sorted = allMessagesFiltered.sort((a, b) => { + return b.lastMessageTime - a.lastMessageTime; + }); - return sorted; + return sorted; + } catch (e) { + console.log('Error fetching messages', e); + throw new Error('Error fetching messages'); + } }; diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..7f00c89 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,36 @@ +type RequestName = + | 'consent' + | 'list' + | 'groups' + | 'conversation_messages' + | 'conversation_consent' + | 'group_sync' + | 'group_messages' + | 'all_messages_list' + | 'group_members'; + +interface LoggerOptions { + name: RequestName; +} + +export async function withRequestLogger( + request: Promise, + options?: LoggerOptions, +) { + let start; + if (__DEV__) { + start = Date.now(); + console.log(`REQUEST_LOGGER: Requesting ${options?.name}`, start); + } + const data = await request; + + if (__DEV__ && start) { + const end = Date.now(); + console.log( + `REQUEST_LOGGER: Response ${options?.name}`, + end, + `Elapsed: ${end - start}ms`, + ); + } + return data; +} diff --git a/yarn.lock b/yarn.lock index e0977d9..b25da05 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9525,10 +9525,10 @@ rxjs "^7.8.0" undici "^5.8.1" -"@xmtp/react-native-sdk@1.27.0-beta.6": - version "1.27.0-beta.6" - resolved "https://registry.yarnpkg.com/@xmtp/react-native-sdk/-/react-native-sdk-1.27.0-beta.6.tgz#171956da15a70049c4040cd9a7f4440d0faacbcc" - integrity sha512-TdxdAuR8eCudNfZLNlYOFmMBcOCTrxCIYLNCqqlcyHiyxSGT4tBB2IwRzvbvZkJpF22qTnzWyPdwtyAo4Cx1mA== +"@xmtp/react-native-sdk@1.27.0-beta.8": + version "1.27.0-beta.8" + resolved "https://registry.yarnpkg.com/@xmtp/react-native-sdk/-/react-native-sdk-1.27.0-beta.8.tgz#4a497725e3b7ca2b13e819df5aee2ef56755847e" + integrity sha512-/t25UL91Ev7rRcGe3+KoEnfIhGI0Z8UOqrhMZXdrV78zCtEx5F7ZWvDlpRHsKPOtWdzbGsWw3np0L5Cq4sH1ZQ== dependencies: "@ethersproject/bytes" "^5.7.0" "@msgpack/msgpack" "^3.0.0-beta2"