From e0f7467706edad02b149570f9c02415cfc7ed7d8 Mon Sep 17 00:00:00 2001 From: trtshen Date: Mon, 2 Mar 2026 16:49:38 +0800 Subject: [PATCH] [CORE-8158] 2.4.y/chat-edit-delete --- .../chat/chat-room/chat-room.component.html | 29 +++- .../chat/chat-room/chat-room.component.scss | 42 ++++++ .../chat-room/chat-room.component.spec.ts | 115 +++++++++++++++- .../chat/chat-room/chat-room.component.ts | 96 +++++++++++++ projects/v3/src/app/pages/chat/chat.module.ts | 2 + .../edit-message-popup.component.html | 54 ++++++++ .../edit-message-popup.component.scss | 99 ++++++++++++++ .../edit-message-popup.component.spec.ts | 129 ++++++++++++++++++ .../edit-message-popup.component.ts | 79 +++++++++++ .../v3/src/app/services/chat.service.spec.ts | 26 ++++ projects/v3/src/app/services/chat.service.ts | 44 +++++- .../src/app/services/pusher.service.spec.ts | 31 ++++- .../v3/src/app/services/pusher.service.ts | 27 ++++ projects/v3/src/styles.scss | 16 ++- 14 files changed, 778 insertions(+), 11 deletions(-) create mode 100644 projects/v3/src/app/pages/chat/edit-message-popup/edit-message-popup.component.html create mode 100644 projects/v3/src/app/pages/chat/edit-message-popup/edit-message-popup.component.scss create mode 100644 projects/v3/src/app/pages/chat/edit-message-popup/edit-message-popup.component.spec.ts create mode 100644 projects/v3/src/app/pages/chat/edit-message-popup/edit-message-popup.component.ts diff --git a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.html b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.html index a1e1b7215..e6375c143 100644 --- a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.html +++ b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.html @@ -40,7 +40,7 @@ - + @@ -52,9 +52,28 @@

{{ getMessageDate(message.sentAt) }}

-
+
+ + +
+ + + + + + +
+ +

{{ message.senderName }}

@@ -121,6 +140,8 @@
+
+ diff --git a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.scss b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.scss index 0f3ba7924..2fc75f05b 100644 --- a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.scss +++ b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.scss @@ -52,7 +52,33 @@ $message-body-size: 60%; text-align: center; margin-bottom: 8px; } + // message container wraps action buttons + message body + .message-container { + .action-container { + display: none; + ion-button { + --color: var(--ion-color-medium); + + ion-icon { + color: var(--ion-color-medium); + font-size: 18px; + } + + &:hover { + &.delete-btn { + --color: var(--ion-color-danger); + ion-icon { color: var(--ion-color-danger); } + } + + &:not(.delete-btn) { + --color: var(--ion-color-primary); + ion-icon { color: var(--ion-color-primary); } + } + } + } + } + } .message-body { padding: 8px 16px; min-width: 48px; @@ -200,6 +226,22 @@ $message-body-size: 60%; color: initial; } + .message-container { + display: flex; + align-items: center; + justify-content: flex-end; + flex-direction: row; + + // reveal action buttons on hover + &:hover { + .action-container { + display: flex; + align-items: center; + margin-right: 4px; + } + } + } + .message-body { background-color: var(--ion-color-primary); diff --git a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts index 755d2e960..884b2c963 100644 --- a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts +++ b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts @@ -3,6 +3,7 @@ import { async, ComponentFixture, TestBed, tick, fakeAsync } from '@angular/core import { RouterTestingModule } from '@angular/router/testing'; import { ChatRoomComponent } from './chat-room.component'; import { ChannelMembers, ChatService } from '@v3/services/chat.service'; +import { NotificationsService } from '@v3/services/notifications.service'; import { of } from 'rxjs'; import { BrowserStorageService } from '@v3/services/storage.service'; import { UtilsService } from '@v3/services/utils.service'; @@ -61,6 +62,8 @@ describe('ChatRoomComponent', () => { 'postNewMessage': of(true), 'markMessagesAsSeen': of(true), 'postAttachmentMessage': of(true), + 'deleteChatMessage': of(true), + 'editChatMessage': of(true), }), }, { @@ -69,7 +72,7 @@ describe('ChatRoomComponent', () => { }, { provide: PusherService, - useValue: jasmine.createSpyObj('PusherService', ['triggerSendMessage', 'triggerTyping']) + useValue: jasmine.createSpyObj('PusherService', ['triggerSendMessage', 'triggerTyping', 'triggerDeleteMessage', 'triggerEditMessage']) }, { provide: FilestackService, @@ -95,7 +98,11 @@ describe('ChatRoomComponent', () => { { provide: ModalController, useValue: modalCtrlSpy - } + }, + { + provide: NotificationsService, + useValue: jasmine.createSpyObj('NotificationsService', ['alert', 'presentToast']), + }, ] }) .compileComponents(); @@ -550,4 +557,108 @@ describe('ChatRoomComponent', () => { }); }); + describe('when testing hasEditableText()', () => { + it('should return true for a message with text content', () => { + const message: any = { uuid: '1', message: '

