diff --git a/package-lock.json b/package-lock.json index dbfffb32..a976f80e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "@ctrl/ngx-emoji-mart": "^8.2.0", "@floating-ui/dom": "^1.6.3", "@ngx-translate/core": "^14.0.0", - "@stream-io/stream-chat-css": "5.3.0", + "@stream-io/stream-chat-css": "5.6.1", "@stream-io/transliterate": "^1.5.2", "angular-mentions": "1.4.0", "dayjs": "^1.11.10", @@ -5427,9 +5427,10 @@ } }, "node_modules/@stream-io/stream-chat-css": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@stream-io/stream-chat-css/-/stream-chat-css-5.3.0.tgz", - "integrity": "sha512-3jdDc8Q8zacLs25MmXhKv8hq8GnQ8v5E8o5i+oDOHotnRsXvDFQTixcJi7TC4heOQcVCqLoy5dOfdg9IZuyObw==" + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@stream-io/stream-chat-css/-/stream-chat-css-5.6.1.tgz", + "integrity": "sha512-Z5QPoy8koPA2+PTLTRHkhORLLdFs4aTmpdFVJC5Jh5b+wbsGhjbNUddAn2wi2Y6XXm27SA9Vc9iVUdnS6PNgMQ==", + "license": "MIT" }, "node_modules/@stream-io/transliterate": { "version": "1.5.2", @@ -27794,9 +27795,9 @@ "dev": true }, "@stream-io/stream-chat-css": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@stream-io/stream-chat-css/-/stream-chat-css-5.3.0.tgz", - "integrity": "sha512-3jdDc8Q8zacLs25MmXhKv8hq8GnQ8v5E8o5i+oDOHotnRsXvDFQTixcJi7TC4heOQcVCqLoy5dOfdg9IZuyObw==" + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@stream-io/stream-chat-css/-/stream-chat-css-5.6.1.tgz", + "integrity": "sha512-Z5QPoy8koPA2+PTLTRHkhORLLdFs4aTmpdFVJC5Jh5b+wbsGhjbNUddAn2wi2Y6XXm27SA9Vc9iVUdnS6PNgMQ==" }, "@stream-io/transliterate": { "version": "1.5.2", diff --git a/package.json b/package.json index b9de4600..236e7635 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,7 @@ "@ctrl/ngx-emoji-mart": "^8.2.0", "@floating-ui/dom": "^1.6.3", "@ngx-translate/core": "^14.0.0", - "@stream-io/stream-chat-css": "5.3.0", + "@stream-io/stream-chat-css": "5.6.1", "@stream-io/transliterate": "^1.5.2", "angular-mentions": "1.4.0", "dayjs": "^1.11.10", diff --git a/projects/customizations-example/src/app/app.component.html b/projects/customizations-example/src/app/app.component.html index bbd6eab2..71c21500 100644 --- a/projects/customizations-example/src/app/app.component.html +++ b/projects/customizations-example/src/app/app.component.html @@ -262,3 +262,16 @@
Latest message text: {{ latestMessageText }}
Latest message id: {{ latestMessage?.id }}
+ + + + diff --git a/projects/customizations-example/src/app/app.component.ts b/projects/customizations-example/src/app/app.component.ts index 260ac4ab..670ce649 100644 --- a/projects/customizations-example/src/app/app.component.ts +++ b/projects/customizations-example/src/app/app.component.ts @@ -36,6 +36,7 @@ import { MessageActionsService, ChannelPreviewInfoContext, MessageReactionsSelectorComponent, + MessageTextContext, } from 'stream-chat-angular'; import { environment } from '../environments/environment'; @@ -95,6 +96,8 @@ export class AppComponent implements AfterViewInit { private emptyMainMessageListTemplate!: TemplateRef; @ViewChild('emptyThreadMessageList') private emptyThreadMessageListTemplate!: TemplateRef; + @ViewChild('messageText') + messageTextTemplate!: TemplateRef; constructor( private chatService: ChatClientService, @@ -189,6 +192,9 @@ export class AppComponent implements AfterViewInit { this.customTemplatesService.emptyThreadMessageListPlaceholder$.next( this.emptyThreadMessageListTemplate ); + this.customTemplatesService.messageTextTemplate$.next( + this.messageTextTemplate + ); } inviteClicked(channel: Channel) { diff --git a/projects/customizations-example/src/app/app.module.ts b/projects/customizations-example/src/app/app.module.ts index b75a0407..ea6553f1 100644 --- a/projects/customizations-example/src/app/app.module.ts +++ b/projects/customizations-example/src/app/app.module.ts @@ -12,6 +12,7 @@ import { PickerModule } from '@ctrl/ngx-emoji-mart'; import { MessageActionComponent } from './message-action/message-action.component'; import { ThreadHeaderComponent } from './thread-header/thread-header.component'; import { IconComponent } from './icon/icon.component'; +import { MessageTextComponent } from './message-text/message-text.component'; @NgModule({ declarations: [ @@ -20,6 +21,7 @@ import { IconComponent } from './icon/icon.component'; MessageActionComponent, ThreadHeaderComponent, IconComponent, + MessageTextComponent, ], imports: [ BrowserModule, diff --git a/projects/customizations-example/src/app/message-text/message-text.component.html b/projects/customizations-example/src/app/message-text/message-text.component.html new file mode 100644 index 00000000..2ea04512 --- /dev/null +++ b/projects/customizations-example/src/app/message-text/message-text.component.html @@ -0,0 +1,24 @@ + + + + + +
+ +
+ + +
diff --git a/projects/customizations-example/src/app/message-text/message-text.component.scss b/projects/customizations-example/src/app/message-text/message-text.component.scss new file mode 100644 index 00000000..5fbf9df5 --- /dev/null +++ b/projects/customizations-example/src/app/message-text/message-text.component.scss @@ -0,0 +1,19 @@ +.message-text { + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 5; + line-clamp: 5; + -webkit-box-orient: vertical; +} + +.message-text.message-text-expanded { + display: block; +} + +button { + display: none; + + &.visible { + display: block; + } +} diff --git a/projects/customizations-example/src/app/message-text/message-text.component.ts b/projects/customizations-example/src/app/message-text/message-text.component.ts new file mode 100644 index 00000000..e2406fb0 --- /dev/null +++ b/projects/customizations-example/src/app/message-text/message-text.component.ts @@ -0,0 +1,18 @@ +import { Component, Input } from '@angular/core'; +import { MessageResponseBase } from 'stream-chat'; +import { DefaultStreamChatGenerics, StreamMessage } from 'stream-chat-angular'; + +@Component({ + selector: 'app-message-text', + templateUrl: './message-text.component.html', + styleUrls: ['./message-text.component.scss'], +}) +export class MessageTextComponent { + @Input() message: + | StreamMessage + | undefined + | MessageResponseBase; + @Input() isQuoted: boolean = false; + @Input() shouldTranslate: boolean = false; + isExpanded = false; +} diff --git a/projects/stream-chat-angular/src/lib/custom-templates.service.ts b/projects/stream-chat-angular/src/lib/custom-templates.service.ts index b71e024d..18661998 100644 --- a/projects/stream-chat-angular/src/lib/custom-templates.service.ts +++ b/projects/stream-chat-angular/src/lib/custom-templates.service.ts @@ -26,6 +26,7 @@ import { MessageContext, MessageReactionsContext, MessageReactionsSelectorContext, + MessageTextContext, ModalContext, NotificationContext, ReadStatusContext, @@ -43,7 +44,7 @@ import { * * For code examples to the different customizations see our [customizations example application](https://github.com/GetStream/stream-chat-angular/tree/master/projects/customizations-example), specifically the [AppComponent](https://github.com/GetStream/stream-chat-angular/tree/master/projects/customizations-example/src/app) (see [README](https://github.com/GetStream/stream-chat-angular/blob/master/README.md#customization-examples) for instructions on how to start the application). * - * You can find the type definitions of the context that is provided for each template [on GitHub](https://github.com/GetStream/stream-chat-angular/blob/master/projects/stream-chat-angu) + * You can find the type definitions of the context that is provided for each template [on GitHub](https://github.com/GetStream/stream-chat-angular/blob/master/projects/stream-chat-angular/src/lib/types.ts) */ @Injectable({ providedIn: 'root', @@ -358,6 +359,12 @@ export class CustomTemplatesService< customMessageMetadataInsideBubbleTemplate$ = new BehaviorSubject< TemplateRef | undefined >(undefined); + /** + * Template to display the text content inside the [message component](/chat/docs/sdk/angular/components/MessageComponent/). The default component is [stream-message-text](/chat/docs/sdk/angular/components/MessageTextComponent/) + */ + messageTextTemplate$ = new BehaviorSubject< + TemplateRef | undefined + >(undefined); constructor() {} } diff --git a/projects/stream-chat-angular/src/lib/message-input/message-input.component.html b/projects/stream-chat-angular/src/lib/message-input/message-input.component.html index 784efc98..29d5296f 100644 --- a/projects/stream-chat-angular/src/lib/message-input/message-input.component.html +++ b/projects/stream-chat-angular/src/lib/message-input/message-input.component.html @@ -91,15 +91,28 @@ [attachments]="quotedMessageAttachments" [messageId]="quotedMessage.id" > -
+
+ + + + +
{ let nativeElement: HTMLElement; @@ -131,6 +132,7 @@ describe('MessageInputComponent', () => { AutocompleteTextareaComponent, AttachmentListComponent, AttachmentPreviewListComponent, + MessageTextComponent, ], providers: [ { @@ -783,10 +785,15 @@ describe('MessageInputComponent', () => { expect(avatar.location).toBe('quoted-message-sender'); expect(avatar.user).toBe(message.user!); expect(attachments.attachments).toEqual([{ id: '1' }]); - expect( - nativeElement.querySelector('[data-testid="quoted-message-text"]') - ?.innerHTML - ).toContain(message.text); + + const textComponent = fixture.debugElement + .query(By.css(quotedMessageContainerSelector)) + .query(By.directive(MessageTextComponent)) + .componentInstance as MessageTextComponent; + + expect(textComponent.message).toBe(message); + expect(textComponent.isQuoted).toBe(true); + expect(textComponent.shouldTranslate).toBe(true); mockMessageToQuote$.next(undefined); fixture.detectChanges(); diff --git a/projects/stream-chat-angular/src/lib/message-input/message-input.component.ts b/projects/stream-chat-angular/src/lib/message-input/message-input.component.ts index f248720e..e17fcb05 100644 --- a/projects/stream-chat-angular/src/lib/message-input/message-input.component.ts +++ b/projects/stream-chat-angular/src/lib/message-input/message-input.component.ts @@ -34,6 +34,7 @@ import { CustomAttachmentUploadContext, DefaultStreamChatGenerics, EmojiPickerContext, + MessageTextContext, StreamMessage, } from '../types'; import { MessageInputConfigService } from './message-input-config.service'; @@ -163,7 +164,7 @@ export class MessageInputComponent private componentFactoryResolver: ComponentFactoryResolver, private cdRef: ChangeDetectorRef, private emojiInputService: EmojiInputService, - private customTemplatesService: CustomTemplatesService, + readonly customTemplatesService: CustomTemplatesService, private messageActionsService: MessageActionsService, public readonly voiceRecorderService: VoiceRecorderService, @Optional() public audioRecorder?: AudioRecorderService @@ -494,6 +495,14 @@ export class MessageInputComponent }; } + getQuotedMessageTextContext(): MessageTextContext { + return { + message: this.quotedMessage, + isQuoted: true, + shouldTranslate: true, + }; + } + async startVoiceRecording() { await this.audioRecorder?.start(); if (this.audioRecorder?.isRecording) { diff --git a/projects/stream-chat-angular/src/lib/message-text/message-text.component.html b/projects/stream-chat-angular/src/lib/message-text/message-text.component.html new file mode 100644 index 00000000..8c16c56c --- /dev/null +++ b/projects/stream-chat-angular/src/lib/message-text/message-text.component.html @@ -0,0 +1,35 @@ +

+ + + + + + + {{ content }} + + + + + + + + {{ messageText || "" }} + + + +

diff --git a/projects/stream-chat-angular/src/lib/message-text/message-text.component.spec.ts b/projects/stream-chat-angular/src/lib/message-text/message-text.component.spec.ts new file mode 100644 index 00000000..803990ee --- /dev/null +++ b/projects/stream-chat-angular/src/lib/message-text/message-text.component.spec.ts @@ -0,0 +1,336 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MessageTextComponent } from './message-text.component'; +import { StreamMessage } from '../types'; +import { SimpleChange } from '@angular/core'; +import { MessageService } from '../message.service'; +import { generateMockMessages } from '../mocks'; + +describe('MessageTextComponent', () => { + let component: MessageTextComponent; + let fixture: ComponentFixture; + let nativeElement: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [MessageTextComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(MessageTextComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + nativeElement = fixture.nativeElement; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display text', () => { + component.message = { + text: 'Hi', + } as StreamMessage; + component.ngOnChanges({ message: {} as SimpleChange }); + fixture.detectChanges(); + + expect( + nativeElement.querySelector('[data-testid="text"]')?.textContent + ).toContain('Hi'); + }); + + it('should add class to quoted message text', () => { + component.message = { + text: 'Hi', + translation: 'Szia', + } as StreamMessage; + component.isQuoted = true; + component.ngOnChanges({ isQuoted: {} as SimpleChange }); + fixture.detectChanges(); + + expect( + nativeElement.querySelector('.str-chat__quoted-message-text-value') + ).not.toBeNull(); + + component.isQuoted = false; + component.ngOnChanges({ isQuoted: {} as SimpleChange }); + fixture.detectChanges(); + + expect( + nativeElement.querySelector('.str-chat__quoted-message-text-value') + ).toBeNull(); + }); + + it('should translate, if #shouldTranslate is true', () => { + component.message = { + text: 'Hi', + translation: 'Szia', + } as StreamMessage; + component.shouldTranslate = true; + component.ngOnChanges({ message: {} as SimpleChange }); + fixture.detectChanges(); + + expect( + nativeElement.querySelector('[data-testid="text"]')?.textContent + ).toContain('Szia'); + }); + + it('should fallback to original text, if #shouldTranslate is true but no translation is provided', () => { + component.message = { + text: 'Hi', + translation: '', + } as StreamMessage; + component.shouldTranslate = true; + component.ngOnChanges({ message: {} as SimpleChange }); + fixture.detectChanges(); + + expect( + nativeElement.querySelector('[data-testid="text"]')?.textContent + ).toContain('Hi'); + }); + + it(`shouldn't display empty text`, () => { + component.message = { + text: '', + } as StreamMessage; + component.ngOnChanges({ message: {} as SimpleChange }); + fixture.detectChanges(); + + expect(nativeElement.querySelector('[data-testid="text"]')).toBeNull(); + }); + + it('should create message parts', () => { + component.message = { + text: '', + } as StreamMessage; + component.ngOnChanges({ message: {} as SimpleChange }); + + expect(component.messageTextParts).toEqual(undefined); + expect(component.messageText).toEqual(''); + + component.message = { + text: 'This is a message without user mentions', + } as StreamMessage; + component.ngOnChanges({ message: {} as SimpleChange }); + + expect(component.messageTextParts).toEqual(undefined); + expect(component.messageText).toEqual( + 'This is a message without user mentions' + ); + + component.message = { + text: 'This is just an email, not a mention test@test.com', + } as StreamMessage; + component.ngOnChanges({ message: {} as SimpleChange }); + + expect(component.messageTextParts).toEqual(undefined); + expect(component.messageText).toEqual( + 'This is just an email, not a mention test@test.com' + ); + + component.message = { + text: 'This is just an email, not a mention test@test.com', + } as StreamMessage; + component.ngOnChanges({ message: {} as SimpleChange }); + + expect(component.messageTextParts).toEqual(undefined); + expect(component.messageText).toEqual( + 'This is just an email, not a mention test@test.com' + ); + + component.message = { + text: 'Hello @Jack', + mentioned_users: [{ id: 'jack', name: 'Jack' }], + } as StreamMessage; + component.ngOnChanges({ message: {} as SimpleChange }); + + expect(component.messageTextParts).toEqual([ + { content: 'Hello ', type: 'text' }, + { + content: '@Jack', + type: 'mention', + user: { id: 'jack', name: 'Jack' }, + }, + ]); + + component.message = { + text: 'Hello @Jack, how are you?', + mentioned_users: [{ id: 'jack', name: 'Jack' }], + } as StreamMessage; + component.ngOnChanges({ message: {} as SimpleChange }); + + expect(component.messageTextParts).toEqual([ + { content: 'Hello ', type: 'text' }, + { + content: '@Jack', + type: 'mention', + user: { id: 'jack', name: 'Jack' }, + }, + { content: ', how are you?', type: 'text' }, + ]); + + component.message = { + text: 'Hello @Jack and @Lucie, how are you?', + mentioned_users: [ + { id: 'id2334', name: 'Jack' }, + { id: 'id3444', name: 'Lucie' }, + ], + } as StreamMessage; + component.ngOnChanges({ message: {} as SimpleChange }); + + expect(component.messageTextParts).toEqual([ + { content: 'Hello ', type: 'text' }, + { + content: '@Jack', + type: 'mention', + user: { id: 'id2334', name: 'Jack' }, + }, + { content: ' and ', type: 'text' }, + { + content: '@Lucie', + type: 'mention', + user: { id: 'id3444', name: 'Lucie' }, + }, + { content: ', how are you?', type: 'text' }, + ]); + + component.message = { + text: 'https://getstream.io/ this is the link @sara', + mentioned_users: [{ id: 'sara' }], + } as StreamMessage; + component.ngOnChanges({ message: {} as SimpleChange }); + + expect(component.messageTextParts).toEqual([ + { + content: + 'https://getstream.io/ this is the link ', + type: 'text', + }, + { content: '@sara', type: 'mention', user: { id: 'sara' } }, + ]); + + component.message = { + text: `This is a message with lots of emojis: 😂😜😂😂, there are compound emojis as well 👨‍👩‍👧`, + html: `This is a message with lots of emojis: 😂😜😂😂, there are compound emojis as well 👨‍👩‍👧`, + mentioned_users: [], + } as any as StreamMessage; + component.ngOnChanges({ message: {} as SimpleChange }); + + const content = component.messageTextParts![0].content; + + expect(content).toContain('😂'); + expect(content).toContain('😜'); + expect(content).toContain('👨‍👩‍👧'); + }); + + it('should add class to emojis in Chrome', () => { + const chrome = (window as typeof window & { chrome: Object }).chrome; + (window as typeof window & { chrome: Object }).chrome = + 'the component now will think that this is a chrome browser'; + component.message = { + text: 'This message contains an emoji 🥑', + html: 'This message contains an emoji 🥑', + } as any as StreamMessage; + component.ngOnChanges({ message: {} as SimpleChange }); + + expect(component.messageTextParts![0].content).toContain( + 'class="str-chat__emoji-display-fix"' + ); + + component.message = { + text: '@sara what do you think about 🥑s? ', + html: '@sara what do you think about 🥑s? ', + mentioned_users: [{ id: 'sara' }], + } as StreamMessage; + component.ngOnChanges({ message: {} as SimpleChange }); + + expect(component.messageTextParts![2].content).toContain( + 'class="str-chat__emoji-display-fix"' + ); + + // Simulate a browser that isn't Google Chrome + (window as typeof window & { chrome: Object | undefined }).chrome = + undefined; + + component.ngOnChanges({ message: {} as SimpleChange }); + + expect(component.messageTextParts![0].content).not.toContain( + 'class="str-chat__emoji-display-fix"' + ); + + // Revert changes to the window object + (window as typeof window & { chrome: Object }).chrome = chrome; + }); + + it('should replace URL links inside text content', () => { + component.message = { + html: '

This is a message with a link https://getstream.io/

\n', + text: 'This is a message with a link https://getstream.io/', + } as any as StreamMessage; + component.ngOnChanges({ message: {} as SimpleChange }); + + expect(component.messageTextParts![0].content).toContain( + ' https://getstream.io/' + ); + + component.message.html = undefined; + component.ngOnChanges({ message: {} as SimpleChange }); + + expect(component.messageTextParts![0].content).toContain( + 'https://getstream.io/' + ); + + component.message.text = 'This is a message with a link google.com'; + component.ngOnChanges({ message: {} as SimpleChange }); + + expect(component.messageTextParts![0].content).toContain( + 'google.com' + ); + + component.message.text = 'This is a message with a link www.google.com'; + component.ngOnChanges({ message: {} as SimpleChange }); + + expect(component.messageTextParts![0].content).toContain( + 'www.google.com' + ); + + component.message.text = + 'This is a message with a link file:///C:/Users/YourName/Documents/example.txt'; + component.ngOnChanges({ message: {} as SimpleChange }); + + expect(component.messageTextParts![0].content).toContain( + 'file:///C:/Users/YourName/Documents/example.txt' + ); + }); + + it('should replace URL links inside text content - custom link renderer', () => { + const service = TestBed.inject(MessageService); + service.customLinkRenderer = (url) => + `${url}`; + component.message = { + html: '

This is a message with a link https://getstream.io/

\n', + text: 'This is a message with a link https://getstream.io/', + } as any as StreamMessage; + component.ngOnChanges({ message: {} as SimpleChange }); + + expect(component.messageTextParts![0].content).toContain( + ' https://getstream.io/' + ); + }); + + it('should respect #displayAs setting', () => { + component.message = generateMockMessages()[0]; + fixture.detectChanges(); + + expect(nativeElement.querySelector('[data-testid="html-content"]')).toBe( + null + ); + + component.displayAs = 'html'; + component.ngOnChanges({ message: {} as SimpleChange }); + fixture.detectChanges(); + + expect( + nativeElement.querySelector('[data-testid="html-content"]') + ).not.toBe(null); + }); +}); diff --git a/projects/stream-chat-angular/src/lib/message-text/message-text.component.ts b/projects/stream-chat-angular/src/lib/message-text/message-text.component.ts new file mode 100644 index 00000000..e6e862c4 --- /dev/null +++ b/projects/stream-chat-angular/src/lib/message-text/message-text.component.ts @@ -0,0 +1,172 @@ +import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { + DefaultStreamChatGenerics, + MentionTemplateContext, + StreamMessage, +} from '../types'; +import { MessageResponseBase, UserResponse } from 'stream-chat'; +import emojiRegex from 'emoji-regex'; +import { MessageService } from '../message.service'; +import { CustomTemplatesService } from '../custom-templates.service'; + +type MessagePart = { + content: string; + type: 'text' | 'mention'; + user?: UserResponse; +}; + +/** + * The `MessageTextComponent` displays the text content of a message. + */ +@Component({ + selector: 'stream-message-text', + templateUrl: './message-text.component.html', + styles: [], +}) +export class MessageTextComponent implements OnChanges { + /** + * The message which text should be displayed + */ + @Input() message: + | StreamMessage + | undefined + | MessageResponseBase; + /** + * `true` if the component displayes a message quote + */ + @Input() isQuoted: boolean = false; + /** + * `true` if the + */ + @Input() shouldTranslate: boolean = false; + messageTextParts: MessagePart[] | undefined = []; + messageText?: string; + displayAs: 'text' | 'html'; + private readonly urlRegexp = + /(?:(?:https?|ftp|file):\/\/|www\.|ftp\.|(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,})(?![^\s]*@[^\s]*)(?:[^\s()<>]+|\([\w\d]+\))*(? { + const mention = `@${user.name || user.id}`; + const precedingText = text.substring(0, text.indexOf(mention)); + let formattedPrecedingText = this.fixEmojiDisplay(precedingText); + formattedPrecedingText = this.wrapLinksWithAnchorTag( + formattedPrecedingText + ); + this.messageTextParts!.push({ + content: formattedPrecedingText, + type: 'text', + }); + this.messageTextParts!.push({ + content: mention, + type: 'mention', + user, + }); + text = text.replace(precedingText + mention, ''); + }); + if (text) { + text = this.fixEmojiDisplay(text); + text = this.wrapLinksWithAnchorTag(text); + this.messageTextParts.push({ content: text, type: 'text' }); + } + } + } + + private getMessageContent() { + const originalContent = this.message?.text; + if (this.shouldTranslate) { + const translation = this.message?.translation; + return translation || originalContent; + } else { + return originalContent; + } + } + + private fixEmojiDisplay(content: string) { + // Wrap emojis in span to display emojis correctly in Chrome https://bugs.chromium.org/p/chromium/issues/detail?id=596223 + // Based on this: https://stackoverflow.com/questions/4565112/javascript-how-to-find-out-if-the-user-browser-is-chrome + /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */ + const isChrome = + !!(window as any).chrome && typeof (window as any).opr === 'undefined'; + /* eslint-enable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */ + content = content.replace( + this.emojiRegexp, + (match) => + `${match}` + ); + + return content; + } + + private wrapLinksWithAnchorTag(content: string) { + if (this.displayAs === 'html') { + return content; + } + content = content.replace(this.urlRegexp, (match) => { + if (this.messageService.customLinkRenderer) { + return this.messageService.customLinkRenderer(match); + } else { + let href = match; + if ( + !href.startsWith('http') && + !href.startsWith('ftp') && + !href.startsWith('file') + ) { + href = `https://${match}`; + } + return `${match}`; + } + }); + + return content; + } +} diff --git a/projects/stream-chat-angular/src/lib/message/message.component.html b/projects/stream-chat-angular/src/lib/message/message.component.html index da296210..27aa9adb 100644 --- a/projects/stream-chat-angular/src/lib/message/message.component.html +++ b/projects/stream-chat-angular/src/lib/message/message.component.html @@ -213,44 +213,26 @@ ) | translate }} -
-

- - - - - - - {{ - content - }} - - - - - - - - {{ messageText || "" }} - - - -

-
+ + + + -
+ + + + + +
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 61af282c..f9ea11b9 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 @@ -14,7 +14,7 @@ import { ChatClientService } from '../chat-client.service'; import { IconComponent } from '../icon/icon.component'; import { MessageActionsBoxComponent } from '../message-actions-box/message-actions-box.component'; import { By } from '@angular/platform-browser'; -import { generateMockMessages, mockCurrentUser, mockMessage } from '../mocks'; +import { mockCurrentUser, mockMessage } from '../mocks'; import { AttachmentListComponent } from '../attachment-list/attachment-list.component'; import { MessageReactionsComponent } from '../message-reactions/message-reactions.component'; import { TranslateModule } from '@ngx-translate/core'; @@ -23,8 +23,8 @@ import { ChangeDetectionStrategy, SimpleChange } from '@angular/core'; import { AvatarPlaceholderComponent } from '../avatar-placeholder/avatar-placeholder.component'; import { BehaviorSubject, of } from 'rxjs'; import { MessageActionsService } from '../message-actions.service'; -import { MessageService } from '../message.service'; import { NgxFloatUiModule } from 'ngx-float-ui'; +import { MessageTextComponent } from '../message-text/message-text.component'; describe('MessageComponent', () => { let component: MessageComponent; @@ -39,7 +39,7 @@ describe('MessageComponent', () => { let queryDeliveredIndicator: () => HTMLElement | null; let queryReadIndicator: () => HTMLElement | null; let queryAvatar: () => AvatarPlaceholderComponent; - let queryText: () => HTMLElement | null; + let queryMessageTextComponent: (testId: string) => MessageTextComponent; let queryMessageActionsBoxComponent: () => | MessageActionsBoxComponent | undefined; @@ -85,6 +85,7 @@ describe('MessageComponent', () => { AttachmentListComponent, MessageReactionsComponent, AvatarPlaceholderComponent, + MessageTextComponent, ], providers: [ { @@ -130,7 +131,9 @@ describe('MessageComponent', () => { queryAvatar = () => fixture.debugElement.query(By.css('[data-testid="avatar"]')) ?.componentInstance as AvatarPlaceholderComponent; - queryText = () => nativeElement.querySelector('[data-testid="text"]'); + queryMessageTextComponent = (testId: string) => + fixture.debugElement.query(By.css(`[data-testid="${testId}"]`)) + ?.componentInstance as MessageTextComponent; queryMessageInner = () => nativeElement.querySelector('[data-testid="inner-message"]'); queryLoadingIndicator = () => @@ -304,7 +307,13 @@ describe('MessageComponent', () => { }); it('should display text message', () => { - expect(queryText()?.textContent).toContain(message.text); + const messageTextComponent = queryMessageTextComponent('text'); + + expect(messageTextComponent.message).toBe(component.message); + expect(messageTextComponent.shouldTranslate).toBe( + component.displayedMessageTextContent === 'translation' + ); + expect(messageTextComponent.isQuoted).toBe(false); }); describe('should display error message', () => { @@ -680,14 +689,6 @@ describe('MessageComponent', () => { ); }); - it(`shouldn't display empty text`, () => { - component.message = { ...component.message!, ...{ text: '' } }; - component.ngOnChanges({ message: {} as SimpleChange }); - fixture.detectChanges(); - - expect(queryText()).toBeNull(); - }); - it('should resend message, if sending is failed', () => { component.message = { ...component.message!, status: 'failed' }; component.ngOnChanges({ message: {} as SimpleChange }); @@ -750,210 +751,6 @@ describe('MessageComponent', () => { expect(systemMessage?.innerHTML).toContain(message.text); }); - it('should create message parts', () => { - component.message = { - text: '', - } as StreamMessage; - component.ngOnChanges({ message: {} as SimpleChange }); - - expect(component.messageTextParts).toEqual(undefined); - expect(component.messageText).toEqual(''); - - component.message = { - text: 'This is a message without user mentions', - } as StreamMessage; - component.ngOnChanges({ message: {} as SimpleChange }); - - expect(component.messageTextParts).toEqual(undefined); - expect(component.messageText).toEqual( - 'This is a message without user mentions' - ); - - component.message = { - text: 'This is just an email, not a mention test@test.com', - } as StreamMessage; - component.ngOnChanges({ message: {} as SimpleChange }); - - expect(component.messageTextParts).toEqual(undefined); - expect(component.messageText).toEqual( - 'This is just an email, not a mention test@test.com' - ); - - component.message = { - text: 'This is just an email, not a mention test@test.com', - } as StreamMessage; - component.ngOnChanges({ message: {} as SimpleChange }); - - expect(component.messageTextParts).toEqual(undefined); - expect(component.messageText).toEqual( - 'This is just an email, not a mention test@test.com' - ); - - component.message = { - text: 'Hello @Jack', - mentioned_users: [{ id: 'jack', name: 'Jack' }], - } as StreamMessage; - component.ngOnChanges({ message: {} as SimpleChange }); - - expect(component.messageTextParts).toEqual([ - { content: 'Hello ', type: 'text' }, - { - content: '@Jack', - type: 'mention', - user: { id: 'jack', name: 'Jack' }, - }, - ]); - - component.message = { - text: 'Hello @Jack, how are you?', - mentioned_users: [{ id: 'jack', name: 'Jack' }], - } as StreamMessage; - component.ngOnChanges({ message: {} as SimpleChange }); - - expect(component.messageTextParts).toEqual([ - { content: 'Hello ', type: 'text' }, - { - content: '@Jack', - type: 'mention', - user: { id: 'jack', name: 'Jack' }, - }, - { content: ', how are you?', type: 'text' }, - ]); - - component.message = { - text: 'Hello @Jack and @Lucie, how are you?', - mentioned_users: [ - { id: 'id2334', name: 'Jack' }, - { id: 'id3444', name: 'Lucie' }, - ], - } as StreamMessage; - component.ngOnChanges({ message: {} as SimpleChange }); - - expect(component.messageTextParts).toEqual([ - { content: 'Hello ', type: 'text' }, - { - content: '@Jack', - type: 'mention', - user: { id: 'id2334', name: 'Jack' }, - }, - { content: ' and ', type: 'text' }, - { - content: '@Lucie', - type: 'mention', - user: { id: 'id3444', name: 'Lucie' }, - }, - { content: ', how are you?', type: 'text' }, - ]); - - component.message = { - text: 'https://getstream.io/ this is the link @sara', - mentioned_users: [{ id: 'sara' }], - } as StreamMessage; - component.ngOnChanges({ message: {} as SimpleChange }); - - expect(component.messageTextParts).toEqual([ - { - content: - 'https://getstream.io/ this is the link ', - type: 'text', - }, - { content: '@sara', type: 'mention', user: { id: 'sara' } }, - ]); - - component.message = { - text: `This is a message with lots of emojis: 😂😜😂😂, there are compound emojis as well 👨‍👩‍👧`, - html: `This is a message with lots of emojis: 😂😜😂😂, there are compound emojis as well 👨‍👩‍👧`, - mentioned_users: [], - } as any as StreamMessage; - component.ngOnChanges({ message: {} as SimpleChange }); - - const content = component.messageTextParts![0].content; - - expect(content).toContain('😂'); - expect(content).toContain('😜'); - expect(content).toContain('👨‍👩‍👧'); - }); - - it('should add class to emojis in Chrome', () => { - const chrome = (window as typeof window & { chrome: Object }).chrome; - (window as typeof window & { chrome: Object }).chrome = - 'the component now will think that this is a chrome browser'; - component.message = { - text: 'This message contains an emoji 🥑', - html: 'This message contains an emoji 🥑', - } as any as StreamMessage; - component.ngOnChanges({ message: {} as SimpleChange }); - - expect(component.messageTextParts![0].content).toContain( - 'class="str-chat__emoji-display-fix"' - ); - - component.message = { - text: '@sara what do you think about 🥑s? ', - html: '@sara what do you think about 🥑s? ', - mentioned_users: [{ id: 'sara' }], - } as StreamMessage; - component.ngOnChanges({ message: {} as SimpleChange }); - - expect(component.messageTextParts![2].content).toContain( - 'class="str-chat__emoji-display-fix"' - ); - - // Simulate a browser that isn't Google Chrome - (window as typeof window & { chrome: Object | undefined }).chrome = - undefined; - - component.ngOnChanges({ message: {} as SimpleChange }); - - expect(component.messageTextParts![0].content).not.toContain( - 'class="str-chat__emoji-display-fix"' - ); - - // Revert changes to the window object - (window as typeof window & { chrome: Object }).chrome = chrome; - }); - - it('should replace URL links inside text content', () => { - component.message = { - html: '

This is a message with a link https://getstream.io/

\n', - text: 'This is a message with a link https://getstream.io/', - } as any as StreamMessage; - component.ngOnChanges({ message: {} as SimpleChange }); - - expect(component.messageTextParts![0].content).toContain( - ' https://getstream.io/' - ); - - component.message.html = undefined; - component.ngOnChanges({ message: {} as SimpleChange }); - - expect(component.messageTextParts![0].content).toContain( - 'https://getstream.io/' - ); - - component.message.text = 'This is a message with a link google.com'; - component.ngOnChanges({ message: {} as SimpleChange }); - - expect(component.messageTextParts![0].content).toContain( - 'google.com' - ); - - component.message.text = 'This is a message with a link www.google.com'; - component.ngOnChanges({ message: {} as SimpleChange }); - - expect(component.messageTextParts![0].content).toContain( - 'www.google.com' - ); - - component.message.text = - 'This is a message with a link file:///C:/Users/YourName/Documents/example.txt'; - component.ngOnChanges({ message: {} as SimpleChange }); - - expect(component.messageTextParts![0].content).toContain( - 'file:///C:/Users/YourName/Documents/example.txt' - ); - }); - it('should display reply count for parent messages', () => { expect(queryReplyCountButton()).toBeNull(); @@ -1165,80 +962,44 @@ describe('MessageComponent', () => { ); }); - it(`should display message translation if exists`, () => { - component.message!.user!.id += 'not'; - component.message!.html = '

How are you?

'; - component.message!.translation = 'Hogy vagy?'; - component.ngOnChanges({ message: {} as SimpleChange }); - fixture.detectChanges(); - - expect(queryText()?.innerHTML).toContain('Hogy vagy?'); - }); - it('should display translation notifce and user should be able to change between translation and original', () => { - component.message!.user!.id = component.message!.user!.id + 'not'; - component.message!.text = 'How are you?'; - component.message!.html = '

How are you?

'; component.message!.translation = 'Hogy vagy?'; component.ngOnChanges({ message: {} as SimpleChange }); fixture.detectChanges(); expect(queryTranslationNotice()).not.toBeNull(); expect(querySeeTranslationButton()).toBeNull(); + expect(queryMessageTextComponent('text').shouldTranslate).toBeTrue(); + expect( + queryMessageTextComponent('quoted-message-text').shouldTranslate + ).toBeTrue(); querySeeOriginalButton()?.click(); fixture.detectChanges(); - expect(queryText()?.innerHTML).toContain('How are you?'); + expect(queryMessageTextComponent('text').shouldTranslate).toBeFalse(); + expect( + queryMessageTextComponent('quoted-message-text').shouldTranslate + ).toBeFalse(); expect(querySeeOriginalButton()).toBeNull(); querySeeTranslationButton()?.click(); fixture.detectChanges(); - expect(queryText()?.innerHTML).toContain('Hogy vagy?'); + expect(queryMessageTextComponent('text').shouldTranslate).toBeTrue(); + expect( + queryMessageTextComponent('quoted-message-text').shouldTranslate + ).toBeTrue(); }); it(`shouldn't display translation notice if message isn't translated`, () => { - component.message!.html = '

How are you?

'; - component.message!.user = currentUser; component.message!.translation = undefined; component.ngOnChanges({ message: {} as SimpleChange }); fixture.detectChanges(); expect(queryTranslationNotice()).toBeNull(); }); - - it('should respect #displayAs setting', () => { - component.message = generateMockMessages()[0]; - fixture.detectChanges(); - - expect(nativeElement.querySelector('[data-testid="html-content"]')).toBe( - null - ); - - component.displayAs = 'html'; - fixture.detectChanges(); - - expect( - nativeElement.querySelector('[data-testid="html-content"]') - ).not.toBe(null); - }); - }); - - it('should replace URL links inside text content - custom link renderer', () => { - const service = TestBed.inject(MessageService); - service.customLinkRenderer = (url) => - `${url}`; - component.message = { - html: '

This is a message with a link https://getstream.io/

\n', - text: 'This is a message with a link https://getstream.io/', - } as any as StreamMessage; - component.ngOnChanges({ message: {} as SimpleChange }); - - expect(component.messageTextParts![0].content).toContain( - ' https://getstream.io/' - ); }); it(`shouldn't display edited flag if message wasn't edited`, () => { 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 f832dabc..7d10dfbe 100644 --- a/projects/stream-chat-angular/src/lib/message/message.component.ts +++ b/projects/stream-chat-angular/src/lib/message/message.component.ts @@ -17,7 +17,6 @@ import { ChannelService } from '../channel.service'; import { ChatClientService } from '../chat-client.service'; import { AttachmentListContext, - MentionTemplateContext, MessageActionsBoxContext, MessageReactionsContext, DefaultStreamChatGenerics, @@ -27,13 +26,12 @@ import { ReadStatusContext, SystemMessageContext, CustomMetadataContext, + MessageTextContext, } from '../types'; -import emojiRegex from 'emoji-regex'; import { Observable, Subscription, take } from 'rxjs'; import { CustomTemplatesService } from '../custom-templates.service'; import { listUsers } from '../list-users'; import { DateParserService } from '../date-parser.service'; -import { MessageService } from '../message.service'; import { MessageActionsService } from '../message-actions.service'; import { NgxFloatUiContentComponent, @@ -41,12 +39,6 @@ import { } from 'ngx-float-ui'; import { TranslateService } from '@ngx-translate/core'; -type MessagePart = { - content: string; - type: 'text' | 'mention'; - user?: UserResponse; -}; - /** * The `Message` component displays a message with additional information such as sender and date, and enables [interaction with the message (i.e. edit or react)](/chat/docs/sdk/angular/concepts/message-interactions/). */ @@ -86,15 +78,12 @@ export class MessageComponent canReceiveReadEvents: boolean | undefined; canReactToMessage: boolean | undefined; isEditedFlagOpened = false; - messageTextParts: MessagePart[] | undefined = []; - messageText?: string; shouldDisplayTranslationNotice = false; displayedMessageTextContent: 'original' | 'translation' = 'original'; imageAttachmentModalState: 'opened' | 'closed' = 'closed'; shouldDisplayThreadLink = false; isSentByCurrentUser = false; readByText = ''; - displayAs: 'text' | 'html'; lastReadUser: UserResponse | undefined = undefined; isOnlyReadByMe = false; isReadByMultipleUsers = false; @@ -114,9 +103,6 @@ export class MessageComponent private subscriptions: Subscription[] = []; private isViewInited = false; private userId?: string; - private readonly urlRegexp = - /(?:(?:https?|ftp|file):\/\/|www\.|ftp\.|(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,})(?![^\s]*@[^\s]*)(?:[^\s()<>]+|\([\w\d]+\))*(? { - const mention = `@${user.name || user.id}`; - const precedingText = text.substring(0, text.indexOf(mention)); - let formattedPrecedingText = this.fixEmojiDisplay(precedingText); - formattedPrecedingText = this.wrapLinksWithAnchorTag( - formattedPrecedingText - ); - this.messageTextParts!.push({ - content: formattedPrecedingText, - type: 'text', - }); - this.messageTextParts!.push({ - content: mention, - type: 'mention', - user, - }); - text = text.replace(precedingText + mention, ''); - }); - if (text) { - text = this.fixEmojiDisplay(text); - text = this.wrapLinksWithAnchorTag(text); - this.messageTextParts.push({ content: text, type: 'text' }); - } - } - } - - private getMessageContent(shouldTranslate: boolean) { - const originalContent = this.message?.text; - if (shouldTranslate) { - const translation = this.message?.translation; - if (translation) { - this.shouldDisplayTranslationNotice = true; - this.displayedMessageTextContent = 'translation'; - } - return translation || originalContent; - } else { - this.displayedMessageTextContent = 'original'; - return originalContent; - } - } - - private fixEmojiDisplay(content: string) { - // Wrap emojis in span to display emojis correctly in Chrome https://bugs.chromium.org/p/chromium/issues/detail?id=596223 - // Based on this: https://stackoverflow.com/questions/4565112/javascript-how-to-find-out-if-the-user-browser-is-chrome - /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */ - const isChrome = - !!(window as any).chrome && typeof (window as any).opr === 'undefined'; - /* eslint-enable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */ - content = content.replace( - this.emojiRegexp, - (match) => - `${match}` - ); - - return content; + displayTranslatedMessage() { + this.shouldDisplayTranslationNotice = true; + this.displayedMessageTextContent = 'translation'; } - private wrapLinksWithAnchorTag(content: string) { - if (this.displayAs === 'html') { - return content; - } - content = content.replace(this.urlRegexp, (match) => { - if (this.messageService.customLinkRenderer) { - return this.messageService.customLinkRenderer(match); - } else { - let href = match; - if ( - !href.startsWith('http') && - !href.startsWith('ftp') && - !href.startsWith('file') - ) { - href = `https://${match}`; - } - return `${match}`; - } - }); - - return content; + displayOriginalMessage() { + this.displayedMessageTextContent = 'original'; } private updateReadByText() { 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 9b487fd3..23d1ed21 100644 --- a/projects/stream-chat-angular/src/lib/stream-chat.module.ts +++ b/projects/stream-chat-angular/src/lib/stream-chat.module.ts @@ -26,6 +26,7 @@ import { UserListComponent } from './user-list/user-list.component'; import { VoiceRecordingModule } from './voice-recording/voice-recording.module'; import { IconModule } from './icon/icon.module'; import { VoiceRecorderService } from './message-input/voice-recorder.service'; +import { MessageTextComponent } from './message-text/message-text.component'; @NgModule({ declarations: [ @@ -49,6 +50,7 @@ import { VoiceRecorderService } from './message-input/voice-recorder.service'; MessageReactionsSelectorComponent, UserListComponent, PaginatedListComponent, + MessageTextComponent, ], imports: [ CommonModule, @@ -81,6 +83,7 @@ import { VoiceRecorderService } from './message-input/voice-recorder.service'; UserListComponent, PaginatedListComponent, IconModule, + MessageTextComponent, ], providers: [VoiceRecorderService], }) diff --git a/projects/stream-chat-angular/src/lib/types.ts b/projects/stream-chat-angular/src/lib/types.ts index 615b1099..6b53514e 100644 --- a/projects/stream-chat-angular/src/lib/types.ts +++ b/projects/stream-chat-angular/src/lib/types.ts @@ -540,3 +540,9 @@ export type CustomAutocomplete = { */ updateOptions?: (searchTerm: string) => Promise; }; + +export type MessageTextContext = { + message: StreamMessage | undefined | MessageResponseBase; + isQuoted: boolean; + shouldTranslate: boolean; +}; diff --git a/projects/stream-chat-angular/src/public-api.ts b/projects/stream-chat-angular/src/public-api.ts index 52033c53..5644d4eb 100644 --- a/projects/stream-chat-angular/src/public-api.ts +++ b/projects/stream-chat-angular/src/public-api.ts @@ -80,3 +80,4 @@ export * from './lib/voice-recorder//voice-recorder-wavebar/voice-recorder-waveb export * from './lib/format-duration'; export * from './lib/message-input/voice-recorder.service'; export * from './lib/voice-recorder/mp3-transcoder'; +export * from './lib/message-text/message-text.component';