diff --git a/packages/backend/src/persistence/storage/postgres/getMessages.ts b/packages/backend/src/persistence/storage/postgres/getMessages.ts index 3eb34cc8f..a5690ce84 100644 --- a/packages/backend/src/persistence/storage/postgres/getMessages.ts +++ b/packages/backend/src/persistence/storage/postgres/getMessages.ts @@ -39,6 +39,9 @@ export const getMessages = ownerId: account.id, encryptedContactName, }, + orderBy: { + createdAt: 'desc', + }, }); if (messageRecord.length === 0) { return []; diff --git a/packages/lib/shared/src/IBackendConnector.ts b/packages/lib/shared/src/IBackendConnector.ts index e163ce2ff..b37dd01e4 100644 --- a/packages/lib/shared/src/IBackendConnector.ts +++ b/packages/lib/shared/src/IBackendConnector.ts @@ -4,7 +4,12 @@ export interface IBackendConnector { ensName: string, size: number, offset: number, - ): Promise; + ): Promise< + { + contact: string; + previewMessage: string; + }[] + >; toggleHideConversation( ensName: string, encryptedContactName: string, @@ -13,12 +18,14 @@ export interface IBackendConnector { getMessagesFromStorage( ensName: string, encryptedContactName: string, - pageNumber: number, + pageSize: number, + offset: number, ): Promise; addMessage( ensName: string, encryptedContactName: string, messageId: string, + createdAt: number, encryptedEnvelopContainer: string, ): Promise; addMessageBatch( diff --git a/packages/lib/storage/src/migrate-storage.test.ts b/packages/lib/storage/src/migrate-storage.test.ts index 6799e85d8..a7762b081 100644 --- a/packages/lib/storage/src/migrate-storage.test.ts +++ b/packages/lib/storage/src/migrate-storage.test.ts @@ -70,11 +70,11 @@ describe('MigrateStorage', () => { const conversations = new Map(); return { - getConversationList: async (page: number) => + getConversations: async (page: number) => Array.from(conversations.keys()).map((contactEnsName) => ({ contactEnsName, isHidden: false, - messageCounter: 0, + previewMessage: undefined, })), getMessages: async (contactEnsName: string, page: number) => [], addMessageBatch: async ( @@ -125,7 +125,7 @@ describe('MigrateStorage', () => { await migrageStorage(db, newStorage, tldResolver); - const newConversations = await newStorage.getConversationList(0); + const newConversations = await newStorage.getConversations(100, 0); 0.45; expect(newConversations.length).toBe(2); expect(newConversations[0].contactEnsName).toBe( @@ -153,7 +153,7 @@ describe('MigrateStorage', () => { await migrageStorage(db, newStorage, tldResolver); - const newConversations = await newStorage.getConversationList(0); + const newConversations = await newStorage.getConversations(100, 0); 0.45; expect(newConversations.length).toBe(2); expect(newConversations[0].contactEnsName).toBe( diff --git a/packages/lib/storage/src/new/cloudStorage/getCloudStorage.ts b/packages/lib/storage/src/new/cloudStorage/getCloudStorage.ts index f4f4f7e8a..cc6ca9333 100644 --- a/packages/lib/storage/src/new/cloudStorage/getCloudStorage.ts +++ b/packages/lib/storage/src/new/cloudStorage/getCloudStorage.ts @@ -1,6 +1,8 @@ import { IBackendConnector } from '@dm3-org/dm3-lib-shared'; import { MessageRecord } from '../chunkStorage/ChunkStorageTypes'; import { Encryption, StorageAPI, StorageEnvelopContainer } from '../types'; +//getCloudStorages is the interface to the cloud storage. +//It encrypts and decrypts the data before sending/reciving it to/from the cloud storage of the DM3 backend export const getCloudStorage = ( backendConnector: IBackendConnector, ensName: string, @@ -10,7 +12,6 @@ export const getCloudStorage = ( const encryptedContactName = await encryption.encryptSync( contactEnsName, ); - console.log('store new contact ', encryptedContactName); return await backendConnector.addConversation( ensName, encryptedContactName, @@ -18,25 +19,38 @@ export const getCloudStorage = ( }; const getConversations = async (size: number, offset: number) => { - const encryptedConversations = await backendConnector.getConversations( + const conversations = await backendConnector.getConversations( ensName, size, offset, ); return await Promise.all( - encryptedConversations.map( - async (encryptedContactName: string) => ({ - contactEnsName: await encryption.decryptSync( - encryptedContactName, - ), + conversations.map( + async ({ + contact, + previewMessage, + }: { + contact: string; + previewMessage: string | null; + }) => ({ + contactEnsName: await encryption.decryptSync(contact), isHidden: false, messageCounter: 0, + previewMessage: previewMessage + ? JSON.parse( + await encryption.decryptAsync(previewMessage), + ) + : null, }), ), ); }; - const getMessages = async (contactEnsName: string, page: number) => { + const getMessages = async ( + contactEnsName: string, + pageSize: number, + offset: number, + ) => { const encryptedContactName = await encryption.encryptSync( contactEnsName, ); @@ -44,7 +58,8 @@ export const getCloudStorage = ( const messageRecords = await backendConnector.getMessagesFromStorage( ensName, encryptedContactName, - page, + pageSize, + offset, ); const decryptedMessageRecords = await Promise.all( messageRecords.map(async (messageRecord: MessageRecord) => { @@ -55,7 +70,6 @@ export const getCloudStorage = ( }), ); - //TODO make type right return decryptedMessageRecords as StorageEnvelopContainer[]; }; @@ -70,11 +84,15 @@ export const getCloudStorage = ( JSON.stringify(envelop), ); + //The client defines the createdAt timestamp for the message so it can be used to sort the messages + const createdAt = Date.now(); + await backendConnector.addMessage( ensName, encryptedContactName, envelop.envelop.metadata?.encryptedMessageHash! ?? envelop.envelop.id, + createdAt, encryptedEnvelopContainer, ); @@ -95,8 +113,11 @@ export const getCloudStorage = ( await encryption.encryptAsync( JSON.stringify(storageEnvelopContainer), ); + //The client defines the createdAt timestamp for the message so it can be used to sort the messages + const createdAt = Date.now(); return { encryptedEnvelopContainer, + createdAt, messageId: storageEnvelopContainer.envelop.metadata ?.encryptedMessageHash! ?? @@ -122,6 +143,8 @@ export const getCloudStorage = ( const encryptedContactName = await encryption.encryptSync( contactEnsName, ); + //The client defines the createdAt timestamp for the message so it can be used to sort the messages + const createdAt = Date.now(); const encryptedMessages: MessageRecord[] = await Promise.all( batch.map( async (storageEnvelopContainer: StorageEnvelopContainer) => { @@ -134,6 +157,7 @@ export const getCloudStorage = ( messageId: storageEnvelopContainer.envelop.metadata ?.encryptedMessageHash!, + createdAt, }; }, ), diff --git a/packages/lib/storage/src/new/types.ts b/packages/lib/storage/src/new/types.ts index 0ed77ca0a..328f8f491 100644 --- a/packages/lib/storage/src/new/types.ts +++ b/packages/lib/storage/src/new/types.ts @@ -4,7 +4,8 @@ export interface StorageAPI { getConversations: (size: number, offset: number) => Promise; getMessages: ( contactEnsName: string, - page: number, + pageSize: number, + offset: number, ) => Promise; addMessageBatch: ( contactEnsName: string, @@ -33,9 +34,12 @@ export interface StorageEnvelopContainer { } export interface Conversation { + //the contactEnsName is the ensName of the contact contactEnsName: string; + //the previewMessage is the last message of the conversation + previewMessage?: Envelop; + //isHidden is a flag to hide the conversation from the conversation list isHidden: boolean; - messageCounter: number; } export type Encryption = { diff --git a/packages/messenger-widget/package.json b/packages/messenger-widget/package.json index 1404195a7..ea58ecf56 100644 --- a/packages/messenger-widget/package.json +++ b/packages/messenger-widget/package.json @@ -34,6 +34,7 @@ "jsonwebtoken": "^9.0.2", "localforage": "^1.10.0", "nacl": "^0.1.3", + "react-infinite-scroll-component": "^6.1.0", "react-scripts": "5.0.0", "rimraf": "^5.0.5", "socket.io-client": "^4.7.5", diff --git a/packages/messenger-widget/src/components/Chat/Chat.css b/packages/messenger-widget/src/components/Chat/Chat.css index 9a9747c8c..ea469323e 100644 --- a/packages/messenger-widget/src/components/Chat/Chat.css +++ b/packages/messenger-widget/src/components/Chat/Chat.css @@ -53,6 +53,9 @@ flex-direction: column; } +.infinite-scroll-component__outerdiv{ + height: auto; +} /* =================== Mobile Responsive CSS =================== */ diff --git a/packages/messenger-widget/src/components/Chat/Chat.tsx b/packages/messenger-widget/src/components/Chat/Chat.tsx index c84e131c6..dce595a87 100644 --- a/packages/messenger-widget/src/components/Chat/Chat.tsx +++ b/packages/messenger-widget/src/components/Chat/Chat.tsx @@ -12,6 +12,7 @@ import { Message } from '../Message/Message'; import { MessageInputBox } from '../MessageInputBox/MessageInputBox'; import { scrollToBottomOfChat } from './scrollToBottomOfChat'; import { ModalContext } from '../../context/ModalContext'; +import InfiniteScroll from 'react-infinite-scroll-component'; export function Chat() { const { account } = useContext(AuthContext); @@ -20,13 +21,31 @@ export function Chat() { const { screenWidth, dm3Configuration } = useContext( DM3ConfigurationContext, ); - const { getMessages, contactIsLoading } = useContext(MessageContext); + const { getMessages, contactIsLoading, loadMoreMessages } = + useContext(MessageContext); const { lastMessageAction } = useContext(ModalContext); const [isProfileConfigured, setIsProfileConfigured] = useState(false); const [showShimEffect, setShowShimEffect] = useState(false); + // state which tracks old msgs loading is active or not + const [loadingOldMsgs, setLoadingOldMsgs] = useState(false); + + // state to track more old msgs exists or not + const [hasMoreOldMsgs, setHasMoreOldMsgs] = useState(true); + + const fetchOldMessages = async () => { + setLoadingOldMsgs(true); + const newMsgCount = await loadMoreMessages( + selectedContact?.contactDetails.account.ensName!, + ); + // if no old msgs are found, sets state to no more old msgs exists + if (!newMsgCount) { + setHasMoreOldMsgs(false); + } + }; + useEffect(() => { if (!selectedContact) { return; @@ -48,14 +67,23 @@ export function Chat() { const isLoading = contactIsLoading( selectedContact?.contactDetails.account.ensName!, ); - setShowShimEffect(isLoading); + // shim effect must be visible only if the messages are loaded first time + if (!messages.length) { + setShowShimEffect(isLoading); + } }, [contactIsLoading]); // scrolls to bottom of chat when messages are loaded useEffect(() => { - if (messages.length && lastMessageAction === MessageActionType.NONE) { + // scrolls to bottom only when old msgs are not fetched + if ( + messages.length && + lastMessageAction === MessageActionType.NONE && + !loadingOldMsgs + ) { scrollToBottomOfChat(); } + setLoadingOldMsgs(false); }, [messages]); /** @@ -142,44 +170,74 @@ export function Chat() { ? 'chat-height-small' : 'chat-height-high', )} + style={{ + overflow: 'auto', + display: 'flex', + flexDirection: 'column-reverse', + }} > - {messages.length > 0 && - messages.map( - ( - storageEnvelopContainer: MessageModel, - index, - ) => ( -
- -
- ), - )} + + Loading old messages... + + } + scrollableTarget="chat-box" + > + {messages.length > 0 && + messages.map( + ( + storageEnvelopContainer: MessageModel, + index, + ) => ( +
+ +
+ ), + )} +
{/* Message, emoji and file attachments */} diff --git a/packages/messenger-widget/src/components/ConfigureProfileBox/ConfigureProfileBox.css b/packages/messenger-widget/src/components/ConfigureProfileBox/ConfigureProfileBox.css index 1999c403c..340b45fe2 100644 --- a/packages/messenger-widget/src/components/ConfigureProfileBox/ConfigureProfileBox.css +++ b/packages/messenger-widget/src/components/ConfigureProfileBox/ConfigureProfileBox.css @@ -2,7 +2,6 @@ padding: 10px 1px 10px 0px; bottom: 1%; margin: 0rem 1rem 1rem 1rem; - margin-top: 0.5rem !important; } .configure-msg-box { diff --git a/packages/messenger-widget/src/components/ConfigureProfileBox/ConfigureProfileBox.tsx b/packages/messenger-widget/src/components/ConfigureProfileBox/ConfigureProfileBox.tsx index d413bda76..af9883fb2 100644 --- a/packages/messenger-widget/src/components/ConfigureProfileBox/ConfigureProfileBox.tsx +++ b/packages/messenger-widget/src/components/ConfigureProfileBox/ConfigureProfileBox.tsx @@ -28,7 +28,7 @@ export default function ConfigureProfileBox() { return showConfigBox ? (
{ ensName: '', }, } as any, - unreadMsgCount: 0, - messageCount: 1, isHidden: false, messageSizeLimit: 100000, }, diff --git a/packages/messenger-widget/src/components/Contacts/Contacts.css b/packages/messenger-widget/src/components/Contacts/Contacts.css index a753c8c8d..d79be03d0 100644 --- a/packages/messenger-widget/src/components/Contacts/Contacts.css +++ b/packages/messenger-widget/src/components/Contacts/Contacts.css @@ -1,6 +1,5 @@ .contacts-scroller { height: calc(100% - 50px) !important; - padding-left: 1rem; margin-top: 2px; } @@ -17,7 +16,7 @@ } .contact-details-container { - padding: 0.5rem 0.5rem 0.5rem 1rem; + padding: 0.5rem 0.5rem 0.5rem 1.5rem; } .contact-details-container:hover .action-container { @@ -79,8 +78,6 @@ .contact-details-container:hover { border-radius: 0px; - padding-left: 2.5rem; - margin-left: -1.5rem; background-color: var(--normal-btn); } @@ -95,8 +92,7 @@ border-top-left-radius: 10px !important; border-bottom-left-radius: 10px !important; padding-left: 0.5rem !important; - margin-left: 0.5rem !important; - margin-right: -1px !important; + margin-left: 1rem !important; } .msg-count { @@ -110,6 +106,11 @@ font-weight: bolder; } + +.infinite-scroll-component__outerdiv{ + height: 100vh; +} + /* =================== Mobile Responsive CSS =================== */ @media only screen and (max-width: 800px) { @@ -155,4 +156,4 @@ .config-btn-container { font-size: 12px !important; } -} +} \ No newline at end of file diff --git a/packages/messenger-widget/src/components/Contacts/Contacts.tsx b/packages/messenger-widget/src/components/Contacts/Contacts.tsx index af5250429..63b07132c 100644 --- a/packages/messenger-widget/src/components/Contacts/Contacts.tsx +++ b/packages/messenger-widget/src/components/Contacts/Contacts.tsx @@ -10,25 +10,46 @@ import { } from '../../utils/enum-type-utils'; import { ContactMenu } from '../ContactMenu/ContactMenu'; import { showMenuInBottom } from './bl'; -import { getAccountDisplayName } from '@dm3-org/dm3-lib-profile'; +import { + getAccountDisplayName, + normalizeEnsName, +} from '@dm3-org/dm3-lib-profile'; import { ContactPreview } from '../../interfaces/utils'; import { DM3ConfigurationContext } from '../../context/DM3ConfigurationContext'; import { UiViewContext } from '../../context/UiViewContext'; import { ModalContext } from '../../context/ModalContext'; +import InfiniteScroll from 'react-infinite-scroll-component'; export function Contacts() { const { dm3Configuration } = useContext(DM3ConfigurationContext); - const { getMessages, getUnreadMessageCount } = useContext(MessageContext); - const { selectedRightView, setSelectedRightView } = + const { messages, getMessages, getUnreadMessageCount, contactIsLoading } = + useContext(MessageContext); + const { selectedRightView, setSelectedRightView, setSelectedLeftView } = useContext(UiViewContext); - const { contacts, setSelectedContactName, selectedContact } = - useContext(ConversationContext); + const { + contacts, + setSelectedContactName, + selectedContact, + loadMoreConversations, + } = useContext(ConversationContext); const { setLastMessageAction } = useContext(ModalContext); const [isMenuAlignedAtBottom, setIsMenuAlignedAtBottom] = useState< boolean | null >(null); + /* Hidden content for highlighting css */ + const [hiddenData, setHiddenData] = useState([]); + + const [hasMoreContact, setHasMoreContact] = useState(true); + + const getMoreContacts = async () => { + const newContactsCount = await loadMoreConversations(); + if (!newContactsCount) { + setHasMoreContact(false); + } + }; + useEffect(() => { if ( !dm3Configuration.showContacts && @@ -68,9 +89,6 @@ export function Contacts() { return uniqueContacts; }; - /* Hidden content for highlighting css */ - const hiddenData: number[] = Array.from({ length: 44 }, (_, i) => i + 1); - const scroller = document.getElementById('chat-scroller'); //If a selected contact is selected and the menu is open, we want to align the menu at the bottom @@ -84,182 +102,249 @@ export function Contacts() { }); } - const getPreviewMessage = (contact: string) => { - const messages = getMessages(contact); + const getPreviewMessage = (contactEnsName: string) => { + const _contact = normalizeEnsName(contactEnsName); + const messages = getMessages(_contact); + if (messages?.length > 0) { - return messages[messages.length - 1].envelop.message.message ?? ''; + return messages[0].envelop.message.message ?? ''; + } + const contact = contacts.find( + (c) => c.contactDetails.account.ensName === _contact, + ); + const previewMessage = contact?.message; + return previewMessage ?? ''; + }; + + const isContactSelected = (id: string) => { + return selectedContact?.contactDetails.account.ensName === id; + }; + + const isContactLoading = (id: string) => { + const contactName = selectedContact?.contactDetails?.account?.ensName; + //If there is no selectedContact return false + if (!contactName) { + return false; } - return ''; + //selectedContact in the state matches the list entry + const contactIsSelected = + selectedContact?.contactDetails.account.ensName === id; + + return contactIsSelected && contactIsLoading(contactName); }; + // updates hidden contacts data for highlighted border + const setHiddenContentForHighlightedBorder = () => { + const element: HTMLElement = document.getElementById( + 'chat-scroller', + ) as HTMLElement; + if (element) { + // fetch height of chat window + const height = element.clientHeight; + // divide it by each contact height to show in UI + const minimumContactCount = height / 64; + // get count of hidden contacts to add + const hiddenContacts = minimumContactCount - contacts.length + 10; + if (hiddenData.length !== hiddenContacts) { + setHiddenData( + Array.from({ length: hiddenContacts }, (_, i) => i + 1), + ); + } + } + }; + + // handles change in screen size + window.addEventListener('resize', setHiddenContentForHighlightedBorder); + + // sets hidden content styles for higlighted border + useEffect(() => { + setHiddenContentForHighlightedBorder(); + }, [contacts]); + return (
6 ? 'scroller-active' : 'scroller-hidden', - )} + className={'contacts-scroller width-fill scroller-active'} > - {contacts.length > 0 && - filterDuplicateContacts(contacts).map((data) => { - const id = data.contactDetails.account.ensName; - const unreadMessageCount = getUnreadMessageCount(id); + } + scrollableTarget="chat-scroller" + > + {contacts.length > 0 && + filterDuplicateContacts(contacts).map((data) => { + const id = data.contactDetails.account.ensName; + const unreadMessageCount = getUnreadMessageCount(id); - return ( - !data.isHidden && ( -
{ - // On change of contact, message action is set to none - // so that it automatically scrolls to latest message. - setLastMessageAction( - MessageActionType.NONE, - ); - setSelectedContactName( - data.contactDetails.account.ensName, - ); - if ( - selectedRightView !== - RightViewSelected.Chat - ) { - setSelectedRightView( - RightViewSelected.Chat, + return ( + !data.isHidden && ( +
{ + // On change of contact, message action is set to none + // so that it automatically scrolls to latest message. + setLastMessageAction( + MessageActionType.NONE, ); - } - setIsMenuAlignedAtBottom( - showMenuInBottom( + setSelectedContactName( data.contactDetails.account.ensName, - ), - ); - }} - > -
-
- profile-pic -
+
+
+ profile-pic +
-
-
+
-

- {getAccountDisplayName( - data.name, - 25, - )} -

-
+
+

+ {getAccountDisplayName( + data.name, + 25, + )} +

+
- {id !== - selectedContact?.contactDetails - .account.ensName && - unreadMessageCount > 0 && ( -
-
- {unreadMessageCount} + {id !== + selectedContact + ?.contactDetails.account + .ensName && + unreadMessageCount > 0 && ( +
+
+ { + unreadMessageCount + } +
-
- )} - - {selectedContact?.contactDetails - .account.ensName === id ? ( - selectedContact.message !== - null ? ( -
-
+ )} + {/* //TODO add loading state for message */} + {isContactSelected(id) ? ( + isContactLoading(id) && + !messages[id].length ? ( +
action - { - + ) : ( +
+
+ action - } + { + + } +
-
+ ) ) : ( -
- loader -
- ) - ) : ( - <> - )} -
+ <> + )} +
-
-

- {getPreviewMessage(id)} -

+
+

+ {getPreviewMessage(id)} +

+
-
- ) - ); - })} + ) + ); + })} - {/* Hidden content for highlighting css */} - {hiddenData.map((data) => ( -
-
-
- ))} + {/* Hidden content for highlighting css */} + {hiddenData.map((data) => ( +
+
+
+ ))} +
); } diff --git a/packages/messenger-widget/src/context/BackendContext.tsx b/packages/messenger-widget/src/context/BackendContext.tsx index e829220e5..53b2ece63 100644 --- a/packages/messenger-widget/src/context/BackendContext.tsx +++ b/packages/messenger-widget/src/context/BackendContext.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { useDeliveryService } from '../hooks/server-side/useDeliveryService'; import { useBackend } from '../hooks/server-side/useBackend'; export type BackendContextType = { @@ -9,7 +8,12 @@ export type BackendContextType = { ensName: string, size: number, offset: number, - ) => Promise; + ) => Promise< + { + contact: string; + previewMessage: string; + }[] + >; toggleHideConversation: ( ensName: string, encryptedContactName: string, @@ -18,12 +22,14 @@ export type BackendContextType = { getMessagesFromStorage: ( ensName: string, encryptedContactName: string, - pageNumber: number, + pageSize: number, + offset: number, ) => Promise; addMessage: ( ensName: string, encryptedContactName: string, messageId: string, + createdAt: number, encryptedEnvelopContainer: string, ) => Promise; addMessageBatch: ( diff --git a/packages/messenger-widget/src/context/ConversationContext.tsx b/packages/messenger-widget/src/context/ConversationContext.tsx index 6f0b1cefa..40fc79ce4 100644 --- a/packages/messenger-widget/src/context/ConversationContext.tsx +++ b/packages/messenger-widget/src/context/ConversationContext.tsx @@ -11,6 +11,7 @@ export type ConversationContextType = { setSelectedContactName: (contactEnsName: string | undefined) => void; initialized: boolean; addConversation: (ensName: string) => ContactPreview | undefined; + loadMoreConversations: () => Promise; hideContact: (ensName: string) => void; }; @@ -24,6 +25,9 @@ export const ConversationContext = React.createContext( addConversation: (ensName: string) => { return {} as ContactPreview; }, + loadMoreConversations: () => { + return new Promise((resolve, reject) => resolve(0)); + }, hideContact: (ensName: string) => {}, }, ); @@ -43,13 +47,14 @@ export const ConversationContextProvider = ({ setSelectedContactName, selectedContact, hideContact, - unhideContact, + loadMoreConversations, } = useConversation(config); return ( number; addMessage: AddMessage; + loadMoreMessages: (contact: string) => Promise; contactIsLoading: (contact: string) => boolean; messages: MessageStorage; }; @@ -22,6 +23,10 @@ export const MessageContext = React.createContext({ new Promise(() => { isSuccess: true; }), + loadMoreMessages: (contact: string) => + new Promise(() => { + return 0; + }), contactIsLoading: (contact: string) => false, messages: {}, }); @@ -30,6 +35,7 @@ export const MessageContextProvider = ({ children }: { children?: any }) => { const { addMessage, getMessages, + loadMoreMessages, getUnreadMessageCount, contactIsLoading, messages, @@ -40,6 +46,7 @@ export const MessageContextProvider = ({ children }: { children?: any }) => { value={{ addMessage, getMessages, + loadMoreMessages, getUnreadMessageCount, contactIsLoading, messages, diff --git a/packages/messenger-widget/src/context/StorageContext.tsx b/packages/messenger-widget/src/context/StorageContext.tsx index 3dd4c6574..a2bf8d254 100644 --- a/packages/messenger-widget/src/context/StorageContext.tsx +++ b/packages/messenger-widget/src/context/StorageContext.tsx @@ -42,7 +42,8 @@ export const StorageContext = React.createContext({ getConversations: async (size: number, offset: number) => Promise.resolve([]), addConversationAsync: (contact: string) => {}, - getMessages: async (contact: string, page: number) => Promise.resolve([]), + getMessages: async (contact: string, pageSize: number, offset: number) => + Promise.resolve([]), getNumberOfMessages: async (contact: string) => Promise.resolve(0), toggleHideContactAsync: async (contact: string, value: boolean) => {}, initialized: false, diff --git a/packages/messenger-widget/src/context/testHelper/getMockedConversationContext.ts b/packages/messenger-widget/src/context/testHelper/getMockedConversationContext.ts index 2cce22060..e9793edfd 100644 --- a/packages/messenger-widget/src/context/testHelper/getMockedConversationContext.ts +++ b/packages/messenger-widget/src/context/testHelper/getMockedConversationContext.ts @@ -16,6 +16,9 @@ export const getMockedConversationContext = ( addConversation: (ensName: string) => { return {} as ContactPreview; }, + loadMoreConversations: () => { + return new Promise((resolve, reject) => resolve(0)); + }, hideContact: (ensName: string) => {}, }; diff --git a/packages/messenger-widget/src/context/testHelper/getMockedStorageContext.ts b/packages/messenger-widget/src/context/testHelper/getMockedStorageContext.ts index bf50d0d3e..3210e4784 100644 --- a/packages/messenger-widget/src/context/testHelper/getMockedStorageContext.ts +++ b/packages/messenger-widget/src/context/testHelper/getMockedStorageContext.ts @@ -38,6 +38,7 @@ export const getMockedStorageContext = ( contactEnsName: 'max.eth', isHidden: false, messageCounter: 1, + previewMessage: undefined, }, ]); }, @@ -49,7 +50,8 @@ export const getMockedStorageContext = ( }, getMessages: function ( contact: string, - page: number, + pageSize: number, + offset: number, ): Promise { throw new Error('Function not implemented.'); }, diff --git a/packages/messenger-widget/src/hooks/conversation/hydrateContact.ts b/packages/messenger-widget/src/hooks/conversation/hydrateContact.ts index d4fb5069f..b6cd4a3a1 100644 --- a/packages/messenger-widget/src/hooks/conversation/hydrateContact.ts +++ b/packages/messenger-widget/src/hooks/conversation/hydrateContact.ts @@ -15,25 +15,22 @@ import { fetchMessageSizeLimit } from '../messages/sizeLimit/fetchSizeLimit'; export const hydrateContract = async ( provider: ethers.providers.JsonRpcProvider, - conversatoinManifest: Conversation, + conversation: Conversation, resolveAliasToTLD: (alias: string) => Promise, addrEnsSubdomain: string, ) => { //If the profile property of the account is defined the user has already used DM3 previously - const account = await fetchAccount( - provider, - conversatoinManifest.contactEnsName, - ); + const account = await _fetchAccount(provider, conversation.contactEnsName); //Has to become fetchMultipleDsProfiles - const contact = await fetchDsProfiles(provider, account); + const contact = await _fetchDsProfiles(provider, account); //get the maximum size limit by looking for the smallest size limit of every ds const maximumSizeLimit = await fetchMessageSizeLimit( contact.deliveryServiceProfiles, ); - const contactPreview = await fetchPreview( + const contactPreview = await _fetchContactPreview( provider, - conversatoinManifest, + conversation, contact, resolveAliasToTLD, maximumSizeLimit, @@ -42,9 +39,9 @@ export const hydrateContract = async ( return contactPreview; }; -const fetchPreview = async ( +const _fetchContactPreview = async ( provider: ethers.providers.JsonRpcProvider, - conversatoinManifest: Conversation, + conversation: Conversation, contact: Contact, resolveAliasToTLD: (alias: string) => Promise, messageSizeLimit: number, @@ -53,23 +50,19 @@ const fetchPreview = async ( return { //display name, if alias is not defined the addr ens name will be used name: await resolveAliasToTLD(contact.account.ensName), - message: '', + message: conversation.previewMessage?.message.message, image: await getAvatarProfilePic( provider, contact.account.ensName, addrEnsSubdomain, ), - //ToDo maybe can be removed aswell - messageCount: conversatoinManifest.messageCounter, - //ToDo field is not used and can be removed - unreadMsgCount: 21, contactDetails: contact, - isHidden: conversatoinManifest.isHidden, + isHidden: conversation.isHidden, messageSizeLimit: messageSizeLimit, }; }; -const fetchAccount = async ( +const _fetchAccount = async ( provider: ethers.providers.JsonRpcProvider, contact: string, ): Promise => { @@ -97,13 +90,13 @@ const fetchAccount = async ( } }; -const fetchDsProfiles = async ( +const _fetchDsProfiles = async ( provider: ethers.providers.JsonRpcProvider, account: Account, ): Promise => { const deliveryServiceEnsNames = account.profile?.deliveryServices ?? []; if (deliveryServiceEnsNames.length === 0) { - //If there is now DS profile the message will be storaged at the client side until they recipient has createed an account + //If there is nop DS profile the message will be storaged at the client side until they recipient has createed an account console.debug( '[fetchDeliverServicePorfile] Cant resolve deliveryServiceEnsName', ); diff --git a/packages/messenger-widget/src/hooks/conversation/useConversation.test.tsx b/packages/messenger-widget/src/hooks/conversation/useConversation.test.tsx index 4a910d109..4dc393377 100644 --- a/packages/messenger-widget/src/hooks/conversation/useConversation.test.tsx +++ b/packages/messenger-widget/src/hooks/conversation/useConversation.test.tsx @@ -35,6 +35,7 @@ import { } from '@dm3-org/dm3-lib-test-helper'; import MockAdapter from 'axios-mock-adapter'; import axios from 'axios'; +import { Envelop } from '@dm3-org/dm3-lib-messaging'; describe('useConversation hook test cases', () => { let sender: MockedUserProfile; @@ -326,8 +327,8 @@ describe('useConversation hook test cases', () => { }); }); - describe('add conversation', () => { - it('Should add multiple contacts', async () => { + describe('load more conversations', () => { + it('Should load more conversations', async () => { const authContext: AuthContextType = getMockedAuthContext({ account: { ensName: 'alice.eth', @@ -341,9 +342,19 @@ describe('useConversation hook test cases', () => { const storageContext: StorageContextType = getMockedStorageContext({ getConversations: function ( - page: number, + pageSize: number, + offset: number, ): Promise { - return Promise.resolve([]); + return Promise.resolve( + Array.from({ length: pageSize }, (_, i) => { + return { + //Use offset here to create a distinct contactEnsName + contactEnsName: 'contact ' + i + offset, + isHidden: false, + previewMessage: undefined, + }; + }), + ); }, addConversationAsync: jest.fn(), initialized: true, @@ -376,11 +387,14 @@ describe('useConversation hook test cases', () => { const { result } = renderHook(() => useConversation(config), { wrapper, }); - await act(async () => result.current.addConversation('bob.eth')); - await act(async () => result.current.addConversation('liza.eth')); - await act(async () => result.current.addConversation('heroku.eth')); - await act(async () => result.current.addConversation('samar.eth')); - await waitFor(() => expect(result.current.contacts.length).toBe(4)); + + await waitFor(() => result.current.initialized); + await waitFor(() => result.current.contacts.length > 1); + expect(result.current.contacts.length).toBe(10); + + await act(async () => result.current.loadMoreConversations()); + await waitFor(() => result.current.contacts.length > 10); + expect(result.current.contacts.length).toBe(20); }); }); @@ -405,8 +419,8 @@ describe('useConversation hook test cases', () => { return Promise.resolve([ { contactEnsName: 'max.eth', + previewMessage: undefined, isHidden: false, - messageCounter: 1, }, ]); }, @@ -450,7 +464,89 @@ describe('useConversation hook test cases', () => { 'max.eth', ); }); - it('add default contact if specified in conversation list', async () => { + it('has last message attached as previewMessage', async () => { + const authContext: AuthContextType = getMockedAuthContext({ + account: { + ensName: 'alice.eth', + profile: { + deliveryServices: ['ds.eth'], + publicEncryptionKey: '', + publicSigningKey: '', + }, + }, + }); + + const storageContext: StorageContextType = getMockedStorageContext({ + getConversations: function ( + page: number, + offset: number, + ): Promise { + return Promise.resolve([ + { + contactEnsName: 'max.eth', + isHidden: false, + previewMessage: undefined, + }, + { + contactEnsName: 'bob.eth', + previewMessage: { + envelop: { + message: { + message: 'Hello from Bob', + }, + }, + } as unknown as Envelop, + isHidden: false, + }, + ]); + }, + addConversationAsync: jest.fn(), + initialized: true, + }); + const deliveryServiceContext: DeliveryServiceContextType = + getMockedDeliveryServiceContext({ + fetchIncommingMessages: function (ensName: string) { + return Promise.resolve([]); + }, + getDeliveryServiceProperties: function (): Promise { + return Promise.resolve([{ sizeLimit: 0 }]); + }, + isInitialized: true, + }); + + const wrapper = ({ children }: { children: any }) => ( + <> + + + + {children} + + + + + ); + + const { result } = renderHook(() => useConversation(config), { + wrapper, + }); + await waitFor(() => expect(result.current.initialized).toBe(true)); + + const conversations = result.current.contacts; + + expect(conversations.length).toBe(2); + expect(conversations[0].contactDetails.account.ensName).toBe( + 'max.eth', + ); + expect(conversations[1].contactDetails.account.ensName).toBe( + 'bob.eth', + ); + expect(conversations[1].contactDetails.account.ensName).toBe( + 'bob.eth', + ); + }); + it('add default contact if specified in config ', async () => { const configurationContext = getMockedDm3Configuration({ dm3Configuration: { ...DEFAULT_DM3_CONFIGURATION, @@ -478,7 +574,7 @@ describe('useConversation hook test cases', () => { { contactEnsName: 'max.eth', isHidden: false, - messageCounter: 1, + previewMessage: undefined, }, ]); }, @@ -562,12 +658,12 @@ describe('useConversation hook test cases', () => { { contactEnsName: 'max.eth', isHidden: false, - messageCounter: 1, + previewMessage: undefined, }, { contactEnsName: 'mydefaultcontract.eth', isHidden: false, - messageCounter: 1, + previewMessage: undefined, }, ]); }, @@ -651,17 +747,17 @@ describe('useConversation hook test cases', () => { { contactEnsName: 'ron.eth', isHidden: true, - messageCounter: 1, + previewMessage: undefined, }, { contactEnsName: 'max.eth', isHidden: false, - messageCounter: 1, + previewMessage: undefined, }, { contactEnsName: 'mydefaultcontract.eth', isHidden: false, - messageCounter: 1, + previewMessage: undefined, }, ]); }, @@ -747,7 +843,7 @@ describe('useConversation hook test cases', () => { { contactEnsName: 'max.eth', isHidden: false, - messageCounter: 1, + previewMessage: undefined, }, ]); }, @@ -797,6 +893,61 @@ describe('useConversation hook test cases', () => { 'bob.eth', ); }); + it('Should add multiple contacts', async () => { + const authContext: AuthContextType = getMockedAuthContext({ + account: { + ensName: 'alice.eth', + profile: { + deliveryServices: ['ds.eth'], + publicEncryptionKey: '', + publicSigningKey: '', + }, + }, + }); + + const storageContext: StorageContextType = getMockedStorageContext({ + getConversations: function ( + page: number, + ): Promise { + return Promise.resolve([]); + }, + addConversationAsync: jest.fn(), + initialized: true, + }); + const deliveryServiceContext: DeliveryServiceContextType = + getMockedDeliveryServiceContext({ + fetchIncommingMessages: function (ensName: string) { + return Promise.resolve([]); + }, + getDeliveryServiceProperties: function (): Promise { + return Promise.resolve([{ sizeLimit: 0 }]); + }, + isInitialized: true, + }); + + const wrapper = ({ children }: { children: any }) => ( + <> + + + + {children} + + + + + ); + + const { result } = renderHook(() => useConversation(config), { + wrapper, + }); + await act(async () => result.current.addConversation('bob.eth')); + await act(async () => result.current.addConversation('liza.eth')); + await act(async () => result.current.addConversation('heroku.eth')); + await act(async () => result.current.addConversation('samar.eth')); + await waitFor(() => expect(result.current.contacts.length).toBe(4)); + }); }); describe('hydrate contact', () => { @@ -816,7 +967,7 @@ describe('useConversation hook test cases', () => { { contactEnsName: sender.account.ensName, isHidden: false, - messageCounter: 1, + previewMessage: undefined, }, ]); }, @@ -837,6 +988,7 @@ describe('useConversation hook test cases', () => { return Promise.resolve(sender.address); }, getResolver: (ensName: string) => { + console.log('mock resolver for ', ensName); if (ensName === sender.account.ensName) { return { getText: () => sender.stringified, @@ -884,7 +1036,12 @@ describe('useConversation hook test cases', () => { wrapper, }); await waitFor(() => expect(result.current.initialized).toBe(true)); - + await waitFor(() => + expect( + result.current.contacts[0].contactDetails + .deliveryServiceProfiles.length, + ).toBe(2), + ); expect( result.current.contacts[0].contactDetails .deliveryServiceProfiles[0], @@ -919,7 +1076,7 @@ describe('useConversation hook test cases', () => { { contactEnsName: sender.account.ensName, isHidden: false, - messageCounter: 1, + previewMessage: undefined, }, ]); }, diff --git a/packages/messenger-widget/src/hooks/conversation/useConversation.tsx b/packages/messenger-widget/src/hooks/conversation/useConversation.tsx index f9f1d4ca6..75d149149 100644 --- a/packages/messenger-widget/src/hooks/conversation/useConversation.tsx +++ b/packages/messenger-widget/src/hooks/conversation/useConversation.tsx @@ -16,7 +16,7 @@ import { ContactPreview, getEmptyContact } from '../../interfaces/utils'; import { useMainnetProvider } from '../mainnetprovider/useMainnetProvider'; import { hydrateContract } from './hydrateContact'; -const DEFAULT_CONVERSATION_PAGE_SIZE = 1; +const DEFAULT_CONVERSATION_PAGE_SIZE = 10; export const useConversation = (config: DM3Configuration) => { const mainnetProvider = useMainnetProvider(); @@ -101,12 +101,7 @@ export const useConversation = (config: DM3Configuration) => { ), ) //Add the conversations to the list - .forEach((conversation) => { - _addConversation( - conversation.contactEnsName, - conversation.isHidden, - ); - }); + .forEach((conversation) => _addConversation(conversation)); initDefaultContact(); setConversationsInitialized(true); @@ -136,7 +131,7 @@ export const useConversation = (config: DM3Configuration) => { //I there are no conversations yet we add the default contact const defaultConversation: Conversation = { contactEnsName: normalizeEnsName(aliasName!), - messageCounter: 0, + previewMessage: undefined, isHidden: false, }; @@ -172,7 +167,6 @@ export const useConversation = (config: DM3Configuration) => { return { contactEnsName, - messageCounter: 0, isHidden: false, }; }) @@ -188,43 +182,37 @@ export const useConversation = (config: DM3Configuration) => { }; const addConversation = (_ensName: string) => { + const contactEnsName = normalizeEnsName(_ensName); + const newConversation: Conversation = { + contactEnsName, + isHidden: false, + previewMessage: undefined, + }; //Adds the conversation to the conversation state - const conversationPreview = _addConversation(_ensName, false); + const conversationPreview = _addConversation(newConversation); //Add the contact to the storage in the background - storeConversationAsync(_ensName); - + storeConversationAsync(contactEnsName); return conversationPreview; }; - const _addConversation = (_ensName: string, isHidden: boolean) => { - const ensName = normalizeEnsName(_ensName); - //Check if the contact is the user itself - const isOwnContact = normalizeEnsName(account!.ensName) === ensName; - //We don't want to add ourselfs - if (isOwnContact) { - return; - } - const alreadyAddedContact = contacts.find( - (existingContact) => - existingContact.contactDetails.account.ensName === ensName, + const loadMoreConversations = async (): Promise => { + const hasDefaultContact = config.defaultContact; + //If a default contact is set we have to subtract one from the conversation count since its not part of the conversation list + const conversationCount = hasDefaultContact + ? contacts.length - 1 + : contacts.length; + //We calculate the offset based on the conversation count divided by the default page size + //offset * pagesize equals the amount of conversations that will be skipped + const offset = conversationCount / DEFAULT_CONVERSATION_PAGE_SIZE; + console.log('load more conversations', conversationCount, offset); + const conversations = await getConversationsFromStorage( + DEFAULT_CONVERSATION_PAGE_SIZE, + Math.floor(offset), ); - //If the contact is already in the list return it - if (alreadyAddedContact) { - //Unhide the contact if it was hidden - if (alreadyAddedContact.isHidden) { - unhideContact(alreadyAddedContact); - } - return alreadyAddedContact; - } - - const newContact: ContactPreview = getEmptyContact(ensName, isHidden); - //Set the new contact to the list - _setContactsSafe([newContact]); - //Hydrate the contact in the background - hydrateExistingContactAsync(newContact); - //Return the new onhydrated contact - return newContact; + //add every conversation + conversations.forEach((conversation) => _addConversation(conversation)); + return conversations.length; }; /** @@ -234,7 +222,7 @@ export const useConversation = (config: DM3Configuration) => { const hydrateExistingContactAsync = async (contact: ContactPreview) => { const conversation: Conversation = { contactEnsName: contact.contactDetails.account.ensName, - messageCounter: contact?.messageCount || 0, + previewMessage: undefined, isHidden: contact.isHidden, }; const hydratedContact = await hydrateContract( @@ -257,7 +245,22 @@ export const useConversation = (config: DM3Configuration) => { }); }; - const toggleHideContact = (_ensName: string, isHidden: boolean) => { + const hideContact = (_ensName: string) => { + const ensName = normalizeEnsName(_ensName); + _toggleHideContact(ensName, true); + setSelectedContactName(undefined); + }; + + const unhideContact = (contact: ContactPreview) => { + _toggleHideContact(contact.contactDetails.account.ensName, false); + const unhiddenContact = { + ...contact, + isHidden: false, + }; + setSelectedContactName(unhiddenContact.contactDetails.account.ensName); + hydrateExistingContactAsync(unhiddenContact); + }; + const _toggleHideContact = (_ensName: string, isHidden: boolean) => { const ensName = normalizeEnsName(_ensName); setContacts((prev) => { return prev.map((existingContact) => { @@ -276,27 +279,48 @@ export const useConversation = (config: DM3Configuration) => { //update the storage toggleHideContactAsync(ensName, isHidden); }; + const _addConversation = (conversation: Conversation) => { + const ensName = normalizeEnsName(conversation.contactEnsName); + //Check if the contact is the user itself + const isOwnContact = normalizeEnsName(account!.ensName) === ensName; + //We don't want to add ourselfs + if (isOwnContact) { + return; + } + const alreadyAddedContact = contacts.find( + (existingContact) => + existingContact.contactDetails.account.ensName === ensName, + ); + //If the contact is already in the list return it + if (alreadyAddedContact) { + //Unhide the contact if it was hidden + if (alreadyAddedContact.isHidden) { + unhideContact(alreadyAddedContact); + } + return alreadyAddedContact; + } - const hideContact = (_ensName: string) => { - const ensName = normalizeEnsName(_ensName); - toggleHideContact(ensName, true); - setSelectedContactName(undefined); - }; + //If the conversation already contains messages the preview message is the last message. The backend attaches that message to the conversation so we can use it here and safe a request to fetch the messages + const previewMessage = conversation.previewMessage?.message?.message; - const unhideContact = (contact: ContactPreview) => { - toggleHideContact(contact.contactDetails.account.ensName, false); - const unhiddenContact = { - ...contact, - isHidden: false, - }; - setSelectedContactName(unhiddenContact.contactDetails.account.ensName); - hydrateExistingContactAsync(unhiddenContact); - }; + const newContact: ContactPreview = getEmptyContact( + ensName, + previewMessage, + conversation.isHidden, + ); + //Set the new contact to the list + _setContactsSafe([newContact]); + //Hydrate the contact in the background + hydrateExistingContactAsync(newContact); + //Return the new onhydrated contact + return newContact; + }; return { contacts, conversationCount, addConversation, + loadMoreConversations, initialized: conversationsInitialized, setSelectedContactName, selectedContact, diff --git a/packages/messenger-widget/src/hooks/messages/renderer/renderMessage.ts b/packages/messenger-widget/src/hooks/messages/renderer/renderMessage.ts index 11b7d29d9..0775864c1 100644 --- a/packages/messenger-widget/src/hooks/messages/renderer/renderMessage.ts +++ b/packages/messenger-widget/src/hooks/messages/renderer/renderMessage.ts @@ -18,10 +18,11 @@ export const renderMessage = (messages: MessageModel[]) => { //Its desirable to have all messages in a conversation sorted by their timestamp. However edited messages are an //exception to this rule, since they should be displayed in the order they were edited. // Therefore we sort the messages by their timestamp and then we eventually replace messages that have been edited + //Messages are sorted DESC, so the pagination adds old messages at the end of the array withReply.sort( (a, b) => - a.envelop.message.metadata.timestamp - - b.envelop.message.metadata.timestamp, + b.envelop.message.metadata.timestamp - + a.envelop.message.metadata.timestamp, ); const withoutEdited = renderEdit(withReply); diff --git a/packages/messenger-widget/src/hooks/messages/sources/handleMessagesFromDeliveryService.ts b/packages/messenger-widget/src/hooks/messages/sources/handleMessagesFromDeliveryService.ts index f3dc3083d..0720843fa 100644 --- a/packages/messenger-widget/src/hooks/messages/sources/handleMessagesFromDeliveryService.ts +++ b/packages/messenger-widget/src/hooks/messages/sources/handleMessagesFromDeliveryService.ts @@ -4,7 +4,7 @@ import { Envelop, MessageState, } from '@dm3-org/dm3-lib-messaging'; -import { MessageModel } from '../useMessage'; +import { MessageModel, MessageSource } from '../useMessage'; import { Account, ProfileKeys } from '@dm3-org/dm3-lib-profile'; import { AddConversation, StoreMessageBatch } from '../../storage/useStorage'; import { Acknoledgment } from '@dm3-org/dm3-lib-delivery'; @@ -30,30 +30,33 @@ export const handleMessagesFromDeliveryService = async ( ); const incommingMessages: MessageModel[] = await Promise.all( - encryptedIncommingMessages.map(async (envelop: EncryptionEnvelop) => { - const decryptedEnvelop: Envelop = { - message: JSON.parse( - await decryptAsymmetric( - profileKeys?.encryptionKeyPair!, - JSON.parse(envelop.message), + encryptedIncommingMessages.map( + async (envelop: EncryptionEnvelop): Promise => { + const decryptedEnvelop: Envelop = { + message: JSON.parse( + await decryptAsymmetric( + profileKeys?.encryptionKeyPair!, + JSON.parse(envelop.message), + ), ), - ), - postmark: JSON.parse( - await decryptAsymmetric( - profileKeys?.encryptionKeyPair!, - JSON.parse(envelop.postmark!), + postmark: JSON.parse( + await decryptAsymmetric( + profileKeys?.encryptionKeyPair!, + JSON.parse(envelop.postmark!), + ), ), - ), - metadata: envelop.metadata, - }; - return { - envelop: decryptedEnvelop, - //Messages from the delivery service are already send by the sender - messageState: MessageState.Send, - messageChunkKey: '', - reactions: [], - }; - }), + metadata: envelop.metadata, + }; + return { + envelop: decryptedEnvelop, + //Messages from the delivery service are already send by the sender + messageState: MessageState.Send, + reactions: [], + //The source of the message is the delivery service + source: MessageSource.DeliveryService, + }; + }, + ), ); const messagesSortedASC = incommingMessages.sort((a, b) => { diff --git a/packages/messenger-widget/src/hooks/messages/sources/handleMessagesFromStorage.ts b/packages/messenger-widget/src/hooks/messages/sources/handleMessagesFromStorage.ts index e8ffe881e..2e127c65c 100644 --- a/packages/messenger-widget/src/hooks/messages/sources/handleMessagesFromStorage.ts +++ b/packages/messenger-widget/src/hooks/messages/sources/handleMessagesFromStorage.ts @@ -1,26 +1,30 @@ import { GetMessages } from '../../storage/useStorage'; -import { MessageModel } from '../useMessage'; +import { MessageModel, MessageSource } from '../useMessage'; export const handleMessagesFromStorage = async ( setContactsLoading: Function, - getNumberOfMessages: (contactName: string) => Promise, getMessagesFromStorage: GetMessages, contactName: string, + pageSize: number, + offSet: number, ) => { setContactsLoading((prev: string[]) => { return [...prev, contactName]; }); - const MAX_MESSAGES_PER_CHUNK = 100; - const numberOfmessages = await getNumberOfMessages(contactName); + const storedMessages = await getMessagesFromStorage( contactName, - Math.floor(numberOfmessages / MAX_MESSAGES_PER_CHUNK), + pageSize, + offSet, ); + return storedMessages.map( (message) => ({ ...message, reactions: [], + //The message has been fetched from teh storage + source: MessageSource.Storage, } as MessageModel), ); }; diff --git a/packages/messenger-widget/src/hooks/messages/sources/handleMessagesFromWebSocket.ts b/packages/messenger-widget/src/hooks/messages/sources/handleMessagesFromWebSocket.ts index 40ac5a7bc..9fcdc9ae7 100644 --- a/packages/messenger-widget/src/hooks/messages/sources/handleMessagesFromWebSocket.ts +++ b/packages/messenger-widget/src/hooks/messages/sources/handleMessagesFromWebSocket.ts @@ -7,7 +7,7 @@ import { import { ProfileKeys, normalizeEnsName } from '@dm3-org/dm3-lib-profile'; import { ContactPreview } from '../../../interfaces/utils'; import { AddConversation, StoreMessageAsync } from '../../storage/useStorage'; -import { MessageStorage } from '../useMessage'; +import { MessageModel, MessageSource, MessageStorage } from '../useMessage'; export const handleMessagesFromWebSocket = async ( addConversation: AddConversation, @@ -46,11 +46,11 @@ export const handleMessagesFromWebSocket = async ( ? MessageState.Read : MessageState.Send; - const messageModel = { + const messageModel: MessageModel = { envelop: decryptedEnvelop, messageState, - messageChunkKey: '', reactions: [], + source: MessageSource.WebSocket, }; setMessages((prev: MessageStorage) => { //Check if message already exists diff --git a/packages/messenger-widget/src/hooks/messages/useMessage.test.tsx b/packages/messenger-widget/src/hooks/messages/useMessage.test.tsx index 6300a91fa..701825b64 100644 --- a/packages/messenger-widget/src/hooks/messages/useMessage.test.tsx +++ b/packages/messenger-widget/src/hooks/messages/useMessage.test.tsx @@ -54,14 +54,6 @@ describe('useMessage hook test cases', () => { expect(loading).toBe(false); }); - it('Should check contact is loading or not ', async () => { - const { result } = renderHook(() => useMessage()); - const unreadMsgCount = await act(async () => - result.current.getUnreadMessageCount(CONTACT_NAME), - ); - expect(unreadMsgCount).toBe(0); - }); - describe('add Message', () => { let sender: MockedUserProfile; let receiver: MockedUserProfile; @@ -106,7 +98,7 @@ describe('useMessage hook test cases', () => { storeMessage: jest.fn(), }); const conversationContext = getMockedConversationContext({ - selectedContact: getEmptyContact('max.eth'), + selectedContact: getEmptyContact('max.eth', undefined), }); const deliveryServiceContext = getMockedDeliveryServiceContext({ //Add websocket mock @@ -163,7 +155,7 @@ describe('useMessage hook test cases', () => { storeMessage: jest.fn(), }); const conversationContext = getMockedConversationContext({ - selectedContact: getEmptyContact('max.eth'), + selectedContact: getEmptyContact('max.eth', undefined), }); const deliveryServiceContext = getMockedDeliveryServiceContext({ //Add websocket mock @@ -220,7 +212,7 @@ describe('useMessage hook test cases', () => { storeMessage: jest.fn(), }); const conversationContext = getMockedConversationContext({ - selectedContact: getEmptyContact('max.eth'), + selectedContact: getEmptyContact('max.eth', undefined), }); const deliveryServiceContext = getMockedDeliveryServiceContext({ //Add websocket mock @@ -287,14 +279,12 @@ describe('useMessage hook test cases', () => { }); const conversationContext = getMockedConversationContext({ - selectedContact: getEmptyContact('max.eth'), + selectedContact: getEmptyContact('max.eth', undefined), contacts: [ { name: '', message: '', image: 'human.svg', - messageCount: 1, - unreadMsgCount: 21, contactDetails: { account: { ensName: receiver.account.ensName, @@ -390,14 +380,12 @@ describe('useMessage hook test cases', () => { }); const conversationContext = getMockedConversationContext({ - selectedContact: getEmptyContact('max.eth'), + selectedContact: getEmptyContact('max.eth', undefined), contacts: [ { name: '', message: '', image: 'human.svg', - messageCount: 1, - unreadMsgCount: 21, contactDetails: { account: { ensName: receiver.account.ensName, @@ -520,8 +508,8 @@ describe('useMessage hook test cases', () => { getNumberOfMessages: jest.fn().mockResolvedValue(3), }); const conversationContext = getMockedConversationContext({ - selectedContact: getEmptyContact('max.eth'), - contacts: [getEmptyContact('alice.eth')], + selectedContact: getEmptyContact('max.eth', undefined), + contacts: [getEmptyContact('alice.eth', undefined)], }); const deliveryServiceContext = getMockedDeliveryServiceContext({ onNewMessage: (cb: Function) => { @@ -597,8 +585,8 @@ describe('useMessage hook test cases', () => { getNumberOfMessages: jest.fn().mockResolvedValue(0), }); const conversationContext = getMockedConversationContext({ - selectedContact: getEmptyContact('max.eth'), - contacts: [getEmptyContact('alice.eth')], + selectedContact: getEmptyContact('max.eth', undefined), + contacts: [getEmptyContact('alice.eth', undefined)], }); const deliveryServiceContext = getMockedDeliveryServiceContext({ onNewMessage: (cb: Function) => { @@ -657,4 +645,219 @@ describe('useMessage hook test cases', () => { expect(result.current.messages['alice.eth'].length).toBe(3); }); }); + describe('message pagination', () => { + let sender: MockedUserProfile; + let receiver: MockedUserProfile; + let ds: any; + + beforeEach(async () => { + sender = await mockUserProfile( + ethers.Wallet.createRandom(), + 'alice.eth', + ['https://example.com'], + ); + receiver = await mockUserProfile( + ethers.Wallet.createRandom(), + 'bob.eth', + ['https://example.com'], + ); + ds = await getMockDeliveryServiceProfile( + ethers.Wallet.createRandom(), + 'https://example.com', + ); + }); + it('should load more messages from Storage', async () => { + const messageFactory = MockMessageFactory(sender, receiver, ds); + //const messages + const storageContext = getMockedStorageContext({ + editMessageBatchAsync: jest.fn(), + storeMessageBatch: jest.fn(), + storeMessage: jest.fn(), + getMessages: async ( + contactName: string, + pageSize: number, + offset: number, + ) => + Promise.all( + Array.from({ length: pageSize }, (_, i) => + messageFactory.createStorageEnvelopContainer( + 'hello dm3 ' + i + offset, + ), + ), + ), + }); + const conversationContext = getMockedConversationContext({ + selectedContact: getEmptyContact('max.eth', undefined), + contacts: [getEmptyContact('alice.eth', undefined)], + }); + const deliveryServiceContext = getMockedDeliveryServiceContext({ + onNewMessage: (cb: Function) => { + console.log('on new message'); + }, + fetchNewMessages: jest.fn().mockResolvedValue([]), + syncAcknowledgment: jest.fn(), + removeOnNewMessageListener: jest.fn(), + }); + const authContext = getMockedAuthContext({ + profileKeys: receiver.profileKeys, + account: { + ensName: 'bob.eth', + profile: { + deliveryServices: ['ds.eth'], + publicEncryptionKey: '', + publicSigningKey: '', + }, + }, + }); + const tldContext = getMockedTldContext({}); + + const wrapper = ({ children }: { children: any }) => ( + <> + + + + + + {children} + + + + + + + ); + + const { result } = renderHook(() => useMessage(), { + wrapper, + }); + //Wait until bobs messages have been initialized + await waitFor( + () => + result.current.contactIsLoading('alice.eth') === false && + result.current.messages['alice.eth'].length > 0, + ); + + expect(result.current.contactIsLoading('alice.eth')).toBe(false); + expect(result.current.messages['alice.eth'].length).toBe(100); + + await act(async () => result.current.loadMoreMessages('alice.eth')); + + //Wait until new messages have been loaded + await waitFor( + () => + result.current.contactIsLoading('alice.eth') === false && + result.current.messages['alice.eth'].length > 100, + ); + + expect(result.current.contactIsLoading('alice.eth')).toBe(false); + expect(result.current.messages['alice.eth'].length).toBe(200); + }); + it('messages from sources different as storage should not be considered in pagination calculation', async () => { + const messageFactory = MockMessageFactory(sender, receiver, ds); + //const messages + const storageContext = getMockedStorageContext({ + editMessageBatchAsync: jest.fn(), + storeMessageBatch: jest.fn(), + storeMessage: jest.fn(), + getMessages: async ( + contactName: string, + pageSize: number, + offset: number, + ) => + Promise.all( + Array.from({ length: pageSize }, (_, i) => + messageFactory.createStorageEnvelopContainer( + 'hello dm3 ' + i + offset, + ), + ), + ), + }); + const conversationContext = getMockedConversationContext({ + selectedContact: getEmptyContact('max.eth', undefined), + contacts: [getEmptyContact('alice.eth', undefined)], + }); + const deliveryServiceContext = getMockedDeliveryServiceContext({ + onNewMessage: (cb: Function) => { + console.log('on new message'); + }, + fetchNewMessages: async (_: string) => + Promise.all( + Array.from({ length: 13 }, (_, i) => + messageFactory.createEncryptedEnvelop( + 'hello dm3 from ds' + i, + ), + ), + ), + syncAcknowledgment: jest.fn(), + removeOnNewMessageListener: jest.fn(), + }); + const authContext = getMockedAuthContext({ + profileKeys: receiver.profileKeys, + account: { + ensName: 'bob.eth', + profile: { + deliveryServices: ['ds.eth'], + publicEncryptionKey: '', + publicSigningKey: '', + }, + }, + }); + const tldContext = getMockedTldContext({}); + + const wrapper = ({ children }: { children: any }) => ( + <> + + + + + + {children} + + + + + + + ); + + const { result } = renderHook(() => useMessage(), { + wrapper, + }); + //Wait until bobs messages have been initialized + await waitFor( + () => + result.current.contactIsLoading('alice.eth') === false && + result.current.messages['alice.eth'].length > 0, + ); + + expect(result.current.contactIsLoading('alice.eth')).toBe(false); + //Initial message number would be storage(100) = Ds (13) == 113 + expect(result.current.messages['alice.eth'].length).toBe(113); + + await act(async () => result.current.loadMoreMessages('alice.eth')); + + //Wait until new messages have been loaded + await waitFor( + () => + result.current.contactIsLoading('alice.eth') === false && + result.current.messages['alice.eth'].length > 133, + ); + + expect(result.current.contactIsLoading('alice.eth')).toBe(false); + expect(result.current.messages['alice.eth'].length).toBe(213); + //991 = 99 message 100(since pageSize starts from 0) = 1 offset + expect( + result.current.messages['alice.eth'][212].envelop.message + .message, + ).toBe('hello dm3 991'); + }); + }); }); diff --git a/packages/messenger-widget/src/hooks/messages/useMessage.tsx b/packages/messenger-widget/src/hooks/messages/useMessage.tsx index 5d08fdabe..e5a241c8b 100644 --- a/packages/messenger-widget/src/hooks/messages/useMessage.tsx +++ b/packages/messenger-widget/src/hooks/messages/useMessage.tsx @@ -1,16 +1,14 @@ -import { encryptAsymmetric, sign } from '@dm3-org/dm3-lib-crypto'; +import { encryptAsymmetric } from '@dm3-org/dm3-lib-crypto'; import { - EncryptionEnvelop, DispatchableEnvelop, + EncryptionEnvelop, Envelop, Message, MessageState, buildEnvelop, } from '@dm3-org/dm3-lib-messaging'; -import { - DeliveryServiceProfile, - normalizeEnsName, -} from '@dm3-org/dm3-lib-profile'; +import { normalizeEnsName } from '@dm3-org/dm3-lib-profile'; +import { sha256, stringify } from '@dm3-org/dm3-lib-shared'; import { StorageEnvelopContainer as StorageEnvelopContainerNew } from '@dm3-org/dm3-lib-storage'; import axios from 'axios'; import { useCallback, useContext, useEffect, useState } from 'react'; @@ -24,12 +22,25 @@ import { checkIfEnvelopAreInSizeLimit } from './sizeLimit/checkIfEnvelopIsInSize import { handleMessagesFromDeliveryService } from './sources/handleMessagesFromDeliveryService'; import { handleMessagesFromStorage } from './sources/handleMessagesFromStorage'; import { handleMessagesFromWebSocket } from './sources/handleMessagesFromWebSocket'; -import { sha256, stringify } from '@dm3-org/dm3-lib-shared'; -import { ContactPreview } from '../../interfaces/utils'; + +const DEFAULT_MESSAGE_PAGESIZE = 100; + +//Message source to identify where a message comes from. This is important to handle pagination of storage messages properly +export enum MessageSource { + //Messages added by the client via addMessage + Client, + //Messages fetched from the storage + Storage, + //Messages fetched from the deliveryService + DeliveryService, + //Messages received from the Websocket + WebSocket, +} export type MessageModel = StorageEnvelopContainerNew & { reactions: Envelop[]; replyToMessageEnvelop?: Envelop; + source: MessageSource; }; export type MessageStorage = { @@ -51,7 +62,6 @@ export const useMessage = () => { const { resolveTLDtoAlias } = useContext(TLDContext); const { - getNumberOfMessages, getMessages: getMessagesFromStorage, storeMessage, storeMessageBatch, @@ -102,11 +112,13 @@ export const useMessage = () => { //Mark messages as read when the selected contact changes useEffect(() => { - const contact = selectedContact?.contactDetails.account.ensName; - if (!contact) { + const _contact = selectedContact?.contactDetails.account.ensName; + if (!_contact) { return; } + const contact = normalizeEnsName(_contact); + const unreadMessages = (messages[contact] ?? []).filter( (message) => message.messageState !== MessageState.Read && @@ -191,8 +203,6 @@ export const useMessage = () => { message: Message, ): Promise<{ isSuccess: boolean; error?: string }> => { const contact = normalizeEnsName(_contactName); - console.log(contacts); - //If a message is empty it should not be added if (!message.message || message.message.trim() === '') { @@ -229,7 +239,7 @@ export const useMessage = () => { }, }, messageState: MessageState.Created, - + source: MessageSource.Client, reactions: [], }; setMessages((prev) => { @@ -286,6 +296,8 @@ export const useMessage = () => { envelop: envelops[0].envelop, messageState: MessageState.Created, reactions: [], + //Message has just been created by the client + source: MessageSource.Client, }; //Add the message to the state @@ -339,9 +351,11 @@ export const useMessage = () => { const initialMessages = await Promise.all([ handleMessagesFromStorage( setContactsLoading, - getNumberOfMessages, getMessagesFromStorage, contactName, + DEFAULT_MESSAGE_PAGESIZE, + //For the first page we use 0 as offset + 0, ), handleMessagesFromDeliveryService( account!, @@ -353,13 +367,51 @@ export const useMessage = () => { syncAcknowledgment, ), ]); - const flatten = initialMessages.reduce( (acc, val) => acc.concat(val), [], ); + await _addMessages(contactName, flatten); + }; + + const loadMoreMessages = async (_contactName: string): Promise => { + const contactName = normalizeEnsName(_contactName); + + const messagesFromContact = messages[contactName] ?? []; + //For the messageCount we only consider messages from the MessageSource storage + const messageCount = messagesFromContact.filter( + (message) => message.source === MessageSource.Storage, + ).length; + + //We dont need to fetch more messages if the previously fetched page is smaller than the default pagesize + const isLastPage = messageCount % DEFAULT_MESSAGE_PAGESIZE !== 0; + if (isLastPage) { + console.log('all messages loaded for ', messagesFromContact); + //No more messages have been added + return 0; + } + + //We calculate the offset based on the messageCount + const offset = Math.floor(messageCount / DEFAULT_MESSAGE_PAGESIZE); + console.log('load more ', messageCount, offset); + + const messagesFromStorage = await handleMessagesFromStorage( + setContactsLoading, + getMessagesFromStorage, + contactName, + DEFAULT_MESSAGE_PAGESIZE, + offset, + ); + return await _addMessages(contactName, messagesFromStorage); + }; - const messages = flatten + const _addMessages = async ( + _contactName: string, + newMessages: MessageModel[], + ) => { + const contactName = normalizeEnsName(_contactName); + + newMessages //filter duplicates .filter((message, index, self) => { if (!message.envelop.metadata?.encryptedMessageHash) { @@ -375,18 +427,24 @@ export const useMessage = () => { ); }); - const withResolvedAliasNames = await resolveAliasNames(messages); + const withResolvedAliasNames = await resolveAliasNames(newMessages); setMessages((prev) => { return { ...prev, - [contactName]: withResolvedAliasNames, + [contactName]: [ + ...(prev[contactName] ?? []), + ...withResolvedAliasNames, + ], }; }); setContactsLoading((prev) => { return prev.filter((contact) => contact !== contactName); }); + + // the count of new messages added + return withResolvedAliasNames.length; }; /** @@ -423,6 +481,7 @@ export const useMessage = () => { getUnreadMessageCount, getMessages, addMessage, + loadMoreMessages, contactIsLoading, }; }; diff --git a/packages/messenger-widget/src/hooks/server-side/BackendConnector.ts b/packages/messenger-widget/src/hooks/server-side/BackendConnector.ts index 90f07c895..0924b8012 100644 --- a/packages/messenger-widget/src/hooks/server-side/BackendConnector.ts +++ b/packages/messenger-widget/src/hooks/server-side/BackendConnector.ts @@ -49,13 +49,19 @@ export class BackendConnector public async getMessagesFromStorage( ensName: string, encryptedContactName: string, - pageNumber: number, + pageSize: number, + offset: number, ) { const url = `/storage/new/${normalizeEnsName( ensName, - )}/getMessages/${encryptedContactName}/${pageNumber}`; + )}/getMessages/${encryptedContactName}`; - const { data } = await this.getAuthenticatedAxiosClient().get(url); + const { data } = await this.getAuthenticatedAxiosClient().get(url, { + params: { + pageSize, + offset, + }, + }); return ( data.map((message: any) => { @@ -68,12 +74,14 @@ export class BackendConnector ensName: string, encryptedContactName: string, messageId: string, + createdAt: number, encryptedEnvelopContainer: string, ) { const url = `/storage/new/${normalizeEnsName(ensName)}/addMessage`; await this.getAuthenticatedAxiosClient().post(url, { encryptedContactName, messageId, + createdAt, encryptedEnvelopContainer, }); } diff --git a/packages/messenger-widget/src/hooks/server-side/useBackend.ts b/packages/messenger-widget/src/hooks/server-side/useBackend.ts index c61dba118..4e81afc90 100644 --- a/packages/messenger-widget/src/hooks/server-side/useBackend.ts +++ b/packages/messenger-widget/src/hooks/server-side/useBackend.ts @@ -76,24 +76,28 @@ export const useBackend = (): IBackendConnector & { getMessagesFromStorage: async ( ensName: string, encryptedContactName: string, - pageNumber: number, + pageSize: number, + offset: number, ) => { return beConnector?.getMessagesFromStorage( ensName, encryptedContactName, - pageNumber, + pageSize, + offset, ); }, addMessage: async ( ensName: string, encryptedContactName: string, messageId: string, + createdAt: number, encryptedEnvelopContainer: string, ) => { return beConnector?.addMessage( ensName, encryptedContactName, messageId, + createdAt, encryptedEnvelopContainer, ); }, diff --git a/packages/messenger-widget/src/hooks/storage/useStorage.tsx b/packages/messenger-widget/src/hooks/storage/useStorage.tsx index f627ca266..927ddf714 100644 --- a/packages/messenger-widget/src/hooks/storage/useStorage.tsx +++ b/packages/messenger-widget/src/hooks/storage/useStorage.tsx @@ -94,13 +94,8 @@ export const useStorage = ( if (!storageApi) { throw Error('Storage not initialized'); } - /** - * Because the storage cannot handle concurrency properly - * we need to catch the error and retry if the message is not yet synced - */ - storageApi.editMessageBatch(contact, batch).catch((e) => { - console.log('message not sync yet'); - }); + + storageApi.editMessageBatch(contact, batch); }; const storeMessageAsync = ( @@ -134,11 +129,15 @@ export const useStorage = ( } storageApi.addConversation(contact); }; - const getMessages = async (contact: string, page: number) => { + const getMessages = async ( + contact: string, + pageSize: number, + offset: number, + ) => { if (!storageApi) { return Promise.resolve([]); } - return storageApi.getMessages(contact, page); + return storageApi.getMessages(contact, pageSize, offset); }; const getNumberOfMessages = async (contact: string) => { @@ -187,7 +186,8 @@ export type GetConversations = ( export type AddConversation = (contact: string) => void; export type GetMessages = ( contact: string, - page: number, + pageSize: number, + offset: number, ) => Promise; export type GetNumberOfMessages = (contact: string) => Promise; export type ToggleHideContactAsync = (contact: string, value: boolean) => void; diff --git a/packages/messenger-widget/src/interfaces/utils.ts b/packages/messenger-widget/src/interfaces/utils.ts index 2efd79704..d7f2c03d5 100644 --- a/packages/messenger-widget/src/interfaces/utils.ts +++ b/packages/messenger-widget/src/interfaces/utils.ts @@ -22,10 +22,8 @@ export interface IButton { export interface ContactPreview { name: string; - message: string | null; + message: string | undefined; image: string; - unreadMsgCount: number; - messageCount: number; contactDetails: Contact; isHidden: boolean; messageSizeLimit: number; @@ -50,13 +48,15 @@ export interface IAttachmentPreview { isImage: boolean; } -export const getEmptyContact = (ensName: string, isHidden: boolean = false) => { +export const getEmptyContact = ( + ensName: string, + message: string | undefined, + isHidden: boolean = false, +) => { const newContact: ContactPreview = { name: getAccountDisplayName(ensName, 25), - message: null, + message, image: humanIcon, - unreadMsgCount: 0, - messageCount: 0, contactDetails: { account: { ensName, diff --git a/packages/messenger-widget/src/views/LeftView/LeftView.tsx b/packages/messenger-widget/src/views/LeftView/LeftView.tsx index b44d308c7..708e32e68 100644 --- a/packages/messenger-widget/src/views/LeftView/LeftView.tsx +++ b/packages/messenger-widget/src/views/LeftView/LeftView.tsx @@ -72,7 +72,7 @@ export default function LeftView() { return (
-
- -
+ {selectedLeftView === LeftViewSelected.Menu && ( +
+ +
+ )}
); } diff --git a/yarn.lock b/yarn.lock index 8eedfd310..a5cedae86 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2440,6 +2440,7 @@ __metadata: react: ^18.2.0 react-app-rewired: ^2.2.1 react-dom: ^18.2.0 + react-infinite-scroll-component: ^6.1.0 react-scripts: 5.0.0 rimraf: ^5.0.5 socket.io-client: ^4.7.5 @@ -27037,6 +27038,17 @@ __metadata: languageName: node linkType: hard +"react-infinite-scroll-component@npm:^6.1.0": + version: 6.1.0 + resolution: "react-infinite-scroll-component@npm:6.1.0" + dependencies: + throttle-debounce: ^2.1.0 + peerDependencies: + react: ">=16.0.0" + checksum: 3708398934366df907dbad215247ebc1033221957ce7e32289ea31750cce70aa16513e2d03743e06c8b868ac7c542d12d5dbb6c830fd408433a4762f3cb5ecfb + languageName: node + linkType: hard + "react-is@npm:^16.13.1": version: 16.13.1 resolution: "react-is@npm:16.13.1" @@ -30322,6 +30334,13 @@ __metadata: languageName: node linkType: hard +"throttle-debounce@npm:^2.1.0": + version: 2.3.0 + resolution: "throttle-debounce@npm:2.3.0" + checksum: 6d90aa2ddb294f8dad13d854a1cfcd88fdb757469669a096a7da10f515ee466857ac1e750649cb9da931165c6f36feb448318e7cb92570f0a3679d20e860a925 + languageName: node + linkType: hard + "through2@npm:^2.0.1": version: 2.0.5 resolution: "through2@npm:2.0.5"