diff --git a/package-lock.json b/package-lock.json index a93b125d2..754321620 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12692,7 +12692,7 @@ }, "packages/runtime": { "name": "@nmshd/runtime", - "version": "4.0.0", + "version": "4.1.0", "license": "MIT", "dependencies": { "@js-soft/docdb-querytranslator": "^1.1.2", @@ -12702,7 +12702,7 @@ "@nmshd/consumption": "3.9.3", "@nmshd/content": "2.8.4", "@nmshd/crypto": "2.0.6", - "@nmshd/transport": "2.2.2", + "@nmshd/transport": "2.3.0", "ajv": "^8.12.0", "ajv-errors": "^3.0.0", "ajv-formats": "^2.1.1", @@ -12727,7 +12727,7 @@ }, "packages/transport": { "name": "@nmshd/transport", - "version": "2.2.2", + "version": "2.3.0", "license": "MIT", "dependencies": { "@js-soft/docdb-access-abstractions": "1.0.3", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 6adb01cca..761d35b9f 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@nmshd/runtime", - "version": "4.0.0", + "version": "4.1.0", "description": "The enmeshed client runtime.", "homepage": "https://enmeshed.eu", "repository": { @@ -67,7 +67,7 @@ "@nmshd/consumption": "3.9.3", "@nmshd/content": "2.8.4", "@nmshd/crypto": "2.0.6", - "@nmshd/transport": "2.2.2", + "@nmshd/transport": "2.3.0", "ajv": "^8.12.0", "ajv-errors": "^3.0.0", "ajv-formats": "^2.1.1", diff --git a/packages/runtime/src/dataViews/DataViewExpander.ts b/packages/runtime/src/dataViews/DataViewExpander.ts index bf5c4b91d..66116676c 100644 --- a/packages/runtime/src/dataViews/DataViewExpander.ts +++ b/packages/runtime/src/dataViews/DataViewExpander.ts @@ -270,7 +270,8 @@ export class DataViewExpander { statusText: `i18n://dvo.message.${status}`, image: "", peer: peer, - content: message.content + content: message.content, + wasReadAt: message.wasReadAt }; if (message.content["@type"] === "Mail" || message.content["@type"] === "RequestMail") { diff --git a/packages/runtime/src/dataViews/transport/MessageDVO.ts b/packages/runtime/src/dataViews/transport/MessageDVO.ts index 78124b430..5d1bcd03c 100644 --- a/packages/runtime/src/dataViews/transport/MessageDVO.ts +++ b/packages/runtime/src/dataViews/transport/MessageDVO.ts @@ -83,6 +83,11 @@ export interface MessageDVO extends DataViewObject { * The content of the message. */ content: unknown; + + /** + * The read indicator of the message + */ + wasReadAt?: string; } export interface RecipientDVO extends Omit { diff --git a/packages/runtime/src/extensibility/facades/transport/MessagesFacade.ts b/packages/runtime/src/extensibility/facades/transport/MessagesFacade.ts index 0f03a8403..4398c7a38 100644 --- a/packages/runtime/src/extensibility/facades/transport/MessagesFacade.ts +++ b/packages/runtime/src/extensibility/facades/transport/MessagesFacade.ts @@ -11,17 +11,23 @@ import { GetMessagesRequest, GetMessagesUseCase, GetMessageUseCase, + MarkMessageAsReadRequest, + MarkMessageAsReadUseCase, + MarkMessageAsUnreadRequest, + MarkMessageAsUnreadUseCase, SendMessageRequest, SendMessageUseCase } from "../../../useCases"; export class MessagesFacade { public constructor( - @Inject private readonly getMessagesUseCase: GetMessagesUseCase, - @Inject private readonly getMessageUseCase: GetMessageUseCase, - @Inject private readonly sendMessageUseCase: SendMessageUseCase, @Inject private readonly downloadAttachmentUseCase: DownloadAttachmentUseCase, - @Inject private readonly getAttachmentMetadataUseCase: GetAttachmentMetadataUseCase + @Inject private readonly getAttachmentMetadataUseCase: GetAttachmentMetadataUseCase, + @Inject private readonly getMessageUseCase: GetMessageUseCase, + @Inject private readonly getMessagesUseCase: GetMessagesUseCase, + @Inject private readonly markMessageAsReadUseCase: MarkMessageAsReadUseCase, + @Inject private readonly markMessageAsUnreadUseCase: MarkMessageAsUnreadUseCase, + @Inject private readonly sendMessageUseCase: SendMessageUseCase ) {} public async sendMessage(request: SendMessageRequest): Promise> { @@ -43,4 +49,12 @@ export class MessagesFacade { public async getAttachmentMetadata(request: GetAttachmentMetadataRequest): Promise> { return await this.getAttachmentMetadataUseCase.execute(request); } + + public async markMessageAsRead(request: MarkMessageAsReadRequest): Promise> { + return await this.markMessageAsReadUseCase.execute(request); + } + + public async markMessageAsUnread(request: MarkMessageAsUnreadRequest): Promise> { + return await this.markMessageAsUnreadUseCase.execute(request); + } } diff --git a/packages/runtime/src/types/transport/MessageDTO.ts b/packages/runtime/src/types/transport/MessageDTO.ts index a89f820d8..d541d8797 100644 --- a/packages/runtime/src/types/transport/MessageDTO.ts +++ b/packages/runtime/src/types/transport/MessageDTO.ts @@ -9,4 +9,5 @@ export interface MessageDTO { createdAt: string; attachments: string[]; isOwn: boolean; + wasReadAt?: string; } diff --git a/packages/runtime/src/types/transport/MessageWithAttachmentsDTO.ts b/packages/runtime/src/types/transport/MessageWithAttachmentsDTO.ts index 1f67c44ba..f3a2ab05c 100644 --- a/packages/runtime/src/types/transport/MessageWithAttachmentsDTO.ts +++ b/packages/runtime/src/types/transport/MessageWithAttachmentsDTO.ts @@ -10,4 +10,5 @@ export interface MessageWithAttachmentsDTO { createdAt: string; attachments: FileDTO[]; isOwn: boolean; + wasReadAt?: string; } diff --git a/packages/runtime/src/useCases/common/Schemas.ts b/packages/runtime/src/useCases/common/Schemas.ts index 6bc964013..d68074ce0 100644 --- a/packages/runtime/src/useCases/common/Schemas.ts +++ b/packages/runtime/src/useCases/common/Schemas.ts @@ -20866,6 +20866,19 @@ export const GetMessagesRequest: any = { } ] }, + "wasReadAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, "participant": { "anyOf": [ { @@ -20885,6 +20898,52 @@ export const GetMessagesRequest: any = { } } +export const MarkMessageAsReadRequest: any = { + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/MarkMessageAsReadRequest", + "definitions": { + "MarkMessageAsReadRequest": { + "type": "object", + "properties": { + "id": { + "$ref": "#/definitions/MessageIdString" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "MessageIdString": { + "type": "string", + "pattern": "MSG[A-Za-z0-9]{17}" + } + } +} + +export const MarkMessageAsUnreadRequest: any = { + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/MarkMessageAsUnreadRequest", + "definitions": { + "MarkMessageAsUnreadRequest": { + "type": "object", + "properties": { + "id": { + "$ref": "#/definitions/MessageIdString" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "MessageIdString": { + "type": "string", + "pattern": "MSG[A-Za-z0-9]{17}" + } + } +} + export const SendMessageRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", "$ref": "#/definitions/SendMessageRequest", diff --git a/packages/runtime/src/useCases/transport/messages/GetMessages.ts b/packages/runtime/src/useCases/transport/messages/GetMessages.ts index 0b0b58c4f..12522e42f 100644 --- a/packages/runtime/src/useCases/transport/messages/GetMessages.ts +++ b/packages/runtime/src/useCases/transport/messages/GetMessages.ts @@ -17,6 +17,7 @@ export interface GetMessagesQuery { attachments?: string | string[]; "recipients.address"?: string | string[]; "recipients.relationshipId"?: string | string[]; + wasReadAt?: string | string[]; participant?: string | string[]; } @@ -42,6 +43,7 @@ export class GetMessagesUseCase extends UseCase((m) => m.attachments)]: true, [`${nameof((m) => m.recipients)}.${nameof((r) => r.address)}`]: true, [`${nameof((m) => m.recipients)}.${nameof((r) => r.relationshipId)}`]: true, + [nameof((m) => m.wasReadAt)]: true, participant: true }, @@ -54,7 +56,8 @@ export class GetMessagesUseCase extends UseCase((r) => r.address)}`, [`${nameof((m) => m.content)}.@type`]: `${nameof((m) => m.cache)}.${nameof((m) => m.content)}.@type`, [`${nameof((m) => m.content)}.body`]: `${nameof((m) => m.cache)}.${nameof((m) => m.content)}.body`, - [`${nameof((m) => m.content)}.subject`]: `${nameof((m) => m.cache)}.${nameof((m) => m.content)}.subject` + [`${nameof((m) => m.content)}.subject`]: `${nameof((m) => m.cache)}.${nameof((m) => m.content)}.subject`, + [nameof((m) => m.wasReadAt)]: [nameof((m) => m.wasReadAt)] }, custom: { diff --git a/packages/runtime/src/useCases/transport/messages/MarkMessageAsRead.ts b/packages/runtime/src/useCases/transport/messages/MarkMessageAsRead.ts new file mode 100644 index 000000000..b68116401 --- /dev/null +++ b/packages/runtime/src/useCases/transport/messages/MarkMessageAsRead.ts @@ -0,0 +1,34 @@ +import { Result } from "@js-soft/ts-utils"; +import { AccountController, CoreId, MessageController } from "@nmshd/transport"; +import { Inject } from "typescript-ioc"; +import { MessageDTO } from "../../../types"; +import { MessageIdString, SchemaRepository, SchemaValidator, UseCase } from "../../common"; +import { MessageMapper } from "./MessageMapper"; + +export interface MarkMessageAsReadRequest { + id: MessageIdString; +} + +class Validator extends SchemaValidator { + public constructor(@Inject schemaRepository: SchemaRepository) { + super(schemaRepository.getSchema("MarkMessageAsReadRequest")); + } +} + +export class MarkMessageAsReadUseCase extends UseCase { + public constructor( + @Inject private readonly messageController: MessageController, + @Inject private readonly accountController: AccountController, + @Inject validator: Validator + ) { + super(validator); + } + + protected async executeInternal(request: MarkMessageAsReadRequest): Promise> { + const updatedMessage = await this.messageController.markMessageAsRead(CoreId.from(request.id)); + + await this.accountController.syncDatawallet(); + + return Result.ok(MessageMapper.toMessageDTO(updatedMessage)); + } +} diff --git a/packages/runtime/src/useCases/transport/messages/MarkMessageAsUnread.ts b/packages/runtime/src/useCases/transport/messages/MarkMessageAsUnread.ts new file mode 100644 index 000000000..a9454d624 --- /dev/null +++ b/packages/runtime/src/useCases/transport/messages/MarkMessageAsUnread.ts @@ -0,0 +1,34 @@ +import { Result } from "@js-soft/ts-utils"; +import { AccountController, CoreId, MessageController } from "@nmshd/transport"; +import { Inject } from "typescript-ioc"; +import { MessageDTO } from "../../../types"; +import { MessageIdString, SchemaRepository, SchemaValidator, UseCase } from "../../common"; +import { MessageMapper } from "./MessageMapper"; + +export interface MarkMessageAsUnreadRequest { + id: MessageIdString; +} + +class Validator extends SchemaValidator { + public constructor(@Inject schemaRepository: SchemaRepository) { + super(schemaRepository.getSchema("MarkMessageAsUnreadRequest")); + } +} + +export class MarkMessageAsUnreadUseCase extends UseCase { + public constructor( + @Inject private readonly messageController: MessageController, + @Inject private readonly accountController: AccountController, + @Inject validator: Validator + ) { + super(validator); + } + + protected async executeInternal(request: MarkMessageAsUnreadRequest): Promise> { + const updatedMessage = await this.messageController.markMessageAsUnread(CoreId.from(request.id)); + + await this.accountController.syncDatawallet(); + + return Result.ok(MessageMapper.toMessageDTO(updatedMessage)); + } +} diff --git a/packages/runtime/src/useCases/transport/messages/MessageMapper.ts b/packages/runtime/src/useCases/transport/messages/MessageMapper.ts index 04e7778a0..f749984e5 100644 --- a/packages/runtime/src/useCases/transport/messages/MessageMapper.ts +++ b/packages/runtime/src/useCases/transport/messages/MessageMapper.ts @@ -31,7 +31,8 @@ export class MessageMapper { recipients: message.cache.recipients.map((r, i) => this.toRecipient(r, message.relationshipIds[i])), createdAt: message.cache.createdAt.toString(), attachments: attachments.map((f) => FileMapper.toFileDTO(f)), - isOwn: message.isOwn + isOwn: message.isOwn, + wasReadAt: message.wasReadAt?.toString() }; } @@ -48,7 +49,8 @@ export class MessageMapper { recipients: message.cache.recipients.map((r, i) => this.toRecipient(r, message.relationshipIds[i])), createdAt: message.cache.createdAt.toString(), attachments: message.cache.attachments.map((a) => a.toString()), - isOwn: message.isOwn + isOwn: message.isOwn, + wasReadAt: message.wasReadAt?.toString() }; } diff --git a/packages/runtime/src/useCases/transport/messages/index.ts b/packages/runtime/src/useCases/transport/messages/index.ts index 6b9a0b08e..b6a75350f 100644 --- a/packages/runtime/src/useCases/transport/messages/index.ts +++ b/packages/runtime/src/useCases/transport/messages/index.ts @@ -2,5 +2,7 @@ export * from "./DownloadAttachment"; export * from "./GetAttachmentMetadata"; export * from "./GetMessage"; export * from "./GetMessages"; +export * from "./MarkMessageAsRead"; +export * from "./MarkMessageAsUnread"; export * from "./MessageMapper"; export * from "./SendMessage"; diff --git a/packages/runtime/test/transport/messages.test.ts b/packages/runtime/test/transport/messages.test.ts index 9f410d5c5..f6f5b0e46 100644 --- a/packages/runtime/test/transport/messages.test.ts +++ b/packages/runtime/test/transport/messages.test.ts @@ -1,65 +1,59 @@ -import { GetMessagesQuery, MessageSentEvent, TransportServices } from "../../src"; +import { CoreDate } from "@nmshd/transport"; +import { GetMessagesQuery, MessageSentEvent } from "../../src"; import { - MockEventBus, - QueryParamConditions, - RuntimeServiceProvider, + ensureActiveRelationship, establishRelationship, exchangeMessage, exchangeMessageWithAttachment, - getRelationship, + QueryParamConditions, + RuntimeServiceProvider, syncUntilHasMessages, + TestRuntimeServices, uploadFile } from "../lib"; const serviceProvider = new RuntimeServiceProvider(); -let transportServices1: TransportServices; -let eventBus1: MockEventBus; -let transportServices2: TransportServices; +let client1: TestRuntimeServices; +let client2: TestRuntimeServices; beforeAll(async () => { const runtimeServices = await serviceProvider.launch(2); - transportServices1 = runtimeServices[0].transport; - eventBus1 = runtimeServices[0].eventBus; - transportServices2 = runtimeServices[1].transport; - await establishRelationship(transportServices1, transportServices2); + client1 = runtimeServices[0]; + client2 = runtimeServices[1]; + await ensureActiveRelationship(client1.transport, client2.transport); }, 30000); beforeEach(() => { - eventBus1.reset(); + client1.eventBus.reset(); }); afterAll(() => serviceProvider.stop()); describe("Messaging", () => { - let transportService2Address: string; let fileId: string; let messageId: string; beforeAll(async () => { - const file = await uploadFile(transportServices1); + const file = await uploadFile(client1.transport); fileId = file.id; - - const relationship = await getRelationship(transportServices1); - transportService2Address = relationship.peer; }); - test("send a Message from TransportServices1 to TransportServices2", async () => { - expect(transportService2Address).toBeDefined(); + test("send a Message from client1.transport to client2.transport", async () => { expect(fileId).toBeDefined(); - const result = await transportServices1.messages.sendMessage({ - recipients: [transportService2Address], + const result = await client1.transport.messages.sendMessage({ + recipients: [client2.address], content: { "@type": "Mail", body: "b", cc: [], subject: "a", - to: [transportService2Address] + to: [client2.address] }, attachments: [fileId] }); expect(result).toBeSuccessful(); - await expect(eventBus1).toHavePublished(MessageSentEvent, (m) => m.data.id === result.value.id); + await expect(client1.eventBus).toHavePublished(MessageSentEvent, (m) => m.data.id === result.value.id); messageId = result.value.id; }); @@ -67,7 +61,7 @@ describe("Messaging", () => { test("receive the message in a sync run", async () => { expect(messageId).toBeDefined(); - const messages = await syncUntilHasMessages(transportServices2); + const messages = await syncUntilHasMessages(client2.transport); expect(messages).toHaveLength(1); const message = messages[0]; @@ -77,14 +71,14 @@ describe("Messaging", () => { body: "b", cc: [], subject: "a", - to: [transportService2Address] + to: [client2.address] }); }); test("receive the message on TransportService2 in /Messages", async () => { expect(messageId).toBeDefined(); - const response = await transportServices2.messages.getMessages({}); + const response = await client2.transport.messages.getMessages({}); expect(response).toBeSuccessful(); expect(response.value).toHaveLength(1); @@ -95,14 +89,14 @@ describe("Messaging", () => { body: "b", cc: [], subject: "a", - to: [transportService2Address] + to: [client2.address] }); }); test("receive the message on TransportService2 in /Messages/{id}", async () => { expect(messageId).toBeDefined(); - const response = await transportServices2.messages.getMessage({ id: messageId }); + const response = await client2.transport.messages.getMessage({ id: messageId }); expect(response).toBeSuccessful(); }); }); @@ -110,7 +104,7 @@ describe("Messaging", () => { describe("Message errors", () => { const fakeAddress = "id1PNvUP4jHD74qo6usnWNoaFGFf33MXZi6c"; test("should throw correct error for empty 'to' in the Message", async () => { - const result = await transportServices1.messages.sendMessage({ + const result = await client1.transport.messages.sendMessage({ recipients: [fakeAddress], content: { "@type": "Mail", @@ -123,7 +117,7 @@ describe("Message errors", () => { }); test("should throw correct error for missing 'to' in the Message", async () => { - const result = await transportServices1.messages.sendMessage({ + const result = await client1.transport.messages.sendMessage({ recipients: [fakeAddress], content: { "@type": "Mail", @@ -135,13 +129,61 @@ describe("Message errors", () => { }); }); +describe("Mark Message as un-/read", () => { + let messageId: string; + beforeEach(async () => { + const result = await client1.transport.messages.sendMessage({ + recipients: [client2.address], + content: { + "@type": "Mail", + body: "A body", + cc: [], + subject: "A subject", + to: [client2.address] + } + }); + await syncUntilHasMessages(client2.transport, 1); + messageId = result.value.id; + }); + + test("Mark Message as read", async () => { + const messageResult = await client2.transport.messages.getMessage({ id: messageId }); + expect(messageResult).toBeSuccessful(); + const message = messageResult.value; + expect(message.wasReadAt).toBeUndefined(); + + const expectedReadTime = CoreDate.utc(); + const updatedMessageResult = await client2.transport.messages.markMessageAsRead({ id: messageId }); + + const updatedMessage = updatedMessageResult.value; + expect(updatedMessage.wasReadAt).toBeDefined(); + const actualReadTime = CoreDate.from(updatedMessage.wasReadAt!); + expect(actualReadTime.isSameOrAfter(expectedReadTime)).toBe(true); + }); + + test("Mark Message as unread", async () => { + const messageResult = await client2.transport.messages.getMessage({ id: messageId }); + expect(messageResult).toBeSuccessful(); + const message = messageResult.value; + expect(message.wasReadAt).toBeUndefined(); + + await client2.transport.messages.markMessageAsRead({ id: messageId }); + const updatedMessageResult = await client2.transport.messages.markMessageAsUnread({ id: messageId }); + + const updatedMessage = updatedMessageResult.value; + expect(updatedMessage.wasReadAt).toBeUndefined(); + }); +}); + describe("Message query", () => { test("query messages", async () => { - const message = await exchangeMessageWithAttachment(transportServices1, transportServices2); - const conditions = new QueryParamConditions(message, transportServices2) + const message = await exchangeMessageWithAttachment(client1.transport, client2.transport); + const updatedMessage = (await client2.transport.messages.markMessageAsRead({ id: message.id })).value; + const conditions = new QueryParamConditions(updatedMessage, client2.transport) .addDateSet("createdAt") .addStringSet("createdBy") - .addStringSet("recipients.address", message.recipients[0].address) + .addDateSet("wasReadAt") + .addStringSet("recipients.address", updatedMessage.recipients[0].address) .addStringSet("content.@type") .addStringSet("content.subject") .addStringSet("content.body") @@ -150,7 +192,7 @@ describe("Message query", () => { .addStringSet("recipients.relationshipId") .addSingleCondition({ key: "participant", - value: [message.createdBy, "id111111111111111111111111111111111"], + value: [updatedMessage.createdBy, "id111111111111111111111111111111111"], expectedResult: true }); @@ -162,27 +204,27 @@ describe("Message query", () => { const recipient1 = additionalRuntimeServices[0].transport; const recipient2 = additionalRuntimeServices[1].transport; - await establishRelationship(transportServices1, recipient1); - await establishRelationship(transportServices1, recipient2); + await establishRelationship(client1.transport, recipient1); + await establishRelationship(client1.transport, recipient2); const addressRecipient1 = (await recipient1.account.getIdentityInfo()).value.address; const addressRecipient2 = (await recipient2.account.getIdentityInfo()).value.address; - const relationshipToRecipient1 = await transportServices1.relationships.getRelationshipByAddress({ address: addressRecipient1 }); - const relationshipToRecipient2 = await transportServices1.relationships.getRelationshipByAddress({ address: addressRecipient2 }); + const relationshipToRecipient1 = await client1.transport.relationships.getRelationshipByAddress({ address: addressRecipient1 }); + const relationshipToRecipient2 = await client1.transport.relationships.getRelationshipByAddress({ address: addressRecipient2 }); - await transportServices1.messages.sendMessage({ + await client1.transport.messages.sendMessage({ content: {}, recipients: [addressRecipient1] }); - await transportServices1.messages.sendMessage({ + await client1.transport.messages.sendMessage({ content: {}, recipients: [addressRecipient2] }); - const messagesToRecipient1 = await transportServices1.messages.getMessages({ query: { "recipients.relationshipId": relationshipToRecipient1.value.id } }); - const messagesToRecipient2 = await transportServices1.messages.getMessages({ query: { "recipients.relationshipId": relationshipToRecipient2.value.id } }); - const messagesToRecipient1Or2 = await transportServices1.messages.getMessages({ + const messagesToRecipient1 = await client1.transport.messages.getMessages({ query: { "recipients.relationshipId": relationshipToRecipient1.value.id } }); + const messagesToRecipient2 = await client1.transport.messages.getMessages({ query: { "recipients.relationshipId": relationshipToRecipient2.value.id } }); + const messagesToRecipient1Or2 = await client1.transport.messages.getMessages({ query: { "recipients.relationshipId": [relationshipToRecipient1.value.id, relationshipToRecipient2.value.id] } }); @@ -192,10 +234,10 @@ describe("Message query", () => { }); test("query Messages withAttachments", async () => { - const messageWithAttachment = await exchangeMessageWithAttachment(transportServices1, transportServices2); - const messageWithoutAttachment = await exchangeMessage(transportServices1, transportServices2); + const messageWithAttachment = await exchangeMessageWithAttachment(client1.transport, client2.transport); + const messageWithoutAttachment = await exchangeMessage(client1.transport, client2.transport); - const messages = await transportServices2.messages.getMessages({ query: { attachments: "+" } }); + const messages = await client2.transport.messages.getMessages({ query: { attachments: "+" } }); expect(messages.value.every((m) => m.attachments.length > 0)).toBe(true); diff --git a/packages/transport/package.json b/packages/transport/package.json index b63fa5b1b..48d42d6d2 100644 --- a/packages/transport/package.json +++ b/packages/transport/package.json @@ -1,6 +1,6 @@ { "name": "@nmshd/transport", - "version": "2.2.2", + "version": "2.3.0", "description": "The transport library handles backbone communication and content encryption.", "homepage": "https://enmeshed.eu", "repository": { diff --git a/packages/transport/src/modules/messages/MessageController.ts b/packages/transport/src/modules/messages/MessageController.ts index aea81c1e8..9eae34e31 100644 --- a/packages/transport/src/modules/messages/MessageController.ts +++ b/packages/transport/src/modules/messages/MessageController.ts @@ -9,9 +9,9 @@ import { MessageSentEvent } from "../../events"; import { AccountController } from "../accounts/AccountController"; import { File } from "../files/local/File"; import { FileReference } from "../files/transmission/FileReference"; -import { RelationshipSecretController } from "../relationships/RelationshipSecretController"; -import { RelationshipsController } from "../relationships/RelationshipsController"; import { Relationship } from "../relationships/local/Relationship"; +import { RelationshipsController } from "../relationships/RelationshipsController"; +import { RelationshipSecretController } from "../relationships/RelationshipSecretController"; import { SynchronizedCollection } from "../sync/SynchronizedCollection"; import { BackboneGetMessagesResponse } from "./backbone/BackboneGetMessages"; import { BackbonePostMessagesRecipientRequest } from "./backbone/BackbonePostMessages"; @@ -204,6 +204,35 @@ export class MessageController extends TransportController { return message; } + public async markMessageAsRead(id: CoreId): Promise { + const messageDoc = await this.messages.read(id.toString()); + if (!messageDoc) { + throw CoreErrors.general.recordNotFound(Message, id.toString()); + } + + const message = Message.from(messageDoc); + if (typeof message.wasReadAt !== "undefined") return message; + + message.wasReadAt = CoreDate.utc(); + await this.messages.update(messageDoc, message); + return message; + } + + public async markMessageAsUnread(id: CoreId): Promise { + const messageDoc = await this.messages.read(id.toString()); + if (!messageDoc) { + throw CoreErrors.general.recordNotFound(Message, id.toString()); + } + + const message = Message.from(messageDoc); + if (typeof message.wasReadAt === "undefined") return message; + + message.wasReadAt = undefined; + await this.messages.update(messageDoc, message); + + return message; + } + @log() public async sendMessage(parameters: ISendMessageParameters): Promise { const parsedParams = SendMessageParameters.from(parameters); diff --git a/packages/transport/src/modules/messages/local/Message.ts b/packages/transport/src/modules/messages/local/Message.ts index 91a9f8bd5..b51f8992b 100644 --- a/packages/transport/src/modules/messages/local/Message.ts +++ b/packages/transport/src/modules/messages/local/Message.ts @@ -12,6 +12,7 @@ export interface IMessage extends ICoreSynchronizable { metadata?: any; metadataModifiedAt?: ICoreDate; relationshipIds: ICoreId[]; + wasReadAt?: ICoreDate; } @type("Message") @@ -26,6 +27,8 @@ export class Message extends CoreSynchronizable implements IMessage { public override readonly metadataProperties = [nameof((r) => r.metadata), nameof((r) => r.metadataModifiedAt)]; + public override readonly userdataProperties = [nameof((r) => r.wasReadAt)]; + @validate() @serialize() public secretKey: CryptoSecretKey; @@ -54,6 +57,10 @@ export class Message extends CoreSynchronizable implements IMessage { @serialize({ type: CoreId }) public relationshipIds: CoreId[]; + @validate({ nullable: true }) + @serialize() + public wasReadAt?: CoreDate; + public static from(value: IMessage): Message { return this.fromAny(value); } diff --git a/packages/transport/test/modules/messages/MessageController.test.ts b/packages/transport/test/modules/messages/MessageController.test.ts index d7b2b62b6..ca1427a28 100644 --- a/packages/transport/test/modules/messages/MessageController.test.ts +++ b/packages/transport/test/modules/messages/MessageController.test.ts @@ -141,4 +141,42 @@ describe("MessageController", function () { const messages = await recipient.messages.getMessagesByRelationshipId(relationshipId); expect(messages).toHaveLength(3); }); + + test("should mark an unread message as read", async function () { + await TestUtil.sendMessage(sender, recipient); + const messages = await TestUtil.syncUntilHasMessages(recipient, 1); + const message = messages[0]; + + const timeBeforeRead = CoreDate.utc(); + const updatedMessage = await recipient.messages.markMessageAsRead(message.id); + const timeAfterRead = CoreDate.utc(); + + expect(updatedMessage.wasReadAt).toBeDefined(); + expect(updatedMessage.wasReadAt!.isSameOrAfter(timeBeforeRead)).toBe(true); + expect(updatedMessage.wasReadAt!.isSameOrBefore(timeAfterRead)).toBe(true); + }); + + test("should not change wasReadAt of a read message", async function () { + await TestUtil.sendMessage(sender, recipient); + const messages = await TestUtil.syncUntilHasMessages(recipient, 1); + const message = messages[0]; + + const updatedMessage = await recipient.messages.markMessageAsRead(message.id); + const firstReadAt = updatedMessage.wasReadAt; + + const unchangedMessage = await recipient.messages.markMessageAsRead(updatedMessage.id); + expect(unchangedMessage.wasReadAt).toBeDefined(); + expect(unchangedMessage.wasReadAt!.equals(firstReadAt!)).toBe(true); + }); + + test("should mark a read message as unread", async function () { + await TestUtil.sendMessage(sender, recipient); + const messages = await TestUtil.syncUntilHasMessages(recipient, 1); + const message = messages[0]; + + const readMessage = await recipient.messages.markMessageAsRead(message.id); + + const unreadMessage = await recipient.messages.markMessageAsUnread(readMessage.id); + expect(unreadMessage.wasReadAt).toBeUndefined(); + }); });