diff --git a/src/domain/common.schema.ts b/src/domain/common.schema.ts index 565b794..92cf7cb 100644 --- a/src/domain/common.schema.ts +++ b/src/domain/common.schema.ts @@ -14,4 +14,14 @@ export interface PublicUserData { displayName: string username: string userid: string +} + +export interface Attachment { + fileId: string + fileName: string + format: string + type: string + path: string + height: number + width: number } \ No newline at end of file diff --git a/src/domain/dmService.schema.ts b/src/domain/dmService.schema.ts new file mode 100644 index 0000000..8b45f2a --- /dev/null +++ b/src/domain/dmService.schema.ts @@ -0,0 +1,112 @@ +import {Attachment, TimeStamp} from "./common.schema"; + +export interface GetMessageReq { + from: number + chatid: string +} + +export interface GetMessagePosReq { + messageId: string + chatid: string +} + +export interface GetPinnedMessagesReq { + chatid: string +} + +export interface EditMessageReq { + message: string + messageId: string + chatid: string + userid: string +} + +export interface FinishMessageReq { + uploadId: string | null + message: string + replyTo: string + replyToMessage: string + chatid: string + userid: string +} + +export interface ReadMessagesReq { + chatid: string + userid: string +} + +export interface PinMessageReq { + chatid: string + messageId: string + userid: string + message: string +} + +export interface UnpinMessageReq { + chatid: string, + messageId: string, + userid: string +} + +export interface DeleteMessagesReq { + messageIds: string[] + chatid: string + userid: string +} + +export interface JoinWsRoomReq { + connId: string + chatid: string + userid: string +} + +// Response schemas +export interface GetMessagePosResp { + messagePos: number +} + +// Types +export interface Message { + msgid: string + author: string + message: string + sent_at: TimeStamp + isEdited: boolean + chatid: string + files: Attachment[] + seen: boolean + replyTo: string + replyToId: string + forwardedFrom: string + forwardedFromName: string +} + +export interface PinnedMessage { + message: string + messageId: string +} + +// WebSocket payloads +export interface WSMessageDeletedPayload { + messageId: string +} + +export interface WSMessageEditedPayload { + messageId: string + message: string +} + +export interface WSMessagePinnedPayload { + chatid: string + messageId: string + message: string +} + +export interface WSMessagesReadPayload { + userid: string +} + +export interface WSMessageUnpinnedPayload { + chatid: string + messageId: string +} \ No newline at end of file diff --git a/src/mocks/handlers/dm.http.ts b/src/mocks/handlers/dm.http.ts new file mode 100644 index 0000000..4375405 --- /dev/null +++ b/src/mocks/handlers/dm.http.ts @@ -0,0 +1,31 @@ +import {http, HttpResponse} from "msw"; +import {Chat} from "../../domain/chatService.schema"; +import {FinishMessageReq, GetMessagePosResp, Message, PinnedMessage} from "../../domain/dmService.schema"; +import {CreateNetworkReq, Network} from "../../domain/networkService.schema"; + +export const dmHandlers = [ + http.get('*/chat/dm/messages', () => { + return HttpResponse.json([{ + message: "This is a message", + }]) + }), + + http.get('*/chat/dm/getMessagePosition', () => { + return HttpResponse.json({ + messagePos: 5000 + }) + }), + + http.get('*/chat/dm/pinnedMessages', () => { + return HttpResponse.json([{ + message: "This is a pinned message", + }]) + }), + + http.post('*/chat/dm/finishMessage', async ({request}) => { + const body = await request.json() as FinishMessageReq + return HttpResponse.json({ + message: body.message, + }) + }), +] \ No newline at end of file diff --git a/src/mocks/index.ts b/src/mocks/index.ts index 060ff83..025d479 100644 --- a/src/mocks/index.ts +++ b/src/mocks/index.ts @@ -4,6 +4,7 @@ import {pictureHandlers} from "./handlers/picture.http"; import {callHandlers} from "./handlers/call.http"; import {fileUploadHandlers} from "./handlers/fUpl.http"; import {chatHandlers} from "./handlers/chat.http"; +import {dmHandlers} from "./handlers/dm.http"; export const allHandlers = [ ...authHandlers, @@ -11,5 +12,6 @@ export const allHandlers = [ ...pictureHandlers, ...callHandlers, ...fileUploadHandlers, - ...chatHandlers + ...chatHandlers, + ...dmHandlers ] \ No newline at end of file diff --git a/src/services/dm.http.test.ts b/src/services/dm.http.test.ts new file mode 100644 index 0000000..b2ca79a --- /dev/null +++ b/src/services/dm.http.test.ts @@ -0,0 +1,29 @@ +import {describe, expect, it} from "vitest"; +import {DMService} from "./dmService"; +import {DatabaseMock} from "../mocks/storage/database"; +import {faker} from "@faker-js/faker/locale/en"; + +describe("DmService", () => { + const service = new DMService("", "", "", new DatabaseMock(), () => {}) + + it("should get messages", async () => { + const messages = await service.get() + expect(messages[0].message).toBe("This is a message") + }) + + it('should get message position', async () => { + const pos = await service.getMessagePos("") + expect(pos).toBe(5000) + }); + + it('should get pinned messages', async () => { + const pinnedMessages = await service.getPinnedMessages() + expect(pinnedMessages[0].message).toBe("This is a pinned message") + }); + + it('should send a new message', async () => { + const message = faker.internet.displayName() + const newMessage = await service.sendMessage(message) + expect(newMessage.message).toBe(message) + }); +}) \ No newline at end of file diff --git a/src/services/dmService.ts b/src/services/dmService.ts new file mode 100644 index 0000000..b37da53 --- /dev/null +++ b/src/services/dmService.ts @@ -0,0 +1,247 @@ +import {DatabaseAPI} from "../storage/database"; +import {AxiosInstance, isAxiosError} from "axios"; +import {MessageListener} from "../domain/websocket.schema"; +import {getClient} from "../core/http"; +import {WebSocketHandler} from "../core/webSocketHandler"; +import { + DeleteMessagesReq, + EditMessageReq, + FinishMessageReq, + GetMessagePosResp, JoinWsRoomReq, + Message, PinMessageReq, + PinnedMessage, ReadMessagesReq, UnpinMessageReq +} from "../domain/dmService.schema"; +import {NetworkInvite} from "../domain/networkService.schema"; +import {GenericErrorBody} from "../domain/http.schema"; +import {FileData} from "../domain/fileUploadService.schema"; +import {FileUploadService} from "./fileUploadService"; + +export class DMService { + userid: string; + chatid: string; + token: string; + database: DatabaseAPI; + client: AxiosInstance + + constructor(userid: string, token: string, chatid: string, database: DatabaseAPI, wsMessageListener: MessageListener) { + this.userid = userid; + this.chatid = chatid; + this.database = database; + this.token = token; + this.client = getClient(false).create({ + headers: { + "Authorization": token, + "X-WS-ID": WebSocketHandler.getInstance().connId + } + }) + WebSocketHandler.getInstance().registerService({ + identifier: chatid, + onNewConnId: this.onNewConnId, + onNewMessage: wsMessageListener, + }) + } + + private onNewConnId(newConnId: string) { + console.log("NetworkService: New connection id") + this.client.defaults.headers["X-WS-ID"] = newConnId; + } + + /** + * Fetches all messages in the chat + * @param from + */ + async get(from: number = 0): Promise { + try { + const resp = await this.client.get(`chat/dm/messages?chatid=${this.chatid}&userid=${this.userid}&from=${from}`); + return resp.data + } catch (e) { + if (isAxiosError(e)) { + throw e; + } + throw new Error("Unexpected error") + } + } + + /** + * Fetches the position of the specified message which can be used in get() to fetch an old message with it's surrounding messages + * @param messageId + */ + async getMessagePos(messageId: string): Promise { + try { + const resp = await this.client.get(`chat/dm/getMessagePosition?chatid=${this.chatid}&userid=${this.userid}&messageId=${messageId}`); + return resp.data.messagePos + } catch (e) { + if (isAxiosError(e)) { + throw e; + } + throw new Error("Unexpected error") + } + } + + /** + * Gets all messages pinned in the chat + */ + async getPinnedMessages(): Promise { + try { + const resp = await this.client.get(`chat/dm/pinnedMessages?chatid=${this.chatid}&userid=${this.userid}`); + return resp.data + } catch (e) { + if (isAxiosError(e)) { + throw e; + } + throw new Error("Unexpected error") + } + } + + /** + * Edits the specified message + * @param messageId + * @param newMessage + */ + async editMessage(messageId: string, newMessage: string): Promise { + try { + const resp = await this.client.patch("chat/dm/editMessage", { + messageId: messageId, + chatid: this.chatid, + userid: this.userid, + message: newMessage + }); + return resp.data + } catch (e) { + if (isAxiosError(e)) { + throw e; + } + throw new Error("Unexpected error") + } + } + + /** + * Sends a new message to the chat + * @param message + * @param replyTo + * @param replyToMessage + * @param attachments + */ + async sendMessage(message: string, replyTo: string | null = null, replyToMessage: string | null = null, attachments: FileData[] | null = null): Promise { + let uploadId = "" + if (attachments) { + const uploader = new FileUploadService(this.token) + uploadId = await uploader.uploadFiles(this.chatid, this.userid, attachments) + } + try { + const resp = await this.client.post("chat/dm/finishMessage", { + message: message, + chatid: this.chatid, + replyTo: replyTo, + replyToMessage: replyToMessage, + userid: this.userid, + uploadId: uploadId, + }); + return resp.data + } catch (e) { + if (isAxiosError(e)) { + throw e; + } + throw new Error("Unexpected error") + } + } + + /** + * Reads all messages sent to you in the chat + */ + async readMessages(): Promise { + try { + await this.client.patch("chat/dm/readMessages", { + chatid: this.chatid, + userid: this.userid, + }); + return + } catch (e) { + if (isAxiosError(e)) { + throw e; + } + throw new Error("Unexpected error") + } + } + + /** + * Pins the specified message + * @param messageId + * @param message + */ + async pinMessage(messageId: string, message: string): Promise { + try { + const resp = await this.client.patch("chat/dm/pinMessage", { + messageId: messageId, + chatid: this.chatid, + userid: this.userid, + message: message + }); + return + } catch (e) { + if (isAxiosError(e)) { + throw e; + } + throw new Error("Unexpected error") + } + } + + /** + * Unpins the specified message + * @param messageId + */ + async unpinMessage(messageId: string): Promise { + try { + const resp = await this.client.patch("chat/dm/unpinMessage", { + messageId: messageId, + chatid: this.chatid, + userid: this.userid, + }); + return + } catch (e) { + if (isAxiosError(e)) { + throw e; + } + throw new Error("Unexpected error") + } + } + + /** + * Deletes the message(s) + * @param messageIds + */ + async deleteMessages(messageIds: string[]): Promise { + try { + const resp = await this.client.patch("chat/dm/deleteMessages", { + chatid: this.chatid, + userid: this.userid, + messageIds: messageIds + }); + return + } catch (e) { + if (isAxiosError(e)) { + throw e; + } + throw new Error("Unexpected error") + } + } + + /** + * Joins the WebSocket room to start receiving realtime messages + */ + async joinWebSocketRoom(): Promise { + try { + const resp = await this.client.patch("chat/dm/joinWebSocketRoom", { + chatid: this.chatid, + userid: this.userid, + connId: WebSocketHandler.getInstance().connId, + }); + return + } catch (e) { + if (isAxiosError(e)) { + throw e; + } + throw new Error("Unexpected error") + } + } +} \ No newline at end of file diff --git a/tests/dmService.test.ts b/tests/dmService.test.ts new file mode 100644 index 0000000..675faad --- /dev/null +++ b/tests/dmService.test.ts @@ -0,0 +1,61 @@ +import {describe, expect, it} from "vitest"; +import {DMService} from "../src/services/dmService"; +import {ChatService} from "../src/services/chatService"; +import {DatabaseMock} from "../src/mocks/storage/database"; +import {faker} from "@faker-js/faker/locale/en"; + +describe("DmService Integration Testing", () => { + const DM_SERVICE_TESTING_CHAT_ID = "000000000000000000000000" + const DM_SERVICE_TESTING_USER_ID = "000000000000000000000000" + const DM_SERVICE_TESTING_MESSAGE_ID = "111111111111111111111111" + const DM_SERVICE_TESTING_TOKEN = "testingToken" + + const service = new DMService( + DM_SERVICE_TESTING_USER_ID, + DM_SERVICE_TESTING_TOKEN, + DM_SERVICE_TESTING_CHAT_ID, + new DatabaseMock(), + () => {} + ) + + it('should get messages', async () => { + const messages = await service.get() + expect(messages[0].message).toBe("This is a message") + expect(messages[0].isEdited).toBeTruthy() + }); + + it('should get message position', async () => { + const pos = await service.getMessagePos(DM_SERVICE_TESTING_MESSAGE_ID) + expect(pos).toBe(0) + }); + + it('should edit message', async () => { + const newMessage = faker.lorem.paragraph() + await service.editMessage(DM_SERVICE_TESTING_MESSAGE_ID, newMessage) + const messages = await service.get() + expect(messages[0].message).toBe(newMessage) + }); + + it('should send a message', async () => { + const message = faker.lorem.paragraph() + const newMessage = await service.sendMessage(message) + expect(newMessage.message).toBe(message) + }); + + it('should read messages', async () => { + await service.readMessages() + }); + + it('should pin and unpin messages', async () => { + let pinnedMessages = await service.getPinnedMessages() + expect(pinnedMessages.length).toBe(0) + + await service.pinMessage(DM_SERVICE_TESTING_MESSAGE_ID, "message") + pinnedMessages = await service.getPinnedMessages() + expect(pinnedMessages.length).toBe(1) + + await service.unpinMessage(DM_SERVICE_TESTING_MESSAGE_ID) + pinnedMessages = await service.getPinnedMessages() + expect(pinnedMessages.length).toBe(0) + }); +}) \ No newline at end of file