Skip to content

Commit

Permalink
Merge pull request #96 from xmtp-labs/ar/streaming-refactor
Browse files Browse the repository at this point in the history
refactor: Refactor Conversation List
alexrisch authored May 28, 2024
2 parents c33c24a + d6d0d83 commit b9d4909
Showing 29 changed files with 604 additions and 496 deletions.
1 change: 1 addition & 0 deletions babel.config.js
Original file line number Diff line number Diff line change
@@ -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',
],
5 changes: 5 additions & 0 deletions src/components/ConversationInput.tsx
Original file line number Diff line number Diff line change
@@ -33,6 +33,8 @@ export const ConversationInput: FC<ConversationInputProps> = ({
// ? getDraftImage(currentAddress, topic) ?? null
// : null,

const textInputRef = React.createRef<TextInput>();

useEffect(() => {
if (text && currentAddress && id) {
mmkvStorage.saveDraftText(currentAddress, id, text);
@@ -129,12 +131,15 @@ export const ConversationInput: FC<ConversationInputProps> = ({
alignItems={'center'}
borderBottomRightRadius={0}>
<TextInput
ref={textInputRef}
autoFocus
value={text}
style={styles.input}
onChangeText={setText}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
returnKeyType={canSend ? 'send' : 'default'}
onSubmitEditing={canSend ? handleSend : textInputRef.current?.blur}
/>
<Pressable onPress={handleSend}>
<Box
143 changes: 143 additions & 0 deletions src/components/ConversationListHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import {useAddress, useENS} from '@thirdweb-dev/react-native';
import {Box, HStack, VStack} from 'native-base';
import React, {FC, useCallback} from 'react';
import {Pressable} from 'react-native';
import {AvatarWithFallback} from '../components/AvatarWithFallback';
import {Button} from '../components/common/Button';
import {Icon} from '../components/common/Icon';
import {Text} from '../components/common/Text';
import {useTypedNavigation} from '../hooks/useTypedNavigation';
import {translate} from '../i18n';
import {ScreenNames} from '../navigation/ScreenNames';
import {colors} from '../theme/colors';

export interface ConversationListHeaderProps {
list: 'ALL_MESSAGES' | 'MESSAGE_REQUESTS';
showPickerModal: () => void;
messageRequestCount: number;
onShowMessageRequests: () => void;
}

export const ConversationListHeader: FC<ConversationListHeaderProps> = ({
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 (
<VStack marginTop={'4px'}>
<HStack
w={'100%'}
justifyContent={'space-between'}
alignItems={'center'}
paddingX={'16px'}
paddingBottom={'8px'}>
<Pressable onPress={handleAccountPress}>
<AvatarWithFallback
address={address ?? ''}
avatarUri={avatarUrl}
size={40}
/>
</Pressable>
<Box
borderRadius={'24px'}
zIndex={10}
paddingX={'16px'}
paddingY={'8px'}
backgroundColor={'white'}
shadow={4}>
<Box>
<Button
onLongPress={navigateToDev}
_pressed={{backgroundColor: 'transparent'}}
size={'sm'}
variant={'ghost'}
leftIcon={
<Icon
name="chat-bubble-oval-left"
size={16}
type="mini"
color="#0F172A"
/>
}
rightIcon={
<Icon
name="chevron-down-thick"
size={16}
type="mini"
color="#0F172A"
/>
}
onPress={showPickerModal}>
<Text typography="text-sm/heavy">
{list === 'ALL_MESSAGES'
? translate('all_messages')
: translate('message_requests')}
</Text>
</Button>
</Box>
</Box>
<Box width={10} />
</HStack>
{list === 'MESSAGE_REQUESTS' ? (
<Box
backgroundColor={colors.actionAlertBG}
paddingY={'8px'}
paddingX={'16px'}>
<Text
typography="text-caption/regular"
textAlign={'center'}
color={colors.actionAlertText}>
{translate('message_requests_from_new_addresses')}
</Text>
</Box>
) : messageRequestCount > 0 ? (
<Pressable onPress={onShowMessageRequests}>
<HStack
backgroundColor={colors.actionPrimary}
padding={'8px'}
borderRadius={'8px'}
alignItems={'center'}
marginX={'16px'}>
<Box paddingLeft={'8px'} paddingRight={'16px'}>
<Icon name="inbox-arrow-down" color={colors.actionPrimaryText} />
</Box>
<Text
flex={1}
color={colors.actionPrimaryText}
typography="text-xs/bold">
{translate('message_requests_count', {
count: String(messageRequestCount),
})}
</Text>
<Box
paddingLeft={'8px'}
paddingRight={'16px'}
justifyContent={'flex-end'}>
<Icon
name="chevron-right-thick"
size={16}
color={colors.actionPrimaryText}
/>
</Box>
</HStack>
</Pressable>
) : (
<Box />
)}
</VStack>
);
};
65 changes: 0 additions & 65 deletions src/components/ConversationListItem.tsx

This file was deleted.

85 changes: 60 additions & 25 deletions src/components/GroupListItem.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,69 @@
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';
import {Text} from './common/Text';

interface GroupListItemProps {
group: Group<SupportedContentTypes>;
display: string;
lastMessageTime: number;
}

export const GroupListItem: FC<GroupListItemProps> = ({
group,
display,
lastMessageTime,
}) => {
const {data: addresses} = useGroupParticipantsQuery(group?.id);
export const GroupListItem: FC<GroupListItemProps> = ({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 (
<Pressable
onPress={() => {
navigate(ScreenNames.Group, {
id: group?.id,
});
}}>
<Pressable onPress={handlePress}>
<HStack space={[2, 3]} alignItems={'center'} w={'100%'} padding={'16px'}>
<Box marginRight={'30px'}>
<GroupAvatarStack
@@ -47,16 +78,20 @@ export const GroupListItem: FC<GroupListItemProps> = ({
typography="text-base/bold">
{groupName}
</Text>
<Text numberOfLines={1} typography="text-sm/regular">
{display}
</Text>
{!isLoading && (
<Text numberOfLines={1} typography="text-sm/regular">
{display}
</Text>
)}
</VStack>
<Text
alignSelf={'flex-start'}
typography="text-xs/regular"
style={{textAlignVertical: 'top'}}>
{getMessageTimeDisplay(lastMessageTime)}
</Text>
{lastMessageTime && (
<Text
alignSelf={'flex-start'}
typography="text-xs/regular"
style={{textAlignVertical: 'top'}}>
{getMessageTimeDisplay(lastMessageTime)}
</Text>
)}
</HStack>
</Pressable>
);
2 changes: 1 addition & 1 deletion src/components/modals/AddGroupParticipantModal.tsx
Original file line number Diff line number Diff line change
@@ -77,7 +77,7 @@ export const AddGroupParticipantModal: FC<GroupInfoModalProps> = ({
try {
await group.addMembers(participants);
DeviceEventEmitter.emit(
`${EventEmitterEvents.GROUP_CHANGED}_${group.id}`,
`${EventEmitterEvents.GROUP_CHANGED}_${group.topic}`,
);
hide();
} catch (err: any) {
11 changes: 7 additions & 4 deletions src/components/modals/GroupInfoModal.tsx
Original file line number Diff line number Diff line change
@@ -93,7 +93,7 @@ export const GroupInfoModal: FC<GroupInfoModalProps> = ({
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<GroupInfoModalProps> = ({
}
mmkvStorage.saveGroupName(
client?.address ?? '',
group?.id ?? '',
group?.topic ?? '',
groupName,
);
setEditing(false);
setGroupName('');
}, [client?.address, group?.id, groupName]);
}, [client?.address, group?.topic, groupName]);

return (
<Modal onBackgroundPress={hide} isOpen={shown}>
@@ -141,7 +141,10 @@ export const GroupInfoModal: FC<GroupInfoModalProps> = ({
<HStack w={'100%'} alignItems={'center'} justifyContent={'center'}>
<Text typography="text-xl/bold" textAlign={'center'}>
{(!!group &&
mmkvStorage.getGroupName(client?.address ?? '', group?.id)) ??
mmkvStorage.getGroupName(
client?.address ?? '',
group?.topic,
)) ??
translate('group')}
</Text>
<Pressable onPress={() => setEditing(true)} alignSelf={'flex-end'}>
4 changes: 4 additions & 0 deletions src/consts/AppConfig.ts
Original file line number Diff line number Diff line change
@@ -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,
};
6 changes: 3 additions & 3 deletions src/hooks/useGroup.ts
Original file line number Diff line number Diff line change
@@ -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]);
};
51 changes: 29 additions & 22 deletions src/hooks/useGroupMessages.ts
Original file line number Diff line number Diff line change
@@ -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<GroupMessagesQueryRequestData>(
[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<GroupMessagesQueryRequestData>(
[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);
};
37 changes: 37 additions & 0 deletions src/hooks/useGroupParticipants.ts
Original file line number Diff line number Diff line change
@@ -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 ?? [];
};
62 changes: 0 additions & 62 deletions src/hooks/useListMessages.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/navigation/StackParams.ts
Original file line number Diff line number Diff line change
@@ -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;
16 changes: 16 additions & 0 deletions src/providers/ClientProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<PropsWithChildren> = ({children}) => {
const [client, setClient] = useState<Client<SupportedContentTypes> | null>(
@@ -40,7 +45,18 @@ export const ClientProvider: FC<PropsWithChildren> = ({children}) => {
keys,
clientOptions,
);
queryClient.prefetchQuery({
queryKey: [QueryKeys.List, newClient?.address],
queryFn: () =>
withRequestLogger(
getAllListMessages(newClient as Client<SupportedContentTypes>),
{
name: 'all_messages_list',
},
),
});
setClient(newClient as Client<SupportedContentTypes>);
streamAllMessages(newClient as Client<SupportedContentTypes>);
} catch (err) {
encryptedStorage.clearClientKeys(address as `0x${string}`);
} finally {
1 change: 1 addition & 0 deletions src/queries/QueryKeys.ts
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@ export enum QueryKeys {
Groups = 'groups',
GroupParticipants = 'group_participants',
GroupMessages = 'group_messages',
FirstGroupMessage = 'first_group_messages',
FramesClient = 'frames_client',
Frame = 'frame',
}
30 changes: 30 additions & 0 deletions src/queries/useFirstGroupMessageQuery.ts
Original file line number Diff line number Diff line change
@@ -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<SupportedContentTypes>[]
| undefined;
export type GroupMessagesQueryError = unknown;

export const useFirstGroupMessageQuery = (topic: string) => {
const {data: group} = useGroup(topic);

return useQuery<GroupMessagesQueryRequestData, GroupMessagesQueryError>({
queryKey: [QueryKeys.FirstGroupMessage, topic],
queryFn: async () => {
if (!group) {
return undefined;
}
const messages: DecodedMessage<SupportedContentTypes>[] =
await group.messages(false, {
// limit: 1,
// direction: 'SORT_DIRECTION_ASCENDING',
});
return messages;
},
enabled: !!group,
});
};
14 changes: 7 additions & 7 deletions src/queries/useGroupMessagesQuery.ts
Original file line number Diff line number Diff line change
@@ -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<SupportedContentTypes>[]
>;
12 changes: 6 additions & 6 deletions src/queries/useGroupParticipantsQuery.ts
Original file line number Diff line number Diff line change
@@ -6,27 +6,27 @@ 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],
});
},
);

return () => {
groupChangeSubscription.remove();
};
}, [id, queryClient]);
}, [topic, queryClient]);

return useQuery({
queryKey: [QueryKeys.GroupParticipants, group?.id],
queryKey: [QueryKeys.GroupParticipants, group?.topic],
queryFn: async () => {
if (!group) {
return [];
4 changes: 2 additions & 2 deletions src/queries/useGroupsQuery.ts
Original file line number Diff line number Diff line change
@@ -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),
});
};
4 changes: 4 additions & 0 deletions src/screens/AccountSettingsScreen.tsx
Original file line number Diff line number Diff line change
@@ -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<ListItem, {section: Section}> = ({
@@ -337,6 +340,7 @@ export const AccountSettingsScreen = () => {
setWalletsShown(true);
}
}}
_pressed={{backgroundColor: 'transparent'}}
variant={'ghost'}
rightIcon={
<Icon
176 changes: 18 additions & 158 deletions src/screens/ConversationListScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,39 @@
import {useIsFocused} from '@react-navigation/native';
import {useAddress, useENS} from '@thirdweb-dev/react-native';
import {Group} from '@xmtp/react-native-sdk';
import {Box, Center, Fab, FlatList, HStack, VStack} from 'native-base';
import React, {FC, useCallback, useMemo, useState} from 'react';
import React, {useCallback, useMemo, useState} from 'react';
import {ListRenderItem, Pressable} from 'react-native';
import {AvatarWithFallback} from '../components/AvatarWithFallback';
import {ConversationListHeader} from '../components/ConversationListHeader';
import {GroupListItem} from '../components/GroupListItem';
import {Button} from '../components/common/Button';
import {Drawer} from '../components/common/Drawer';
import {Icon} from '../components/common/Icon';
import {Screen} from '../components/common/Screen';
import {Text} from '../components/common/Text';
import {SupportedContentTypes} from '../consts/ContentTypes';
import {TestIds} from '../consts/TestIds';
import {useClient} from '../hooks/useClient';
import {useListMessages} from '../hooks/useListMessages';
import {useTypedNavigation} from '../hooks/useTypedNavigation';
import {translate} from '../i18n';
import {ListGroup, ListMessages} from '../models/ListMessages';
import {ScreenNames} from '../navigation/ScreenNames';
import {useListQuery} from '../queries/useListQuery';
import {colors} from '../theme/colors';

const EmptyBackground = require('../../assets/images/Bg_asset.svg').default;

const keyExtractor = (item: ListGroup) => item.group?.id ?? '';
const keyExtractor = (item: Group<SupportedContentTypes>) => 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<SupportedContentTypes>[] = [];
const requestsItems: Group<SupportedContentTypes>[] = [];
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<ListHeaderProps> = ({
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 (
<VStack marginTop={'4px'}>
<HStack
w={'100%'}
justifyContent={'space-between'}
alignItems={'center'}
paddingX={'16px'}
paddingBottom={'8px'}>
<Pressable onPress={handleAccountPress}>
<AvatarWithFallback
address={address ?? ''}
avatarUri={avatarUrl}
size={40}
/>
</Pressable>
<Box
borderRadius={'24px'}
zIndex={10}
paddingX={'16px'}
paddingY={'8px'}
backgroundColor={'white'}
shadow={4}>
<Box>
<Button
onLongPress={navigateToDev}
_pressed={{backgroundColor: 'transparent'}}
size={'sm'}
variant={'ghost'}
leftIcon={
<Icon
name="chat-bubble-oval-left"
size={16}
type="mini"
color="#0F172A"
/>
}
rightIcon={
<Icon
name="chevron-down-thick"
size={16}
type="mini"
color="#0F172A"
/>
}
onPress={showPickerModal}>
<Text typography="text-sm/heavy">
{list === 'ALL_MESSAGES'
? translate('all_messages')
: translate('message_requests')}
</Text>
</Button>
</Box>
</Box>
<Box width={10} />
</HStack>
{list === 'MESSAGE_REQUESTS' ? (
<Box
backgroundColor={colors.actionAlertBG}
paddingY={'8px'}
paddingX={'16px'}>
<Text
typography="text-caption/regular"
textAlign={'center'}
color={colors.actionAlertText}>
{translate('message_requests_from_new_addresses')}
</Text>
</Box>
) : messageRequestCount > 0 ? (
<Pressable onPress={onShowMessageRequests}>
<HStack
backgroundColor={colors.actionPrimary}
padding={'8px'}
borderRadius={'8px'}
alignItems={'center'}
marginX={'16px'}>
<Box paddingLeft={'8px'} paddingRight={'16px'}>
<Icon name="inbox-arrow-down" color={colors.actionPrimaryText} />
</Box>
<Text
flex={1}
color={colors.actionPrimaryText}
typography="text-xs/bold">
{translate('message_requests_count', {
count: String(messageRequestCount),
})}
</Text>
<Box
paddingLeft={'8px'}
paddingRight={'16px'}
justifyContent={'flex-end'}>
<Icon
name="chevron-right-thick"
size={16}
color={colors.actionPrimaryText}
/>
</Box>
</HStack>
</Pressable>
) : (
<Box />
)}
</VStack>
);
};

export const ConversationListScreen = () => {
const [list, setList] = useState<'ALL_MESSAGES' | 'MESSAGE_REQUESTS'>(
'ALL_MESSAGES',
@@ -214,15 +77,12 @@ export const ConversationListScreen = () => {
[],
);

const renderItem: ListRenderItem<ListGroup> = useCallback(({item}) => {
return (
<GroupListItem
group={item.group}
display={item.display}
lastMessageTime={item.lastMessageTime}
/>
);
}, []);
const renderItem: ListRenderItem<Group<SupportedContentTypes>> = useCallback(
({item}) => {
return <GroupListItem group={item} />;
},
[],
);

return (
<>
@@ -235,7 +95,7 @@ export const ConversationListScreen = () => {
keyExtractor={keyExtractor}
data={list === 'ALL_MESSAGES' ? messages : messageRequests}
ListHeaderComponent={
<ListHeader
<ConversationListHeader
showPickerModal={showPicker}
list={list}
messageRequestCount={messageRequests.length}
28 changes: 14 additions & 14 deletions src/screens/GroupScreen.tsx
Original file line number Diff line number Diff line change
@@ -25,14 +25,14 @@ import {colors} from '../theme/colors';

const keyExtractor = (item: string) => 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<string | null>(null);
const [reactId, setReactId] = useState<string | null>(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 = () => {
}}>
<Box backgroundColor={colors.backgroundPrimary} paddingBottom={10}>
<GroupHeader
groupId={group?.id ?? ''}
groupId={group?.topic ?? ''}
peerAddresses={addresses ?? []}
onGroupPress={() => setShowGroupModal(true)}
/>
@@ -207,7 +207,7 @@ export const GroupScreen = () => {
<ConversationInput
sendMessage={sendMessage}
currentAddress={myAddress}
id={id}
id={topic}
/>
) : (
<HStack justifyContent={'space-around'} marginX={'40px'}>
2 changes: 1 addition & 1 deletion src/screens/NewConversationScreen.tsx
Original file line number Diff line number Diff line change
@@ -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);
26 changes: 19 additions & 7 deletions src/screens/OnboardingEnableIdentityScreen.tsx
Original file line number Diff line number Diff line change
@@ -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);
91 changes: 70 additions & 21 deletions src/services/mmkvStorage.ts
Original file line number Diff line number Diff line change
@@ -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
80 changes: 53 additions & 27 deletions src/services/pushNotifications.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,29 @@
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,
CHANNEL_NAME,
PUSH_SERVER,
} from '../consts/PushNotifications';

export class PushNotificatons {
export class PushNotifications {
client: Client<SupportedContentTypes>;
pushClient: XMTPPush;

constructor(client: Client<SupportedContentTypes>) {
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]);
};
}
92 changes: 24 additions & 68 deletions src/utils/getAllListMessages.ts
Original file line number Diff line number Diff line change
@@ -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<any> | null) => {
export const getAllListMessages = async (
client?: Client<SupportedContentTypes> | 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<ListGroup>[] =
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<ListMessages>(
(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');
4 changes: 2 additions & 2 deletions src/utils/idExtractors.ts
Original file line number Diff line number Diff line change
@@ -13,6 +13,6 @@ export const getConversationId = (
return conversation.topic;
};

export const getGroupId = (group: Group<SupportedContentTypes>) => {
return group.id;
export const getGroupTopic = (group: Group<SupportedContentTypes>) => {
return group.topic;
};
46 changes: 46 additions & 0 deletions src/utils/streamAllMessages.ts
Original file line number Diff line number Diff line change
@@ -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<SupportedContentTypes>,
) => {
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<Group<SupportedContentTypes>[]>(
[QueryKeys.List, client?.address],
prev => {
return [newGroup, ...(prev ?? [])];
},
);
});

client.conversations.streamAllGroupMessages(async newMessage => {
console.log('NEW MESSAGE:', newMessage);
queryClient.setQueryData<DecodedMessage<SupportedContentTypes>[]>(
[QueryKeys.FirstGroupMessage, newMessage.topic],
prev => {
return [newMessage, ...(prev ?? [])];
},
);
});
};

export const cancelStreamAllMessages = async (
client?: Client<SupportedContentTypes> | null,
) => {
if (cancelStreamGroups) {
const cancel = await cancelStreamGroups;
cancel();
}
client?.conversations.cancelStreamAllMessages();
};

0 comments on commit b9d4909

Please sign in to comment.