Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions projects/v3/src/app/pages/chat/chat-room/chat-room.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
</div>

<ion-list lines="none" color="light" class="chat-list" [ngClass]="{'desktop-view': !isMobile}">
<ion-item *ngFor="let message of messageList" color="light">
<ion-item *ngFor="let message of messageList; let i = index" color="light">
<ng-container *ngIf="isLastMessage(message)">
<ion-avatar [ngClass]="getAvatarClass(message)" slot="start">
<img [src]="message.senderAvatar" [attr.alt]="message.senderName ? message.senderName + ' avatar' : 'User avatar'">
Expand All @@ -52,9 +52,28 @@
<p>{{ getMessageDate(message.sentAt) }}</p>
</div>

<div class="message-body"
[ngClass]="getClassForMessageBody(message.file)"
aria-describedby="message-content">
<div class="message-container">

<!-- action buttons — only shown for sender's own messages -->
<div class="action-container" *ngIf="message.isSender">
<ion-button class="delete-btn" fill="clear" size="small"
[attr.title]="'Delete Message'"
[attr.aria-label]="'Delete message'"
(click)="deleteMessage(message.uuid)">
<ion-icon name="trash" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small"
*ngIf="hasEditableText(message)"
[attr.title]="'Edit Message'"
[attr.aria-label]="'Edit message'"
(click)="openEditMessagePopup(i)">
<ion-icon name="create" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
</div>

<div class="message-body"
[ngClass]="getClassForMessageBody(message.file)"
aria-describedby="message-content">

<ng-container *ngIf="!message.isSender">
<p class="seen-text subtitle-1 black">{{ message.senderName }}</p>
Expand Down Expand Up @@ -121,6 +140,8 @@
</ng-container>
</div>

</div><!-- /message-container -->

</ion-label>
</ion-item>

Expand Down
42 changes: 42 additions & 0 deletions projects/v3/src/app/pages/chat/chat-room/chat-room.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down
115 changes: 113 additions & 2 deletions projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -61,6 +62,8 @@ describe('ChatRoomComponent', () => {
'postNewMessage': of(true),
'markMessagesAsSeen': of(true),
'postAttachmentMessage': of(true),
'deleteChatMessage': of(true),
'editChatMessage': of(true),
}),
},
{
Expand All @@ -69,7 +72,7 @@ describe('ChatRoomComponent', () => {
},
{
provide: PusherService,
useValue: jasmine.createSpyObj('PusherService', ['triggerSendMessage', 'triggerTyping'])
useValue: jasmine.createSpyObj('PusherService', ['triggerSendMessage', 'triggerTyping', 'triggerDeleteMessage', 'triggerEditMessage'])
},
{
provide: FilestackService,
Expand All @@ -95,7 +98,11 @@ describe('ChatRoomComponent', () => {
{
provide: ModalController,
useValue: modalCtrlSpy
}
},
{
provide: NotificationsService,
useValue: jasmine.createSpyObj('NotificationsService', ['alert', 'presentToast']),
},
]
})
.compileComponents();
Expand Down Expand Up @@ -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: '<p>hello</p>' };
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: '<p></p>' };
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: '<p>hello</p>', 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<any>(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();
});
});

});
96 changes: 96 additions & 0 deletions projects/v3/src/app/pages/chat/chat-room/chat-room.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
}
2 changes: 2 additions & 0 deletions projects/v3/src/app/pages/chat/chat.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -39,6 +40,7 @@ Quill.register('modules/magicUrl', MagicUrl);
ChatViewComponent,
ChatInfoComponent,
AttachmentPopoverComponent,
EditMessagePopupComponent,
],
providers: [],
exports: [ChatRoomComponent],
Expand Down
Loading