hello

' }; + expect(component.hasEditableText(message)).toBeTrue(); + }); + + it('should return false for a message with empty text', () => { + const message: any = { uuid: '1', message: '' }; + expect(component.hasEditableText(message)).toBeFalse(); + }); + + it('should return false for a message with null text', () => { + const message: any = { uuid: '1', message: null }; + expect(component.hasEditableText(message)).toBeFalse(); + }); + + it('should return false for a message with only empty html tags', () => { + const message: any = { uuid: '1', message: '

' }; + expect(component.hasEditableText(message)).toBeFalse(); + }); + }); + + describe('when testing removeMessageFromList()', () => { + beforeEach(() => { + component.messageList = [ + { uuid: 'msg-1', isSender: true, message: 'a', file: null, created: '', scheduled: '', sentAt: '' } as any, + { uuid: 'msg-2', isSender: true, message: 'b', file: null, created: '', scheduled: '', sentAt: '' } as any, + { uuid: 'msg-3', isSender: false, message: 'c', file: null, created: '', scheduled: '', sentAt: '' } as any, + ]; + component.chatChannel = { + uuid: 'ch-1', name: 'Team 1', avatar: '', pusherChannel: 'pusher-ch', + isAnnouncement: false, isDirectMessage: false, readonly: false, + roles: [], unreadMessageCount: 0, lastMessage: '', lastMessageCreated: '', canEdit: false, + }; + component.channelUuid = 'ch-1'; + }); + + it('should remove the message from the list and trigger pusher', () => { + pusherSpy.triggerDeleteMessage.and.returnValue(); + component.removeMessageFromList('msg-2'); + expect(component.messageList.length).toBe(2); + expect(component.messageList.find(m => m.uuid === 'msg-2')).toBeUndefined(); + expect(pusherSpy.triggerDeleteMessage).toHaveBeenCalledWith('pusher-ch', { + channelUuid: 'ch-1', + uuid: 'msg-2', + }); + }); + + it('should do nothing if message uuid not found', () => { + component.removeMessageFromList('non-existent'); + expect(component.messageList.length).toBe(3); + expect(pusherSpy.triggerDeleteMessage).not.toHaveBeenCalled(); + }); + }); + + describe('when testing deleteMessage()', () => { + let notificationsService: any; + + beforeEach(() => { + notificationsService = TestBed.inject(NotificationsService); + component.chatChannel = { + uuid: 'ch-1', name: 'Team 1', avatar: '', pusherChannel: 'pusher-ch', + isAnnouncement: false, isDirectMessage: false, readonly: false, + roles: [], unreadMessageCount: 0, lastMessage: '', lastMessageCreated: '', canEdit: false, + }; + component.channelUuid = 'ch-1'; + component.messageList = [ + { uuid: 'msg-1', isSender: true, message: 'a', file: null, created: '', scheduled: '', sentAt: '' } as any, + ]; + }); + + it('should call notificationsService.alert for confirmation', () => { + spyOn(notificationsService, 'alert'); + component.deleteMessage('msg-1'); + expect(notificationsService.alert).toHaveBeenCalled(); + }); + }); + + describe('when testing openEditMessagePopup()', () => { + it('should create a modal with the correct message', async () => { + component.messageList = [ + { uuid: 'msg-1', isSender: true, message: '

