diff --git a/projects/stream-chat-angular/src/lib/message/message.component.spec.ts b/projects/stream-chat-angular/src/lib/message/message.component.spec.ts
index 7081c29b..f0d6d4c0 100644
--- a/projects/stream-chat-angular/src/lib/message/message.component.spec.ts
+++ b/projects/stream-chat-angular/src/lib/message/message.component.spec.ts
@@ -1,10 +1,11 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-
import {
- MessageResponseBase,
- ReactionResponse,
- UserResponse,
-} from 'stream-chat';
+ ComponentFixture,
+ TestBed,
+ fakeAsync,
+ tick,
+} from '@angular/core/testing';
+
+import { MessageResponseBase, UserResponse } from 'stream-chat';
import { DefaultStreamChatGenerics, StreamMessage } from '../types';
import { LoadingIndicatorComponent } from '../loading-indicator/loading-indicator.component';
import { MessageComponent } from './message.component';
@@ -23,6 +24,7 @@ import { AvatarPlaceholderComponent } from '../avatar-placeholder/avatar-placeho
import { BehaviorSubject, of } from 'rxjs';
import { MessageActionsService } from '../message-actions.service';
import { MessageService } from '../message.service';
+import { NgxFloatUiModule } from 'ngx-float-ui';
describe('MessageComponent', () => {
let component: MessageComponent;
@@ -37,24 +39,22 @@ describe('MessageComponent', () => {
let queryDeliveredIndicator: () => HTMLElement | null;
let queryReadIndicator: () => HTMLElement | null;
let queryAvatar: () => AvatarPlaceholderComponent;
- let queryMessageOptions: () => HTMLElement | null;
- let queryActionIcon: () => HTMLElement | null;
let queryText: () => HTMLElement | null;
let queryMessageActionsBoxComponent: () =>
| MessageActionsBoxComponent
| undefined;
let queryAttachmentComponent: () => AttachmentListComponent;
- let queryReactionIcon: () => HTMLElement | null;
- let queryMessageReactions: () => MessageReactionsComponent;
let queryMessageInner: () => HTMLElement | null;
let queryLoadingIndicator: () => HTMLElement | null;
let queryDeletedMessageContainer: () => HTMLElement | null;
let querySystemMessageContainer: () => HTMLElement | null;
let queryReplyCountButton: () => HTMLButtonElement | null;
- let queryReplyInThreadIcon: () => HTMLElement | null;
let queryTranslationNotice: () => HTMLElement | null;
let querySeeOriginalButton: () => HTMLButtonElement | null;
let querySeeTranslationButton: () => HTMLButtonElement | null;
+ let queryMessageBubble: () => HTMLElement | null;
+ let queryMessageOptions: () => HTMLElement | null;
+ let queryMessageOptionsButton: () => HTMLElement | null;
let resendMessageSpy: jasmine.Spy;
let setAsActiveParentMessageSpy: jasmine.Spy;
let jumpToMessageSpy: jasmine.Spy;
@@ -69,7 +69,7 @@ describe('MessageComponent', () => {
jumpToMessageSpy = jasmine.createSpy('jumpToMessage');
currentUser = mockCurrentUser();
TestBed.configureTestingModule({
- imports: [TranslateModule.forRoot()],
+ imports: [TranslateModule.forRoot(), NgxFloatUiModule],
declarations: [
MessageComponent,
AvatarComponent,
@@ -117,24 +117,17 @@ describe('MessageComponent', () => {
queryAvatar = () =>
fixture.debugElement.query(By.css('[data-testid="avatar"]'))
?.componentInstance as AvatarPlaceholderComponent;
- queryMessageOptions = () =>
- nativeElement.querySelector('[data-testid=message-options]');
- queryActionIcon = () =>
- nativeElement.querySelector('[data-testid="action-icon"]');
queryText = () => nativeElement.querySelector('[data-testid="text"]');
- queryReactionIcon = () =>
- nativeElement.querySelector('[data-testid="reaction-icon"]');
- queryMessageReactions = () =>
- fixture.debugElement.query(By.directive(MessageReactionsComponent))
- .componentInstance as MessageReactionsComponent;
queryMessageInner = () =>
nativeElement.querySelector('[data-testid="inner-message"]');
queryLoadingIndicator = () =>
nativeElement.querySelector('[data-testid="loading-indicator"]');
queryReplyCountButton = () =>
nativeElement.querySelector('[data-testid="reply-count-button"]');
- queryReplyInThreadIcon = () =>
- nativeElement.querySelector('[data-testid="reply-in-thread"]');
+ queryMessageOptions = () =>
+ nativeElement.querySelector('[data-testid="message-options"]');
+ queryMessageOptionsButton = () =>
+ nativeElement.querySelector('[data-testid="message-options-button"]');
message = mockMessage();
component.message = message;
component.ngOnChanges({ message: {} as SimpleChange });
@@ -156,6 +149,8 @@ describe('MessageComponent', () => {
nativeElement.querySelector('[data-testid="see-original"]');
querySeeTranslationButton = () =>
nativeElement.querySelector('[data-testid="see-translation"]');
+ queryMessageBubble = () =>
+ nativeElement.querySelector('[data-testid="message-bubble"]');
component.enabledMessageActions = [
'read-events',
'send-reaction',
@@ -425,7 +420,6 @@ describe('MessageComponent', () => {
fixture.detectChanges();
expect(component.areOptionsVisible).toBe(false);
- expect(queryMessageOptions()).toBeNull();
});
it('if message sending failed', () => {
@@ -457,67 +451,159 @@ describe('MessageComponent', () => {
});
});
- it('should display message options for regular messages', () => {
- expect(queryMessageOptions()).not.toBeNull();
- });
+ describe('message menu when touch support is available', () => {
+ beforeEach(() => {
+ component.hasTouchSupport = true;
+ fixture.detectChanges();
+ });
- it('should display message actions for regular messages', () => {
- component.enabledMessageActions = ['delete'];
- component.ngOnChanges({ enabledMessageActions: {} as SimpleChange });
- fixture.detectChanges();
+ it('should display message options for regular messages on long press', fakeAsync(() => {
+ spyOn(component['messageMenuTrigger'], 'show');
+ component.hasTouchSupport = true;
- expect(queryActionIcon()).not.toBeNull();
- });
+ const messageBubble = queryMessageBubble()!;
+ const touchStart = new TouchEvent('touchstart');
+ messageBubble.dispatchEvent(touchStart);
+ tick(200);
- it(`shouldn't display message actions if there are no enabled message actions`, () => {
- component.enabledMessageActions = [];
- component.ngOnChanges({ enabledMessageActions: {} as SimpleChange });
- fixture.detectChanges();
+ expect(component['messageMenuTrigger'].show).not.toHaveBeenCalled();
- expect(queryActionIcon()).toBeNull();
- });
+ tick(200);
- it(`shouldn't display message actions if there is no visible message action`, () => {
- component.enabledMessageActions = ['flag-message'];
- component.ngOnChanges({ enabledMessageActions: {} as SimpleChange });
- fixture.detectChanges();
+ expect(component['messageMenuTrigger'].show).toHaveBeenCalled();
+ }));
+
+ it(`shouldn't display message options for regular messages on short press`, fakeAsync(() => {
+ spyOn(component['messageMenuTrigger'], 'show');
+ component.hasTouchSupport = true;
+
+ const messageBubble = queryMessageBubble()!;
+ const touchStart = new MouseEvent('touchstart');
+ messageBubble.dispatchEvent(touchStart);
+ tick(200);
- expect(queryActionIcon()).toBeNull();
+ const mouseUpEvent = new MouseEvent('touchend');
+ messageBubble.dispatchEvent(mouseUpEvent);
+
+ expect(component['messageMenuTrigger'].show).not.toHaveBeenCalled();
+ }));
+
+ it(`shouldn't display message options if #areOptionsVisible is false`, fakeAsync(() => {
+ component.areOptionsVisible = false;
+ component.hasTouchSupport = true;
+ spyOn(component['messageMenuTrigger'], 'show');
+
+ const messageBubble = queryMessageBubble()!;
+ const mouseDownEvent = new TouchEvent('touchstart');
+ messageBubble.dispatchEvent(mouseDownEvent);
+ tick(400);
+
+ expect(component['messageMenuTrigger'].show).not.toHaveBeenCalled();
+ }));
+
+ it('should call custom message actions click handler', fakeAsync(() => {
+ const service = TestBed.inject(MessageActionsService);
+ const spy = jasmine.createSpy();
+ service.customActionClickHandler = spy;
+ component.enabledMessageActions = ['update-own-message', 'flag-message'];
+ component.ngOnChanges({ enabledMessageActions: {} as SimpleChange });
+ fixture.detectChanges();
+ spyOn(component['messageMenuTrigger'], 'show');
+ const messageBubble = queryMessageBubble()!;
+ const mouseDownEvent = new TouchEvent('touchstart');
+ messageBubble.dispatchEvent(mouseDownEvent);
+ tick(400);
+
+ expect(spy).toHaveBeenCalledWith({
+ message: component.message,
+ enabledActions: component.enabledMessageActions,
+ isMine: component.isSentByCurrentUser,
+ customActions: service.customActions$.getValue(),
+ messageTextHtmlElement: component['messageTextElement']?.nativeElement,
+ });
+
+ expect(component['messageMenuTrigger'].show).not.toHaveBeenCalled();
+ }));
+
+ it(`shouldn't display the message options button`, () => {
+ expect(queryMessageOptions()).toBeNull();
+ });
});
- it('should open and close message actions box', () => {
- component.enabledMessageActions = ['update-own-message', 'flag-message'];
- component.ngOnChanges({ enabledMessageActions: {} as SimpleChange });
- fixture.detectChanges();
+ describe('message menu without touch support', () => {
+ beforeEach(() => {
+ component.hasTouchSupport = false;
+ fixture.detectChanges();
+ });
+
+ it('should display message options for regular messages', () => {
+ expect(component.areMessageOptionsOpen).toBeFalse();
+
+ queryMessageOptionsButton()?.click();
+ fixture.detectChanges();
+
+ expect(component.areMessageOptionsOpen).toBeTrue();
+ });
- expect(component.isActionBoxOpen).toBe(false);
+ it(`shouldn't display message options for regular messages on long click`, fakeAsync(() => {
+ const messageBubble = queryMessageBubble()!;
+ const mouseDownEvent = new MouseEvent('mousedown', { button: 0 });
+ messageBubble.dispatchEvent(mouseDownEvent);
+ tick(200);
+ const mouseUpEvent = new MouseEvent('mouseup');
+ messageBubble.dispatchEvent(mouseUpEvent);
+ fixture.detectChanges();
- queryActionIcon()?.click();
+ expect(component.areMessageOptionsOpen).toBeFalse();
+ }));
+
+ it(`shouldn't display message options if #areOptionsVisible is false`, fakeAsync(() => {
+ component.areOptionsVisible = false;
+
+ queryMessageOptionsButton()?.click();
+ fixture.detectChanges();
+
+ expect(component.areMessageOptionsOpen).toBeTrue();
+ }));
+
+ it('should call custom message actions click handler', () => {
+ const service = TestBed.inject(MessageActionsService);
+ const spy = jasmine.createSpy();
+ service.customActionClickHandler = spy;
+ queryMessageOptionsButton()?.click();
+ fixture.detectChanges();
+
+ expect(spy).toHaveBeenCalledWith({
+ message: component.message,
+ enabledActions: component.enabledMessageActions,
+ isMine: component.isSentByCurrentUser,
+ customActions: service.customActions$.getValue(),
+ messageTextHtmlElement: component['messageTextElement']?.nativeElement,
+ });
+ });
+ });
+
+ it(`shouldn't display message options if there are no enabled message actions`, () => {
+ component.enabledMessageActions = [];
+ component.ngOnChanges({ enabledMessageActions: {} as SimpleChange });
fixture.detectChanges();
- expect(component.isActionBoxOpen).toBe(true);
+ expect(component.areOptionsVisible).toBeFalse();
});
- it('should call custom message actions click handler', () => {
+ it(`shouldn't display message actions if there is no visible message action`, () => {
+ component.enabledMessageActions = ['flag-message'];
const service = TestBed.inject(MessageActionsService);
- const spy = jasmine.createSpy();
- service.customActionClickHandler = spy;
- component.enabledMessageActions = ['update-own-message', 'flag-message'];
+ service.defaultActions.find(
+ (a) => a.actionName === 'copy-message-text'
+ )!.isVisible = () => false;
component.ngOnChanges({ enabledMessageActions: {} as SimpleChange });
fixture.detectChanges();
- queryActionIcon()?.click();
-
- expect(spy).toHaveBeenCalledWith({
- message: component.message,
- enabledActions: component.enabledMessageActions,
- isMine: component.isSentByCurrentUser,
- customActions: service.customActions$.getValue(),
- });
+ expect(component.areOptionsVisible).toBeFalse();
});
it('should provide #isMine to message actions box', () => {
- component.isActionBoxOpen = true;
fixture.detectChanges();
const messageActionsBoxComponent = queryMessageActionsBoxComponent()!;
@@ -531,7 +617,6 @@ describe('MessageComponent', () => {
});
it('should provide #message to message actions box', () => {
- component.isActionBoxOpen = true;
fixture.detectChanges();
const messageActionsBoxComponent = queryMessageActionsBoxComponent()!;
@@ -579,64 +664,6 @@ describe('MessageComponent', () => {
);
});
- it('should display reactions icon, if user can react to message', () => {
- const message = {
- ...mockMessage(),
- id: 'messagId',
- reaction_counts: { haha: 1 },
- latest_reactions: [
- { type: 'wow', user: { id: 'sara', name: 'Sara', image: 'image/url' } },
- { type: 'sad', user: { id: 'ben', name: 'Ben' } },
- ] as ReactionResponse[],
- own_reactions: [
- { type: 'wow', user: { id: 'sara', name: 'Sara', image: 'image/url' } },
- ] as any as ReactionResponse[],
- text: 'Hi',
- };
- component.message = message as any as StreamMessage;
- component.enabledMessageActions = [];
- component.ngOnChanges({
- enabledMessageActions: {} as SimpleChange,
- message: {} as SimpleChange,
- });
- fixture.detectChanges();
-
- expect(queryReactionIcon()).toBeNull();
-
- component.enabledMessageActions = ['send-reaction'];
- component.ngOnChanges({ enabledMessageActions: {} as SimpleChange });
- component.isReactionSelectorOpen = true;
- fixture.detectChanges();
- const messageReactions = queryMessageReactions();
-
- expect(queryReactionIcon()).not.toBeNull();
- expect(messageReactions.messageId).toBe(message.id);
- expect(messageReactions.latestReactions).toBe(message.latest_reactions);
- expect(messageReactions.messageReactionCounts).toBe(
- message.reaction_counts
- );
-
- expect(messageReactions.ownReactions).toBe(message.own_reactions);
- expect(messageReactions.isSelectorOpen).toBe(true);
-
- messageReactions.isSelectorOpenChange.next(false);
-
- expect(component.isReactionSelectorOpen).toBeFalse();
- });
-
- it('should toggle reactions selector', () => {
- component.enabledMessageActions = ['send-reaction'];
- component.ngOnChanges({ enabledMessageActions: {} as SimpleChange });
- fixture.detectChanges();
-
- expect(component.isReactionSelectorOpen).toBeFalse();
-
- queryReactionIcon()?.click();
- fixture.detectChanges();
-
- expect(component.isReactionSelectorOpen).toBeTrue();
- });
-
it(`shouldn't display empty text`, () => {
component.message = { ...component.message!, ...{ text: '' } };
component.ngOnChanges({ message: {} as SimpleChange });
@@ -694,7 +721,7 @@ describe('MessageComponent', () => {
expect(queryDeletedMessageContainer()).not.toBeNull();
expect(queryAvatar()).toBeUndefined();
- expect(queryMessageOptions()).toBeNull();
+ expect(component.areOptionsVisible).toBeFalse();
});
it('should display system message', () => {
@@ -912,27 +939,6 @@ describe('MessageComponent', () => {
expect(setAsActiveParentMessageSpy).toHaveBeenCalledWith(component.message);
});
- it('should display reply in thread icon, if user has the necessary capability', () => {
- expect(queryReplyInThreadIcon()).not.toBeNull();
-
- component.enabledMessageActions = [];
- component.ngOnChanges({ enabledMessageActions: {} as SimpleChange });
- fixture.detectChanges();
-
- expect(queryReplyInThreadIcon()).toBeNull();
- });
-
- it('should select parent message, if reply in thread is clicked', () => {
- component.enabledMessageActions = ['send-reply'];
- component.ngOnChanges({ enabledMessageActions: {} as SimpleChange });
- fixture.detectChanges();
-
- queryReplyInThreadIcon()?.click();
- fixture.detectChanges();
-
- expect(setAsActiveParentMessageSpy).toHaveBeenCalledWith(component.message);
- });
-
describe('in thread mode', () => {
beforeEach(() => {
component.mode = 'thread';
@@ -958,9 +964,8 @@ describe('MessageComponent', () => {
expect(queryDeliveredIndicator()).toBeNull();
});
- it('should not display message actions for parent meesage', () => {
- expect(queryActionIcon()).toBeNull();
- expect(queryReactionIcon()).toBeNull();
+ it('should not display message options for parent meesage', () => {
+ expect(component.areOptionsVisible).toBeFalse();
});
it('should not display reply count for parent meesage', () => {
@@ -969,42 +974,6 @@ describe('MessageComponent', () => {
expect(queryReplyCountButton()).toBeNull();
});
-
- it(`shouldn't display reply in thread for thread replies`, () => {
- component.enabledMessageActions = ['send-reply'];
- component.message!.parent_id = 'parentMessage';
- component.ngOnChanges({
- message: {} as SimpleChange,
- enabledMessageActions: {} as SimpleChange,
- });
- fixture.detectChanges();
-
- expect(queryReplyInThreadIcon()).toBeNull();
- });
-
- it('should display message actions for thread replies', () => {
- component.enabledMessageActions = ['update-any-message'];
- component.message!.parent_id = 'parentMessage';
- component.ngOnChanges({
- message: {} as SimpleChange,
- enabledMessageActions: {} as SimpleChange,
- });
- fixture.detectChanges();
-
- expect(queryActionIcon()).not.toBeNull();
- });
-
- it('should display message reactions for thread replies', () => {
- component.enabledMessageActions = ['send-reaction'];
- component.message!.parent_id = 'parentMessage';
- component.ngOnChanges({
- message: {} as SimpleChange,
- enabledMessageActions: {} as SimpleChange,
- });
- fixture.detectChanges();
-
- expect(queryReactionIcon()).not.toBeNull();
- });
});
it('should apply necessary CSS class, if highlighted', () => {
@@ -1031,7 +1000,7 @@ describe('MessageComponent', () => {
enabledMessageActions: {} as any as SimpleChange,
});
- expect(component.visibleMessageActionsCount).toBe(3);
+ expect(component.visibleMessageActionsCount).toBe(3 + 1);
component.enabledMessageActions = [
'pin-message',
@@ -1046,7 +1015,7 @@ describe('MessageComponent', () => {
enabledMessageActions: {} as any as SimpleChange,
});
- expect(component.visibleMessageActionsCount).toBe(4);
+ expect(component.visibleMessageActionsCount).toBe(4 + 1);
const customActions = [
{
@@ -1059,7 +1028,7 @@ describe('MessageComponent', () => {
const service = TestBed.inject(MessageActionsService);
service.customActions$.next(customActions);
- expect(component.visibleMessageActionsCount).toBe(5);
+ expect(component.visibleMessageActionsCount).toBe(5 + 1);
});
describe('quoted message', () => {
diff --git a/projects/stream-chat-angular/src/lib/message/message.component.ts b/projects/stream-chat-angular/src/lib/message/message.component.ts
index e9c8bbb2..b3e004f3 100644
--- a/projects/stream-chat-angular/src/lib/message/message.component.ts
+++ b/projects/stream-chat-angular/src/lib/message/message.component.ts
@@ -8,6 +8,8 @@ import {
ChangeDetectorRef,
ChangeDetectionStrategy,
AfterViewInit,
+ ViewChild,
+ ElementRef,
} from '@angular/core';
import { Attachment, UserResponse } from 'stream-chat';
import { ChannelService } from '../channel.service';
@@ -32,7 +34,10 @@ import { listUsers } from '../list-users';
import { DateParserService } from '../date-parser.service';
import { MessageService } from '../message.service';
import { MessageActionsService } from '../message-actions.service';
-import { NgxFloatUiContentComponent } from 'ngx-float-ui';
+import {
+ NgxFloatUiContentComponent,
+ NgxFloatUiLooseDirective,
+} from 'ngx-float-ui';
type MessagePart = {
content: string;
@@ -74,10 +79,7 @@ export class MessageComponent
@Input() isHighlighted = false;
canReceiveReadEvents: boolean | undefined;
canReactToMessage: boolean | undefined;
- isActionBoxOpen = false;
isEditedFlagOpened = false;
- isReactionSelectorOpen = false;
- visibleMessageActionsCount = 0;
messageTextParts: MessagePart[] | undefined = [];
messageText?: string;
shouldDisplayTranslationNotice = false;
@@ -99,7 +101,9 @@ export class MessageComponent
replyCountParam: { replyCount: number | undefined } = {
replyCount: undefined,
};
+ areMessageOptionsOpen = false;
canDisplayReadStatus = false;
+ hasTouchSupport = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
private quotedMessageAttachments: Attachment[] | undefined;
private subscriptions: Subscription[] = [];
private isViewInited = false;
@@ -107,6 +111,14 @@ export class MessageComponent
private readonly urlRegexp =
/(?:(?:https?|ftp|file):\/\/|www\.|ftp\.)(?:\([-A-Z0-9+&@#/%=~_|$?!:,.]*\)|[-A-Z0-9+&@#/%=~_|$?!:,.])*(?:\([-A-Z0-9+&@#/%=~_|$?!:,.]*\)|[A-Z0-9+&@#/%=~_|$])/gim;
private emojiRegexp = new RegExp(emojiRegex(), 'g');
+ @ViewChild('messageMenuTrigger')
+ messageMenuTrigger!: NgxFloatUiLooseDirective;
+ @ViewChild('messageMenuFloat')
+ messageMenuFloat!: NgxFloatUiContentComponent;
+ @ViewChild('messageTextElement') messageTextElement?: ElementRef;
+ private showMessageMenuTimeout?: ReturnType;
+ private shouldPreventMessageMenuClose = false;
+ private _visibleMessageActionsCount = 0;
constructor(
private chatClientService: ChatClientService,
@@ -120,6 +132,17 @@ export class MessageComponent
this.displayAs = this.messageService.displayAs;
}
+ get visibleMessageActionsCount() {
+ return this._visibleMessageActionsCount;
+ }
+
+ set visibleMessageActionsCount(count: number) {
+ this._visibleMessageActionsCount = count;
+ if (this.areOptionsVisible && this._visibleMessageActionsCount === 0) {
+ this.areOptionsVisible = false;
+ }
+ }
+
ngOnInit(): void {
this.subscriptions.push(
this.chatClientService.user$.subscribe((u) => {
@@ -215,16 +238,19 @@ export class MessageComponent
this.shouldDisplayThreadLink =
!!this.message?.reply_count && this.mode !== 'thread';
}
- if (changes.message || changes.mode) {
+ if (changes.message || changes.mode || changes.enabledMessageActions) {
this.areOptionsVisible = this.message
? !(
!this.message.type ||
this.message.type === 'error' ||
this.message.type === 'system' ||
+ this.message.type === 'deleted' ||
this.message.type === 'ephemeral' ||
this.message.status === 'failed' ||
this.message.status === 'sending' ||
- (this.mode === 'thread' && !this.message.parent_id)
+ (this.mode === 'thread' && !this.message.parent_id) ||
+ this.message.deleted_at ||
+ this.enabledMessageActions.length === 0
)
: false;
}
@@ -249,7 +275,45 @@ export class MessageComponent
this.subscriptions.forEach((s) => s.unsubscribe());
}
- messageActionsClicked() {
+ mousePushedDown(event: MouseEvent) {
+ if (
+ !this.hasTouchSupport ||
+ event.button !== 0 ||
+ !this.areOptionsVisible
+ ) {
+ return;
+ }
+ this.startMessageMenuShowTimer({ fromTouch: false });
+ }
+
+ mouseReleased() {
+ this.stopMessageMenuShowTimer();
+ }
+
+ touchStarted() {
+ if (!this.areOptionsVisible) {
+ return;
+ }
+ this.startMessageMenuShowTimer({ fromTouch: true });
+ }
+
+ touchEnded() {
+ this.stopMessageMenuShowTimer();
+ }
+
+ messageBubbleClicked(event: Event) {
+ if (!this.hasTouchSupport) {
+ return;
+ }
+ if (this.shouldPreventMessageMenuClose) {
+ event.stopPropagation();
+ this.shouldPreventMessageMenuClose = false;
+ } else if (this.areMessageOptionsOpen) {
+ this.messageMenuTrigger?.hide();
+ }
+ }
+
+ messageOptionsButtonClicked() {
if (!this.message) {
return;
}
@@ -259,9 +323,10 @@ export class MessageComponent
enabledActions: this.enabledMessageActions,
customActions: this.messageActionsService.customActions$.getValue(),
isMine: this.isSentByCurrentUser,
+ messageTextHtmlElement: this.messageTextElement?.nativeElement,
});
} else {
- this.isActionBoxOpen = !this.isActionBoxOpen;
+ this.areMessageOptionsOpen = !this.areMessageOptionsOpen;
}
}
@@ -303,9 +368,6 @@ export class MessageComponent
return {
messageReactionCounts: this.message?.reaction_counts || {},
latestReactions: this.message?.latest_reactions || [],
- isSelectorOpen: this.isReactionSelectorOpen,
- isSelectorOpenChangeHandler: (isOpen) =>
- (this.isReactionSelectorOpen = isOpen),
messageId: this.message?.id,
ownReactions: this.message?.own_reactions || [],
};
@@ -347,6 +409,7 @@ export class MessageComponent
isMine: this.isSentByCurrentUser,
enabledActions: this.enabledMessageActions,
message: this.message,
+ messageTextHtmlElement: this.messageTextElement?.nativeElement,
};
}
@@ -499,4 +562,34 @@ export class MessageComponent
(u) => u.id !== this.userId
)[0];
}
+
+ private startMessageMenuShowTimer(options: { fromTouch: boolean }) {
+ this.stopMessageMenuShowTimer();
+ this.showMessageMenuTimeout = setTimeout(() => {
+ if (!this.message) {
+ return;
+ }
+ if (this.messageActionsService.customActionClickHandler) {
+ this.messageActionsService.customActionClickHandler({
+ message: this.message,
+ enabledActions: this.enabledMessageActions,
+ customActions: this.messageActionsService.customActions$.getValue(),
+ isMine: this.isSentByCurrentUser,
+ messageTextHtmlElement: this.messageTextElement?.nativeElement,
+ });
+ return;
+ } else {
+ this.shouldPreventMessageMenuClose = !options.fromTouch;
+ this.messageMenuTrigger?.show();
+ }
+ this.showMessageMenuTimeout = undefined;
+ }, 400);
+ }
+
+ private stopMessageMenuShowTimer() {
+ if (this.showMessageMenuTimeout) {
+ clearTimeout(this.showMessageMenuTimeout);
+ this.showMessageMenuTimeout = undefined;
+ }
+ }
}
diff --git a/projects/stream-chat-angular/src/lib/stream-chat.module.ts b/projects/stream-chat-angular/src/lib/stream-chat.module.ts
index 8494c180..df91ecc0 100644
--- a/projects/stream-chat-angular/src/lib/stream-chat.module.ts
+++ b/projects/stream-chat-angular/src/lib/stream-chat.module.ts
@@ -27,6 +27,7 @@ import { VoiceRecordingComponent } from './voice-recording/voice-recording.compo
import { VoiceRecordingWavebarComponent } from './voice-recording/voice-recording-wavebar/voice-recording-wavebar.component';
import { NgxFloatUiModule } from 'ngx-float-ui';
import { TranslateModule } from '@ngx-translate/core';
+import { MessageReactionsSelectorComponent } from './message-reactions-selector/message-reactions-selector.component';
@NgModule({
declarations: [
@@ -54,6 +55,7 @@ import { TranslateModule } from '@ngx-translate/core';
MessageBouncePromptComponent,
VoiceRecordingComponent,
VoiceRecordingWavebarComponent,
+ MessageReactionsSelectorComponent,
],
imports: [
CommonModule,
@@ -86,6 +88,7 @@ import { TranslateModule } from '@ngx-translate/core';
MessageBouncePromptComponent,
VoiceRecordingComponent,
VoiceRecordingWavebarComponent,
+ MessageReactionsSelectorComponent,
],
})
export class StreamChatModule {}
diff --git a/projects/stream-chat-angular/src/lib/types.ts b/projects/stream-chat-angular/src/lib/types.ts
index 160afb07..dd4c4038 100644
--- a/projects/stream-chat-angular/src/lib/types.ts
+++ b/projects/stream-chat-angular/src/lib/types.ts
@@ -235,20 +235,46 @@ export type IconContext = {
icon: Icon | undefined;
};
-export type MessageActionsBoxContext = {
+export type MessageActionsBoxContext<
+ T extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
+> = {
isMine: boolean;
- message: StreamMessage | undefined;
+ message: StreamMessage | undefined;
enabledActions: string[];
+ messageTextHtmlElement: HTMLElement | undefined;
};
+export type MessageActionHandlerExtraParams = {
+ isMine: boolean;
+ messageTextHtmlElement?: HTMLElement;
+};
+
+export type MessageActionHandler<
+ T extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
+> = (
+ message: StreamMessage,
+ params: MessageActionHandlerExtraParams
+) => void;
+
export type MessageActionBoxItemContext<
T extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
> = {
actionName: string;
actionLabelOrTranslationKey: ((message: StreamMessage) => string) | string;
message: StreamMessage;
- isMine: boolean;
- actionHandler: (message: StreamMessage, isMine: boolean) => void;
+ actionHandlerExtraParams: MessageActionHandlerExtraParams;
+ actionHandler: MessageActionHandler;
+};
+
+export type MessageReactionActionItem<
+ T extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
+> = {
+ actionName: 'react';
+ isVisible: (
+ enabledActions: string[],
+ isMine: boolean,
+ message: StreamMessage
+ ) => boolean;
};
type MessageActionItemBase<
@@ -260,13 +286,21 @@ type MessageActionItemBase<
isMine: boolean,
message: StreamMessage
) => boolean;
- actionHandler: (message: StreamMessage, isMine: boolean) => void;
+ actionHandler: MessageActionHandler;
};
export type MessageActionItem<
T extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
> = MessageActionItemBase & {
- actionName: 'quote' | 'pin' | 'flag' | 'edit' | 'delete' | 'mark-unread';
+ actionName:
+ | 'quote'
+ | 'pin'
+ | 'flag'
+ | 'edit'
+ | 'delete'
+ | 'mark-unread'
+ | 'thread-reply'
+ | 'copy-message-text';
};
export type CustomMessageActionItem<
@@ -275,13 +309,16 @@ export type CustomMessageActionItem<
actionName: string;
};
+export type MessageReactionsSelectorContext = {
+ messageId: string | undefined;
+ ownReactions: ReactionResponse[];
+};
+
export type MessageReactionsContext = {
messageId: string | undefined;
messageReactionCounts: { [key in MessageReactionType]?: number };
- isSelectorOpen: boolean;
latestReactions: ReactionResponse[];
ownReactions: ReactionResponse[];
- isSelectorOpenChangeHandler: (isOpen: boolean) => void;
};
export type ModalContext = {
@@ -408,12 +445,7 @@ export type MessageReactionClickDetails = {
export type MessageActionsClickDetails<
T extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
-> = {
- message: StreamMessage;
- enabledActions: string[];
- isMine: boolean;
- customActions: CustomMessageActionItem[];
-};
+> = MessageActionsBoxContext & { customActions: CustomMessageActionItem[] };
export type GroupStyleOptions = {
noGroupByUser?: boolean;
diff --git a/projects/stream-chat-angular/src/public-api.ts b/projects/stream-chat-angular/src/public-api.ts
index 83e19a58..151aa43e 100644
--- a/projects/stream-chat-angular/src/public-api.ts
+++ b/projects/stream-chat-angular/src/public-api.ts
@@ -62,3 +62,4 @@ export * from './lib/message-actions.service';
export * from './lib/voice-recording/voice-recording.component';
export * from './lib/voice-recording/voice-recording-wavebar/voice-recording-wavebar.component';
export * from './lib/is-on-separate-date';
+export * from './lib/message-reactions-selector/message-reactions-selector.component';