From f14b18aa55d17713f60c6f0b98787d7e2ec85bbc Mon Sep 17 00:00:00 2001 From: Bryce McMath <32586431+bryce-mcmath@users.noreply.github.com> Date: Tue, 30 Apr 2024 11:12:29 -0700 Subject: [PATCH] feat: refactor chat msg fetching and order contacts by most recent msg (#1126) Signed-off-by: Bryce McMath --- .../components/listItems/ContactListItem.tsx | 90 ++----- .../legacy/core/App/hooks/chat-messages.tsx | 243 ++++++++++++++++++ .../legacy/core/App/localization/en/index.ts | 2 + .../legacy/core/App/localization/fr/index.ts | 2 + .../core/App/localization/pt-br/index.ts | 2 + packages/legacy/core/App/screens/Chat.tsx | 216 +--------------- .../legacy/core/App/screens/ListContacts.tsx | 53 ++-- packages/legacy/core/App/utils/contacts.ts | 57 ++++ .../core/__mocks__/@credo-ts/react-hooks.ts | 36 +++ .../core/__tests__/screens/Chat.test.tsx | 25 +- .../__tests__/screens/ListContacts.test.tsx | 83 ++---- .../__snapshots__/ListContacts.test.tsx.snap | 33 +-- 12 files changed, 456 insertions(+), 386 deletions(-) create mode 100644 packages/legacy/core/App/hooks/chat-messages.tsx create mode 100644 packages/legacy/core/App/utils/contacts.ts diff --git a/packages/legacy/core/App/components/listItems/ContactListItem.tsx b/packages/legacy/core/App/components/listItems/ContactListItem.tsx index d0d80ccb72..ec7ed959c6 100644 --- a/packages/legacy/core/App/components/listItems/ContactListItem.tsx +++ b/packages/legacy/core/App/components/listItems/ContactListItem.tsx @@ -1,36 +1,17 @@ -import type { - BasicMessageRecord, - ConnectionRecord, - CredentialExchangeRecord, - ProofExchangeRecord, -} from '@credo-ts/core' +import type { ConnectionRecord } from '@credo-ts/core' -import { useBasicMessagesByConnectionId } from '@credo-ts/react-hooks' import { StackNavigationProp } from '@react-navigation/stack' -import React, { useCallback, useEffect, useMemo, useState } from 'react' +import React, { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native' +import { View, StyleSheet, TouchableOpacity, Image, Text } from 'react-native' import { useStore } from '../../contexts/store' import { useTheme } from '../../contexts/theme' -import { useCredentialsByConnectionId } from '../../hooks/credentials' -import { useProofsByConnectionId } from '../../hooks/proofs' -import { Role } from '../../types/chat' +import { useChatMessagesByConnection } from '../../hooks/chat-messages' import { ContactStackParams, Screens, Stacks } from '../../types/navigators' -import { - formatTime, - getConnectionName, - getCredentialEventLabel, - getCredentialEventRole, - getProofEventLabel, - getProofEventRole, -} from '../../utils/helpers' +import { formatTime, getConnectionName } from '../../utils/helpers' import { testIdWithKey } from '../../utils/testable' -interface CondensedMessage { - text: string - createdAt: Date -} interface Props { contact: ConnectionRecord navigation: StackNavigationProp @@ -39,10 +20,9 @@ interface Props { const ContactListItem: React.FC = ({ contact, navigation }) => { const { t } = useTranslation() const { TextTheme, ColorPallet, ListItems } = useTheme() - const basicMessages = useBasicMessagesByConnectionId(contact.id) - const credentials = useCredentialsByConnectionId(contact.id) - const proofs = useProofsByConnectionId(contact.id) - const [message, setMessage] = useState({ text: '', createdAt: contact.createdAt }) + const messages = useChatMessagesByConnection(contact) + const message = messages[0] + const hasOnlyInitialMessage = messages.length < 2 const [store] = useStore() const styles = StyleSheet.create({ @@ -90,48 +70,6 @@ const ContactListItem: React.FC = ({ contact, navigation }) => { }, }) - useEffect(() => { - const transformedMessages: Array = basicMessages.map((record: BasicMessageRecord) => { - return { - text: record.content, - createdAt: record.updatedAt || record.createdAt, - } - }) - - transformedMessages.push( - ...credentials.map((record: CredentialExchangeRecord) => { - const role = getCredentialEventRole(record) - const userLabel = role === Role.me ? `${t('Chat.UserYou')} ` : '' - const actionLabel = t(getCredentialEventLabel(record) as any) - return { - text: `${userLabel}${actionLabel}.`, - createdAt: record.updatedAt || record.createdAt, - } - }) - ) - - transformedMessages.push( - ...proofs.map((record: ProofExchangeRecord) => { - const role = getProofEventRole(record) - const userLabel = role === Role.me ? `${t('Chat.UserYou')} ` : '' - const actionLabel = t(getProofEventLabel(record) as any) - - return { - text: `${userLabel}${actionLabel}.`, - createdAt: record.updatedAt || record.createdAt, - } - }) - ) - - // don't show a message snippet for the initial connection - const connectedMessage = { - text: '', - createdAt: contact.createdAt, - } - - setMessage([...transformedMessages.sort((a: any, b: any) => b.createdAt - a.createdAt), connectedMessage][0]) - }, [basicMessages, credentials, proofs]) - const navigateToContact = useCallback(() => { navigation .getParent() @@ -169,13 +107,17 @@ const ContactListItem: React.FC = ({ contact, navigation }) => { {contactLabel} - {formatTime(message.createdAt, { shortMonth: true, trim: true })} + {message && ( + {formatTime(message.createdAt, { shortMonth: true, trim: true })} + )} - - {message.text} - + {message && !hasOnlyInitialMessage && ( + + {message.text} + + )} diff --git a/packages/legacy/core/App/hooks/chat-messages.tsx b/packages/legacy/core/App/hooks/chat-messages.tsx new file mode 100644 index 0000000000..d91c1742ac --- /dev/null +++ b/packages/legacy/core/App/hooks/chat-messages.tsx @@ -0,0 +1,243 @@ +import { + BasicMessageRecord, + ConnectionRecord, + CredentialExchangeRecord, + CredentialState, + ProofExchangeRecord, + ProofState, +} from '@credo-ts/core' +import { useBasicMessagesByConnectionId } from '@credo-ts/react-hooks' +import { isPresentationReceived } from '@hyperledger/aries-bifold-verifier' +import { useNavigation } from '@react-navigation/core' +import { StackNavigationProp } from '@react-navigation/stack' +import React, { Fragment, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Linking, Text } from 'react-native' + +import { ChatEvent } from '../components/chat/ChatEvent' +import { ExtendedChatMessage, CallbackType } from '../components/chat/ChatMessage' +import { useStore } from '../contexts/store' +import { useTheme } from '../contexts/theme' +import { useCredentialsByConnectionId } from '../hooks/credentials' +import { useProofsByConnectionId } from '../hooks/proofs' +import { Role } from '../types/chat' +import { RootStackParams, ContactStackParams, Screens, Stacks } from '../types/navigators' +import { + getConnectionName, + getCredentialEventLabel, + getCredentialEventRole, + getMessageEventRole, + getProofEventLabel, + getProofEventRole, +} from '../utils/helpers' + +/** + * Determines the callback to be called when the button below a given chat message is pressed, if it exists. + * + * eg. 'View offer' -> opens the credential offer screen + * + * @param {CredentialExchangeRecord | ProofExchangeRecord} record - The record to determine the callback type for. + * @returns {CallbackType} The callback type for the given record. + */ +const callbackTypeForMessage = (record: CredentialExchangeRecord | ProofExchangeRecord) => { + if ( + record instanceof CredentialExchangeRecord && + (record.state === CredentialState.Done || record.state === CredentialState.OfferReceived) + ) { + return CallbackType.CredentialOffer + } + + if ( + (record instanceof ProofExchangeRecord && isPresentationReceived(record) && record.isVerified !== undefined) || + record.state === ProofState.RequestReceived || + (record.state === ProofState.Done && record.isVerified === undefined) + ) { + return CallbackType.ProofRequest + } + + if ( + record instanceof ProofExchangeRecord && + (record.state === ProofState.PresentationSent || record.state === ProofState.Done) + ) { + return CallbackType.PresentationSent + } +} + +/** + * Custom hook for retrieving chat messages for a given connection. This hook includes some of + * the JSX for rendering the chat messages, including the logic for handling links in messages. + * + * @param {ConnectionRecord} connection - The connection to retrieve chat messages for. + * @returns {ExtendedChatMessage[]} The chat messages for the given connection. + */ +export const useChatMessagesByConnection = (connection: ConnectionRecord): ExtendedChatMessage[] => { + const [messages, setMessages] = useState>([]) + const [store] = useStore() + const { t } = useTranslation() + const { ChatTheme: theme, ColorPallet } = useTheme() + const navigation = useNavigation>() + const basicMessages = useBasicMessagesByConnectionId(connection.id) + const credentials = useCredentialsByConnectionId(connection.id) + const proofs = useProofsByConnectionId(connection.id) + const [theirLabel, setTheirLabel] = useState(getConnectionName(connection, store.preferences.alternateContactNames)) + + // This useEffect is for properly rendering changes to the alt contact name, useMemo did not pick them up + useEffect(() => { + setTheirLabel(getConnectionName(connection, store.preferences.alternateContactNames)) + }, [connection, store.preferences.alternateContactNames]) + + useEffect(() => { + const transformedMessages: Array = basicMessages.map((record: BasicMessageRecord) => { + const role = getMessageEventRole(record) + // eslint-disable-next-line + const linkRegex = /(?:https?\:\/\/\w+(?:\.\w+)+\S*)|(?:[\w\d\.\_\-]+@\w+(?:\.\w+)+)/gm + // eslint-disable-next-line + const mailRegex = /^[\w\d\.\_\-]+@\w+(?:\.\w+)+$/gm + const links = record.content.match(linkRegex) ?? [] + const handleLinkPress = (link: string) => { + if (link.match(mailRegex)) { + link = 'mailto:' + link + } + Linking.openURL(link) + } + const msgText = ( + + {record.content.split(linkRegex).map((split, i) => { + if (i < links.length) { + const link = links[i] + return ( + + {split} + handleLinkPress(link)} + style={{ color: ColorPallet.brand.link, textDecorationLine: 'underline' }} + accessibilityRole={'link'} + > + {link} + + + ) + } + return {split} + })} + + ) + return { + _id: record.id, + text: record.content, + renderEvent: () => msgText, + createdAt: record.updatedAt || record.createdAt, + type: record.type, + user: { _id: role }, + } + }) + + transformedMessages.push( + ...credentials.map((record: CredentialExchangeRecord) => { + const role = getCredentialEventRole(record) + const userLabel = role === Role.me ? t('Chat.UserYou') : theirLabel + const actionLabel = t(getCredentialEventLabel(record) as any) + + return { + _id: record.id, + text: actionLabel, + renderEvent: () => , + createdAt: record.updatedAt || record.createdAt, + type: record.type, + user: { _id: role }, + messageOpensCallbackType: callbackTypeForMessage(record), + onDetails: () => { + const navMap: { [key in CredentialState]?: () => void } = { + [CredentialState.Done]: () => { + navigation.navigate(Stacks.ContactStack as any, { + screen: Screens.CredentialDetails, + params: { credential: record }, + }) + }, + [CredentialState.OfferReceived]: () => { + navigation.navigate(Stacks.ContactStack as any, { + screen: Screens.CredentialOffer, + params: { credentialId: record.id }, + }) + }, + } + const nav = navMap[record.state] + if (nav) { + nav() + } + }, + } + }) + ) + + transformedMessages.push( + ...proofs.map((record: ProofExchangeRecord) => { + const role = getProofEventRole(record) + const userLabel = role === Role.me ? t('Chat.UserYou') : theirLabel + const actionLabel = t(getProofEventLabel(record) as any) + + return { + _id: record.id, + text: actionLabel, + renderEvent: () => , + createdAt: record.updatedAt || record.createdAt, + type: record.type, + user: { _id: role }, + messageOpensCallbackType: callbackTypeForMessage(record), + onDetails: () => { + const toProofDetails = () => { + navigation.navigate(Stacks.ContactStack as any, { + screen: Screens.ProofDetails, + params: { + recordId: record.id, + isHistory: true, + senderReview: + record.state === ProofState.PresentationSent || + (record.state === ProofState.Done && record.isVerified === undefined), + }, + }) + } + const navMap: { [key in ProofState]?: () => void } = { + [ProofState.Done]: toProofDetails, + [ProofState.PresentationSent]: toProofDetails, + [ProofState.PresentationReceived]: toProofDetails, + [ProofState.RequestReceived]: () => { + navigation.navigate(Stacks.ContactStack as any, { + screen: Screens.ProofRequest, + params: { proofId: record.id }, + }) + }, + } + const nav = navMap[record.state] + if (nav) { + nav() + } + }, + } + }) + ) + + const connectedMessage = connection + ? { + _id: 'connected', + text: `${t('Chat.YouConnected')} ${theirLabel}`, + renderEvent: () => ( + + {t('Chat.YouConnected')} + {theirLabel} + + ), + createdAt: connection.createdAt, + user: { _id: Role.me }, + } + : undefined + + setMessages( + connectedMessage + ? [...transformedMessages.sort((a: any, b: any) => b.createdAt - a.createdAt), connectedMessage] + : transformedMessages.sort((a: any, b: any) => b.createdAt - a.createdAt) + ) + }, [basicMessages, credentials, proofs, theirLabel]) + + return messages +} diff --git a/packages/legacy/core/App/localization/en/index.ts b/packages/legacy/core/App/localization/en/index.ts index a522a4ecb0..102f4b948e 100644 --- a/packages/legacy/core/App/localization/en/index.ts +++ b/packages/legacy/core/App/localization/en/index.ts @@ -126,6 +126,8 @@ const translation = { "Message1044": "There was a problem while initializing onboarding.", "Title1045": "Unable to initialize agent.", "Message1045": "There was a problem while initializing agent.", + "Title1046": "Unable to fetch contacts.", + "Message1046": "There was a problem while fetching contacts.", }, "ActivityLog": { "Your": "Your", diff --git a/packages/legacy/core/App/localization/fr/index.ts b/packages/legacy/core/App/localization/fr/index.ts index 87aa846404..08d454f2bc 100644 --- a/packages/legacy/core/App/localization/fr/index.ts +++ b/packages/legacy/core/App/localization/fr/index.ts @@ -126,6 +126,8 @@ const translation = { "Message1044": "There was a problem while initializing onboarding. (FR)", "Title1045": "Unable to initialize agent. (FR)", "Message1045": "There was a problem while initializing agent. (FR)", + "Title1046": "Unable to fetch contacts. (FR)", + "Message1046": "There was a problem while fetching contacts. (FR)", }, "ActivityLog": { "Your": "Votre ", diff --git a/packages/legacy/core/App/localization/pt-br/index.ts b/packages/legacy/core/App/localization/pt-br/index.ts index 8fe00ad055..873fb839b6 100644 --- a/packages/legacy/core/App/localization/pt-br/index.ts +++ b/packages/legacy/core/App/localization/pt-br/index.ts @@ -125,6 +125,8 @@ const translation = { "Message1044": "Ocorreu um problema ao incializar o onboarding.", "Title1045": "Não foi possível inicializar o agente.", "Message1045": "Ocorreu um erro ao inicializar o agente.", + "Title1046": "Unable to fetch contacts. (PT-BR)", + "Message1046": "There was a problem while fetching contacts. (PT-BR)", }, "StatusMessages": { "InitAgent": "Iniciando agente .." diff --git a/packages/legacy/core/App/screens/Chat.tsx b/packages/legacy/core/App/screens/Chat.tsx index 83256f810e..cbf259c7ab 100644 --- a/packages/legacy/core/App/screens/Chat.tsx +++ b/packages/legacy/core/App/screens/Chat.tsx @@ -1,18 +1,9 @@ -import { - BasicMessageRecord, - BasicMessageRepository, - CredentialExchangeRecord, - CredentialState, - ProofExchangeRecord, - ProofState, -} from '@credo-ts/core' +import { BasicMessageRepository, ConnectionRecord } from '@credo-ts/core' import { useAgent, useBasicMessagesByConnectionId, useConnectionById } from '@credo-ts/react-hooks' -import { isPresentationReceived } from '@hyperledger/aries-bifold-verifier' import { useIsFocused, useNavigation } from '@react-navigation/core' import { StackNavigationProp, StackScreenProps } from '@react-navigation/stack' -import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Linking, Text } from 'react-native' import { GiftedChat, IMessage } from 'react-native-gifted-chat' import { SafeAreaView } from 'react-native-safe-area-context' @@ -20,24 +11,15 @@ import InfoIcon from '../components/buttons/InfoIcon' import { renderComposer, renderInputToolbar, renderSend } from '../components/chat' import ActionSlider from '../components/chat/ActionSlider' import { renderActions } from '../components/chat/ChatActions' -import { ChatEvent } from '../components/chat/ChatEvent' -import { CallbackType, ChatMessage, ExtendedChatMessage } from '../components/chat/ChatMessage' +import { ChatMessage } from '../components/chat/ChatMessage' import { useNetwork } from '../contexts/network' import { useStore } from '../contexts/store' import { useTheme } from '../contexts/theme' -import { useCredentialsByConnectionId } from '../hooks/credentials' -import { useProofsByConnectionId } from '../hooks/proofs' +import { useChatMessagesByConnection } from '../hooks/chat-messages' import { Role } from '../types/chat' import { BasicMessageMetadata, basicMessageCustomMetadata } from '../types/metadata' -import { ContactStackParams, RootStackParams, Screens, Stacks } from '../types/navigators' -import { - getConnectionName, - getCredentialEventLabel, - getCredentialEventRole, - getMessageEventRole, - getProofEventLabel, - getProofEventRole, -} from '../utils/helpers' +import { RootStackParams, ContactStackParams, Screens, Stacks } from '../types/navigators' +import { getConnectionName } from '../utils/helpers' type ChatProps = StackScreenProps | StackScreenProps @@ -51,16 +33,13 @@ const Chat: React.FC = ({ route }) => { const { t } = useTranslation() const { agent } = useAgent() const navigation = useNavigation>() - const connection = useConnectionById(connectionId) + const connection = useConnectionById(connectionId) as ConnectionRecord const basicMessages = useBasicMessagesByConnectionId(connectionId) - const credentials = useCredentialsByConnectionId(connectionId) - const proofs = useProofsByConnectionId(connectionId) + const chatMessages = useChatMessagesByConnection(connection) const isFocused = useIsFocused() const { assertConnectedNetwork, silentAssertConnectedNetwork } = useNetwork() - const [messages, setMessages] = useState>([]) const [showActionSlider, setShowActionSlider] = useState(false) const { ChatTheme: theme, Assets } = useTheme() - const { ColorPallet } = useTheme() const [theirLabel, setTheirLabel] = useState(getConnectionName(connection, store.preferences.alternateContactNames)) // This useEffect is for properly rendering changes to the alt contact name, useMemo did not pick them up @@ -91,183 +70,6 @@ const Chat: React.FC = ({ route }) => { }) }, [basicMessages]) - useEffect(() => { - const transformedMessages: Array = basicMessages.map((record: BasicMessageRecord) => { - const role = getMessageEventRole(record) - // eslint-disable-next-line - const linkRegex = /(?:https?\:\/\/\w+(?:\.\w+)+\S*)|(?:[\w\d\.\_\-]+@\w+(?:\.\w+)+)/gm - // eslint-disable-next-line - const mailRegex = /^[\w\d\.\_\-]+@\w+(?:\.\w+)+$/gm - const links = record.content.match(linkRegex) ?? [] - const handleLinkPress = (link: string) => { - if (link.match(mailRegex)) { - link = 'mailto:' + link - } - Linking.openURL(link) - } - const msgText = ( - - {record.content.split(linkRegex).map((split, i) => { - if (i < links.length) { - const link = links[i] - return ( - - {split} - handleLinkPress(link)} - style={{ color: ColorPallet.brand.link, textDecorationLine: 'underline' }} - accessibilityRole={'link'} - > - {link} - - - ) - } - return {split} - })} - - ) - return { - _id: record.id, - text: record.content, - renderEvent: () => msgText, - createdAt: record.updatedAt || record.createdAt, - type: record.type, - user: { _id: role }, - } - }) - - const callbackTypeForMessage = (record: CredentialExchangeRecord | ProofExchangeRecord) => { - if ( - record instanceof CredentialExchangeRecord && - (record.state === CredentialState.Done || record.state === CredentialState.OfferReceived) - ) { - return CallbackType.CredentialOffer - } - - if ( - (record instanceof ProofExchangeRecord && isPresentationReceived(record) && record.isVerified !== undefined) || - record.state === ProofState.RequestReceived || - (record.state === ProofState.Done && record.isVerified === undefined) - ) { - return CallbackType.ProofRequest - } - - if ( - record instanceof ProofExchangeRecord && - (record.state === ProofState.PresentationSent || record.state === ProofState.Done) - ) { - return CallbackType.PresentationSent - } - } - - transformedMessages.push( - ...credentials.map((record: CredentialExchangeRecord) => { - const role = getCredentialEventRole(record) - const userLabel = role === Role.me ? t('Chat.UserYou') : theirLabel - const actionLabel = t(getCredentialEventLabel(record) as any) - - return { - _id: record.id, - text: actionLabel, - renderEvent: () => , - createdAt: record.updatedAt || record.createdAt, - type: record.type, - user: { _id: role }, - messageOpensCallbackType: callbackTypeForMessage(record), - onDetails: () => { - const navMap: { [key in CredentialState]?: () => void } = { - [CredentialState.Done]: () => { - navigation.navigate(Stacks.ContactStack as any, { - screen: Screens.CredentialDetails, - params: { credential: record }, - }) - }, - [CredentialState.OfferReceived]: () => { - navigation.navigate(Stacks.ContactStack as any, { - screen: Screens.CredentialOffer, - params: { credentialId: record.id }, - }) - }, - } - const nav = navMap[record.state] - if (nav) { - nav() - } - }, - } - }) - ) - - transformedMessages.push( - ...proofs.map((record: ProofExchangeRecord) => { - const role = getProofEventRole(record) - const userLabel = role === Role.me ? t('Chat.UserYou') : theirLabel - const actionLabel = t(getProofEventLabel(record) as any) - - return { - _id: record.id, - text: actionLabel, - renderEvent: () => , - createdAt: record.updatedAt || record.createdAt, - type: record.type, - user: { _id: role }, - messageOpensCallbackType: callbackTypeForMessage(record), - onDetails: () => { - const toProofDetails = () => { - navigation.navigate(Stacks.ContactStack as any, { - screen: Screens.ProofDetails, - params: { - recordId: record.id, - isHistory: true, - senderReview: - record.state === ProofState.PresentationSent || - (record.state === ProofState.Done && record.isVerified === undefined), - }, - }) - } - const navMap: { [key in ProofState]?: () => void } = { - [ProofState.Done]: toProofDetails, - [ProofState.PresentationSent]: toProofDetails, - [ProofState.PresentationReceived]: toProofDetails, - [ProofState.RequestReceived]: () => { - navigation.navigate(Stacks.ContactStack as any, { - screen: Screens.ProofRequest, - params: { proofId: record.id }, - }) - }, - } - const nav = navMap[record.state] - if (nav) { - nav() - } - }, - } - }) - ) - - const connectedMessage = connection - ? { - _id: 'connected', - text: `${t('Chat.YouConnected')} ${theirLabel}`, - renderEvent: () => ( - - {t('Chat.YouConnected')} - {theirLabel} - - ), - createdAt: connection.createdAt, - user: { _id: Role.me }, - } - : undefined - - setMessages( - connectedMessage - ? [...transformedMessages.sort((a: any, b: any) => b.createdAt - a.createdAt), connectedMessage] - : transformedMessages.sort((a: any, b: any) => b.createdAt - a.createdAt) - ) - }, [basicMessages, credentials, proofs, theirLabel]) - const onSend = useCallback( async (messages: IMessage[]) => { await agent?.basicMessages.sendMessage(connectionId, messages[0].text) @@ -304,7 +106,7 @@ const Chat: React.FC = ({ route }) => { return ( null} diff --git a/packages/legacy/core/App/screens/ListContacts.tsx b/packages/legacy/core/App/screens/ListContacts.tsx index 66cddd416f..25eb95f068 100644 --- a/packages/legacy/core/App/screens/ListContacts.tsx +++ b/packages/legacy/core/App/screens/ListContacts.tsx @@ -1,17 +1,21 @@ import { ConnectionRecord, ConnectionType, DidExchangeState } from '@credo-ts/core' -import { useConnections } from '@credo-ts/react-hooks' +import { useAgent } from '@credo-ts/react-hooks' import { StackNavigationProp } from '@react-navigation/stack' -import React, { useEffect } from 'react' +import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { FlatList, StyleSheet, View } from 'react-native' +import { DeviceEventEmitter, FlatList, StyleSheet, View } from 'react-native' import HeaderButton, { ButtonLocation } from '../components/buttons/HeaderButton' import ContactListItem from '../components/listItems/ContactListItem' import EmptyListContacts from '../components/misc/EmptyListContacts' +import { EventTypes } from '../constants' import { useConfiguration } from '../contexts/configuration' import { useStore } from '../contexts/store' import { useTheme } from '../contexts/theme' +import { BifoldError } from '../types/error' import { ContactStackParams, Screens, Stacks } from '../types/navigators' +import { BifoldAgent } from '../utils/agent' +import { fetchContactsByLatestMessage } from '../utils/contacts' import { testIdWithKey } from '../utils/testable' interface ListContactsProps { @@ -21,6 +25,10 @@ interface ListContactsProps { const ListContacts: React.FC = ({ navigation }) => { const { ColorPallet } = useTheme() const { t } = useTranslation() + const { agent } = useAgent() + const [connections, setConnections] = useState([]) + const [store] = useStore() + const { contactHideList } = useConfiguration() const style = StyleSheet.create({ list: { backgroundColor: ColorPallet.brand.secondaryBackground, @@ -31,20 +39,32 @@ const ListContacts: React.FC = ({ navigation }) => { marginHorizontal: 16, }, }) - const { records } = useConnections() - const [store] = useStore() - const { contactHideList } = useConfiguration() - // Filter out mediator agents and hidden contacts when not in dev mode - let connections: ConnectionRecord[] = records - if (!store.preferences.developerModeEnabled) { - connections = records.filter((r) => { - return ( - !r.connectionTypes.includes(ConnectionType.Mediator) && - !contactHideList?.includes((r.theirLabel || r.alias) ?? '') && - r.state === DidExchangeState.Completed - ) + + useEffect(() => { + const fetchAndSetConnections = async () => { + if (!agent) return + let orderedContacts = await fetchContactsByLatestMessage(agent as BifoldAgent) + + // if developer mode is disabled, filter out mediator connections and connections in the hide list + if (!store.preferences.developerModeEnabled) { + orderedContacts = orderedContacts.filter((r) => { + return ( + !r.connectionTypes.includes(ConnectionType.Mediator) && + !contactHideList?.includes((r.theirLabel || r.alias) ?? '') && + r.state === DidExchangeState.Completed + ) + }) + } + + setConnections(orderedContacts) + } + + fetchAndSetConnections().catch((err) => { + agent?.config.logger.error('Error fetching contacts:', err) + const error = new BifoldError(t('Error.Title1046'), t('Error.Message1046'), (err as Error)?.message ?? err, 1046) + DeviceEventEmitter.emit(EventTypes.ERROR_ADDED, error) }) - } + }, [agent]) const onPressAddContact = () => { navigation.getParent()?.navigate(Stacks.ConnectStack, { screen: Screens.Scan, params: { defaultToConnect: true } }) @@ -79,6 +99,7 @@ const ListContacts: React.FC = ({ navigation }) => { keyExtractor={(connection) => connection.id} renderItem={({ item: connection }) => } ListEmptyComponent={() => } + showsVerticalScrollIndicator={false} /> ) diff --git a/packages/legacy/core/App/utils/contacts.ts b/packages/legacy/core/App/utils/contacts.ts new file mode 100644 index 0000000000..37ba4ee3fe --- /dev/null +++ b/packages/legacy/core/App/utils/contacts.ts @@ -0,0 +1,57 @@ +import { BasicMessageRecord, ConnectionRecord, CredentialExchangeRecord, ProofExchangeRecord } from '@credo-ts/core' + +import { BifoldAgent } from './agent' + +interface ConnectionWithMessages { + conn: ConnectionRecord + msgs: (BasicMessageRecord | CredentialExchangeRecord | ProofExchangeRecord)[] +} + +interface ConnectionWithLatestMessage { + conn: ConnectionRecord + latestMsg: BasicMessageRecord | CredentialExchangeRecord | ProofExchangeRecord +} + +/** + * Function to fetch contacts (connections) in order of latest chat message without using hooks + * @param agent - Credo agent + * @returns ConnectionRecord[] sorted by most recent message + */ +export const fetchContactsByLatestMessage = async (agent: BifoldAgent): Promise => { + const connections = await agent.connections.getAll() + const connectionsWithMessages = await Promise.all( + connections.map( + async (conn: ConnectionRecord): Promise => ({ + conn, + msgs: [ + ...(await agent.basicMessages.findAllByQuery({ connectionId: conn.id })), + ...(await agent.proofs.findAllByQuery({ connectionId: conn.id })), + ...(await agent.credentials.findAllByQuery({ connectionId: conn.id })), + ], + }) + ) + ) + + const connectionsWithLatestMessage: ConnectionWithLatestMessage[] = connectionsWithMessages.map((pair) => { + return { + conn: pair.conn, + latestMsg: pair.msgs.reduce( + (acc, cur) => { + const accDate = acc.updatedAt || acc.createdAt + const curDate = cur.updatedAt || cur.createdAt + return accDate > curDate ? acc : cur + }, + // Initial value if no messages exist for this connection is a placeholder with the date the connection was created + { createdAt: pair.conn.createdAt } as BasicMessageRecord | CredentialExchangeRecord | ProofExchangeRecord + ), + } + }) + + return connectionsWithLatestMessage + .sort( + (a, b) => + new Date(b.latestMsg.updatedAt || b.latestMsg.createdAt).valueOf() - + new Date(a.latestMsg.updatedAt || a.latestMsg.createdAt).valueOf() + ) + .map((pair) => pair.conn) +} diff --git a/packages/legacy/core/__mocks__/@credo-ts/react-hooks.ts b/packages/legacy/core/__mocks__/@credo-ts/react-hooks.ts index cbaf9286ed..e13a968b29 100644 --- a/packages/legacy/core/__mocks__/@credo-ts/react-hooks.ts +++ b/packages/legacy/core/__mocks__/@credo-ts/react-hooks.ts @@ -5,6 +5,9 @@ import { CredentialExchangeRecord, CredentialProtocolOptions, ProofExchangeRecord, + ConnectionRecord, + DidExchangeRole, + DidExchangeState, } from '@credo-ts/core' const useCredentials = jest.fn().mockReturnValue({ records: [] } as any) @@ -21,6 +24,7 @@ const mockCredentialModule = { .mockReturnValue( Promise.resolve({} as CredentialProtocolOptions.GetCredentialFormatDataReturn<[LegacyIndyCredentialFormat]>) ), + findAllByQuery: jest.fn().mockReturnValue(Promise.resolve([])), } const mockProofModule = { getCredentialsForRequest: jest.fn(), @@ -30,6 +34,36 @@ const mockProofModule = { findRequestMessage: jest.fn(), requestProof: jest.fn(), update: jest.fn(), + findAllByQuery: jest.fn().mockReturnValue(Promise.resolve([])), +} +const mockBasicMessagesModule = { + findAllByQuery: jest.fn().mockReturnValue(Promise.resolve([])), +} +const mockConnectionsModule = { + getAll: jest.fn().mockReturnValue( + Promise.resolve([ + new ConnectionRecord({ + id: '1', + did: '9gtPKWtaUKxJir5YG2VPxX', + theirLabel: 'Faber', + role: DidExchangeRole.Responder, + theirDid: '2SBuq9fpLT8qUiQKr2RgBe', + threadId: '1', + state: DidExchangeState.Completed, + createdAt: new Date('2020-01-02T00:00:00.000Z'), + }), + new ConnectionRecord({ + id: '2', + did: '2SBuq9fpLT8qUiQKr2RgBe', + role: DidExchangeRole.Requester, + theirLabel: 'Bob', + theirDid: '9gtPKWtaUKxJir5YG2VPxX', + threadId: '1', + state: DidExchangeState.Completed, + createdAt: new Date('2020-01-01T00:00:00.000Z'), + }), + ]) + ), } const mockMediationRecipient = { @@ -52,6 +86,8 @@ const useAgent = () => ({ agent: { credentials: mockCredentialModule, proofs: mockProofModule, + basicMessages: mockBasicMessagesModule, + connections: mockConnectionsModule, mediationRecipient: mockMediationRecipient, oob: mockOobModule, context: mockAgentContext, diff --git a/packages/legacy/core/__tests__/screens/Chat.test.tsx b/packages/legacy/core/__tests__/screens/Chat.test.tsx index 028944bd45..2070d76ad5 100644 --- a/packages/legacy/core/__tests__/screens/Chat.test.tsx +++ b/packages/legacy/core/__tests__/screens/Chat.test.tsx @@ -1,5 +1,11 @@ -import { BasicMessageRecord, BasicMessageRole } from '@credo-ts/core' -import { useBasicMessagesByConnectionId } from '@credo-ts/react-hooks' +import { + BasicMessageRecord, + BasicMessageRole, + ConnectionRecord, + DidExchangeRole, + DidExchangeState, +} from '@credo-ts/core' +import { useBasicMessagesByConnectionId, useConnectionById } from '@credo-ts/react-hooks' import { render } from '@testing-library/react-native' import React from 'react' @@ -19,6 +25,15 @@ jest.mock('@react-navigation/native', () => { const props = { params: { connectionId: '1' } } +const connection = new ConnectionRecord({ + id: '1', + createdAt: new Date(2024, 1, 1), + state: DidExchangeState.Completed, + role: DidExchangeRole.Requester, + theirDid: 'did:example:123', + theirLabel: 'Alice', +}) + const unseenMessage = new BasicMessageRecord({ threadId: '1', connectionId: '1', @@ -66,6 +81,10 @@ const testBasicMessages: BasicMessageRecord[] = [unseenMessage, seenMessage] describe('Chat screen', () => { beforeEach(() => { jest.clearAllMocks() + // @ts-ignore + useConnectionById.mockReturnValue(connection) + // @ts-ignore + useBasicMessagesByConnectionId.mockReturnValue(testBasicMessages) }) test('Renders correctly', async () => { @@ -91,6 +110,8 @@ describe('Chat screen with messages', () => { beforeEach(() => { jest.clearAllMocks() // @ts-ignore + useConnectionById.mockReturnValue(connection) + // @ts-ignore useBasicMessagesByConnectionId.mockReturnValue(testBasicMessages) jest.spyOn(network, 'useNetwork').mockImplementation(() => ({ silentAssertConnectedNetwork: () => true, diff --git a/packages/legacy/core/__tests__/screens/ListContacts.test.tsx b/packages/legacy/core/__tests__/screens/ListContacts.test.tsx index 727a566ccd..8c91d1479e 100644 --- a/packages/legacy/core/__tests__/screens/ListContacts.test.tsx +++ b/packages/legacy/core/__tests__/screens/ListContacts.test.tsx @@ -1,7 +1,5 @@ -import { ConnectionRecord, DidExchangeRole, DidExchangeState } from '@credo-ts/core' -import { useConnections } from '@credo-ts/react-hooks' import { useNavigation } from '@react-navigation/core' -import { act, fireEvent, render } from '@testing-library/react-native' +import { fireEvent, render, waitFor } from '@testing-library/react-native' import React from 'react' import { ConfigurationContext } from '../../App/contexts/configuration' @@ -21,60 +19,30 @@ jest.mock('react-native-localize', () => {}) const navigation = useNavigation() describe('ListContacts Component', () => { - const testContactRecord1 = new ConnectionRecord({ - id: '1', - did: '9gtPKWtaUKxJir5YG2VPxX', - theirLabel: 'Faber', - role: DidExchangeRole.Responder, - theirDid: '2SBuq9fpLT8qUiQKr2RgBe', - threadId: '1', - state: DidExchangeState.Completed, - createdAt: new Date('2020-01-01T00:00:00.000Z'), - }) - const testContactRecord2 = new ConnectionRecord({ - id: '2', - did: '2SBuq9fpLT8qUiQKr2RgBe', - role: DidExchangeRole.Requester, - theirLabel: 'Bob', - theirDid: '9gtPKWtaUKxJir5YG2VPxX', - threadId: '1', - state: DidExchangeState.Completed, - createdAt: new Date('2020-01-01T00:00:00.000Z'), - }) - beforeEach(() => { jest.clearAllMocks() - - // @ts-ignore - useConnections.mockReturnValue({ records: [testContactRecord1, testContactRecord2] }) }) - const renderView = () => { - return render( + test('Renders correctly', async () => { + const tree = render( ) - } - - test('Renders correctly', async () => { - const tree = renderView() - await act(async () => {}) + await waitFor(() => {}) + await new Promise((r) => setTimeout(r, 2000)) expect(tree).toMatchSnapshot() - - const faberContact = await tree.findByText('Faber', { exact: true }) - const bobContact = await tree.findByText('Bob', { exact: false }) - - expect(faberContact).not.toBe(null) - expect(bobContact).not.toBe(null) }) test('pressing on a contact in the list takes the user to a contact history screen', async () => { - const navigation = useNavigation() - const tree = renderView() + const { findByText } = render( + + + + ) - await act(async () => { - const connectionRecord = await tree.findByText('Faber', { exact: false }) + await waitFor(async () => { + const connectionRecord = await findByText('Faber', { exact: true }) fireEvent(connectionRecord, 'press') expect(navigation.navigate).toBeCalledWith('Contacts Stack', { screen: 'Chat', @@ -86,7 +54,6 @@ describe('ListContacts Component', () => { }) test('Hide list filters out specific contacts', async () => { - const navigation = useNavigation() const tree = render( { ) - await act(async () => {}) - - const faberContact = await tree.queryByText('Faber', { exact: false }) - const bobContact = await tree.queryByText('Bob', { exact: false }) - - expect(faberContact).toBe(null) - expect(bobContact).not.toBe(null) + await waitFor(async () => { + const faberContact = await tree.queryByText('Faber', { exact: true }) + const bobContact = await tree.queryByText('Bob', { exact: true }) + expect(faberContact).toBe(null) + expect(bobContact).not.toBe(null) + }) }) test('Hide list does not filter out specific contacts when developer mode is enabled', async () => { @@ -128,12 +94,11 @@ describe('ListContacts Component', () => { ) - await act(async () => {}) - - const faberContact = await tree.queryByText('Faber', { exact: false }) - const bobContact = await tree.queryByText('Bob', { exact: false }) - - expect(faberContact).not.toBe(null) - expect(bobContact).not.toBe(null) + await waitFor(async () => { + const faberContact = await tree.queryByText('Faber', { exact: true }) + const bobContact = await tree.queryByText('Bob', { exact: true }) + expect(faberContact).not.toBe(null) + expect(bobContact).not.toBe(null) + }) }) }) diff --git a/packages/legacy/core/__tests__/screens/__snapshots__/ListContacts.test.tsx.snap b/packages/legacy/core/__tests__/screens/__snapshots__/ListContacts.test.tsx.snap index 28af88ebbd..39c69ebeb0 100644 --- a/packages/legacy/core/__tests__/screens/__snapshots__/ListContacts.test.tsx.snap +++ b/packages/legacy/core/__tests__/screens/__snapshots__/ListContacts.test.tsx.snap @@ -12,7 +12,7 @@ exports[`ListContacts Component Renders correctly 1`] = ` "alias": undefined, "autoAcceptConnection": undefined, "connectionTypes": Array [], - "createdAt": "2020-01-01T00:00:00.000Z", + "createdAt": "2020-01-02T00:00:00.000Z", "did": "9gtPKWtaUKxJir5YG2VPxX", "errorMessage": undefined, "id": "1", @@ -68,6 +68,7 @@ exports[`ListContacts Component Renders correctly 1`] = ` removeClippedSubviews={false} renderItem={[Function]} scrollEventThrottle={50} + showsVerticalScrollIndicator={false} stickyHeaderIndices={Array []} style={ Object { @@ -205,23 +206,11 @@ exports[`ListContacts Component Renders correctly 1`] = ` } } > - Jan 1, 2020 + Jan 2, 2020 - - - + @@ -367,19 +356,7 @@ exports[`ListContacts Component Renders correctly 1`] = ` - - - +