hello

', file: null, created: '2025-01-01', scheduled: '', sentAt: '2025-01-01', senderUuid: 'u1', senderName: 'user', senderRole: 'participant', senderAvatar: '' } as any, + ]; + component.chatChannel = { + uuid: 'ch-1', name: 'Team 1', avatar: '', pusherChannel: 'pusher-ch', + isAnnouncement: false, isDirectMessage: false, readonly: false, + roles: [], unreadMessageCount: 0, lastMessage: '', lastMessageCreated: '', canEdit: false, + }; + component.channelUuid = 'ch-1'; + + const dismissPromise = new Promise(resolve => resolve({ data: { updateSuccess: false } })); + const mockModal = { + present: jasmine.createSpy('present').and.returnValue(Promise.resolve()), + onWillDismiss: jasmine.createSpy('onWillDismiss').and.returnValue(dismissPromise), + }; + modalCtrlSpy.create.and.returnValue(Promise.resolve(mockModal as any)); + + await component.openEditMessagePopup(0); + + expect(modalCtrlSpy.create).toHaveBeenCalled(); + expect(mockModal.present).toHaveBeenCalled(); + }); + }); + }); diff --git a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.ts b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.ts index c898a024f..a779a5a21 100644 --- a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.ts +++ b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.ts @@ -10,6 +10,7 @@ import { FilestackService } from '@v3/services/filestack.service'; import { ChatService, ChatChannel, Message, MessageListResult, ChannelMembers, FileResponse } from '@v3/services/chat.service'; import { ChatPreviewComponent } from '../chat-preview/chat-preview.component'; import { ChatInfoComponent } from '../chat-info/chat-info.component'; +import { EditMessagePopupComponent } from '../edit-message-popup/edit-message-popup.component'; import { Subject, timer } from 'rxjs'; import { debounceTime, switchMap, takeUntil, tap } from 'rxjs/operators'; import { QuillModules } from 'ngx-quill'; @@ -1098,4 +1099,99 @@ export class ChatRoomComponent implements OnInit, OnDestroy, AfterViewInit { download(file: FileResponse): void { return this.utils.downloadFile(file.url, file.name); } + + /** + * delete a message with confirmation dialog. + * flow: confirm → api call → remove from local list → trigger pusher + */ + deleteMessage(messageUuid: string) { + this.notificationsService.alert({ + header: $localize`Delete Message?`, + message: $localize`Are you sure you want to delete this message? This action cannot be undone.`, + cssClass: 'message-delete-alert', + buttons: [ + { + text: $localize`Cancel`, + role: 'cancel', + }, + { + text: $localize`Delete Message`, + cssClass: 'danger', + handler: () => { + this.chatService.deleteChatMessage(messageUuid).subscribe({ + next: () => { + this.utils.broadcastEvent('chat:info-update', true); + this.removeMessageFromList(messageUuid); + }, + error: (err) => { + console.error('error deleting message', err); + }, + }); + }, + }, + ], + }); + } + + /** + * remove message from local array and trigger pusher client event. + */ + removeMessageFromList(messageUuid: string) { + const deletedMessageIndex = this.messageList.findIndex( + (message) => message.uuid === messageUuid + ); + if (deletedMessageIndex === -1) { + return; + } + this.messageList.splice(deletedMessageIndex, 1); + + this.pusherService.triggerDeleteMessage(this.chatChannel.pusherChannel, { + channelUuid: this.channelUuid, + uuid: messageUuid, + }); + } + + /** + * open edit modal for a sent message. + */ + async openEditMessagePopup(index: number) { + const modal = await this.modalController.create({ + component: EditMessagePopupComponent, + cssClass: 'chat-edit-message-popup', + componentProps: { + chatMessage: this.messageList[index], + }, + backdropDismiss: false, + }); + await modal.present(); + + modal.onWillDismiss().then((data) => { + if (data?.data?.updateSuccess && data?.data?.newMessageData) { + this.messageList[index].message = data.data.newMessageData; + + // notify other clients via pusher + this.pusherService.triggerEditMessage(this.chatChannel.pusherChannel, { + channelUuid: this.channelUuid, + uuid: this.messageList[index].uuid, + isSender: this.messageList[index].isSender, + message: this.messageList[index].message, + file: JSON.stringify(this.messageList[index].file), + created: this.messageList[index].created, + senderUuid: this.messageList[index].senderUuid, + senderName: this.messageList[index].senderName, + senderRole: this.messageList[index].senderRole, + senderAvatar: this.messageList[index].senderAvatar, + sentAt: this.messageList[index].sentAt, + }); + } + this.utils.broadcastEvent('chat:info-update', true); + }); + } + + /** + * returns true if the message has editable text content (not attachment-only). + */ + hasEditableText(message: Message): boolean { + return !!message?.message && !this.utils.isQuillContentEmpty(message.message); + } } diff --git a/projects/v3/src/app/pages/chat/chat.module.ts b/projects/v3/src/app/pages/chat/chat.module.ts index c84e68706..d59bb84f2 100644 --- a/projects/v3/src/app/pages/chat/chat.module.ts +++ b/projects/v3/src/app/pages/chat/chat.module.ts @@ -10,6 +10,7 @@ import { ChatInfoComponent } from './chat-info/chat-info.component'; import { ComponentsModule } from '../../components/components.module'; import { PersonalisedHeaderModule } from '@v3/app/personalised-header/personalised-header.module'; import { AttachmentPopoverComponent } from './attachment-popover/attachment-popover.component'; +import { EditMessagePopupComponent } from './edit-message-popup/edit-message-popup.component'; import Quill from 'quill'; import MagicUrl from 'quill-magic-url'; @@ -39,6 +40,7 @@ Quill.register('modules/magicUrl', MagicUrl); ChatViewComponent, ChatInfoComponent, AttachmentPopoverComponent, + EditMessagePopupComponent, ], providers: [], exports: [ChatRoomComponent], diff --git a/projects/v3/src/app/pages/chat/edit-message-popup/edit-message-popup.component.html b/projects/v3/src/app/pages/chat/edit-message-popup/edit-message-popup.component.html new file mode 100644 index 000000000..e6b561fe5 --- /dev/null +++ b/projects/v3/src/app/pages/chat/edit-message-popup/edit-message-popup.component.html @@ -0,0 +1,54 @@ + + + + {{ updateSuccess ? 'Message Updated' : 'Edit Message' }} + + + + + + + + + + +
+ +

