diff --git a/packages/backend/migrations/20240704093840_/migration.sql b/packages/backend/migrations/20240704093840_/migration.sql new file mode 100644 index 000000000..ec445e6c2 --- /dev/null +++ b/packages/backend/migrations/20240704093840_/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "HaltedMessage" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "encryptedEnvelopContainer" TEXT NOT NULL, + "encryptedContactName" TEXT NOT NULL, + "ownerId" TEXT NOT NULL, + + CONSTRAINT "HaltedMessage_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "HaltedMessage" ADD CONSTRAINT "HaltedMessage_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/backend/migrations/20240704094732_/migration.sql b/packages/backend/migrations/20240704094732_/migration.sql new file mode 100644 index 000000000..8691a3e66 --- /dev/null +++ b/packages/backend/migrations/20240704094732_/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `encryptedContactName` on the `HaltedMessage` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "HaltedMessage" DROP COLUMN "encryptedContactName"; diff --git a/packages/backend/migrations/20240704141910_/migration.sql b/packages/backend/migrations/20240704141910_/migration.sql new file mode 100644 index 000000000..10f1fc0b7 --- /dev/null +++ b/packages/backend/migrations/20240704141910_/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - You are about to drop the `HaltedMessage` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "HaltedMessage" DROP CONSTRAINT "HaltedMessage_ownerId_fkey"; + +-- AlterTable +ALTER TABLE "EncryptedMessage" ADD COLUMN "isHalted" BOOLEAN NOT NULL DEFAULT false; + +-- DropTable +DROP TABLE "HaltedMessage"; diff --git a/packages/backend/schema.prisma b/packages/backend/schema.prisma index 286829ed5..38bb48aaa 100644 --- a/packages/backend/schema.prisma +++ b/packages/backend/schema.prisma @@ -1,4 +1,6 @@ datasource db { + //Use this URL for local development + //url = "postgresql://prisma:prisma@localhost:5433/tests" url = env("DATABASE_URL") provider = "postgresql" } @@ -16,6 +18,7 @@ model EncryptedMessage { conversation Conversation @relation(fields: [conversationId], references: [id]) ownerId String owner Account @relation(fields: [ownerId], references: [id]) + isHalted Boolean @default(false) } model Conversation { diff --git a/packages/backend/schemas.sh b/packages/backend/schemas.sh index b44d34170..0391a7dfc 100644 --- a/packages/backend/schemas.sh +++ b/packages/backend/schemas.sh @@ -5,3 +5,5 @@ yarn ts-json-schema-generator -f tsconfig.json --path schema/storage/AddMessageB yarn ts-json-schema-generator -f tsconfig.json --path schema/storage/AddMessageRequest.ts --type _AddMessageRequest -o ./src/schema/storage/AddMessageRequest.schema.json --no-type-check \ yarn ts-json-schema-generator -f tsconfig.json --path schema/storage/PaginatedRequest.ts --type _PaginatedRequest -o ./src/schema/storage/PaginatedRequest.schema.json --no-type-check \ + +yarn ts-json-schema-generator -f tsconfig.json --path schema/storage/AddHaltedMessageRequest.ts --type _AddHaltedMessageRequest -o ./src/schema/storage/AddHaltedMessageRequest.schema.json --no-type-check \ diff --git a/packages/backend/src/persistence/getDatabase.ts b/packages/backend/src/persistence/getDatabase.ts index 55f19c99b..faa510a6b 100644 --- a/packages/backend/src/persistence/getDatabase.ts +++ b/packages/backend/src/persistence/getDatabase.ts @@ -6,8 +6,8 @@ import { createClient } from 'redis'; import Pending from './pending'; import Session from './session'; import Storage from './storage'; -import { MessageRecord } from './storage/postgres/dto/MessageRecord'; import { ConversationRecord } from './storage/postgres/dto/ConversationRecord'; +import { MessageRecord } from './storage/postgres/dto/MessageRecord'; export enum RedisPrefix { Conversation = 'conversation:', @@ -88,6 +88,10 @@ export async function getDatabase( getNumberOfConverations: Storage.getNumberOfConversations(prisma), //Storage Toggle Hide Conversation toggleHideConversation: Storage.toggleHideConversation(prisma), + //Storage Get Halted Messages + getHaltedMessages: Storage.getHaltedMessages(prisma), + //Storage Delete Halted Message + clearHaltedMessage: Storage.clearHaltedMessage(prisma), //Get the user db migration status getUserDbMigrationStatus: Storage.getUserDbMigrationStatus(redis), //Set the user db migration status to true @@ -145,6 +149,12 @@ export interface IDatabase extends ISessionDatabase { encryptedContactName: string, isHidden: boolean, ) => Promise; + getHaltedMessages: (ensName: string) => Promise; + clearHaltedMessage: ( + ensName: string, + aliasName: string, + messageId: string, + ) => Promise; getUserDbMigrationStatus: (ensName: string) => Promise; setUserDbMigrated: (ensName: string) => Promise; } diff --git a/packages/backend/src/persistence/storage/index.ts b/packages/backend/src/persistence/storage/index.ts index 6383776e4..3563533eb 100644 --- a/packages/backend/src/persistence/storage/index.ts +++ b/packages/backend/src/persistence/storage/index.ts @@ -11,6 +11,8 @@ import { toggleHideConversation } from './postgres/toggleHideConversation'; import { MessageRecord } from './postgres/dto/MessageRecord'; import { getUserDbMigrationStatus } from './getUserDbMigrationStatus'; import { setUserDbMigrated } from './setUserDbMigrated'; +import { getHaltedMessages } from './postgres/haltedMessage/getHaltedMessages'; +import { clearHaltedMessage } from './postgres/haltedMessage/clearHaltedMessage'; export default { getUserStorageOld, @@ -23,6 +25,8 @@ export default { getNumberOfConversations, getNumberOfMessages, toggleHideConversation, + getHaltedMessages, + clearHaltedMessage, getUserDbMigrationStatus, setUserDbMigrated, }; diff --git a/packages/backend/src/persistence/storage/postgres/addMessageBatch.ts b/packages/backend/src/persistence/storage/postgres/addMessageBatch.ts index 856abf32c..608383fae 100644 --- a/packages/backend/src/persistence/storage/postgres/addMessageBatch.ts +++ b/packages/backend/src/persistence/storage/postgres/addMessageBatch.ts @@ -21,7 +21,12 @@ export const addMessageBatch = ); //store each message in the db const createMessagePromises = messageBatch.map( - ({ messageId, createdAt, encryptedEnvelopContainer }) => { + ({ + messageId, + createdAt, + encryptedEnvelopContainer, + isHalted, + }) => { //The database stores the date as an ISO 8601 string. Hence we need to convert it to a Date object const createAtDate = new Date(createdAt); return db.encryptedMessage.create({ @@ -32,6 +37,7 @@ export const addMessageBatch = conversationId: conversation.id, encryptedContactName, encryptedEnvelopContainer, + isHalted, }, }); }, diff --git a/packages/backend/src/persistence/storage/postgres/dto/MessageRecord.ts b/packages/backend/src/persistence/storage/postgres/dto/MessageRecord.ts index 78913695b..d904a2d80 100644 --- a/packages/backend/src/persistence/storage/postgres/dto/MessageRecord.ts +++ b/packages/backend/src/persistence/storage/postgres/dto/MessageRecord.ts @@ -7,4 +7,6 @@ export type MessageRecord = { messageId: string; //The actual encrypted message encryptedEnvelopContainer: string; + //The message is halted if the message hasnot been delivered yet + isHalted: boolean; }; diff --git a/packages/backend/src/persistence/storage/postgres/editMessageBatch.ts b/packages/backend/src/persistence/storage/postgres/editMessageBatch.ts index bbec9ac11..3a63cb2c3 100644 --- a/packages/backend/src/persistence/storage/postgres/editMessageBatch.ts +++ b/packages/backend/src/persistence/storage/postgres/editMessageBatch.ts @@ -10,7 +10,6 @@ export const editMessageBatch = encryptedContactName: string, editMessageBatchPayload: MessageRecord[], ) => { - console.log('editMessageBatchPayload', editMessageBatchPayload); const account = await getOrCreateAccount(db, ensName); await Promise.all( diff --git a/packages/backend/src/persistence/storage/postgres/haltedMessage/clearHaltedMessage.test.ts b/packages/backend/src/persistence/storage/postgres/haltedMessage/clearHaltedMessage.test.ts new file mode 100644 index 000000000..4ee194c70 --- /dev/null +++ b/packages/backend/src/persistence/storage/postgres/haltedMessage/clearHaltedMessage.test.ts @@ -0,0 +1,116 @@ +import { PrismaClient } from '@prisma/client'; +import { clearHaltedMessage } from './clearHaltedMessage'; +import { getOrCreateAccount } from '../utils/getOrCreateAccount'; +import { addMessageBatch } from '../addMessageBatch'; + +// Mock the PrismaClient +const mockDb = new PrismaClient(); + +describe('deleteHaltedMessage', () => { + let prismaClient: PrismaClient; + + beforeEach(async () => { + prismaClient = new PrismaClient(); + }); + + afterEach(async () => { + await prismaClient.encryptedMessage.deleteMany({}); + await prismaClient.conversation.deleteMany({}); + await prismaClient.account.deleteMany({}); + + prismaClient.$disconnect(); + }); + it('should return false if account does not exist', async () => { + const result = await clearHaltedMessage(mockDb)( + 'bob.eth', + 'bob.eth', + 'messageId', + ); + expect(result).toBe(false); + }); + + it('should return false if message does not exist', async () => { + const result = await clearHaltedMessage(mockDb)( + 'existing', + 'existing', + 'messageId', + ); + expect(result).toBe(false); + }); + + it('should return true if message is successfully deleted', async () => { + const account = await getOrCreateAccount(prismaClient, 'bob.eth'); + //create message first + const messageRecord1 = { + messageId: 'messageId1', + createdAt: 123, + encryptedEnvelopContainer: 'encryptedEnvelopContainer', + isHalted: true, + }; + const messageRecord2 = { + messageId: 'messageId2', + createdAt: 456, + encryptedEnvelopContainer: 'encryptedEnvelopContainer', + isHalted: false, + }; + + await addMessageBatch(prismaClient)('bob.eth', 'alice.eth', [ + messageRecord1, + messageRecord2, + ]); + + const result = await clearHaltedMessage(mockDb)( + 'bob.eth', + 'bob.eth', + 'messageId1', + ); + expect(result).toBe(true); + }); + it('should rename to aliasName', async () => { + const account = await getOrCreateAccount(prismaClient, 'bob.eth'); + //create message first + const messageRecord1 = { + messageId: 'messageId1', + createdAt: 123, + encryptedEnvelopContainer: 'encryptedEnvelopContainer', + isHalted: true, + }; + const messageRecord2 = { + messageId: 'messageId2', + createdAt: 456, + encryptedEnvelopContainer: 'encryptedEnvelopContainer', + isHalted: false, + }; + + await addMessageBatch(prismaClient)('bob.eth', 'alice.eth', [ + messageRecord1, + messageRecord2, + ]); + + const beforeClearHaltedMessage = + await prismaClient.encryptedMessage.findFirst({ + where: { + id: 'messageId1', + }, + }); + + expect(beforeClearHaltedMessage?.encryptedContactName).toBe( + 'alice.eth', + ); + + const result = await clearHaltedMessage(mockDb)( + 'bob.eth', + '0x123.addr.dm3.eth', + 'messageId1', + ); + + const message = await prismaClient.encryptedMessage.findFirst({ + where: { + id: 'messageId1', + }, + }); + + expect(message?.encryptedContactName).toBe('0x123.addr.dm3.eth'); + expect(result).toBe(true); + }); +}); diff --git a/packages/backend/src/persistence/storage/postgres/haltedMessage/clearHaltedMessage.ts b/packages/backend/src/persistence/storage/postgres/haltedMessage/clearHaltedMessage.ts new file mode 100644 index 000000000..1abf02eb5 --- /dev/null +++ b/packages/backend/src/persistence/storage/postgres/haltedMessage/clearHaltedMessage.ts @@ -0,0 +1,63 @@ +import { PrismaClient } from '@prisma/client'; + +export const clearHaltedMessage = + (db: PrismaClient) => + /** + * + * @param ensName the ensName the messages have been stored originally i.E alice.eth + * @param aliasName the aliasNamespace the cleint uses after resolving the ensName i.E Alice. ie addr.user.dm3.eth + * @param messageId the messageId + * @returns + */ + async (ensName: string, aliasName: string, messageId: string) => { + //Find the account first we want to get the messages for + const account = await db.account.findFirst({ + where: { + id: ensName, + }, + }); + //If the contact does not exist, there is no message that can be deleted + if (!account) { + console.log('cleatHaltedMessages: account not found'); + return false; + } + + const message = await db.encryptedMessage.findFirst({ + where: { + id: messageId, + ownerId: account.id, + }, + }); + + if (!message) { + console.log(`cleatHaltedMessages: message ${messageId} not found`); + return false; + } + + try { + await db.encryptedMessage.update({ + where: { + id: messageId, + }, + data: { + //Message is no longer halted + isHalted: false, + //Use alias name + encryptedContactName: aliasName, + }, + }); + + await db.conversation.update({ + where: { + id: message.conversationId, + }, + data: { + encryptedContactName: aliasName, + }, + }); + return true; + } catch (e) { + console.error('clear halted error', e); + return false; + } + }; diff --git a/packages/backend/src/persistence/storage/postgres/haltedMessage/getHaltedMessages.test.ts b/packages/backend/src/persistence/storage/postgres/haltedMessage/getHaltedMessages.test.ts new file mode 100644 index 000000000..8e8e64131 --- /dev/null +++ b/packages/backend/src/persistence/storage/postgres/haltedMessage/getHaltedMessages.test.ts @@ -0,0 +1,97 @@ +import { PrismaClient } from '@prisma/client'; +import { getHaltedMessages } from './getHaltedMessages'; +import { getOrCreateAccount } from '../utils/getOrCreateAccount'; +import { addMessageBatch } from '../addMessageBatch'; +import { clearHaltedMessage } from './clearHaltedMessage'; + +describe('getHaltedMessages', () => { + let prismaClient: PrismaClient; + + beforeEach(async () => { + prismaClient = new PrismaClient(); + }); + + afterEach(async () => { + await prismaClient.encryptedMessage.deleteMany({}); + await prismaClient.conversation.deleteMany({}); + await prismaClient.account.deleteMany({}); + prismaClient.$disconnect(); + }); + + it('should get halted messages for a given account', async () => { + const ensName = 'test'; + const messageRecord1 = { + messageId: 'messageId1', + createdAt: 123, + encryptedEnvelopContainer: 'encrypted', + isHalted: true, + }; + + const messageRecord2 = { + messageId: 'messageId2', + createdAt: 456, + encryptedEnvelopContainer: 'encryptedEnvelopContainer', + isHalted: false, + }; + + await getOrCreateAccount(prismaClient, ensName); + await addMessageBatch(prismaClient)(ensName, 'alice.eth', [ + messageRecord1, + messageRecord2, + ]); + + const haltedMessages = await getHaltedMessages(prismaClient)(ensName); + + expect(haltedMessages).toHaveLength(1); + expect(haltedMessages[0]).toEqual({ + messageId: messageRecord1.messageId, + createdAt: new Date(123), + isHalted: true, + encryptedEnvelopContainer: messageRecord1.encryptedEnvelopContainer, + }); + }); + it('should not return messages that has been cleared', async () => { + const ensName = 'test'; + const messageRecord1 = { + messageId: '1', + createdAt: 123, + encryptedEnvelopContainer: 'encrypted', + isHalted: true, + }; + + const messageRecord2 = { + messageId: 'messageId2', + createdAt: 456, + encryptedEnvelopContainer: 'encryptedEnvelopContainer', + isHalted: false, + }; + + await getOrCreateAccount(prismaClient, ensName); + await addMessageBatch(prismaClient)(ensName, 'alice.eth', [ + messageRecord1, + messageRecord2, + ]); + + const haltedMessages = await getHaltedMessages(prismaClient)(ensName); + console.log(haltedMessages); + + expect(haltedMessages).toHaveLength(1); + expect(haltedMessages[0]).toEqual({ + messageId: messageRecord1.messageId, + createdAt: new Date(123), + isHalted: true, + encryptedEnvelopContainer: messageRecord1.encryptedEnvelopContainer, + }); + + await clearHaltedMessage(prismaClient)( + ensName, + ensName, + messageRecord1.messageId, + ); + + const haltedMessagesAfterClear = await getHaltedMessages(prismaClient)( + ensName, + ); + expect(haltedMessagesAfterClear).toHaveLength(0); + }); +}); diff --git a/packages/backend/src/persistence/storage/postgres/haltedMessage/getHaltedMessages.ts b/packages/backend/src/persistence/storage/postgres/haltedMessage/getHaltedMessages.ts new file mode 100644 index 000000000..b643b0311 --- /dev/null +++ b/packages/backend/src/persistence/storage/postgres/haltedMessage/getHaltedMessages.ts @@ -0,0 +1,34 @@ +import { PrismaClient } from '@prisma/client'; + +export const getHaltedMessages = + (db: PrismaClient) => async (ensName: string) => { + //Find the account first we want to get the messages for + const account = await db.account.findFirst({ + where: { + id: ensName, + }, + }); + + //If the contact does not exist, return an empty array + if (!account) { + return []; + } + + const messageRecord = await db.encryptedMessage.findMany({ + where: { + ownerId: account.id, + isHalted: true, + }, + }); + + if (messageRecord.length === 0) { + return []; + } + + return messageRecord.map((message: any) => ({ + messageId: message.id, + encryptedEnvelopContainer: message.encryptedEnvelopContainer, + createdAt: message.createdAt, + isHalted: message.isHalted, + })); + }; diff --git a/packages/backend/src/schema/storage/AddHaltedMessageRequest.schema.json b/packages/backend/src/schema/storage/AddHaltedMessageRequest.schema.json new file mode 100644 index 000000000..e96d4382b --- /dev/null +++ b/packages/backend/src/schema/storage/AddHaltedMessageRequest.schema.json @@ -0,0 +1,26 @@ +{ + "$ref": "#/definitions/_AddHaltedMessageRequest", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "_AddHaltedMessageRequest": { + "additionalProperties": false, + "properties": { + "createdAt": { + "type": "number" + }, + "encryptedEnvelopContainer": { + "type": "string" + }, + "messageId": { + "type": "string" + } + }, + "required": [ + "messageId", + "createdAt", + "encryptedEnvelopContainer" + ], + "type": "object" + } + } +} \ No newline at end of file diff --git a/packages/backend/src/schema/storage/AddHaltedMessageSchema.ts b/packages/backend/src/schema/storage/AddHaltedMessageSchema.ts new file mode 100644 index 000000000..1bf618c5c --- /dev/null +++ b/packages/backend/src/schema/storage/AddHaltedMessageSchema.ts @@ -0,0 +1,9 @@ +import AddHaltedMessageRequestSchema from './AddHaltedMessageRequest.schema.json'; + +//This schema defines how the body of the AddHaltedMessage request has to look like +export interface _AddHaltedMessageRequest { + messageId: string; + createdAt: number; + encryptedEnvelopContainer: string; +} +export const AddHaltedMessageRequest = AddHaltedMessageRequestSchema; diff --git a/packages/backend/src/schema/storage/AddMessageBatchRequest.schema.json b/packages/backend/src/schema/storage/AddMessageBatchRequest.schema.json index 153b4f606..abd31670b 100644 --- a/packages/backend/src/schema/storage/AddMessageBatchRequest.schema.json +++ b/packages/backend/src/schema/storage/AddMessageBatchRequest.schema.json @@ -11,6 +11,9 @@ "encryptedEnvelopContainer": { "type": "string" }, + "isHalted": { + "type": "boolean" + }, "messageId": { "type": "string" } @@ -18,7 +21,8 @@ "required": [ "createdAt", "messageId", - "encryptedEnvelopContainer" + "encryptedEnvelopContainer", + "isHalted" ], "type": "object" }, diff --git a/packages/backend/src/schema/storage/AddMessageRequest.schema.json b/packages/backend/src/schema/storage/AddMessageRequest.schema.json index 1a923e698..b4474cdc8 100644 --- a/packages/backend/src/schema/storage/AddMessageRequest.schema.json +++ b/packages/backend/src/schema/storage/AddMessageRequest.schema.json @@ -14,6 +14,9 @@ "encryptedEnvelopContainer": { "type": "string" }, + "isHalted": { + "type": "boolean" + }, "messageId": { "type": "string" } @@ -22,7 +25,8 @@ "encryptedEnvelopContainer", "encryptedContactName", "messageId", - "createdAt" + "createdAt", + "isHalted" ], "type": "object" } diff --git a/packages/backend/src/schema/storage/AddMesssageRequest.ts b/packages/backend/src/schema/storage/AddMesssageRequest.ts index e678a8ef9..73c046bbf 100644 --- a/packages/backend/src/schema/storage/AddMesssageRequest.ts +++ b/packages/backend/src/schema/storage/AddMesssageRequest.ts @@ -10,6 +10,8 @@ export interface _AddMessageRequest { messageId: string; //The time the message was created, also defined by the client createdAt: number; + //The message is halted if the message has not been delivered yet + isHalted: boolean; } export const AddMessageRequest = AddMessageRequestSchema.definitions._AddMessageRequest; diff --git a/packages/backend/src/schema/storage/EditMessageBatchRequest.schema.json b/packages/backend/src/schema/storage/EditMessageBatchRequest.schema.json index 906133877..c5c786128 100644 --- a/packages/backend/src/schema/storage/EditMessageBatchRequest.schema.json +++ b/packages/backend/src/schema/storage/EditMessageBatchRequest.schema.json @@ -11,6 +11,9 @@ "encryptedEnvelopContainer": { "type": "string" }, + "isHalted": { + "type": "boolean" + }, "messageId": { "type": "string" } @@ -18,7 +21,8 @@ "required": [ "createdAt", "messageId", - "encryptedEnvelopContainer" + "encryptedEnvelopContainer", + "isHalted" ], "type": "object" }, diff --git a/packages/backend/src/storage.test.ts b/packages/backend/src/storage.test.ts index a2c9d0c6a..5f99e60ab 100644 --- a/packages/backend/src/storage.test.ts +++ b/packages/backend/src/storage.test.ts @@ -456,6 +456,7 @@ describe('Storage', () => { encryptedContactName: sha256(receiver.account.ensName), messageId: '123', createdAt: 0, + isHalted: false, }); await request(app) .post(`/new/bob.eth/addMessage`) @@ -467,6 +468,7 @@ describe('Storage', () => { encryptedContactName: sha256(receiver.account.ensName), messageId: '456', createdAt: 1, + isHalted: false, }); await request(app) .post(`/new/bob.eth/addMessage`) @@ -478,6 +480,7 @@ describe('Storage', () => { encryptedContactName: sha256(receiver.account.ensName), messageId: '789', createdAt: 2, + isHalted: false, }); const { body } = await request(app) @@ -487,8 +490,6 @@ describe('Storage', () => { }) .send(); - console.log(body); - expect(body.length).toBe(1); expect(body[0].contact).toEqual(sha256(receiver.account.ensName)); expect(JSON.parse(body[0].previewMessage)).toEqual(envelop3); @@ -505,8 +506,6 @@ describe('Storage', () => { }) .send(); - console.log(body); - expect(status).toBe(400); }); @@ -519,8 +518,6 @@ describe('Storage', () => { }) .send(); - console.log(body); - expect(status).toBe(400); }); }); @@ -621,6 +618,7 @@ describe('Storage', () => { encryptedContactName: sha256(receiver.account.ensName), messageId: '123', createdAt: 1, + isHalted: false, }); expect(status).toBe(200); @@ -674,6 +672,7 @@ describe('Storage', () => { encryptedContactName: sha256(receiver.account.ensName), messageId: sha256('bob.eth' + '123'), createdAt: 2, + isHalted: false, }); const tokenAlice = generateAuthJWT('alice.eth', serverSecret); @@ -688,6 +687,7 @@ describe('Storage', () => { encryptedContactName: sha256(sender.account.ensName), messageId: sha256('alice.eth' + '123'), createdAt: 2, + isHalted: false, }); const { body: bobConversations } = await request(app) @@ -793,6 +793,7 @@ describe('Storage', () => { encryptedContactName: 'alice.eth', messageId: sha256('alice.eth' + '123'), createdAt: 1, + isHalted: false, }); const { body: bobConversations } = await request(app) @@ -836,6 +837,7 @@ describe('Storage', () => { encryptedContactName: sha256(receiver.account.ensName), messageId: '123', createdAt: 2, + isHalted: false, }); expect(status).toBe(200); @@ -889,6 +891,7 @@ describe('Storage', () => { encryptedContactName: sha256(receiver.account.ensName), messageId: '123', createdAt: 1, + isHalted: false, }); await request(app) .post(`/new/bob.eth/addMessage`) @@ -900,6 +903,7 @@ describe('Storage', () => { encryptedContactName: sha256(receiver.account.ensName), messageId: '456', createdAt: 2, + isHalted: false, }); const { status } = await request(app) @@ -912,6 +916,7 @@ describe('Storage', () => { encryptedContactName: sha256(receiver.account.ensName), messageId: '123', createdAt: 3, + isHalted: false, }); expect(status).toBe(400); @@ -950,6 +955,71 @@ describe('Storage', () => { ).toStrictEqual(envelop); }); }); + describe('halted Messages', () => { + it('clears halted message', async () => { + const messageFactory = MockMessageFactory( + sender, + receiver, + deliveryService, + ); + const envelop1 = await messageFactory.createEncryptedEnvelop( + 'Hello1', + ); + + const { status } = await request(app) + .post(`/new/bob.eth/addMessage`) + .set({ + authorization: 'Bearer ' + token, + }) + .send({ + encryptedEnvelopContainer: JSON.stringify(envelop1), + encryptedContactName: sha256(receiver.account.ensName), + messageId: envelop1.metadata.encryptedMessageHash, + createdAt: 1, + isHalted: true, + }); + expect(status).toBe(200); + + const { status: getMessagesStatus, body: messages } = await request( + app, + ) + .get(`/new/bob.eth/getHaltedMessages/`) + .set({ + authorization: 'Bearer ' + token, + }) + .send(); + + expect(getMessagesStatus).toBe(200); + expect(messages.length).toBe(1); + expect( + JSON.parse(messages[0].encryptedEnvelopContainer), + ).toStrictEqual(envelop1); + + const { status: deleteStatus } = await request(app) + .post(`/new/bob.eth/clearHaltedMessage/`) + .set({ + authorization: 'Bearer ' + token, + }) + .send({ + messageId: messages[0].messageId, + }); + + expect(deleteStatus).toBe(200); + + const { + status: getMessagesStatusAfterDelete, + body: messagesAfterDelete, + } = await request(app) + .get(`/new/bob.eth/getHaltedMessages/`) + .set({ + authorization: 'Bearer ' + token, + }) + .send(); + + expect(getMessagesStatusAfterDelete).toBe(200); + expect(messagesAfterDelete.length).toBe(0); + }); + }); describe('addMessageBatch', () => { describe('schema', () => { it('should return 400 if encryptedContactName is missing', async () => { @@ -1020,11 +1090,13 @@ describe('Storage', () => { encryptedEnvelopContainer: JSON.stringify(envelop), messageId: '123', createdAt: 1, + isHalted: false, }, { encryptedEnvelopContainer: JSON.stringify(envelop), messageId: '456', createdAt: 2, + isHalted: false, }, ], }); @@ -1071,7 +1143,7 @@ describe('Storage', () => { const envelop = await messageFactory.createEncryptedEnvelop( 'Hello1', ); - await request(app) + const x = await request(app) .post(`/new/bob.eth/addMessage`) .set({ authorization: 'Bearer ' + token, @@ -1081,6 +1153,7 @@ describe('Storage', () => { encryptedContactName: sha256(receiver.account.ensName), messageId: '123', createdAt: 1, + isHalted: false, }); await request(app) @@ -1093,6 +1166,7 @@ describe('Storage', () => { encryptedContactName: sha256(receiver.account.ensName), messageId: '456', createdAt: 2, + isHalted: false, }); const { status: addDuplicateStatus } = await request(app) @@ -1105,6 +1179,7 @@ describe('Storage', () => { encryptedContactName: sha256(receiver.account.ensName), messageId: '123', createdAt: 3, + isHalted: false, }); const { status, body } = await request(app) @@ -1176,6 +1251,7 @@ describe('Storage', () => { messageId: 'testMessageId', encryptedEnvelopContainer: 'testEncryptedEnvelopContainer', + isHalted: false, }, ], }; @@ -1224,6 +1300,7 @@ describe('Storage', () => { createdAt: 123, messageId: 'testMessageId', encryptedEnvelopContainer: 'testEncryptedEnvelopContainer', + isHalted: false, }, ]; @@ -1260,6 +1337,7 @@ describe('Storage', () => { createdAt: 123, messageId: 'testMessageId', encryptedEnvelopContainer: 'testEncryptedEnvelopContainer', + isHalted: false, }, ]; const { status } = await request(app) @@ -1272,6 +1350,7 @@ describe('Storage', () => { encryptedContactName: sha256(receiver.account.ensName), messageId: '123', createdAt: 123456, + isHalted: false, }); expect(status).toBe(200); @@ -1280,6 +1359,7 @@ describe('Storage', () => { createdAt: 123, messageId: 'testMessageId', encryptedEnvelopContainer: 'NEW ENVELOP', + isHalted: false, }, ]; diff --git a/packages/backend/src/storage.ts b/packages/backend/src/storage.ts index 12e3303ad..2c4a122d5 100644 --- a/packages/backend/src/storage.ts +++ b/packages/backend/src/storage.ts @@ -10,6 +10,8 @@ import { AddMessageBatchRequest } from './schema/storage/AddMessageBatchRequest' import { AddMessageRequest } from './schema/storage/AddMesssageRequest'; import { EditMessageBatchRequest } from './schema/storage/EditMessageBatchRequest'; import { PaginatedRequest } from './schema/storage/PaginatedRequest'; +import { AddHaltedMessageRequest } from './schema/storage/AddHaltedMessageSchema'; +import { MessageRecord } from './persistence/storage'; const DEFAULT_CONVERSATION_PAGE_SIZE = 10; const DEFAULT_MESSAGE_PAGE_SIZE = 100; @@ -72,6 +74,7 @@ export default ( encryptedContactName, messageId, createdAt, + isHalted, } = req.body; const schemaIsValid = validateSchema(AddMessageRequest, req.body); @@ -94,6 +97,7 @@ export default ( messageId: uniqueMessageId, encryptedEnvelopContainer, createdAt, + isHalted, }, ], ); @@ -123,11 +127,12 @@ export default ( await db.addMessageBatch( ensName, encryptedContactName, - messageBatch.map((message: any) => ({ + messageBatch.map((message: MessageRecord) => ({ messageId: getUniqueMessageId(message.messageId), createdAt: message.createdAt, encryptedEnvelopContainer: message.encryptedEnvelopContainer, + isHalted: message.isHalted, })), ); return res.send(); @@ -254,6 +259,45 @@ export default ( }, ); + router.get('/new/:ensName/getHaltedMessages', async (req, res, next) => { + try { + const ensName = normalizeEnsName(req.params.ensName); + const messages = await db.getHaltedMessages(ensName); + return res.json(messages); + } catch (err) { + next(err); + } + }); + + router.post('/new/:ensName/clearHaltedMessage', async (req, res, next) => { + try { + const { messageId, aliasName } = req.body; + + if (!messageId) { + res.status(400).send('invalid schema'); + return; + } + + const ensName = normalizeEnsName(req.params.ensName); + + const success = await db.clearHaltedMessage( + ensName, + //If the aliasName is not provided, we use the ensName as the client has no intention to use an alias + aliasName, + messageId, + ); + + if (success) { + return res.send(); + } + res.status(400).send('unable to clear halted message'); + } catch (err) { + console.error('clearHaltedMessage error'); + console.error(err); + next(err); + } + }); + router.post( '/new/:ensName/toggleHideConversation', async (req, res, next) => { diff --git a/packages/lib/shared/src/IBackendConnector.ts b/packages/lib/shared/src/IBackendConnector.ts index 61b7bbca5..d62627860 100644 --- a/packages/lib/shared/src/IBackendConnector.ts +++ b/packages/lib/shared/src/IBackendConnector.ts @@ -22,12 +22,19 @@ export interface IBackendConnector { pageSize: number, offset: number, ): Promise; + getHaltedMessages: (ensName: string) => Promise; + clearHaltedMessages: ( + ensName: string, + messageId: string, + aliasName: string, + ) => Promise; addMessage( ensName: string, encryptedContactName: string, messageId: string, createdAt: number, encryptedEnvelopContainer: string, + isHalted: boolean, ): Promise; addMessageBatch( ensName: string, diff --git a/packages/lib/storage/src/migrate-storage.test.ts b/packages/lib/storage/src/migrate-storage.test.ts deleted file mode 100644 index 6f01b30d5..000000000 --- a/packages/lib/storage/src/migrate-storage.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { - createStorageKey, - getStorageKeyCreationMessage, -} from '@dm3-org/dm3-lib-crypto'; -import { createProfileKeys } from '@dm3-org/dm3-lib-profile'; -import { ethers } from 'ethers'; -import { StorageEnvelopContainer } from './new/types'; -import { Message, Envelop, MessageState } from '@dm3-org/dm3-lib-messaging'; -import { StorageAPI } from '../dist'; -import { Conversation } from './new/types'; -import { migrageStorage } from './new/migrateStorage'; -import { createDB } from './Storage'; - -const USER_1 = 'alice.eth'; -const USER_2 = 'bob.eth'; - -const getMockProfileKeys = async () => { - const nonce = '0'; - const wallet = new ethers.Wallet( - '0xac58f2f021d6f148fd621b355edbd0ebadcf9682019015ef1219cf9c0c2ddc8b', - ); - - const nonceMsg = getStorageKeyCreationMessage(nonce, wallet.address); - const signedMessage = await wallet.signMessage(nonceMsg); - - return await createProfileKeys( - await createStorageKey(signedMessage), - nonce, - ); -}; -const getStorageEnvelopeContainer = (msg: string, timestamp: number = 0) => { - const message: Message = { - metadata: { - to: '', - from: USER_1, - timestamp, - type: 'NEW', - }, - message: msg, - signature: '', - }; - - const envelop: Envelop = { - message, - metadata: { - deliveryInformation: { - from: '', - to: '', - deliveryInstruction: '', - }, - encryptedMessageHash: '', - version: '', - encryptionScheme: '', - signature: '', - }, - }; - - return { - messageState: MessageState.Created, - envelop: envelop, - deliveryServiceIncommingTimestamp: 123, - } as StorageEnvelopContainer; -}; -describe('MigrateStorage', () => { - let newStorage: StorageAPI; - - beforeEach(() => { - //Mock newStorage - newStorage = (() => { - const conversations = new Map(); - - return { - getConversations: async (page: number) => - Array.from(conversations.keys()).map((contactEnsName) => ({ - contactEnsName, - isHidden: false, - previewMessage: undefined, - updatedAt: 0, - })), - getMessages: async (contactEnsName: string, page: number) => [], - addMessageBatch: async ( - contactEnsName: string, - batch: StorageEnvelopContainer[], - ) => { - conversations.set(contactEnsName, [ - ...(conversations.get(contactEnsName) ?? []), - ...batch, - ]); - return ''; - }, - editMessageBatch: async ( - contactEnsName: string, - editedMessage: StorageEnvelopContainer[], - ) => {}, - getNumberOfMessages: async (contactEnsName: string) => 0, - getNumberOfConverations: async () => 0, - addConversation: async (contactEnsName: string) => { - conversations.set(contactEnsName, []); - }, - addMessage: async ( - contactEnsName: string, - envelop: StorageEnvelopContainer, - ) => '', - toggleHideConversation: async ( - contactEnsName: string, - isHidden: boolean, - ) => {}, - }; - })(); - }); - it('should migrate storage', async () => { - const profileKeys = await getMockProfileKeys(); - const db = createDB(profileKeys); - db.conversations.set(USER_1, [ - getStorageEnvelopeContainer('hello', 1), - getStorageEnvelopeContainer('dm3', 2), - ]); - db.conversations.set(USER_2, [ - getStorageEnvelopeContainer('123', 1), - getStorageEnvelopeContainer('456', 2), - ]); - - const tldResolver = async (ensName: string) => { - return ensName.replace('.eth', '.addr.user.dm3.eth'); - }; - - await migrageStorage(db, newStorage, tldResolver); - - const newConversations = await newStorage.getConversations(100, 0); - 0.45; - expect(newConversations.length).toBe(2); - expect(newConversations[0].contactEnsName).toBe( - 'alice.addr.user.dm3.eth', - ); - expect(newConversations[1].contactEnsName).toBe( - 'bob.addr.user.dm3.eth', - ); - }); - it('resolve tld names', async () => { - const profileKeys = await getMockProfileKeys(); - const db = createDB(profileKeys); - db.conversations.set(USER_1, [ - getStorageEnvelopeContainer('hello', 1), - getStorageEnvelopeContainer('dm3', 2), - ]); - db.conversations.set('foo.addr.user.dm3.gno', [ - getStorageEnvelopeContainer('123', 1), - getStorageEnvelopeContainer('456', 2), - ]); - - const tldResolver = async (ensName: string) => { - return ensName.replace('.eth', '.addr.user.dm3.eth'); - }; - - await migrageStorage(db, newStorage, tldResolver); - - const newConversations = await newStorage.getConversations(100, 0); - 0.45; - expect(newConversations.length).toBe(2); - expect(newConversations[0].contactEnsName).toBe( - 'alice.addr.user.dm3.eth', - ); - expect(newConversations[1].contactEnsName).toBe( - 'foo.addr.user.dm3.gno', - ); - }); -}); diff --git a/packages/lib/storage/src/new/cloudStorage/getCloudStorage.ts b/packages/lib/storage/src/new/cloudStorage/getCloudStorage.ts index 5d5b85184..d4cfa3834 100644 --- a/packages/lib/storage/src/new/cloudStorage/getCloudStorage.ts +++ b/packages/lib/storage/src/new/cloudStorage/getCloudStorage.ts @@ -75,10 +75,37 @@ export const getCloudStorage = ( return decryptedMessageRecords as StorageEnvelopContainer[]; }; + const getHaltedMessages = async () => { + const messages = await backendConnector.getHaltedMessages(ensName); + const decryptedMessages = await Promise.all( + messages.map(async (message: MessageRecord) => { + const decryptedEnvelopContainer = await encryption.decryptAsync( + message.encryptedEnvelopContainer, + ); + + return JSON.parse(decryptedEnvelopContainer); + }), + ); + + return decryptedMessages as StorageEnvelopContainer[]; + }; + + const clearHaltedMessages = async ( + messageId: string, + aliasName: string, + ) => { + const encryptedAliasName = await encryption.encryptSync(aliasName); + await backendConnector.clearHaltedMessages( + ensName, + messageId, + encryptedAliasName, + ); + }; const _addMessage = async ( contactEnsName: string, envelop: StorageEnvelopContainer, + isHalted: boolean, ) => { const encryptedContactName = await encryption.encryptSync( contactEnsName, @@ -97,6 +124,7 @@ export const getCloudStorage = ( envelop.envelop.id, createdAt, encryptedEnvelopContainer, + isHalted, ); return ''; @@ -161,6 +189,7 @@ export const getCloudStorage = ( storageEnvelopContainer.envelop.metadata ?.encryptedMessageHash!, createdAt, + isHalted: false, }; }, ), @@ -208,6 +237,8 @@ export const getCloudStorage = ( addMessage: _addMessage, addMessageBatch: _addMessageBatch, editMessageBatch: _editMessageBatch, + getHaltedMessages, + clearHaltedMessages, getNumberOfMessages: _getNumberOfMessages, getNumberOfConverations: _getNumberOfConversations, toggleHideConversation: _toggleHideConversation, diff --git a/packages/lib/storage/src/new/types.ts b/packages/lib/storage/src/new/types.ts index bfd9c6386..5c7401267 100644 --- a/packages/lib/storage/src/new/types.ts +++ b/packages/lib/storage/src/new/types.ts @@ -7,6 +7,11 @@ export interface StorageAPI { pageSize: number, offset: number, ) => Promise; + getHaltedMessages: () => Promise; + clearHaltedMessages: ( + messageId: string, + aliasName: string, + ) => Promise; addMessageBatch: ( contactEnsName: string, batch: StorageEnvelopContainer[], @@ -21,6 +26,7 @@ export interface StorageAPI { addMessage: ( contactEnsName: string, envelop: StorageEnvelopContainer, + ishalted: boolean, ) => Promise; toggleHideConversation: ( contactEnsName: string, diff --git a/packages/messenger-widget/src/context/BackendContext.tsx b/packages/messenger-widget/src/context/BackendContext.tsx index b41399d33..8b0d67d06 100644 --- a/packages/messenger-widget/src/context/BackendContext.tsx +++ b/packages/messenger-widget/src/context/BackendContext.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useBackend } from '../hooks/server-side/useBackend'; +import { MessageRecord } from '@dm3-org/dm3-lib-storage'; export type BackendContextType = { isInitialized: boolean; @@ -26,12 +27,19 @@ export type BackendContextType = { pageSize: number, offset: number, ) => Promise; + getHaltedMessages: (ensName: string) => Promise; + clearHaltedMessages: ( + ensName: string, + aliasName: string, + messageId: string, + ) => Promise; addMessage: ( ensName: string, encryptedContactName: string, messageId: string, createdAt: number, encryptedEnvelopContainer: string, + isHalted: boolean, ) => Promise; addMessageBatch: ( ensName: string, @@ -56,6 +64,8 @@ export const BackendContext = React.createContext({ getConversations: async () => [], toggleHideConversation: () => {}, getMessagesFromStorage: async () => [], + getHaltedMessages: async (ensName: string) => [], + clearHaltedMessages: async () => {}, addMessage: async () => {}, addMessageBatch: () => {}, editMessageBatch: () => {}, @@ -70,6 +80,8 @@ export const BackendContextProvider = ({ children }: { children?: any }) => { getConversations, toggleHideConversation, getMessagesFromStorage, + getHaltedMessages, + clearHaltedMessages, addMessage, addMessageBatch, editMessageBatch, @@ -85,6 +97,8 @@ export const BackendContextProvider = ({ children }: { children?: any }) => { getConversations, toggleHideConversation, getMessagesFromStorage, + getHaltedMessages, + clearHaltedMessages, addMessage, addMessageBatch, editMessageBatch, diff --git a/packages/messenger-widget/src/context/StorageContext.tsx b/packages/messenger-widget/src/context/StorageContext.tsx index a2bf8d254..69ae2603b 100644 --- a/packages/messenger-widget/src/context/StorageContext.tsx +++ b/packages/messenger-widget/src/context/StorageContext.tsx @@ -2,7 +2,9 @@ import { StorageEnvelopContainer } from '@dm3-org/dm3-lib-storage'; import React, { useContext } from 'react'; import { AddConversation, + ClearHaltedMessages, GetConversations, + GetHaltedMessages, GetMessages, GetNumberOfMessages, StoreMessageAsync, @@ -22,6 +24,8 @@ export type StorageContextType = { addConversationAsync: AddConversation; getNumberOfMessages: GetNumberOfMessages; getMessages: GetMessages; + getHaltedMessages: GetHaltedMessages; + clearHaltedMessages: ClearHaltedMessages; toggleHideContactAsync: ToggleHideContactAsync; initialized: boolean; }; @@ -30,6 +34,7 @@ export const StorageContext = React.createContext({ storeMessage: async ( contact: string, envelop: StorageEnvelopContainer, + isHalted?: boolean, ) => {}, storeMessageBatch: async ( contact: string, @@ -44,6 +49,9 @@ export const StorageContext = React.createContext({ addConversationAsync: (contact: string) => {}, getMessages: async (contact: string, pageSize: number, offset: number) => Promise.resolve([]), + getHaltedMessages: async () => Promise.resolve([]), + clearHaltedMessages: async (messageId: string, aliasName: string) => + Promise.resolve(), getNumberOfMessages: async (contact: string) => Promise.resolve(0), toggleHideContactAsync: async (contact: string, value: boolean) => {}, initialized: false, @@ -61,6 +69,8 @@ export const StorageContextProvider = ({ children }: { children?: any }) => { addConversationAsync, getNumberOfMessages, getMessages, + getHaltedMessages, + clearHaltedMessages, toggleHideContactAsync, initialized, } = useStorage(account, backendContext, profileKeys); @@ -74,6 +84,8 @@ export const StorageContextProvider = ({ children }: { children?: any }) => { addConversationAsync, getNumberOfMessages, getMessages, + getHaltedMessages, + clearHaltedMessages, toggleHideContactAsync, initialized, }} diff --git a/packages/messenger-widget/src/context/testHelper/getMockedStorageContext.ts b/packages/messenger-widget/src/context/testHelper/getMockedStorageContext.ts index e7516cd48..00d59b749 100644 --- a/packages/messenger-widget/src/context/testHelper/getMockedStorageContext.ts +++ b/packages/messenger-widget/src/context/testHelper/getMockedStorageContext.ts @@ -56,6 +56,15 @@ export const getMockedStorageContext = ( ): Promise { throw new Error('Function not implemented.'); }, + getHaltedMessages: function (): Promise { + throw new Error('Function not implemented.'); + }, + clearHaltedMessages: function ( + messageId: string, + aliasName: string, + ): Promise { + throw new Error('Function not implemented.'); + }, toggleHideContactAsync: function ( contact: string, value: boolean, diff --git a/packages/messenger-widget/src/hooks/conversation/hydrateContact.ts b/packages/messenger-widget/src/hooks/conversation/hydrateContact.ts index 582a59ae5..34d0b58d6 100644 --- a/packages/messenger-widget/src/hooks/conversation/hydrateContact.ts +++ b/packages/messenger-widget/src/hooks/conversation/hydrateContact.ts @@ -1,15 +1,13 @@ import { Account, - DeliveryServiceProfile, - getDeliveryServiceProfile, getUserProfile, normalizeEnsName, } from '@dm3-org/dm3-lib-profile'; import { Conversation } from '@dm3-org/dm3-lib-storage/dist/new/types'; -import axios from 'axios'; import { ethers } from 'ethers'; import { Contact } from '../../interfaces/context'; import { ContactPreview } from '../../interfaces/utils'; +import { fetchDsProfiles } from '../../utils/deliveryService/fetchDsProfiles'; import { getAvatarProfilePic } from '../../utils/ens-utils'; import { fetchMessageSizeLimit } from '../messages/sizeLimit/fetchSizeLimit'; @@ -22,7 +20,7 @@ export const hydrateContract = async ( //If the profile property of the account is defined the user has already used DM3 previously 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( @@ -90,41 +88,3 @@ const _fetchAccount = async ( }; } }; - -const _fetchDsProfiles = async ( - provider: ethers.providers.JsonRpcProvider, - account: Account, -): Promise => { - const deliveryServiceEnsNames = account.profile?.deliveryServices ?? []; - if (deliveryServiceEnsNames.length === 0) { - //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', - ); - return { - account, - deliveryServiceProfiles: [], - }; - } - - //Resolve every ds profile in the contacts profile - const dsProfilesWithUnknowns = await Promise.all( - deliveryServiceEnsNames.map((deliveryServiceEnsName: string) => { - console.debug('fetch ds profile of', deliveryServiceEnsName); - return getDeliveryServiceProfile( - deliveryServiceEnsName, - provider!, - async (url: string) => (await axios.get(url)).data, - ); - }), - ); - //filter unknown profiles. A profile if unknown if the profile could not be fetched. We don't want to deal with them in the UI - const deliveryServiceProfiles = dsProfilesWithUnknowns.filter( - (profile): profile is DeliveryServiceProfile => profile !== undefined, - ); - - return { - account, - deliveryServiceProfiles, - }; -}; diff --git a/packages/messenger-widget/src/hooks/conversation/useConversation.test.tsx b/packages/messenger-widget/src/hooks/conversation/useConversation.test.tsx index a2b5337c2..9fc82a286 100644 --- a/packages/messenger-widget/src/hooks/conversation/useConversation.test.tsx +++ b/packages/messenger-widget/src/hooks/conversation/useConversation.test.tsx @@ -2,13 +2,26 @@ import { Conversation, StorageEnvelopContainer, } from '@dm3-org/dm3-lib-storage'; +import { + MockDeliveryServiceProfile, + MockedUserProfile, + getMockDeliveryServiceProfile, + mockUserProfile, +} from '@dm3-org/dm3-lib-test-helper'; import '@testing-library/jest-dom'; import { act, renderHook, waitFor } from '@testing-library/react'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { ethers } from 'ethers'; import { AuthContext, AuthContextType } from '../../context/AuthContext'; import { DeliveryServiceContext, DeliveryServiceContextType, } from '../../context/DeliveryServiceContext'; +import { + MainnetProviderContext, + MainnetProviderContextType, +} from '../../context/ProviderContext'; import { StorageContext, StorageContextType, @@ -20,26 +33,11 @@ import { DEFAULT_DM3_CONFIGURATION, getMockedDm3Configuration, } from '../../context/testHelper/getMockedDm3Configuration'; +import { getMockedMainnetProviderContext } from '../../context/testHelper/getMockedMainnetProviderContext'; import { getMockedStorageContext } from '../../context/testHelper/getMockedStorageContext'; import { getMockedTldContext } from '../../context/testHelper/getMockedTldContext'; import { DM3Configuration } from '../../widget'; import { useConversation } from './useConversation'; -import { - MainnetProviderContext, - MainnetProviderContextType, -} from '../../context/ProviderContext'; -import { getMockedMainnetProviderContext } from '../../context/testHelper/getMockedMainnetProviderContext'; -import { ethers } from 'ethers'; -import { - MockDeliveryServiceProfile, - MockedUserProfile, - getMockDeliveryServiceProfile, - mockUserProfile, -} 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'; -import { getDeliveryServiceProfile } from '@dm3-org/dm3-lib-profile'; describe('useConversation hook test cases', () => { let sender: MockedUserProfile; diff --git a/packages/messenger-widget/src/hooks/haltDelivery/useHaltDelivery.test.tsx b/packages/messenger-widget/src/hooks/haltDelivery/useHaltDelivery.test.tsx new file mode 100644 index 000000000..10f5defc1 --- /dev/null +++ b/packages/messenger-widget/src/hooks/haltDelivery/useHaltDelivery.test.tsx @@ -0,0 +1,131 @@ +import { Conversation } from '@dm3-org/dm3-lib-storage'; +import { + MockDeliveryServiceProfile, + MockedUserProfile, + getMockDeliveryServiceProfile, + mockUserProfile, +} from '@dm3-org/dm3-lib-test-helper'; +import '@testing-library/jest-dom'; +import { renderHook } from '@testing-library/react'; +import MockAdapter from 'axios-mock-adapter'; +import { ethers } from 'ethers'; +import { AuthContext, AuthContextType } from '../../context/AuthContext'; +import { + DeliveryServiceContext, + DeliveryServiceContextType, +} from '../../context/DeliveryServiceContext'; +import { + StorageContext, + StorageContextType, +} from '../../context/StorageContext'; +import { getMockedAuthContext } from '../../context/testHelper/getMockedAuthContext'; +import { getMockedDeliveryServiceContext } from '../../context/testHelper/getMockedDeliveryServiceContext'; +import { + DEFAULT_DM3_CONFIGURATION, + getMockedDm3Configuration, +} from '../../context/testHelper/getMockedDm3Configuration'; +import { getMockedStorageContext } from '../../context/testHelper/getMockedStorageContext'; +import { DM3Configuration } from '../../widget'; +import { useHaltDelivery } from './useHaltDelivery'; + +describe('useConversation hook test cases', () => { + let sender: MockedUserProfile; + let receiver: MockedUserProfile; + let ds1: MockDeliveryServiceProfile; + let ds2: MockDeliveryServiceProfile; + + let axiosMock: MockAdapter; + + beforeEach(async () => { + sender = await mockUserProfile( + ethers.Wallet.createRandom(), + 'alice.eth', + ['ds1.eth', 'ds2.eth'], + ); + receiver = await mockUserProfile( + ethers.Wallet.createRandom(), + 'bob.eth', + ['ds1.eth'], + ); + ds1 = await getMockDeliveryServiceProfile( + ethers.Wallet.createRandom(), + 'http://ds1.api', + ); + ds2 = await getMockDeliveryServiceProfile( + ethers.Wallet.createRandom(), + 'http://ds2.api', + ); + }); + + const configurationContext = getMockedDm3Configuration({ + dm3Configuration: { + ...DEFAULT_DM3_CONFIGURATION, + }, + }); + const config: DM3Configuration = configurationContext.dm3Configuration!; + + describe('halt delivery', () => { + it('Should select a contact', 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(), + toggleHideContactAsync: jest.fn(), + getHaltedMessages: () => Promise.resolve([]), + 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(() => useHaltDelivery(), { + wrapper, + }); + // await act(async () => result.current.addConversation(CONTACT_NAME)); + // expect(result.current.selectedContact).toBe(undefined); + // await act(async () => + // result.current.setSelectedContactName(CONTACT_NAME), + // ); + // await waitFor(() => { + // const { selectedContact } = result.current; + // expect(selectedContact?.contactDetails.account.ensName).toBe( + // CONTACT_NAME, + // ); + // }); + }); + }); +}); diff --git a/packages/messenger-widget/src/hooks/haltDelivery/useHaltDelivery.ts b/packages/messenger-widget/src/hooks/haltDelivery/useHaltDelivery.ts new file mode 100644 index 000000000..17e8f88f4 --- /dev/null +++ b/packages/messenger-widget/src/hooks/haltDelivery/useHaltDelivery.ts @@ -0,0 +1,157 @@ +import { encryptAsymmetric } from '@dm3-org/dm3-lib-crypto'; +import { buildEnvelop } from '@dm3-org/dm3-lib-messaging'; +import { + Account, + DeliveryServiceProfile, + getUserProfile, +} from '@dm3-org/dm3-lib-profile'; +import { useContext, useEffect } from 'react'; +import { AuthContext } from '../../context/AuthContext'; +import { MainnetProviderContext } from '../../context/ProviderContext'; +import { StorageContext } from '../../context/StorageContext'; +import { TLDContext } from '../../context/TLDContext'; +import { fetchDsProfiles } from '../../utils/deliveryService/fetchDsProfiles'; +import { submitEnvelopsToReceiversDs } from '../../utils/deliveryService/submitEnvelopsToReceiversDs'; + +export const useHaltDelivery = () => { + const { + getHaltedMessages, + clearHaltedMessages, + initialized: storageInitialized, + } = useContext(StorageContext); + + const { account: sendersAccount, profileKeys } = useContext(AuthContext); + const { provider } = useContext(MainnetProviderContext); + const { resolveTLDtoAlias } = useContext(TLDContext); + + useEffect(() => { + if (!storageInitialized) { + return; + } + // Fetch all messages the user has halted. Then check if they can be delivered now. + const handleHaltedMessages = async () => { + const haltedMessages = await getHaltedMessages(); + //Get all recipients of the halted messages + const recipients = Array.from( + new Set( + haltedMessages.map( + (message) => message.envelop.message.metadata.to, + ), + ), + ); + //Resolve the tldNames to their aliases + const resolvedAliases = await Promise.all( + recipients.map(async (ensName) => ({ + ensName, + aliasName: await resolveTLDtoAlias(ensName), + })), + ); + + //For each recipient, get the users account + const withAccounts = await Promise.all( + resolvedAliases.map( + async ({ + ensName, + aliasName, + }: { + ensName: string; + aliasName: string; + }) => ({ + ensName, + aliasName, + profile: ( + await getUserProfile(provider, aliasName) + )?.profile, + }), + ), + ); + //Filter out users that have no profile + const dm3Users = withAccounts.filter( + (account: Account) => account.profile !== undefined, + ); + + //for every dm3User find every message + + const dm3UsersWithMessages = dm3Users.map((dm3User) => { + const messages = haltedMessages.filter( + (message) => + message.envelop.message.metadata.to === dm3User.ensName, + ); + return { ...dm3User, messages }; + }); + + //fetch the ds profiles of every recipient + const withDsProfile = await Promise.all( + dm3UsersWithMessages.map(async (dm3User) => ({ + ...dm3User, + deliveryServiceProfiles: ( + await fetchDsProfiles(provider, dm3User) + ).deliveryServiceProfiles, + })), + ); + + const envelops = await Promise.all( + withDsProfile.map((receiverAccount) => { + return Promise.all( + //Outer loop gets through every message + receiverAccount.messages.map((message) => { + return Promise.all( + //Inner loop gets through every ds profile + //messsage x dsProfile = envelops + receiverAccount.deliveryServiceProfiles.map( + async ( + dsProfile: DeliveryServiceProfile, + ) => { + //build the dispatchable envelop containing the deliveryInformation of the receiver + const dispatchableEnvelop = + await buildEnvelop( + message.envelop.message, + ( + publicKey: string, + msg: string, + ) => + encryptAsymmetric( + publicKey, + msg, + ), + { + from: sendersAccount!, + to: receiverAccount!, + deliverServiceProfile: + dsProfile, + keys: profileKeys!, + }, + ); + return { + //To clear the envelop that has been used to store the halted message + haltedEnvelopId: + message.envelop.metadata + ?.encryptedMessageHash!, + ...dispatchableEnvelop, + //we keep the alias name for the receiver. In case it differes from the ensName + aliasName: + receiverAccount.aliasName, + }; + }, + ), + ); + }), + ); + }), + ); + //because we have a nested array we have to flatten it 2 times + //The envelops are now ready to be disptched + const dispatchableEnvelops = envelops.flat(2); + + await submitEnvelopsToReceiversDs(dispatchableEnvelops); + + dispatchableEnvelops.map((envelop) => { + clearHaltedMessages(envelop.haltedEnvelopId, envelop.aliasName); + }); + }; + + handleHaltedMessages(); + }, [storageInitialized]); + + return {}; +}; diff --git a/packages/messenger-widget/src/hooks/messages/useMessage.tsx b/packages/messenger-widget/src/hooks/messages/useMessage.tsx index 487a9d9ef..b27e2aee1 100644 --- a/packages/messenger-widget/src/hooks/messages/useMessage.tsx +++ b/packages/messenger-widget/src/hooks/messages/useMessage.tsx @@ -22,6 +22,8 @@ import { checkIfEnvelopAreInSizeLimit } from './sizeLimit/checkIfEnvelopIsInSize import { handleMessagesFromDeliveryService } from './sources/handleMessagesFromDeliveryService'; import { handleMessagesFromStorage } from './sources/handleMessagesFromStorage'; import { handleMessagesFromWebSocket } from './sources/handleMessagesFromWebSocket'; +import { useHaltDelivery } from '../haltDelivery/useHaltDelivery'; +import { submitEnvelopsToReceiversDs } from '../../utils/deliveryService/submitEnvelopsToReceiversDs'; const DEFAULT_MESSAGE_PAGESIZE = 100; @@ -73,6 +75,9 @@ export const useMessage = () => { initialized: storageInitialized, } = useContext(StorageContext); + //load halt delivery here to be able to store messages as halted + useHaltDelivery(); + const [messages, setMessages] = useState({}); const [contactsLoading, setContactsLoading] = useState([]); @@ -254,7 +259,8 @@ export const useMessage = () => { [contact]: [...(prev[contact] ?? []), messageModel], }; }); - storeMessage(contact, messageModel); + //Store the message and mark it as halted + storeMessage(contact, messageModel, true); return { isSuccess: true }; } @@ -330,27 +336,10 @@ export const useMessage = () => { }; } //Send the envelops to the delivery service - await submitEnveloptsToReceiversDs(envelops); + await submitEnvelopsToReceiversDs(envelops); return { isSuccess: true }; }; - const submitEnveloptsToReceiversDs = async ( - envelops: DispatchableEnvelop[], - ) => { - //Every DispatchableEnvelop is sent to the delivery service - await Promise.all( - envelops.map(async (envelop) => { - return await axios - .create({ baseURL: envelop.deliveryServiceUrl }) - .post('/rpc', { - jsonrpc: '2.0', - method: 'dm3_submitMessage', - params: [JSON.stringify(envelop.encryptedEnvelop)], - }); - }), - ); - }; - const loadInitialMessages = async (_contactName: string) => { const contactName = normalizeEnsName(_contactName); const initialMessages = await Promise.all([ diff --git a/packages/messenger-widget/src/hooks/server-side/BackendConnector.ts b/packages/messenger-widget/src/hooks/server-side/BackendConnector.ts index 0924b8012..2b616d63a 100644 --- a/packages/messenger-widget/src/hooks/server-side/BackendConnector.ts +++ b/packages/messenger-widget/src/hooks/server-side/BackendConnector.ts @@ -70,12 +70,35 @@ export class BackendConnector ); } + public async getHaltedMessages(ensName: string) { + const url = `/storage/new/${normalizeEnsName( + ensName, + )}/getHaltedMessages/`; + const { data } = await this.getAuthenticatedAxiosClient().get(url, {}); + return data ?? []; + } + public async clearHaltedMessages( + ensName: string, + messageId: string, + aliasName: string, + ) { + const url = `/storage/new/${normalizeEnsName( + ensName, + )}/clearHaltedMessage/`; + const { data } = await this.getAuthenticatedAxiosClient().post(url, { + aliasName, + messageId, + }); + return data ?? []; + } + public async addMessage( ensName: string, encryptedContactName: string, messageId: string, createdAt: number, encryptedEnvelopContainer: string, + isHalted: boolean, ) { const url = `/storage/new/${normalizeEnsName(ensName)}/addMessage`; await this.getAuthenticatedAxiosClient().post(url, { @@ -83,6 +106,7 @@ export class BackendConnector messageId, createdAt, encryptedEnvelopContainer, + isHalted, }); } diff --git a/packages/messenger-widget/src/hooks/server-side/useBackend.ts b/packages/messenger-widget/src/hooks/server-side/useBackend.ts index 4e81afc90..63f71e31f 100644 --- a/packages/messenger-widget/src/hooks/server-side/useBackend.ts +++ b/packages/messenger-widget/src/hooks/server-side/useBackend.ts @@ -86,12 +86,27 @@ export const useBackend = (): IBackendConnector & { offset, ); }, + getHaltedMessages: async (ensName: string) => { + return beConnector?.getHaltedMessages(ensName); + }, + clearHaltedMessages: async ( + ensName: string, + aliasName: string, + messageId: string, + ) => { + return beConnector?.clearHaltedMessages( + ensName, + aliasName, + messageId, + ); + }, addMessage: async ( ensName: string, encryptedContactName: string, messageId: string, createdAt: number, encryptedEnvelopContainer: string, + isHalted: boolean, ) => { return beConnector?.addMessage( ensName, @@ -99,6 +114,7 @@ export const useBackend = (): IBackendConnector & { messageId, createdAt, encryptedEnvelopContainer, + isHalted, ); }, addMessageBatch: ( diff --git a/packages/messenger-widget/src/hooks/storage/useStorage.tsx b/packages/messenger-widget/src/hooks/storage/useStorage.tsx index 927ddf714..91745bba9 100644 --- a/packages/messenger-widget/src/hooks/storage/useStorage.tsx +++ b/packages/messenger-widget/src/hooks/storage/useStorage.tsx @@ -101,11 +101,12 @@ export const useStorage = ( const storeMessageAsync = ( contact: string, envelop: StorageEnvelopContainerNew, + isHalted: boolean = false, ) => { if (!storageApi) { throw Error('Storage not initialized'); } - storageApi.addMessage(contact, envelop); + storageApi.addMessage(contact, envelop, isHalted); }; const storeMessageBatch = async ( contact: string, @@ -139,6 +140,22 @@ export const useStorage = ( } return storageApi.getMessages(contact, pageSize, offset); }; + const clearHaltedMessages = async ( + messageId: string, + aliasName: string, + ) => { + if (!storageApi) { + return Promise.resolve(); + } + return storageApi.clearHaltedMessages(messageId, aliasName); + }; + + const getHaltedMessages = async () => { + if (!storageApi) { + return Promise.resolve([]); + } + return storageApi.getHaltedMessages(); + }; const getNumberOfMessages = async (contact: string) => { if (!storageApi) { @@ -161,6 +178,8 @@ export const useStorage = ( getConversations, addConversationAsync, getMessages, + getHaltedMessages, + clearHaltedMessages, getNumberOfMessages, toggleHideContactAsync, initialized, @@ -170,6 +189,7 @@ export const useStorage = ( export type StoreMessageAsync = ( contact: string, envelop: StorageEnvelopContainerNew, + isHalted?: boolean, ) => void; export type editMessageBatchAsync = ( contact: string, @@ -189,5 +209,10 @@ export type GetMessages = ( pageSize: number, offset: number, ) => Promise; +export type GetHaltedMessages = () => Promise; +export type ClearHaltedMessages = ( + messageId: string, + aliasName: string, +) => Promise; export type GetNumberOfMessages = (contact: string) => Promise; export type ToggleHideContactAsync = (contact: string, value: boolean) => void; diff --git a/packages/messenger-widget/src/utils/deliveryService/fetchDsProfiles.ts b/packages/messenger-widget/src/utils/deliveryService/fetchDsProfiles.ts new file mode 100644 index 000000000..ce7035296 --- /dev/null +++ b/packages/messenger-widget/src/utils/deliveryService/fetchDsProfiles.ts @@ -0,0 +1,46 @@ +import { + getDeliveryServiceProfile, + DeliveryServiceProfile, + Account, +} from '@dm3-org/dm3-lib-profile'; +import axios from 'axios'; +import { ethers } from 'ethers'; +import { Contact } from '../../interfaces/context'; + +export const fetchDsProfiles = async ( + provider: ethers.providers.JsonRpcProvider, + account: Account, +): Promise => { + const deliveryServiceEnsNames = account.profile?.deliveryServices ?? []; + if (deliveryServiceEnsNames.length === 0) { + //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', + ); + return { + account, + deliveryServiceProfiles: [], + }; + } + + //Resolve every ds profile in the contacts profile + const dsProfilesWithUnknowns = await Promise.all( + deliveryServiceEnsNames.map((deliveryServiceEnsName: string) => { + console.debug('fetch ds profile of', deliveryServiceEnsName); + return getDeliveryServiceProfile( + deliveryServiceEnsName, + provider!, + async (url: string) => (await axios.get(url)).data, + ); + }), + ); + //filter unknown profiles. A profile if unknown if the profile could not be fetched. We don't want to deal with them in the UI + const deliveryServiceProfiles = dsProfilesWithUnknowns.filter( + (profile): profile is DeliveryServiceProfile => profile !== undefined, + ); + + return { + account, + deliveryServiceProfiles, + }; +}; diff --git a/packages/messenger-widget/src/utils/deliveryService/submitEnvelopsToReceiversDs.ts b/packages/messenger-widget/src/utils/deliveryService/submitEnvelopsToReceiversDs.ts new file mode 100644 index 000000000..81229b95e --- /dev/null +++ b/packages/messenger-widget/src/utils/deliveryService/submitEnvelopsToReceiversDs.ts @@ -0,0 +1,19 @@ +import { DispatchableEnvelop } from '@dm3-org/dm3-lib-messaging'; +import axios from 'axios'; + +export const submitEnvelopsToReceiversDs = async ( + envelops: DispatchableEnvelop[], +) => { + //Every DispatchableEnvelop is sent to the delivery service + await Promise.all( + envelops.map(async (envelop) => { + return await axios + .create({ baseURL: envelop.deliveryServiceUrl }) + .post('/rpc', { + jsonrpc: '2.0', + method: 'dm3_submitMessage', + params: [JSON.stringify(envelop.encryptedEnvelop)], + }); + }), + ); +};