From d6d0d83666dfea62d4bf30a6720649c21c7250bd Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Sat, 25 May 2024 22:27:23 -0600 Subject: [PATCH] refactor: Refactor Conversation List Reworked some streaming usage Updated Groups id to topic instead Added additional App Config --- babel.config.js | 1 + src/components/ConversationInput.tsx | 5 + src/components/ConversationListHeader.tsx | 143 ++++++++++++++ src/components/ConversationListItem.tsx | 65 ------- src/components/GroupListItem.tsx | 85 ++++++--- .../modals/AddGroupParticipantModal.tsx | 2 +- src/components/modals/GroupInfoModal.tsx | 11 +- src/consts/AppConfig.ts | 4 + src/hooks/useGroup.ts | 6 +- src/hooks/useGroupMessages.ts | 51 ++--- src/hooks/useGroupParticipants.ts | 37 ++++ src/hooks/useListMessages.ts | 62 ------ src/navigation/StackParams.ts | 2 +- src/providers/ClientProvider.tsx | 16 ++ src/queries/QueryKeys.ts | 1 + src/queries/useFirstGroupMessageQuery.ts | 30 +++ src/queries/useGroupMessagesQuery.ts | 14 +- src/queries/useGroupParticipantsQuery.ts | 12 +- src/queries/useGroupsQuery.ts | 4 +- src/screens/AccountSettingsScreen.tsx | 4 + src/screens/ConversationListScreen.tsx | 176 ++---------------- src/screens/GroupScreen.tsx | 28 +-- src/screens/NewConversationScreen.tsx | 2 +- .../OnboardingEnableIdentityScreen.tsx | 26 ++- src/services/mmkvStorage.ts | 91 ++++++--- src/services/pushNotifications.ts | 80 +++++--- src/utils/getAllListMessages.ts | 92 +++------ src/utils/idExtractors.ts | 4 +- src/utils/streamAllMessages.ts | 46 +++++ 29 files changed, 604 insertions(+), 496 deletions(-) create mode 100644 src/components/ConversationListHeader.tsx delete mode 100644 src/components/ConversationListItem.tsx create mode 100644 src/hooks/useGroupParticipants.ts delete mode 100644 src/hooks/useListMessages.ts create mode 100644 src/queries/useFirstGroupMessageQuery.ts create mode 100644 src/utils/streamAllMessages.ts diff --git a/babel.config.js b/babel.config.js index 950d3b2..b3c86b1 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,6 +1,7 @@ module.exports = { presets: ['module:@react-native/babel-preset'], plugins: [ + ['@babel/plugin-proposal-decorators', {legacy: true}], '@babel/plugin-proposal-export-namespace-from', 'react-native-reanimated/plugin', ], diff --git a/src/components/ConversationInput.tsx b/src/components/ConversationInput.tsx index a0bb642..c6af3d1 100644 --- a/src/components/ConversationInput.tsx +++ b/src/components/ConversationInput.tsx @@ -33,6 +33,8 @@ export const ConversationInput: FC = ({ // ? getDraftImage(currentAddress, topic) ?? null // : null, + const textInputRef = React.createRef(); + useEffect(() => { if (text && currentAddress && id) { mmkvStorage.saveDraftText(currentAddress, id, text); @@ -126,12 +128,15 @@ export const ConversationInput: FC = ({ alignItems={'center'} borderBottomRightRadius={0}> setFocus(true)} onBlur={() => setFocus(false)} + returnKeyType={canSend ? 'send' : 'default'} + onSubmitEditing={canSend ? handleSend : textInputRef.current?.blur} /> void; + messageRequestCount: number; + onShowMessageRequests: () => void; +} + +export const ConversationListHeader: FC = ({ + list, + showPickerModal, + messageRequestCount, + onShowMessageRequests, +}) => { + const {navigate} = useTypedNavigation(); + const address = useAddress(); + const {data} = useENS(); + const {avatarUrl} = data ?? {}; + + const handleAccountPress = useCallback(() => { + navigate(ScreenNames.Account); + }, [navigate]); + const navigateToDev = useCallback(() => { + if (__DEV__) { + navigate(ScreenNames.Dev); + } + }, [navigate]); + return ( + + + + + + + + + + + + + {list === 'MESSAGE_REQUESTS' ? ( + + + {translate('message_requests_from_new_addresses')} + + + ) : messageRequestCount > 0 ? ( + + + + + + + {translate('message_requests_count', { + count: String(messageRequestCount), + })} + + + + + + + ) : ( + + )} + + ); +}; diff --git a/src/components/ConversationListItem.tsx b/src/components/ConversationListItem.tsx deleted file mode 100644 index 2187c20..0000000 --- a/src/components/ConversationListItem.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import {Conversation} from '@xmtp/react-native-sdk'; -import {HStack, VStack} from 'native-base'; -import React, {FC} from 'react'; -import {Pressable} from 'react-native'; -import {useContactInfo} from '../hooks/useContactInfo'; -import {useTypedNavigation} from '../hooks/useTypedNavigation'; -import {ScreenNames} from '../navigation/ScreenNames'; -import {getMessageTimeDisplay} from '../utils/getMessageTimeDisplay'; -import {AvatarWithFallback} from './AvatarWithFallback'; -import {Text} from './common/Text'; - -interface ConversationListItemProps { - conversation: Conversation; - display: string; - lastMessageTime: number; -} - -/** - * - * @deprecated only leaving v3 for now - * - */ -export const ConversationListItem: FC = ({ - conversation, - display, - lastMessageTime, -}) => { - const {navigate} = useTypedNavigation(); - const {displayName, avatarUrl} = useContactInfo(conversation.peerAddress); - - return ( - { - navigate(ScreenNames.Group, { - id: conversation.topic, - }); - }}> - - - - - {displayName} - - - {display} - - - - {getMessageTimeDisplay(lastMessageTime)} - - - - ); -}; diff --git a/src/components/GroupListItem.tsx b/src/components/GroupListItem.tsx index 65a207f..88abbd2 100644 --- a/src/components/GroupListItem.tsx +++ b/src/components/GroupListItem.tsx @@ -1,11 +1,12 @@ import {Group} from '@xmtp/react-native-sdk/build/lib/Group'; import {Box, HStack, VStack} from 'native-base'; -import React, {FC} from 'react'; +import React, {FC, useCallback, useMemo} from 'react'; import {Pressable} from 'react-native'; import {SupportedContentTypes} from '../consts/ContentTypes'; import {useGroupName} from '../hooks/useGroupName'; import {useTypedNavigation} from '../hooks/useTypedNavigation'; import {ScreenNames} from '../navigation/ScreenNames'; +import {useFirstGroupMessageQuery} from '../queries/useFirstGroupMessageQuery'; import {useGroupParticipantsQuery} from '../queries/useGroupParticipantsQuery'; import {getMessageTimeDisplay} from '../utils/getMessageTimeDisplay'; import {GroupAvatarStack} from './GroupAvatarStack'; @@ -13,26 +14,56 @@ import {Text} from './common/Text'; interface GroupListItemProps { group: Group; - display: string; - lastMessageTime: number; } -export const GroupListItem: FC = ({ - group, - display, - lastMessageTime, -}) => { - const {data: addresses} = useGroupParticipantsQuery(group?.id); +export const GroupListItem: FC = ({group}) => { + const topic = group?.topic; + const {data: addresses} = useGroupParticipantsQuery(topic); const {navigate} = useTypedNavigation(); - const groupName = useGroupName(addresses ?? [], group?.id); + const groupName = useGroupName(addresses ?? [], topic); + const {data: messages, isLoading, isError} = useFirstGroupMessageQuery(topic); + const firstMessage = messages?.[0]; + + const handlePress = useCallback(() => { + navigate(ScreenNames.Group, { + topic, + }); + }, [topic, navigate]); + + const display: string | undefined = useMemo(() => { + if (!firstMessage) { + return ''; + } + let text = ''; + try { + const content = firstMessage.content(); + if (typeof content === 'string') { + text = content; + } else { + text = firstMessage.fallback ?? ''; + } + } catch (e) { + text = firstMessage.fallback ?? ''; + } + return text; + }, [firstMessage]); + + const lastMessageTime: number | undefined = useMemo(() => { + if (isLoading) { + return undefined; + } + if (isError) { + return undefined; + } + if (!firstMessage) { + return undefined; + } + + return firstMessage?.sent; + }, [firstMessage, isLoading, isError]); return ( - { - navigate(ScreenNames.Group, { - id: group?.id, - }); - }}> + = ({ typography="text-base/bold"> {groupName} - - {display} - + {!isLoading && ( + + {display} + + )} - - {getMessageTimeDisplay(lastMessageTime)} - + {lastMessageTime && ( + + {getMessageTimeDisplay(lastMessageTime)} + + )} ); diff --git a/src/components/modals/AddGroupParticipantModal.tsx b/src/components/modals/AddGroupParticipantModal.tsx index 2c641b4..3e8bc9e 100644 --- a/src/components/modals/AddGroupParticipantModal.tsx +++ b/src/components/modals/AddGroupParticipantModal.tsx @@ -77,7 +77,7 @@ export const AddGroupParticipantModal: FC = ({ try { await group.addMembers(participants); DeviceEventEmitter.emit( - `${EventEmitterEvents.GROUP_CHANGED}_${group.id}`, + `${EventEmitterEvents.GROUP_CHANGED}_${group.topic}`, ); hide(); } catch (err: any) { diff --git a/src/components/modals/GroupInfoModal.tsx b/src/components/modals/GroupInfoModal.tsx index 412226f..3cb25af 100644 --- a/src/components/modals/GroupInfoModal.tsx +++ b/src/components/modals/GroupInfoModal.tsx @@ -93,7 +93,7 @@ export const GroupInfoModal: FC = ({ try { await group?.removeMembers([address]); DeviceEventEmitter.emit( - `${EventEmitterEvents.GROUP_CHANGED}_${group?.id}`, + `${EventEmitterEvents.GROUP_CHANGED}_${group?.topic}`, ); hide(); } catch (err: any) { @@ -110,12 +110,12 @@ export const GroupInfoModal: FC = ({ } mmkvStorage.saveGroupName( client?.address ?? '', - group?.id ?? '', + group?.topic ?? '', groupName, ); setEditing(false); setGroupName(''); - }, [client?.address, group?.id, groupName]); + }, [client?.address, group?.topic, groupName]); return ( @@ -141,7 +141,10 @@ export const GroupInfoModal: FC = ({ {(!!group && - mmkvStorage.getGroupName(client?.address ?? '', group?.id)) ?? + mmkvStorage.getGroupName( + client?.address ?? '', + group?.topic, + )) ?? translate('group')} setEditing(true)} alignSelf={'flex-end'}> diff --git a/src/consts/AppConfig.ts b/src/consts/AppConfig.ts index afe16d0..27397ea 100644 --- a/src/consts/AppConfig.ts +++ b/src/consts/AppConfig.ts @@ -1,6 +1,10 @@ +import {Platform} from 'react-native'; + // Just a way to gate some features that are not ready yet export const AppConfig = { LENS_ENABLED: false, XMTP_ENV: 'dev' as 'local' | 'dev' | 'production', MULTI_WALLET: false, + PUSH_NOTIFICATIONS: Platform.OS === 'ios', + GROUP_CONSENT: false, }; diff --git a/src/hooks/useGroup.ts b/src/hooks/useGroup.ts index 67e7a01..962a5de 100644 --- a/src/hooks/useGroup.ts +++ b/src/hooks/useGroup.ts @@ -1,14 +1,14 @@ import {useMemo} from 'react'; import {useGroupsQuery} from '../queries/useGroupsQuery'; -export const useGroup = (id: string) => { +export const useGroup = (topic: string) => { const groupsQuery = useGroupsQuery(); return useMemo(() => { const {data, ...rest} = groupsQuery; const {entities} = data ?? {}; - const groupData = entities?.[id]; + const groupData = entities?.[topic]; return {data: groupData, ...rest}; - }, [groupsQuery, id]); + }, [groupsQuery, topic]); }; diff --git a/src/hooks/useGroupMessages.ts b/src/hooks/useGroupMessages.ts index c4c080a..4e80102 100644 --- a/src/hooks/useGroupMessages.ts +++ b/src/hooks/useGroupMessages.ts @@ -8,31 +8,38 @@ import { } from '../queries/useGroupMessagesQuery'; import {useGroup} from './useGroup'; -export const useGroupMessages = (id: string) => { - const {data: group} = useGroup(id); +export const useGroupMessages = (topic: string) => { + const {data: group} = useGroup(topic); const queryClient = useQueryClient(); - useEffect(() => { - const cancelStream = group?.streamGroupMessages(async message => { - queryClient.setQueryData( - [QueryKeys.GroupMessages, group?.id], - prevMessages => [message, ...(prevMessages ?? [])], - ); - if (message.contentTypeId === ContentTypes.GroupMembershipChange) { - await group.sync(); - const addresses = await group.memberAddresses(); - queryClient.setQueryData( - [QueryKeys.GroupParticipants, group?.id], - addresses, + useEffect( + () => { + const cancelStream = group?.streamGroupMessages(async message => { + queryClient.setQueryData( + [QueryKeys.GroupMessages, topic], + prevMessages => [message, ...(prevMessages ?? [])], ); - } - }); - return () => { - cancelStream?.then(callback => { - callback(); + if (message.contentTypeId === ContentTypes.GroupMembershipChange) { + await group.sync(); + const addresses = await group.memberAddresses(); + queryClient.setQueryData( + [QueryKeys.GroupParticipants, topic], + addresses, + ); + } }); - }; - }, [group, queryClient]); + return () => { + cancelStream?.then(callback => { + callback(); + }); + }; + }, + // iOS - Rerender causes lost stream, these shouldn't change anyways so it should be fine + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + // group, queryClient, topic + ], + ); - return useGroupMessagesQuery(id); + return useGroupMessagesQuery(topic); }; diff --git a/src/hooks/useGroupParticipants.ts b/src/hooks/useGroupParticipants.ts new file mode 100644 index 0000000..f059699 --- /dev/null +++ b/src/hooks/useGroupParticipants.ts @@ -0,0 +1,37 @@ +import {useQueryClient} from '@tanstack/react-query'; +import {useEffect, useState} from 'react'; +import {DeviceEventEmitter} from 'react-native'; +import {EventEmitterEvents} from '../consts/EventEmitters'; +import {QueryKeys} from '../queries/QueryKeys'; +import {useGroupParticipantsQuery} from '../queries/useGroupParticipantsQuery'; +import {mmkvStorage} from '../services/mmkvStorage'; + +export const useGroupParticipants = (topic: string) => { + const queryClient = useQueryClient(); + const [localParticipants] = useState(mmkvStorage.getGroupParticipants(topic)); + + useEffect(() => { + const groupChangeSubscription = DeviceEventEmitter.addListener( + `${EventEmitterEvents.GROUP_CHANGED}_${topic}`, + () => { + queryClient.refetchQueries({ + queryKey: [QueryKeys.GroupParticipants, topic], + }); + }, + ); + + return () => { + groupChangeSubscription.remove(); + }; + }, [topic, queryClient]); + + const query = useGroupParticipantsQuery(topic); + + useEffect(() => { + if (query.isSuccess) { + mmkvStorage.saveGroupParticipants(topic, query.data ?? []); + } + }, [query.data, query.isSuccess, topic]); + + return query.isSuccess ? query.data : localParticipants ?? []; +}; diff --git a/src/hooks/useListMessages.ts b/src/hooks/useListMessages.ts deleted file mode 100644 index 788dca2..0000000 --- a/src/hooks/useListMessages.ts +++ /dev/null @@ -1,62 +0,0 @@ -import {useQueryClient} from '@tanstack/react-query'; -import {XMTPPush} from '@xmtp/react-native-sdk'; -import {useEffect} from 'react'; -import {ListMessages} from '../models/ListMessages'; -import {QueryKeys} from '../queries/QueryKeys'; -import {useListQuery} from '../queries/useListQuery'; -import {useClient} from './useClient'; - -export const useListMessages = () => { - const {client} = useClient(); - const queryClient = useQueryClient(); - - useEffect(() => { - const startStream = () => { - if (!client) { - return; - } - client.conversations.streamGroups(async newGroup => { - console.log('NEW GROUP:', newGroup); - const pushClient = new XMTPPush(client); - pushClient.subscribe([newGroup.topic]); - let content = ''; - try { - const groupMessages = await newGroup.messages(); - try { - const lastMessageContent = groupMessages[0]?.content(); - content = - typeof lastMessageContent === 'string' - ? lastMessageContent - : groupMessages[0]?.fallback ?? ''; - } catch (err) { - content = groupMessages[0]?.fallback ?? ''; - } - } catch (err) { - content = ''; - } - queryClient.setQueryData( - [QueryKeys.List, client?.address], - prev => { - return [ - { - group: newGroup, - display: content, - lastMessageTime: Date.now(), - isRequest: false, - }, - ...(prev ?? []), - ]; - }, - ); - }); - }; - - startStream(); - - return () => { - client?.conversations.cancelStreamGroups(); - }; - }, [client, queryClient]); - - return useListQuery(); -}; diff --git a/src/navigation/StackParams.ts b/src/navigation/StackParams.ts index ecff88c..9b7af6f 100644 --- a/src/navigation/StackParams.ts +++ b/src/navigation/StackParams.ts @@ -12,7 +12,7 @@ export type OnboardingStackParams = { export type AuthenticatedStackParams = { [ScreenNames.Account]: undefined; [ScreenNames.ConversationList]: undefined; - [ScreenNames.Group]: {id: string}; + [ScreenNames.Group]: {topic: string}; [ScreenNames.NewConversation]: {addresses: string[]}; [ScreenNames.Search]: undefined; [ScreenNames.QRCode]: undefined; diff --git a/src/providers/ClientProvider.tsx b/src/providers/ClientProvider.tsx index 89b5617..0742575 100644 --- a/src/providers/ClientProvider.tsx +++ b/src/providers/ClientProvider.tsx @@ -3,8 +3,13 @@ import {Client} from '@xmtp/react-native-sdk'; import React, {FC, PropsWithChildren, useEffect, useState} from 'react'; import {SupportedContentTypes} from '../consts/ContentTypes'; import {ClientContext} from '../context/ClientContext'; +import {QueryKeys} from '../queries/QueryKeys'; import {encryptedStorage} from '../services/encryptedStorage'; +import {queryClient} from '../services/queryClient'; import {createClientOptions} from '../utils/clientOptions'; +import {getAllListMessages} from '../utils/getAllListMessages'; +import {withRequestLogger} from '../utils/logger'; +import {streamAllMessages} from '../utils/streamAllMessages'; export const ClientProvider: FC = ({children}) => { const [client, setClient] = useState | null>( @@ -40,7 +45,18 @@ export const ClientProvider: FC = ({children}) => { keys, clientOptions, ); + queryClient.prefetchQuery({ + queryKey: [QueryKeys.List, newClient?.address], + queryFn: () => + withRequestLogger( + getAllListMessages(newClient as Client), + { + name: 'all_messages_list', + }, + ), + }); setClient(newClient as Client); + streamAllMessages(newClient as Client); } catch (err) { encryptedStorage.clearClientKeys(address as `0x${string}`); } finally { diff --git a/src/queries/QueryKeys.ts b/src/queries/QueryKeys.ts index d45f556..8fb330d 100644 --- a/src/queries/QueryKeys.ts +++ b/src/queries/QueryKeys.ts @@ -6,6 +6,7 @@ export enum QueryKeys { Groups = 'groups', GroupParticipants = 'group_participants', GroupMessages = 'group_messages', + FirstGroupMessage = 'first_group_messages', FramesClient = 'frames_client', Frame = 'frame', } diff --git a/src/queries/useFirstGroupMessageQuery.ts b/src/queries/useFirstGroupMessageQuery.ts new file mode 100644 index 0000000..f9bfc67 --- /dev/null +++ b/src/queries/useFirstGroupMessageQuery.ts @@ -0,0 +1,30 @@ +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 type GroupMessagesQueryRequestData = + | DecodedMessage[] + | undefined; +export type GroupMessagesQueryError = unknown; + +export const useFirstGroupMessageQuery = (topic: string) => { + const {data: group} = useGroup(topic); + + return useQuery({ + queryKey: [QueryKeys.FirstGroupMessage, topic], + queryFn: async () => { + if (!group) { + return undefined; + } + const messages: DecodedMessage[] = + await group.messages(false, { + // limit: 1, + // direction: 'SORT_DIRECTION_ASCENDING', + }); + return messages; + }, + enabled: !!group, + }); +}; diff --git a/src/queries/useGroupMessagesQuery.ts b/src/queries/useGroupMessagesQuery.ts index c70a7b5..4fb6273 100644 --- a/src/queries/useGroupMessagesQuery.ts +++ b/src/queries/useGroupMessagesQuery.ts @@ -4,7 +4,6 @@ import {ContentTypes, SupportedContentTypes} from '../consts/ContentTypes'; import {useGroup} from '../hooks/useGroup'; import {EntityObject} from '../utils/entities'; import {getMessageId} from '../utils/idExtractors'; -import {withRequestLogger} from '../utils/logger'; import {QueryKeys} from './QueryKeys'; export type GroupMessagesQueryRequestData = @@ -27,22 +26,23 @@ export interface GroupMessagesQueryData reactionsEntities: MessageIdReactionsMapping; } -export const useGroupMessagesQuery = (id: string) => { - const {data: group} = useGroup(id); +export interface GroupMessagesQueryOptions { + limit?: number; +} + +export const useGroupMessagesQuery = (topic: string) => { + const {data: group} = useGroup(topic); return useQuery< GroupMessagesQueryRequestData, GroupMessagesQueryError, GroupMessagesQueryData >({ - queryKey: [QueryKeys.GroupMessages, id], + queryKey: [QueryKeys.GroupMessages, topic], queryFn: async () => { if (!group) { return []; } - await withRequestLogger(group.sync(), { - name: 'group_sync', - }); return group.messages() as Promise< DecodedMessage[] >; diff --git a/src/queries/useGroupParticipantsQuery.ts b/src/queries/useGroupParticipantsQuery.ts index d33af3d..86b5199 100644 --- a/src/queries/useGroupParticipantsQuery.ts +++ b/src/queries/useGroupParticipantsQuery.ts @@ -6,16 +6,16 @@ import {useGroup} from '../hooks/useGroup'; import {withRequestLogger} from '../utils/logger'; import {QueryKeys} from './QueryKeys'; -export const useGroupParticipantsQuery = (id: string) => { - const {data: group} = useGroup(id); +export const useGroupParticipantsQuery = (topic: string) => { + const {data: group} = useGroup(topic); const queryClient = useQueryClient(); useEffect(() => { const groupChangeSubscription = DeviceEventEmitter.addListener( - `${EventEmitterEvents.GROUP_CHANGED}_${id}`, + `${EventEmitterEvents.GROUP_CHANGED}_${topic}`, () => { queryClient.refetchQueries({ - queryKey: [QueryKeys.GroupParticipants, id], + queryKey: [QueryKeys.GroupParticipants, topic], }); }, ); @@ -23,10 +23,10 @@ export const useGroupParticipantsQuery = (id: string) => { return () => { groupChangeSubscription.remove(); }; - }, [id, queryClient]); + }, [topic, queryClient]); return useQuery({ - queryKey: [QueryKeys.GroupParticipants, group?.id], + queryKey: [QueryKeys.GroupParticipants, group?.topic], queryFn: async () => { if (!group) { return []; diff --git a/src/queries/useGroupsQuery.ts b/src/queries/useGroupsQuery.ts index 514f097..fa79c19 100644 --- a/src/queries/useGroupsQuery.ts +++ b/src/queries/useGroupsQuery.ts @@ -1,7 +1,7 @@ import {useQuery} from '@tanstack/react-query'; import {useClient} from '../hooks/useClient'; import {createEntityObject} from '../utils/entities'; -import {getGroupId} from '../utils/idExtractors'; +import {getGroupTopic} from '../utils/idExtractors'; import {QueryKeys} from './QueryKeys'; export const useGroupsQuery = () => { @@ -13,6 +13,6 @@ export const useGroupsQuery = () => { const groups = await client?.conversations.listGroups(); return groups || []; }, - select: data => createEntityObject(data, getGroupId), + select: data => createEntityObject(data, getGroupTopic), }); }; diff --git a/src/screens/AccountSettingsScreen.tsx b/src/screens/AccountSettingsScreen.tsx index 40c11d4..12d2782 100644 --- a/src/screens/AccountSettingsScreen.tsx +++ b/src/screens/AccountSettingsScreen.tsx @@ -36,6 +36,7 @@ import {encryptedStorage} from '../services/encryptedStorage'; import {mmkvStorage} from '../services/mmkvStorage'; import {colors, greens, reds} from '../theme/colors'; import {formatAddress} from '../utils/formatAddress'; +import {cancelStreamAllMessages} from '../utils/streamAllMessages'; interface Address { display: string; @@ -206,6 +207,7 @@ export const AccountSettingsScreen = () => { } await encryptedStorage.clearClientKeys(address as `0x${string}`); setClient(null); + cancelStreamAllMessages(client); disconnect() .then(() => {}) .catch(); @@ -221,6 +223,7 @@ export const AccountSettingsScreen = () => { address, setClient, disconnect, + client, ]); const renderItem: SectionListRenderItem = ({ @@ -337,6 +340,7 @@ export const AccountSettingsScreen = () => { setWalletsShown(true); } }} + _pressed={{backgroundColor: 'transparent'}} variant={'ghost'} rightIcon={ item.group?.id ?? ''; +const keyExtractor = (item: Group) => item.topic ?? ''; const useData = () => { const {client} = useClient(); const {data, isLoading, refetch, isRefetching, isError, error} = - useListMessages(); + useListQuery(); const {listItems, requests} = useMemo(() => { - const listMessages: ListMessages = []; - const requestsItems: ListMessages = []; + const listMessages: Group[] = []; + const requestsItems: Group[] = []; data?.forEach(item => { - if ('conversation' in item) { - if (item.isRequest) { - requestsItems.push(item); - } else { - listMessages.push(item); - } - } else { - listMessages.push(item); - } + // TODO: add a check for isRequest + listMessages.push(item); }); return {listItems: listMessages, requests: requestsItems}; }, [data]); @@ -58,136 +51,6 @@ const useData = () => { }; }; -interface ListHeaderProps { - list: 'ALL_MESSAGES' | 'MESSAGE_REQUESTS'; - showPickerModal: () => void; - messageRequestCount: number; - onShowMessageRequests: () => void; -} -const ListHeader: FC = ({ - list, - showPickerModal, - messageRequestCount, - onShowMessageRequests, -}) => { - const {navigate} = useTypedNavigation(); - const address = useAddress(); - const {data} = useENS(); - const {avatarUrl} = data ?? {}; - - const handleAccountPress = useCallback(() => { - navigate(ScreenNames.Account); - }, [navigate]); - const navigateToDev = useCallback(() => { - if (__DEV__) { - navigate(ScreenNames.Dev); - } - }, [navigate]); - return ( - - - - - - - - - - - - - {list === 'MESSAGE_REQUESTS' ? ( - - - {translate('message_requests_from_new_addresses')} - - - ) : messageRequestCount > 0 ? ( - - - - - - - {translate('message_requests_count', { - count: String(messageRequestCount), - })} - - - - - - - ) : ( - - )} - - ); -}; - export const ConversationListScreen = () => { const [list, setList] = useState<'ALL_MESSAGES' | 'MESSAGE_REQUESTS'>( 'ALL_MESSAGES', @@ -214,15 +77,12 @@ export const ConversationListScreen = () => { [], ); - const renderItem: ListRenderItem = useCallback(({item}) => { - return ( - - ); - }, []); + const renderItem: ListRenderItem> = useCallback( + ({item}) => { + return ; + }, + [], + ); return ( <> @@ -235,7 +95,7 @@ export const ConversationListScreen = () => { keyExtractor={keyExtractor} data={list === 'ALL_MESSAGES' ? messages : messageRequests} ListHeaderComponent={ - item; -const useData = (id: string) => { - const {data: messages, refetch, isRefetching} = useGroupMessages(id); - const {data: addresses} = useGroupParticipantsQuery(id); +const useData = (topic: string) => { + const {data: messages, refetch, isRefetching} = useGroupMessages(topic); + const {data: addresses} = useGroupParticipantsQuery(topic); const {client} = useClient(); - const {data: group} = useGroup(id); + const {data: group} = useGroup(topic); return { - name: group?.id, + name: topic, myAddress: client?.address, messages, refetch, @@ -60,13 +60,13 @@ const getInitialConsentState = ( export const GroupScreen = () => { const {params} = useRoute(); - const {id} = params as {id: string}; + const {topic} = params as {topic: string}; const {myAddress, messages, addresses, group, client, refetch, isRefetching} = - useData(id); + useData(topic); const [showGroupModal, setShowGroupModal] = useState(false); const [showAddModal, setShowAddModal] = useState(false); const [consent, setConsent] = useState<'allowed' | 'denied' | 'unknown'>( - getInitialConsentState(myAddress ?? '', group?.id ?? ''), + getInitialConsentState(myAddress ?? '', group?.topic ?? ''), ); const [replyId, setReplyId] = useState(null); const [reactId, setReactId] = useState(null); @@ -141,16 +141,16 @@ export const GroupScreen = () => { client?.contacts.allow(addresses); } setConsent('allowed'); - mmkvStorage.saveConsent(myAddress ?? '', id ?? '', true); - }, [addresses, client?.contacts, myAddress, id]); + mmkvStorage.saveConsent(myAddress ?? '', topic ?? '', true); + }, [addresses, client?.contacts, myAddress, topic]); const onBlock = useCallback(() => { if (addresses) { client?.contacts.deny(addresses); } setConsent('denied'); - mmkvStorage.saveConsent(myAddress ?? '', id ?? '', false); - }, [addresses, client?.contacts, id, myAddress]); + mmkvStorage.saveConsent(myAddress ?? '', topic ?? '', false); + }, [addresses, client?.contacts, topic, myAddress]); const setReply = useCallback( (id: string) => { @@ -184,7 +184,7 @@ export const GroupScreen = () => { }}> setShowGroupModal(true)} /> @@ -207,7 +207,7 @@ export const GroupScreen = () => { ) : ( diff --git a/src/screens/NewConversationScreen.tsx b/src/screens/NewConversationScreen.tsx index 044cf73..1cd8ef9 100644 --- a/src/screens/NewConversationScreen.tsx +++ b/src/screens/NewConversationScreen.tsx @@ -61,7 +61,7 @@ export const NewConversationScreen = () => { Alert.alert('Error sending message', error?.message); } if (group) { - replace(ScreenNames.Group, {id: group.id}); + replace(ScreenNames.Group, {topic: group.topic}); } } catch (error: any) { Alert.alert('Error creating group', error?.message); diff --git a/src/screens/OnboardingEnableIdentityScreen.tsx b/src/screens/OnboardingEnableIdentityScreen.tsx index 662a120..f0c7139 100644 --- a/src/screens/OnboardingEnableIdentityScreen.tsx +++ b/src/screens/OnboardingEnableIdentityScreen.tsx @@ -1,8 +1,8 @@ import {useDisconnect, useSigner} from '@thirdweb-dev/react-native'; import {Client} from '@xmtp/react-native-sdk'; import {StatusBar, VStack} from 'native-base'; -import {useCallback, useEffect, useState} from 'react'; -import {Alert, DeviceEventEmitter, Image, Platform} from 'react-native'; +import React, {useCallback, useEffect, useState} from 'react'; +import {Alert, DeviceEventEmitter, Image} from 'react-native'; import {Button} from '../components/common/Button'; import {Icon} from '../components/common/Icon'; import {Screen} from '../components/common/Screen'; @@ -12,10 +12,15 @@ import {useClientContext} from '../context/ClientContext'; import {useTypedNavigation} from '../hooks/useTypedNavigation'; import {translate} from '../i18n'; import {ScreenNames} from '../navigation/ScreenNames'; +import {QueryKeys} from '../queries/QueryKeys'; import {encryptedStorage} from '../services/encryptedStorage'; -import {PushNotificatons} from '../services/pushNotifications'; +import {PushNotifications} from '../services/pushNotifications'; +import {queryClient} from '../services/queryClient'; import {colors} from '../theme/colors'; import {createClientOptions} from '../utils/clientOptions'; +import {getAllListMessages} from '../utils/getAllListMessages'; +import {withRequestLogger} from '../utils/logger'; +import {streamAllMessages} from '../utils/streamAllMessages'; type Step = 'CREATE_IDENTITY' | 'ENABLE_IDENTITY'; @@ -66,14 +71,21 @@ export const OnboardingEnableIdentityScreen = () => { await createIdentityPromise(); }, }); - if (Platform.OS !== 'android') { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _ = new PushNotificatons(client); - } + + const pushClient = new PushNotifications(client); + pushClient.subscribeToAllGroups(); const keys = await client.exportKeyBundle(); const address = client.address; encryptedStorage.saveClientKeys(address as `0x${string}`, keys); + queryClient.prefetchQuery({ + queryKey: [QueryKeys.List, client?.address], + queryFn: () => + withRequestLogger(getAllListMessages(client), { + name: 'all_messages_list', + }), + }); setClient(client); + streamAllMessages(client); } catch (e: any) { console.log('Error creating client', e); Alert.alert('Error creating client', e?.message); diff --git a/src/services/mmkvStorage.ts b/src/services/mmkvStorage.ts index 52aaddf..8c538a2 100644 --- a/src/services/mmkvStorage.ts +++ b/src/services/mmkvStorage.ts @@ -22,6 +22,9 @@ enum MMKVKeys { // Groups GROUP_NAME = 'GROUP_NAME', GROUP_ID_PUSH_SUBSCRIPTION = 'GROUP_ID_PUSH_SUBSCRIPTION', + GROUP_PARTICIPANTS = 'GROUP_PARTICIPANTS', + + GROUP_FIRST_MESSAGE_CONTENT = 'GROUP_FIRST_MESSAGE_CONTENT', } export const mmkvstorage = new MMKV(); @@ -223,48 +226,94 @@ class MMKVStorage { // #region Group Name - private getGroupNameKey = (address: string, groupId: string) => { - return `${MMKVKeys.GROUP_NAME}_${address}_${groupId}`; + private getGroupNameKey = (address: string, topic: string) => { + return `${MMKVKeys.GROUP_NAME}_${address}_${topic}`; }; - saveGroupName = (address: string, groupId: string, groupName: string) => { - return this.storage.set(this.getGroupNameKey(address, groupId), groupName); + saveGroupName = (address: string, topic: string, groupName: string) => { + return this.storage.set(this.getGroupNameKey(address, topic), groupName); }; - getGroupName = (address: string, groupId: string) => { - return this.storage.getString(this.getGroupNameKey(address, groupId)); + getGroupName = (address: string, topic: string) => { + return this.storage.getString(this.getGroupNameKey(address, topic)); }; - clearGroupName = (address: string, groupId: string) => { - return this.storage.delete(this.getGroupNameKey(address, groupId)); + clearGroupName = (address: string, topic: string) => { + return this.storage.delete(this.getGroupNameKey(address, topic)); }; //#endregion Group Name //#region Group Id Push Subscription - private getGroupIdPushSubscriptionKey = (groupId: string) => { - return `${MMKVKeys.GROUP_ID_PUSH_SUBSCRIPTION}_${groupId}`; + private getGroupIdPushSubscriptionKey = (topic: string) => { + return `${MMKVKeys.GROUP_ID_PUSH_SUBSCRIPTION}_${topic}`; }; - saveGroupIdPushSubscription = ( - groupId: string, - pushSubscription: boolean, - ) => { + saveGroupIdPushSubscription = (topic: string, pushSubscription: boolean) => { return this.storage.set( - this.getGroupIdPushSubscriptionKey(groupId), + this.getGroupIdPushSubscriptionKey(topic), pushSubscription, ); }; - getGroupIdPushSubscription = (groupId: string) => { - return this.storage.getBoolean(this.getGroupIdPushSubscriptionKey(groupId)); + getGroupIdPushSubscription = (topic: string) => { + return this.storage.getBoolean(this.getGroupIdPushSubscriptionKey(topic)); + }; + + clearGroupIdPushSubscription = (topic: string) => { + return this.storage.delete(this.getGroupIdPushSubscriptionKey(topic)); + }; + + //#endregion Group Id Push Subscription + + //#region Group Participants + + private getGroupParticipantsKey = (topic: string) => { + return `${MMKVKeys.GROUP_PARTICIPANTS}_${topic}`; + }; + + saveGroupParticipants = (topic: string, participants: string[]) => { + return this.storage.set( + this.getGroupParticipantsKey(topic), + participants.join(','), + ); + }; + + getGroupParticipants = (topic: string): string[] => { + return ( + this.storage.getString(this.getGroupParticipantsKey(topic))?.split(',') ?? + [] + ); + }; + + clearGroupParticipants = (topic: string) => { + return this.storage.delete(this.getGroupParticipantsKey(topic)); }; - clearGroupIdPushSubscription = (groupId: string) => { - return this.storage.delete(this.getGroupIdPushSubscriptionKey(groupId)); + //#endregion Group Participants + + //#region Group First Message + + private getGroupFirstMessageContentKey = (topic: string) => { + return `${MMKVKeys.GROUP_FIRST_MESSAGE_CONTENT}_${topic}`; }; + + saveGroupFirstMessageContent = (topic: string, message: string) => { + return this.storage.set( + this.getGroupFirstMessageContentKey(topic), + message, + ); + }; + + getGroupFirstMessageContent = (topic: string) => { + return this.storage.getString(this.getGroupFirstMessageContentKey(topic)); + }; + + clearGroupFirstMessageContent = (topic: string) => { + return this.storage.delete(this.getGroupFirstMessageContentKey(topic)); + }; + + //#endregion Group First Message } export const mmkvStorage = new MMKVStorage(); - -// #endregion Group Name diff --git a/src/services/pushNotifications.ts b/src/services/pushNotifications.ts index 64d5852..e2ccf9a 100644 --- a/src/services/pushNotifications.ts +++ b/src/services/pushNotifications.ts @@ -1,6 +1,7 @@ import PushNotificationIOS from '@react-native-community/push-notification-ios'; import {Client, XMTPPush} from '@xmtp/react-native-sdk'; import RNPush from 'react-native-push-notification'; +import {AppConfig} from '../consts/AppConfig'; import {SupportedContentTypes} from '../consts/ContentTypes'; import { CHANNEL_ID, @@ -8,42 +9,21 @@ import { PUSH_SERVER, } from '../consts/PushNotifications'; -export class PushNotificatons { +export class PushNotifications { client: Client; + pushClient: XMTPPush; + constructor(client: Client) { this.client = client; - this.configure(); - } - configure = () => { - const client = this.client; + this.pushClient = new XMTPPush(client); RNPush.configure({ async onRegister(registrationData) { try { const token = registrationData.token; console.log('PUSH NOTIFICATION TOKEN:', token); - XMTPPush.register(PUSH_SERVER, token); - await Promise.all([ - client.contacts.refreshConsentList(), - client.conversations.syncGroups(), - ]); - await client.contacts.refreshConsentList(); - await client.conversations.syncGroups(); - const conversations = await client.conversations.listGroups(); - const pushClient = new XMTPPush(client); - pushClient.subscribe(conversations.map(c => c.topic)); - for (const conversation of conversations) { - RNPush.createChannel( - { - channelId: CHANNEL_ID + conversation.topic, // (required) - channelName: CHANNEL_NAME + conversation.topic, // (required) - }, - created => - console.log( - `PUSH NOTIFICATION createChannel returned '${created}'`, - ), - ); + if (AppConfig.PUSH_NOTIFICATIONS) { + XMTPPush.register(PUSH_SERVER, token); } - console.log('PUSH NOTIFICATION Registered push token:', token); } catch (error) { console.error( 'PUSH NOTIFICATION Failed to register push token:', @@ -115,5 +95,51 @@ export class PushNotificatons { popInitialNotification: true, requestPermissions: true, }); + } + + subscribeToAllGroups = async () => { + const client = this.client; + await Promise.all([ + client.contacts.refreshConsentList(), + client.conversations.syncGroups(), + ]); + const groups = await client.conversations.listGroups(); + const topics = groups.map(c => c.topic); + const allowedTopics: string[] = []; + + await Promise.allSettled( + groups.map(group => + client.contacts.isGroupAllowed(group.topic).then(allowed => { + if (!AppConfig.GROUP_CONSENT || allowed) { + allowedTopics.push(group.topic); + } + }), + ), + ); + console.log('PUSH NOTIFICATION TOPICS:', topics); + this.subscribeToGroups(allowedTopics); + }; + + subscribeToGroups = async (topics: string[]) => { + if (topics.length === 0) { + return; + } + if (AppConfig.PUSH_NOTIFICATIONS) { + await this.pushClient.subscribe(topics); + } + for (const topic of topics) { + RNPush.createChannel( + { + channelId: CHANNEL_ID + topic, // (required) + channelName: CHANNEL_NAME + topic, // (required) + }, + created => + console.log(`PUSH NOTIFICATION createChannel returned '${created}'`), + ); + } + }; + + subscribeToGroup = async (topic: string) => { + return this.subscribeToGroups([topic]); }; } diff --git a/src/utils/getAllListMessages.ts b/src/utils/getAllListMessages.ts index 7b14746..dfa73b9 100644 --- a/src/utils/getAllListMessages.ts +++ b/src/utils/getAllListMessages.ts @@ -1,86 +1,42 @@ import {Client} from '@xmtp/react-native-sdk'; -import {Platform} from 'react-native'; -import {ListGroup, ListMessages} from '../models/ListMessages'; +import {SupportedContentTypes} from '../consts/ContentTypes'; import {mmkvStorage} from '../services/mmkvStorage'; -import {PushNotificatons} from '../services/pushNotifications'; +import {PushNotifications} from '../services/pushNotifications'; import {withRequestLogger} from './logger'; -export const getAllListMessages = async (client?: Client | null) => { +export const getAllListMessages = async ( + client?: Client | null, +) => { try { if (!client) { return []; } - const [consentList] = await Promise.all([ - withRequestLogger(client.contacts.refreshConsentList(), { - name: 'consent', - }), - withRequestLogger(client.conversations.syncGroups(), { - name: 'group_sync', - }), - ]); - - consentList.forEach(async item => { - mmkvStorage.saveConsent( - client.address, - item.value, - item.permissionType === 'allowed', + try { + const consentList = await withRequestLogger( + client.contacts.refreshConsentList(), + { + name: 'consent', + }, ); - }); - if (Platform.OS !== 'android') { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _ = new PushNotificatons(client); + consentList.forEach(item => { + mmkvStorage.saveConsent( + client.address, + item.value, + item.permissionType === 'allowed', + ); + }); + } catch (e) { + console.log('Error fetching messages', e); + throw new Error('Error fetching messages'); } + const pushClient = new PushNotifications(client); + pushClient.subscribeToAllGroups(); const groups = await withRequestLogger(client.conversations.listGroups(), { name: 'groups', }); - const allMessages: PromiseSettledResult[] = - await Promise.allSettled( - groups.map(async group => { - // const hasPushSubscription = mmkvStorage.getGroupIdPushSubscription( - // group.topic, - // ); - // // if (!hasPushSubscription) { - // console.log('PUSH NOTIFICATION Subscribing to group', group.topic); - // XMTPPush.subscribe([group.topic]); - // mmkvStorage.saveGroupIdPushSubscription(group.topic, true); - // // } - 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; - }, - [], - ); - return allMessagesFiltered; + return groups; } catch (e) { console.log('Error fetching messages', e); throw new Error('Error fetching messages'); diff --git a/src/utils/idExtractors.ts b/src/utils/idExtractors.ts index 2fc81c9..30946b2 100644 --- a/src/utils/idExtractors.ts +++ b/src/utils/idExtractors.ts @@ -13,6 +13,6 @@ export const getConversationId = ( return conversation.topic; }; -export const getGroupId = (group: Group) => { - return group.id; +export const getGroupTopic = (group: Group) => { + return group.topic; }; diff --git a/src/utils/streamAllMessages.ts b/src/utils/streamAllMessages.ts new file mode 100644 index 0000000..8d940aa --- /dev/null +++ b/src/utils/streamAllMessages.ts @@ -0,0 +1,46 @@ +import {Client, DecodedMessage, Group} from '@xmtp/react-native-sdk'; +import {Platform} from 'react-native'; +import {SupportedContentTypes} from '../consts/ContentTypes'; +import {QueryKeys} from '../queries/QueryKeys'; +import {PushNotifications} from '../services/pushNotifications'; +import {queryClient} from '../services/queryClient'; + +let cancelStreamGroups: null | Promise<() => void> = null; + +export const streamAllMessages = async ( + client: Client, +) => { + cancelStreamGroups = client.conversations.streamGroups(async newGroup => { + console.log('NEW GROUP:', newGroup); + if (Platform.OS !== 'android') { + const pushClient = new PushNotifications(client); + pushClient.subscribeToGroup(newGroup.topic); + } + queryClient.setQueryData[]>( + [QueryKeys.List, client?.address], + prev => { + return [newGroup, ...(prev ?? [])]; + }, + ); + }); + + client.conversations.streamAllGroupMessages(async newMessage => { + console.log('NEW MESSAGE:', newMessage); + queryClient.setQueryData[]>( + [QueryKeys.FirstGroupMessage, newMessage.topic], + prev => { + return [newMessage, ...(prev ?? [])]; + }, + ); + }); +}; + +export const cancelStreamAllMessages = async ( + client?: Client | null, +) => { + if (cancelStreamGroups) { + const cancel = await cancelStreamGroups; + cancel(); + } + client?.conversations.cancelStreamAllMessages(); +};