Your message has been updated.

+
+ +
+
+
+ + + +
+ +
+
+
+ + + + + + {{ updateSuccess ? 'Close' : 'Cancel' }} + + + + Update Message + + + + diff --git a/projects/v3/src/app/pages/chat/edit-message-popup/edit-message-popup.component.scss b/projects/v3/src/app/pages/chat/edit-message-popup/edit-message-popup.component.scss new file mode 100644 index 000000000..78b4c6525 --- /dev/null +++ b/projects/v3/src/app/pages/chat/edit-message-popup/edit-message-popup.component.scss @@ -0,0 +1,99 @@ +:host { + // ensure the component fills the modal + display: flex; + flex-direction: column; + height: 100%; +} + +ion-header { + ion-toolbar { + --background: white; + --border-width: 0 0 1px 0; + --border-color: var(--ion-color-light-shade, #d7d8da); + + ion-title { + font-weight: 600; + } + + ion-button { + --color: var(--ion-color-medium, #92949c); + } + } +} + +.edit-message-content { + --background: white; +} + +.editor-wrapper { + display: flex; + flex-direction: column; + height: 100%; +} + +.edit-message-editor { + display: flex; + flex-direction: column; + flex: 1; + + ::ng-deep { + .ql-toolbar.ql-snow { + border: 1px solid var(--ion-color-light-shade, #d7d8da); + border-radius: 8px 8px 0 0; + background: var(--ion-color-light, #f4f5f8); + padding: 8px; + flex-shrink: 0; + } + + .ql-container.ql-snow { + border: 1px solid var(--ion-color-light-shade, #d7d8da); + border-top: none; + border-radius: 0 0 8px 8px; + flex: 1; + min-height: 150px; + font-size: 14px; + } + + .ql-editor { + min-height: 150px; + padding: 12px 16px; + + &.ql-blank::before { + color: var(--ion-color-medium, #92949c); + font-style: normal; + } + } + } +} + +.success-state { + padding: 32px 16px; + + .success-icon { + font-size: 56px; + margin-bottom: 16px; + } + + p { + color: var(--ion-text-color, #000); + margin-top: 8px; + } + + .updated-message-preview { + margin-top: 20px; + padding: 12px 16px; + background: var(--ion-color-light, #f4f5f8); + border-radius: 8px; + text-align: left; + border: 1px solid var(--ion-color-light-shade, #d7d8da); + } +} + +ion-footer { + .footer-toolbar { + --background: white; + --border-width: 1px 0 0 0; + --border-color: var(--ion-color-light-shade, #d7d8da); + padding: 4px 8px; + } +} diff --git a/projects/v3/src/app/pages/chat/edit-message-popup/edit-message-popup.component.spec.ts b/projects/v3/src/app/pages/chat/edit-message-popup/edit-message-popup.component.spec.ts new file mode 100644 index 000000000..491308cdd --- /dev/null +++ b/projects/v3/src/app/pages/chat/edit-message-popup/edit-message-popup.component.spec.ts @@ -0,0 +1,129 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ModalController } from '@ionic/angular'; +import { ChatService } from '@v3/services/chat.service'; +import { of, throwError } from 'rxjs'; +import { EditMessagePopupComponent } from './edit-message-popup.component'; +import { FormsModule } from '@angular/forms'; + +describe('EditMessagePopupComponent', () => { + let component: EditMessagePopupComponent; + let fixture: ComponentFixture; + let chatServiceSpy: jasmine.SpyObj; + let modalCtrlSpy: jasmine.SpyObj; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [FormsModule], + declarations: [EditMessagePopupComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + providers: [ + { + provide: ChatService, + useValue: jasmine.createSpyObj('ChatService', ['editChatMessage']), + }, + { + provide: ModalController, + useValue: jasmine.createSpyObj('ModalController', ['dismiss']), + }, + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EditMessagePopupComponent); + component = fixture.componentInstance; + chatServiceSpy = TestBed.inject(ChatService) as jasmine.SpyObj; + modalCtrlSpy = TestBed.inject(ModalController) as jasmine.SpyObj; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('ngOnInit()', () => { + it('should populate message and uuid from chatMessage input', () => { + component.chatMessage = { + uuid: 'msg-uuid-1', + sender: null, + isSender: true, + message: '

hello world

', + file: null, + created: '2025-01-01', + scheduled: null, + sentAt: '2025-01-01', + }; + component.ngOnInit(); + expect(component.message).toEqual('

hello world

'); + expect(component.messageUuid).toEqual('msg-uuid-1'); + }); + + it('should handle null chatMessage gracefully', () => { + component.chatMessage = null; + component.ngOnInit(); + expect(component.message).toEqual(''); + expect(component.messageUuid).toEqual(''); + }); + }); + + describe('editMessage()', () => { + beforeEach(() => { + component.messageUuid = 'msg-uuid-1'; + component.message = '

updated text

'; + }); + + it('should call chatService.editChatMessage and set updateSuccess on success', () => { + chatServiceSpy.editChatMessage.and.returnValue(of({ data: { editChatLog: { success: true } } })); + component.editMessage(); + expect(chatServiceSpy.editChatMessage).toHaveBeenCalledWith({ + uuid: 'msg-uuid-1', + message: '

updated text

', + }); + expect(component.updateSuccess).toBeTrue(); + expect(component.sending).toBeFalse(); + }); + + it('should set sending to false on error', () => { + chatServiceSpy.editChatMessage.and.returnValue(throwError(() => new Error('api error'))); + component.editMessage(); + expect(component.sending).toBeFalse(); + expect(component.updateSuccess).toBeFalse(); + }); + + it('should not call api if messageUuid is empty', () => { + component.messageUuid = ''; + component.editMessage(); + expect(chatServiceSpy.editChatMessage).not.toHaveBeenCalled(); + }); + + it('should not call api if already sending', () => { + component.sending = true; + component.editMessage(); + expect(chatServiceSpy.editChatMessage).not.toHaveBeenCalled(); + }); + }); + + describe('close()', () => { + it('should dismiss with updateSuccess and newMessageData on success', async () => { + component.updateSuccess = true; + component.message = '

edited

'; + modalCtrlSpy.dismiss.and.returnValue(Promise.resolve(true)); + await component.close(); + expect(modalCtrlSpy.dismiss).toHaveBeenCalledWith({ + updateSuccess: true, + newMessageData: '

edited

', + }); + }); + + it('should dismiss with null newMessageData when not updated', async () => { + component.updateSuccess = false; + component.message = '

draft

'; + modalCtrlSpy.dismiss.and.returnValue(Promise.resolve(true)); + await component.close(); + expect(modalCtrlSpy.dismiss).toHaveBeenCalledWith({ + updateSuccess: false, + newMessageData: null, + }); + }); + }); +}); diff --git a/projects/v3/src/app/pages/chat/edit-message-popup/edit-message-popup.component.ts b/projects/v3/src/app/pages/chat/edit-message-popup/edit-message-popup.component.ts new file mode 100644 index 000000000..7560a0f81 --- /dev/null +++ b/projects/v3/src/app/pages/chat/edit-message-popup/edit-message-popup.component.ts @@ -0,0 +1,79 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { ModalController } from '@ionic/angular'; +import { ChatService, EditMessageParam, Message } from '@v3/services/chat.service'; +import { QuillModules } from 'ngx-quill'; + +/** + * popup component for editing a sent chat message. + * displays a quill editor pre-populated with the message text. + */ +@Component({ + selector: 'app-edit-message-popup', + templateUrl: './edit-message-popup.component.html', + styleUrls: ['./edit-message-popup.component.scss'], +}) +export class EditMessagePopupComponent implements OnInit { + @Input() chatMessage: Message; + + message: string = ''; + messageUuid: string = ''; + updateSuccess = false; + sending = false; + + editorModules: QuillModules = { + toolbar: [ + ['bold', 'italic', 'underline', 'strike'], + [{ list: 'ordered' }, { list: 'bullet' }], + ['link'], + ], + }; + + constructor( + private modalController: ModalController, + private chatService: ChatService + ) {} + + ngOnInit() { + if (this.chatMessage) { + this.message = this.chatMessage.message || ''; + this.messageUuid = this.chatMessage.uuid || ''; + } + } + + /** + * submit edit to the api. + */ + editMessage() { + if (!this.messageUuid || this.sending) { + return; + } + + this.sending = true; + const editParam: EditMessageParam = { + uuid: this.messageUuid, + message: this.message, + }; + + this.chatService.editChatMessage(editParam).subscribe({ + next: () => { + this.updateSuccess = true; + this.sending = false; + }, + error: (error) => { + this.sending = false; + console.error('error editing message', error); + }, + }); + } + + /** + * dismiss the modal, returning update status and new message data. + */ + async close() { + const returnData = { + updateSuccess: this.updateSuccess || false, + newMessageData: this.updateSuccess ? this.message : null, + }; + await this.modalController.dismiss(returnData); + } +} diff --git a/projects/v3/src/app/services/chat.service.spec.ts b/projects/v3/src/app/services/chat.service.spec.ts index 8d27cc6ea..34cc8c402 100644 --- a/projects/v3/src/app/services/chat.service.spec.ts +++ b/projects/v3/src/app/services/chat.service.spec.ts @@ -424,4 +424,30 @@ describe('ChatService', () => { }); }); + describe('when testing deleteChatMessage()', () => { + it('should call graphQLMutate with correct uuid', () => { + const response = { data: { deleteChatLog: { success: true } } }; + apolloSpy.graphQLMutate.and.returnValue(of(response)); + service.deleteChatMessage('msg-uuid-1').subscribe(res => { + expect(res).toEqual(response); + }); + expect(apolloSpy.graphQLMutate).toHaveBeenCalled(); + const args = apolloSpy.graphQLMutate.calls.mostRecent().args; + expect(args[1]).toEqual({ uuid: 'msg-uuid-1' }); + }); + }); + + describe('when testing editChatMessage()', () => { + it('should call graphQLMutate with correct params', () => { + const response = { data: { editChatLog: { success: true } } }; + apolloSpy.graphQLMutate.and.returnValue(of(response)); + service.editChatMessage({ uuid: 'msg-uuid-1', message: '

updated

' }).subscribe(res => { + expect(res).toEqual(response); + }); + expect(apolloSpy.graphQLMutate).toHaveBeenCalled(); + const args = apolloSpy.graphQLMutate.calls.mostRecent().args; + expect(args[1]).toEqual({ uuid: 'msg-uuid-1', message: '

updated

' }); + }); + }); + }); diff --git a/projects/v3/src/app/services/chat.service.ts b/projects/v3/src/app/services/chat.service.ts index 2eea99323..1fc06a81a 100644 --- a/projects/v3/src/app/services/chat.service.ts +++ b/projects/v3/src/app/services/chat.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { ApolloService } from '@v3/services/apollo.service'; import { RequestService } from 'request'; -import { map } from 'rxjs/operators'; +import { delay, map } from 'rxjs/operators'; import { Observable, of } from 'rxjs'; import { UtilsService } from '@v3/services/utils.service'; import { PusherService } from '@v3/services/pusher.service'; @@ -91,6 +91,11 @@ export interface MessageListResult { messages: Message[]; } +export interface EditMessageParam { + uuid: string; + message?: string; +} + interface NewMessageParam { channelUuid: string; message: string; @@ -471,6 +476,43 @@ export class ChatService { }; } + /** + * delete a chat message by uuid. + */ + deleteChatMessage(uuid: string): Observable { + if (environment.demo) { + return of({}).pipe(delay(1000)); + } + return this.apolloService.graphQLMutate( + `mutation deleteChatMessage($uuid: String!) { + deleteChatLog(uuid: $uuid) { + success + } + }`, + { uuid } + ); + } + + /** + * edit a chat message (text content). + */ + editChatMessage(data: EditMessageParam): Observable { + if (environment.demo) { + return of({}).pipe(delay(1000)); + } + return this.apolloService.graphQLMutate( + `mutation editChatMessage($uuid: String!, $message: String) { + editChatLog(uuid: $uuid, message: $message) { + success + } + }`, + { + uuid: data.uuid, + message: data.message, + } + ); + } + logChatError(data) { return this.apolloService.logError(JSON.stringify(data)).subscribe(); } diff --git a/projects/v3/src/app/services/pusher.service.spec.ts b/projects/v3/src/app/services/pusher.service.spec.ts index 16701a986..cca1002e3 100644 --- a/projects/v3/src/app/services/pusher.service.spec.ts +++ b/projects/v3/src/app/services/pusher.service.spec.ts @@ -391,6 +391,35 @@ describe('PusherService', async () => { }); }); + describe('triggerDeleteMessage()', () => { + it('should do nothing if channel not found', () => { + service['channels'].chat = []; + service.triggerDeleteMessage('non-existent', { channelUuid: 'ch-1', uuid: 'msg-1' }); + // no error thrown = pass + }); + + it('should call subscription.trigger with correct event name and data', () => { + const mockSubscription = { trigger: jasmine.createSpy('trigger') }; + service['channels'].chat = [{ name: 'test-channel', subscription: mockSubscription as any }]; + const data = { channelUuid: 'ch-1', uuid: 'msg-1' }; + service.triggerDeleteMessage('test-channel', data); + expect(mockSubscription.trigger).toHaveBeenCalledWith('client-chat-delete-message', data); + }); + }); -}); + describe('triggerEditMessage()', () => { + it('should do nothing if channel not found', () => { + service['channels'].chat = []; + service.triggerEditMessage('non-existent', {} as any); + // no error thrown = pass + }); + + it('should call subscription.trigger with correct event name and data', () => { + const mockSubscription = { trigger: jasmine.createSpy('trigger') }; + service['channels'].chat = [{ name: 'test-channel', subscription: mockSubscription as any }]; + const data = { channelUuid: 'ch-1', uuid: 'msg-1', message: 'hi', file: '', isSender: true, created: '', senderUuid: '', senderName: '', senderRole: '', senderAvatar: '', sentAt: '' }; + service.triggerEditMessage('test-channel', data); + expect(mockSubscription.trigger).toHaveBeenCalledWith('client-chat-edit-message', data); + }); + }); diff --git a/projects/v3/src/app/services/pusher.service.ts b/projects/v3/src/app/services/pusher.service.ts index d6f9edfb2..9fa785d89 100644 --- a/projects/v3/src/app/services/pusher.service.ts +++ b/projects/v3/src/app/services/pusher.service.ts @@ -28,6 +28,11 @@ export interface SendMessageParam { sentAt: string; } +export interface DeleteMessageTriggerParam { + channelUuid: string; + uuid: string; +} + class PusherChannel { name: string; subscription?: Channel; @@ -351,4 +356,26 @@ export class PusherService { channel.subscription.trigger('client-chat-new-message', data); } + /** + * trigger a client event to notify other members that a message was deleted. + */ + triggerDeleteMessage(channelName: string, data: DeleteMessageTriggerParam) { + const channel = this.channels.chat.find(c => c.name === channelName); + if (!channel) { + return; + } + channel.subscription.trigger('client-chat-delete-message', data); + } + + /** + * trigger a client event to notify other members that a message was edited. + */ + triggerEditMessage(channelName: string, data: SendMessageParam) { + const channel = this.channels.chat.find(c => c.name === channelName); + if (!channel) { + return; + } + channel.subscription.trigger('client-chat-edit-message', data); + } + } diff --git a/projects/v3/src/styles.scss b/projects/v3/src/styles.scss index 2f8bc1b5e..cadea23a3 100644 --- a/projects/v3/src/styles.scss +++ b/projects/v3/src/styles.scss @@ -249,11 +249,11 @@ ion-chip.label { text-decoration: none; z-index: 10000; border-radius: 0 0 4px 0; - + // Calculate darker shade if primary color is too light // Falls back to a WCAG-compliant dark green (#2a6d3f = white:dark green = ~7.5:1) background-color: color-mix(in srgb, var(--ion-color-primary) 60%, #1a4d2a); - + // Fallback for browsers without color-mix support @supports not (color-mix(in srgb, white, black)) { background-color: var(--ion-color-primary-shade, #2a6d3f); @@ -515,6 +515,16 @@ quill-editor .ql-toolbar.ql-snow { --height: 500px; } +// edit message modal sizing +.chat-edit-message-popup { + --width: 600px; + --max-width: 90vw; + --height: 500px; + --max-height: 80vh; + --border-radius: 12px; + --box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15); +} + // Alert styles .wide-alert { .alert-wrapper { @@ -533,4 +543,4 @@ quill-editor .ql-toolbar.ql-snow { .txt-red { color: red; -} \ No newline at end of file +}