From ff0de6dd3d96682e1a676e1259000f5913e97050 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Sat, 18 Oct 2025 12:38:32 +0200 Subject: [PATCH 1/7] Add messageDraftChange output to message input --- .../message-input.component.html | 4 +- .../message-input.component.spec.ts | 198 +++++++++++++++++- .../message-input/message-input.component.ts | 99 ++++++++- 3 files changed, 293 insertions(+), 8 deletions(-) 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 98b595de..2e4e6422 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 @@ -171,9 +171,9 @@ [autoFocus]="autoFocus" [placeholder]="textareaPlaceholder" [(value)]="textareaValue" - (valueChange)="typingStart$.next()" + (valueChange)="typingStart$.next(); updateMessageDraft()" (send)="messageSent()" - (userMentions)="mentionedUsers = $event" + (userMentions)="userMentionsChanged($event)" (pasteFromClipboard)="itemsPasted($event)" > diff --git a/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts b/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts index 254310cd..35adc7cf 100644 --- a/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts @@ -36,7 +36,7 @@ import { CustomTemplatesService } from '../custom-templates.service'; import { MessageInputConfigService } from './message-input-config.service'; import { MessageTextComponent } from '../message-text/message-text.component'; -describe('MessageInputComponent', () => { +fdescribe('MessageInputComponent', () => { let nativeElement: HTMLElement; let component: MessageInputComponent; let fixture: ComponentFixture; @@ -1144,4 +1144,200 @@ describe('MessageInputComponent', () => { expect(queryFileInput()?.disabled).toBe(true); expect(queryVoiceRecorderButton()?.disabled).toBe(true); }); + + describe('message draft output', () => { + it('should emit undefined when all message fields are cleared', () => { + // Parent id doesn't count here + component.mode = 'thread'; + mockActiveParentMessageId$.next('parentMessageId'); + attachmentService.mapToAttachments.and.returnValue([]); + + attachmentService.resetAttachmentUploads(); + component.quotedMessage = undefined; + component.textareaValue = ''; + component['pollId'] = undefined; + component.mentionedUsers = []; + + const messageDraftSpy = jasmine.createSpy(); + component.messageDraftChange.subscribe(messageDraftSpy); + messageDraftSpy.calls.reset(); + component.updateMessageDraft(); + + expect(messageDraftSpy).toHaveBeenCalledWith(undefined); + }); + + it('should emit message draft when textarea value changes', () => { + const messageDraftSpy = jasmine.createSpy(); + component.messageDraftChange.subscribe(messageDraftSpy); + messageDraftSpy.calls.reset(); + queryTextarea()?.valueChange.next('Hello, world!'); + + expect(messageDraftSpy).toHaveBeenCalledWith({ + text: 'Hello, world!', + attachments: undefined, + mentioned_users: [], + parent_id: undefined, + quoted_message_id: undefined, + poll_id: undefined, + }); + }); + + it('should emit message draft when mentioned users change', () => { + const messageDraftSpy = jasmine.createSpy(); + component.messageDraftChange.subscribe(messageDraftSpy); + messageDraftSpy.calls.reset(); + queryTextarea()?.userMentions.next([{ id: 'user1', name: 'User 1' }]); + fixture.detectChanges(); + + expect(messageDraftSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + mentioned_users: ['user1'], + }) + ); + }); + + it('should emit message draft when poll is added', () => { + const messageDraftSpy = jasmine.createSpy(); + component.messageDraftChange.subscribe(messageDraftSpy); + messageDraftSpy.calls.reset(); + component.addPoll('poll1'); + fixture.detectChanges(); + + expect(messageDraftSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + poll_id: 'poll1', + }) + ); + }); + + it('should emit message draft when attachment is added', () => { + const messageDraftSpy = jasmine.createSpy(); + component.messageDraftChange.subscribe(messageDraftSpy); + messageDraftSpy.calls.reset(); + attachmentService.mapToAttachments.and.returnValue([{ type: 'file' }]); + attachmentService.attachmentUploads$.next([ + { + type: 'file', + state: 'success', + url: 'url', + file: { name: 'file.pdf', type: 'application/pdf' } as File, + } as AttachmentUpload, + ]); + + expect(messageDraftSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + attachments: [{ type: 'file' }], + }) + ); + }); + + it('should not emit if attachment upload is in progress', () => { + const messageDraftSpy = jasmine.createSpy(); + component.messageDraftChange.subscribe(messageDraftSpy); + messageDraftSpy.calls.reset(); + attachmentService.mapToAttachments.and.returnValue([]); + attachmentService.attachmentUploads$.next([ + { + type: 'file', + state: 'uploading', + url: 'url', + file: { name: 'file.pdf', type: 'application/pdf' } as File, + } as AttachmentUpload, + ]); + + expect(messageDraftSpy).not.toHaveBeenCalled(); + }); + + it('should emit message draft when custom attachment is added', () => { + const messageDraftSpy = jasmine.createSpy(); + component.messageDraftChange.subscribe(messageDraftSpy); + messageDraftSpy.calls.reset(); + const customAttachment = { + type: 'image', + image_url: 'url', + }; + attachmentService.mapToAttachments.and.returnValue([customAttachment]); + attachmentService.customAttachments$.next([customAttachment]); + + expect(messageDraftSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + attachments: [customAttachment], + }) + ); + }); + + it('should emit undefined if message is sent', async () => { + const messageDraftSpy = jasmine.createSpy(); + component.messageDraftChange.subscribe(messageDraftSpy); + queryTextarea()?.valueChange.next('Hello'); + messageDraftSpy.calls.reset(); + await component.messageSent(); + fixture.detectChanges(); + + expect(messageDraftSpy).toHaveBeenCalledOnceWith(undefined); + }); + + it('should not emit undefined even if message request fails (users can retry from preview added to message list)', async () => { + const messageDraftSpy = jasmine.createSpy(); + component.messageDraftChange.subscribe(messageDraftSpy); + queryTextarea()?.valueChange.next('Hello'); + messageDraftSpy.calls.reset(); + sendMessageSpy.and.throwError('error'); + await component.messageSent(); + fixture.detectChanges(); + + expect(messageDraftSpy).toHaveBeenCalledOnceWith(undefined); + }); + + it('should emit if quoted message changes', () => { + const messageDraftSpy = jasmine.createSpy(); + component.messageDraftChange.subscribe(messageDraftSpy); + messageDraftSpy.calls.reset(); + const quotedMessage = mockMessage(); + mockMessageToQuote$.next(quotedMessage); + fixture.detectChanges(); + + expect(messageDraftSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + quoted_message_id: quotedMessage.id, + }) + ); + }); + + it(`shouldn't emit if in edit mode`, () => { + component.message = mockMessage(); + fixture.detectChanges(); + const messageDraftSpy = jasmine.createSpy(); + component.messageDraftChange.subscribe(messageDraftSpy); + messageDraftSpy.calls.reset(); + queryTextarea()?.valueChange.next('Hello'); + fixture.detectChanges(); + + expect(messageDraftSpy).not.toHaveBeenCalled(); + }); + + it('should not emit if active channel changes', () => { + const messageDraftSpy = jasmine.createSpy(); + component.messageDraftChange.subscribe(messageDraftSpy); + queryTextarea()?.valueChange.next('Hello'); + messageDraftSpy.calls.reset(); + mockActiveChannel$.next({ + ...mockActiveChannel$.getValue(), + id: 'new-channel', + } as any as Channel); + fixture.detectChanges(); + + expect(messageDraftSpy).not.toHaveBeenCalled(); + }); + + it(`shouldn't emit if parent message id changes (it's basically same as active channel changes)`, () => { + const messageDraftSpy = jasmine.createSpy(); + component.messageDraftChange.subscribe(messageDraftSpy); + messageDraftSpy.calls.reset(); + mockActiveParentMessageId$.next('parentMessageId'); + fixture.detectChanges(); + + expect(messageDraftSpy).not.toHaveBeenCalled(); + }); + }); }); 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 0c8e2634..850e1766 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 @@ -21,8 +21,13 @@ import { ViewChild, } from '@angular/core'; import { combineLatest, Observable, Subject, Subscription, timer } from 'rxjs'; -import { first, map, take, tap } from 'rxjs/operators'; -import { Attachment, Channel, UserResponse } from 'stream-chat'; +import { distinctUntilChanged, first, map, take, tap } from 'rxjs/operators'; +import { + Attachment, + Channel, + DraftMessagePayload, + UserResponse, +} from 'stream-chat'; import { AttachmentService } from '../attachment.service'; import { ChannelService } from '../channel.service'; import { textareaInjectionToken } from '../injection-tokens'; @@ -118,6 +123,18 @@ export class MessageInputComponent @Output() readonly messageUpdate = new EventEmitter<{ message: StreamMessage; }>(); + /** + * Emits the messsage draft whenever the composed message changes. + * - If the user clears the message input, or sends the message, undefined is emitted. + * - If active channel changes, nothing is emitted. + * + * To save and fetch message drafts, you can use the [Stream message drafts API](https://getstream.io/chat/docs/javascript/drafts/). + * + * Message draft only works for new messages, nothing is emitted when input is in edit mode. + */ + @Output() readonly messageDraftChange = new EventEmitter< + DraftMessagePayload | undefined + >(); @ContentChild(TemplateRef) voiceRecorderRef: | TemplateRef<{ service: VoiceRecorderService }> | undefined; @@ -159,6 +176,8 @@ export class MessageInputComponent private readonly slowModeTextareaPlaceholder = 'streamChat.Slow Mode ON'; private messageToEdit?: StreamMessage; private pollId: string | undefined; + private isChannelChangeResetInProgress = false; + private isSendingMessage = false; constructor( private channelService: ChannelService, @@ -186,13 +205,35 @@ export class MessageInputComponent } ) ); + this.subscriptions.push( + this.attachmentService.attachmentUploads$ + .pipe( + distinctUntilChanged( + (prev, current) => + prev.filter((v) => v.state === 'success').length === + current.filter((v) => v.state === 'success').length + ) + ) + .subscribe(() => { + this.updateMessageDraft(); + }) + ); + this.subscriptions.push( + this.attachmentService.customAttachments$.subscribe(() => { + this.updateMessageDraft(); + }) + ); this.subscriptions.push( this.channelService.activeChannel$.subscribe((channel) => { if (channel && this.channel && channel.id !== this.channel.id) { + this.isChannelChangeResetInProgress = true; this.textareaValue = ''; this.attachmentService.resetAttachmentUploads(); this.pollId = undefined; this.voiceRecorderService.isRecorderVisible$.next(false); + // Preemptively deselect quoted message, to avoid unwanted draft emission + this.channelService.selectMessageToQuote(undefined); + this.isChannelChangeResetInProgress = false; } const capabilities = channel?.data?.own_capabilities as string[]; if (capabilities) { @@ -209,11 +250,13 @@ export class MessageInputComponent this.channelService.messageToQuote$.subscribe((m) => { const isThreadReply = m && m.parent_id; if ( - (this.mode === 'thread' && isThreadReply) || - (this.mode === 'thread' && this.quotedMessage && !m) || - (this.mode === 'main' && !isThreadReply) + ((this.mode === 'thread' && isThreadReply) || + (this.mode === 'thread' && this.quotedMessage && !m) || + (this.mode === 'main' && !isThreadReply)) && + (!!m || !!this.quotedMessage) ) { this.quotedMessage = m; + this.updateMessageDraft(); } }) ); @@ -366,6 +409,7 @@ export class MessageInputComponent if (this.isCooldownInProgress) { return; } + this.isSendingMessage = true; let attachmentUploadInProgressCounter!: number; this.attachmentService.attachmentUploadInProgressCounter$ .pipe(first()) @@ -428,11 +472,15 @@ export class MessageInputComponent this.attachmentService.resetAttachmentUploads(); } } catch (error) { + this.isSendingMessage = false; if (this.isUpdate) { this.notificationService.addTemporaryNotification( 'streamChat.Edit message request failed' ); } + } finally { + this.isSendingMessage = false; + this.updateMessageDraft(); } void this.channelService.typingStopped(this.parentMessageId); if (this.quotedMessage) { @@ -534,9 +582,50 @@ export class MessageInputComponent addPoll = (pollId: string) => { this.isComposerOpen = false; this.pollId = pollId; + this.updateMessageDraft(); void this.messageSent(); }; + userMentionsChanged(userMentions: UserResponse[]) { + if ( + userMentions.map((u) => u.id).join(',') !== + this.mentionedUsers.map((u) => u.id).join(',') + ) { + this.mentionedUsers = userMentions; + this.updateMessageDraft(); + } + } + + updateMessageDraft() { + if ( + this.isSendingMessage || + this.isChannelChangeResetInProgress || + this.isUpdate + ) { + return; + } + const attachments = this.attachmentService.mapToAttachments(); + + if ( + !this.textareaValue && + !this.mentionedUsers.length && + !attachments?.length && + !this.pollId && + !this.quotedMessage?.id + ) { + this.messageDraftChange.emit(undefined); + } else { + this.messageDraftChange.emit({ + text: this.textareaValue, + attachments: this.attachmentService.mapToAttachments(), + mentioned_users: this.mentionedUsers.map((user) => user.id), + poll_id: this.pollId, + parent_id: this.parentMessageId, + quoted_message_id: this.quotedMessage?.id, + }); + } + } + async voiceRecordingReady(recording: AudioRecording) { try { await this.attachmentService.uploadVoiceRecording(recording); From 4e8c1de5ca373bbf913c7dfdabd43e42b335ee84 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Sat, 18 Oct 2025 13:28:39 +0200 Subject: [PATCH 2/7] Implement load draft --- .../src/lib/channel.service.ts | 5 +- .../message-input.component.spec.ts | 168 +++++++++++++++++- .../message-input/message-input.component.ts | 34 +++- 3 files changed, 204 insertions(+), 3 deletions(-) diff --git a/projects/stream-chat-angular/src/lib/channel.service.ts b/projects/stream-chat-angular/src/lib/channel.service.ts index 3b5c1562..e75dc23e 100644 --- a/projects/stream-chat-angular/src/lib/channel.service.ts +++ b/projects/stream-chat-angular/src/lib/channel.service.ts @@ -1138,7 +1138,10 @@ export class ChannelService { * Selects or deselects the current message to quote reply to * @param message The message to select, if called with `undefined`, it deselects the message */ - selectMessageToQuote(message: StreamMessage | undefined) { + selectMessageToQuote(message: StreamMessage | undefined | MessageResponse) { + if (message && !this.isStreamMessage(message)) { + message = this.transformToStreamMessage(message); + } this.messageToQuoteSubject.next(message); } diff --git a/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts b/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts index 35adc7cf..3d4d1d3a 100644 --- a/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts @@ -10,7 +10,14 @@ import { import { By } from '@angular/platform-browser'; import { TranslateModule } from '@ngx-translate/core'; import { BehaviorSubject, Subject, of } from 'rxjs'; -import { Attachment, Channel, UserResponse } from 'stream-chat'; +import { + Attachment, + Channel, + ChannelResponse, + DraftMessage, + MessageResponse, + UserResponse, +} from 'stream-chat'; import { AttachmentService } from '../attachment.service'; import { ChannelService } from '../channel.service'; import { ChatClientService } from '../chat-client.service'; @@ -1306,6 +1313,7 @@ fdescribe('MessageInputComponent', () => { it(`shouldn't emit if in edit mode`, () => { component.message = mockMessage(); + component.ngOnChanges({ message: {} as any as SimpleChange }); fixture.detectChanges(); const messageDraftSpy = jasmine.createSpy(); component.messageDraftChange.subscribe(messageDraftSpy); @@ -1340,4 +1348,162 @@ fdescribe('MessageInputComponent', () => { expect(messageDraftSpy).not.toHaveBeenCalled(); }); }); + + describe('load draft', () => { + it(`shouldn't load draft if draft's channel id doesn't match the active channel id`, () => { + const channel = mockActiveChannel$.getValue(); + fixture.detectChanges(); + component.loadDraft({ + channel: { cid: `not${channel.cid}` } as any as ChannelResponse, + message: { + text: 'Hello, world!', + } as any as DraftMessage, + channel_cid: 'messaging:123', + created_at: new Date().toISOString(), + }); + fixture.detectChanges(); + + expect(component.textareaValue).not.toBe('Hello, world!'); + }); + + it(`shouldn't load draft if draft's parent id doesn't match the active parent message id`, () => { + const channel = mockActiveChannel$.getValue(); + const parentMessageId = 'parentMessageId'; + mockActiveParentMessageId$.next(parentMessageId); + fixture.detectChanges(); + component.loadDraft({ + channel: { cid: `not${channel.cid}` } as any as ChannelResponse, + message: { + text: 'Hello, world!', + parent_id: 'not' + parentMessageId, + } as any as DraftMessage, + channel_cid: 'messaging:123', + created_at: new Date().toISOString(), + }); + fixture.detectChanges(); + + expect(component.textareaValue).not.toBe('Hello, world!'); + }); + + it(`shouldn't load draft if in edit mode`, () => { + const channel = mockActiveChannel$.getValue(); + const messageToEdit = mockMessage(); + messageToEdit.text = 'This message is being edited'; + component.message = messageToEdit; + component.ngOnChanges({ message: {} as any as SimpleChange }); + fixture.detectChanges(); + component.loadDraft({ + channel: { cid: channel.cid } as any as ChannelResponse, + message: { + text: 'Hello, world!', + } as any as DraftMessage, + channel_cid: 'messaging:123', + created_at: new Date().toISOString(), + }); + fixture.detectChanges(); + + expect(component.textareaValue).toBe(messageToEdit.text); + }); + + it(`should select message to quote if quoted message is set`, () => { + const channel = mockActiveChannel$.getValue(); + const mockQuotedMessage = mockMessage(); + mockQuotedMessage.id = 'quotedMessageId'; + selectMessageToQuoteSpy.calls.reset(); + + component.loadDraft({ + channel: { cid: channel.cid } as any as ChannelResponse, + message: { + text: 'Hello, world!', + } as any as DraftMessage, + channel_cid: 'messaging:123', + created_at: new Date().toISOString(), + quoted_message: mockQuotedMessage as any as MessageResponse, + }); + fixture.detectChanges(); + + expect(selectMessageToQuoteSpy).toHaveBeenCalledOnceWith( + mockQuotedMessage + ); + }); + + it(`should deselect message to quote if draft doesn't contain quoted message`, () => { + mockMessageToQuote$.next(mockMessage()); + const channel = mockActiveChannel$.getValue(); + + component.loadDraft({ + channel: { cid: channel.cid } as any as ChannelResponse, + message: { + text: 'Hello, world!', + } as any as DraftMessage, + channel_cid: 'messaging:123', + created_at: new Date().toISOString(), + quoted_message: undefined, + }); + fixture.detectChanges(); + + expect(selectMessageToQuoteSpy).toHaveBeenCalledOnceWith(undefined); + }); + + it(`should set all fields from draft`, () => { + const channel = mockActiveChannel$.getValue(); + const draft = { + channel: { cid: channel.cid } as any as ChannelResponse, + message: { + text: 'Hello, world!', + mentioned_users: ['user1', 'user2'], + poll_id: 'poll1', + attachments: [{ type: 'file', url: 'url' }], + } as any as DraftMessage, + channel_cid: 'messaging:123', + created_at: new Date().toISOString(), + }; + attachmentService.createFromAttachments.calls.reset(); + + component.loadDraft(draft); + fixture.detectChanges(); + + expect(component.textareaValue).toBe('Hello, world!'); + expect(component.mentionedUsers).toEqual([ + { id: 'user1' }, + { id: 'user2' }, + ]); + expect(component['pollId']).toBe('poll1'); + expect(attachmentService.createFromAttachments).toHaveBeenCalledOnceWith([ + { type: 'file', url: 'url' }, + ]); + }); + + it(`shouldn't emit message draft when loading a draft (avoid infinite loop)`, async () => { + const channel = mockActiveChannel$.getValue(); + attachmentService.createFromAttachments.and.callFake(() => { + attachmentService.attachmentUploads$.next([ + { + type: 'file', + state: 'success', + url: 'url', + file: { name: 'file.pdf', type: 'application/pdf' } as File, + } as AttachmentUpload, + ]); + }); + const draft = { + channel: { cid: channel.cid } as any as ChannelResponse, + message: { + text: 'Hello, world!', + mentioned_users: ['user1', 'user2'], + poll_id: 'poll1', + attachments: [{ type: 'file', url: 'url' }], + } as any as DraftMessage, + channel_cid: 'messaging:123', + created_at: new Date().toISOString(), + }; + const spy = jasmine.createSpy(); + component.messageDraftChange.subscribe(spy); + spy.calls.reset(); + component.loadDraft(draft); + fixture.detectChanges(); + + expect(spy).not.toHaveBeenCalled(); + }); + }); }); 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 850e1766..51196f4b 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 @@ -26,6 +26,7 @@ import { Attachment, Channel, DraftMessagePayload, + DraftResponse, UserResponse, } from 'stream-chat'; import { AttachmentService } from '../attachment.service'; @@ -130,7 +131,7 @@ export class MessageInputComponent * * To save and fetch message drafts, you can use the [Stream message drafts API](https://getstream.io/chat/docs/javascript/drafts/). * - * Message draft only works for new messages, nothing is emitted when input is in edit mode. + * Message draft only works for new messages, nothing is emitted when input is in edit mode (if `message` input is set). */ @Output() readonly messageDraftChange = new EventEmitter< DraftMessagePayload | undefined @@ -178,6 +179,7 @@ export class MessageInputComponent private pollId: string | undefined; private isChannelChangeResetInProgress = false; private isSendingMessage = false; + private isLoadingDraft = false; constructor( private channelService: ChannelService, @@ -598,6 +600,7 @@ export class MessageInputComponent updateMessageDraft() { if ( + this.isLoadingDraft || this.isSendingMessage || this.isChannelChangeResetInProgress || this.isUpdate @@ -641,6 +644,35 @@ export class MessageInputComponent return !!this.message; } + /** + * + * @param draft DraftResponse to load into the message input. + * - Draft messages are only supported for new messages, input is ignored in edit mode (if `message` input is set). + * - If channel id doesn't match the active channel id, the draft is ignored. + * - If a thread message is loaded, and the input isn't it thread mode or parent ids don't match, the draft is ignored. + * @returns + */ + loadDraft(draft: DraftResponse) { + if ( + this.channel?.cid !== draft.channel?.cid || + draft?.message?.parent_id !== this.parentMessageId || + this.isUpdate + ) { + return; + } + this.isLoadingDraft = true; + this.channelService.selectMessageToQuote(draft.quoted_message); + + this.textareaValue = draft.message?.text || ''; + this.mentionedUsers = + draft?.message?.mentioned_users?.map((id) => ({ id })) || []; + this.pollId = draft?.message?.poll_id; + this.attachmentService.createFromAttachments( + draft?.message?.attachments || [] + ); + this.isLoadingDraft = false; + } + private deleteUpload(upload: AttachmentUpload) { if (this.isUpdate) { // Delay delete to avoid modal detecting this click as outside click From 80e7e06f1fdfdf9d2518b02b355439978ecbf172 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Sat, 18 Oct 2025 13:54:59 +0200 Subject: [PATCH 3/7] fix: unnecesary draft emission when channel is changed --- .../src/lib/channel.service.spec.ts | 13 +++++++++++++ .../src/lib/channel.service.ts | 16 ++++++++++++++++ .../message-input.component.spec.ts | 14 ++++++++++++-- .../lib/message-input/message-input.component.ts | 14 ++++++++------ 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/projects/stream-chat-angular/src/lib/channel.service.spec.ts b/projects/stream-chat-angular/src/lib/channel.service.spec.ts index 0c817d35..f313b313 100644 --- a/projects/stream-chat-angular/src/lib/channel.service.spec.ts +++ b/projects/stream-chat-angular/src/lib/channel.service.spec.ts @@ -359,6 +359,9 @@ describe('ChannelService', () => { pinnedMessagesSpy.calls.reset(); typingUsersSpy.calls.reset(); typingUsersInThreadSpy.calls.reset(); + const channelSwitchStateSpy = jasmine.createSpy(); + service.channelSwitchState$.subscribe(channelSwitchStateSpy); + channelSwitchStateSpy.calls.reset(); service.deselectActiveChannel(); expect(messagesSpy).toHaveBeenCalledWith([]); @@ -381,6 +384,10 @@ describe('ChannelService', () => { expect(messagesSpy).not.toHaveBeenCalled(); expect(service.isMessageLoadingInProgress).toBeFalse(); + + expect(channelSwitchStateSpy.calls.count()).toBe(2); + expect(channelSwitchStateSpy.calls.first().args[0]).toBe('start'); + expect(channelSwitchStateSpy.calls.mostRecent().args[0]).toBe('end'); }); it('should tell if user #hasMoreChannels$', async () => { @@ -457,6 +464,9 @@ describe('ChannelService', () => { spyOn(newActiveChannel, 'markRead'); const pinnedMessages = generateMockMessages(); newActiveChannel.state.pinnedMessages = pinnedMessages; + const channelSwitchStateSpy = jasmine.createSpy(); + service.channelSwitchState$.subscribe(channelSwitchStateSpy); + channelSwitchStateSpy.calls.reset(); service.setAsActiveChannel(newActiveChannel); result = spy.calls.mostRecent().args[0] as Channel; @@ -467,6 +477,9 @@ describe('ChannelService', () => { expect(pinnedMessagesSpy).toHaveBeenCalledWith(pinnedMessages); expect(typingUsersSpy).toHaveBeenCalledWith([]); expect(typingUsersInThreadSpy).toHaveBeenCalledWith([]); + expect(channelSwitchStateSpy.calls.count()).toBe(2); + expect(channelSwitchStateSpy.calls.first().args[0]).toBe('start'); + expect(channelSwitchStateSpy.calls.mostRecent().args[0]).toBe('end'); }); it('should emit #activeChannelMessages$', async () => { diff --git a/projects/stream-chat-angular/src/lib/channel.service.ts b/projects/stream-chat-angular/src/lib/channel.service.ts index e75dc23e..1920395c 100644 --- a/projects/stream-chat-angular/src/lib/channel.service.ts +++ b/projects/stream-chat-angular/src/lib/channel.service.ts @@ -307,6 +307,12 @@ export class ChannelService { beforeUpdateMessage?: ( message: StreamMessage ) => StreamMessage | Promise; + /** + * Since switching channels changes the state of multiple obserables, this observable can be used to check if all observables are updated. + * - `end` means all observables are in stable state + * - `start` means all observables are in unstable state + */ + channelSwitchState$: Observable<'start' | 'end'>; /** * @internal */ @@ -357,6 +363,9 @@ export class ChannelService { private channelQueryStateSubject = new BehaviorSubject< ChannelQueryState | undefined >(undefined); + private channelSwitchStateSubject = new BehaviorSubject<'start' | 'end'>( + 'end' + ); private channelQuery?: | ChannelQuery | ((queryType: ChannelQueryType) => Promise); @@ -511,6 +520,9 @@ export class ChannelService { this.channelQueryState$ = this.channelQueryStateSubject .asObservable() .pipe(shareReplay(1)); + this.channelSwitchState$ = this.channelSwitchStateSubject + .asObservable() + .pipe(shareReplay(1)); } /** @@ -557,6 +569,7 @@ export class ChannelService { * @param channel */ setAsActiveChannel(channel: Channel) { + this.channelSwitchStateSubject.next('start'); const prevActiveChannel = this.activeChannelSubject.getValue(); if (prevActiveChannel?.cid === channel.cid) { return; @@ -585,12 +598,14 @@ export class ChannelService { ); } this.setChannelState(channel); + this.channelSwitchStateSubject.next('end'); } /** * Deselects the currently active (if any) channel */ deselectActiveChannel() { + this.channelSwitchStateSubject.next('start'); const activeChannel = this.activeChannelSubject.getValue(); if (!activeChannel) { return; @@ -611,6 +626,7 @@ export class ChannelService { this.activeChannelUnreadCount = undefined; this.areReadEventsPaused = false; this.isMessageLoadingInProgress = false; + this.channelSwitchStateSubject.next('end'); } /** diff --git a/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts b/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts index 3d4d1d3a..1ba0bd95 100644 --- a/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts @@ -43,7 +43,7 @@ import { CustomTemplatesService } from '../custom-templates.service'; import { MessageInputConfigService } from './message-input-config.service'; import { MessageTextComponent } from '../message-text/message-text.component'; -fdescribe('MessageInputComponent', () => { +describe('MessageInputComponent', () => { let nativeElement: HTMLElement; let component: MessageInputComponent; let fixture: ComponentFixture; @@ -58,6 +58,7 @@ fdescribe('MessageInputComponent', () => { let mockActiveParentMessageId$: BehaviorSubject; let sendMessageSpy: jasmine.Spy; let updateMessageSpy: jasmine.Spy; + let channelSwitchState$: BehaviorSubject<'start' | 'end'>; let channel: Channel; let user: UserResponse; let attachmentService: { @@ -127,6 +128,7 @@ fdescribe('MessageInputComponent', () => { ], }, }); + channelSwitchState$ = new BehaviorSubject<'start' | 'end'>('end'); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), StreamAvatarModule], declarations: [ @@ -151,6 +153,7 @@ fdescribe('MessageInputComponent', () => { typingStarted: typingStartedSpy, typingStopped: typingStoppedSpy, latestMessageDateByUserByChannels$, + channelSwitchState$: channelSwitchState$, }, }, { @@ -1152,7 +1155,7 @@ fdescribe('MessageInputComponent', () => { expect(queryVoiceRecorderButton()?.disabled).toBe(true); }); - describe('message draft output', () => { + describe('message draft change', () => { it('should emit undefined when all message fields are cleared', () => { // Parent id doesn't count here component.mode = 'thread'; @@ -1325,14 +1328,21 @@ fdescribe('MessageInputComponent', () => { }); it('should not emit if active channel changes', () => { + mockMessageToQuote$.next(mockMessage()); + const messageDraftSpy = jasmine.createSpy(); component.messageDraftChange.subscribe(messageDraftSpy); queryTextarea()?.valueChange.next('Hello'); + fixture.detectChanges(); + messageDraftSpy.calls.reset(); + channelSwitchState$.next('start'); + mockMessageToQuote$.next(undefined); mockActiveChannel$.next({ ...mockActiveChannel$.getValue(), id: 'new-channel', } as any as Channel); + channelSwitchState$.next('end'); fixture.detectChanges(); expect(messageDraftSpy).not.toHaveBeenCalled(); 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 51196f4b..25fe7366 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 @@ -228,14 +228,12 @@ export class MessageInputComponent this.subscriptions.push( this.channelService.activeChannel$.subscribe((channel) => { if (channel && this.channel && channel.id !== this.channel.id) { - this.isChannelChangeResetInProgress = true; this.textareaValue = ''; this.attachmentService.resetAttachmentUploads(); this.pollId = undefined; this.voiceRecorderService.isRecorderVisible$.next(false); // Preemptively deselect quoted message, to avoid unwanted draft emission this.channelService.selectMessageToQuote(undefined); - this.isChannelChangeResetInProgress = false; } const capabilities = channel?.data?.own_capabilities as string[]; if (capabilities) { @@ -248,14 +246,18 @@ export class MessageInputComponent } }) ); + this.subscriptions.push( + this.channelService.channelSwitchState$.subscribe((state) => { + this.isChannelChangeResetInProgress = state === 'start'; + }) + ); this.subscriptions.push( this.channelService.messageToQuote$.subscribe((m) => { const isThreadReply = m && m.parent_id; if ( - ((this.mode === 'thread' && isThreadReply) || - (this.mode === 'thread' && this.quotedMessage && !m) || - (this.mode === 'main' && !isThreadReply)) && - (!!m || !!this.quotedMessage) + (this.mode === 'thread' && isThreadReply) || + (this.mode === 'thread' && this.quotedMessage && !m) || + (this.mode === 'main' && !isThreadReply) ) { this.quotedMessage = m; this.updateMessageDraft(); From 808a19dc42d63d9b9b81240a6a22b9975566262a Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Sat, 18 Oct 2025 13:56:40 +0200 Subject: [PATCH 4/7] fix lint issues --- .../src/lib/message-input/message-input.component.spec.ts | 2 +- .../src/lib/message-input/message-input.component.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts b/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts index 1ba0bd95..30754d04 100644 --- a/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts @@ -1484,7 +1484,7 @@ describe('MessageInputComponent', () => { ]); }); - it(`shouldn't emit message draft when loading a draft (avoid infinite loop)`, async () => { + it(`shouldn't emit message draft when loading a draft (avoid infinite loop)`, () => { const channel = mockActiveChannel$.getValue(); attachmentService.createFromAttachments.and.callFake(() => { attachmentService.attachmentUploads$.next([ 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 25fe7366..bf88c267 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 @@ -651,8 +651,7 @@ export class MessageInputComponent * @param draft DraftResponse to load into the message input. * - Draft messages are only supported for new messages, input is ignored in edit mode (if `message` input is set). * - If channel id doesn't match the active channel id, the draft is ignored. - * - If a thread message is loaded, and the input isn't it thread mode or parent ids don't match, the draft is ignored. - * @returns + * - If a thread message is loaded, and the input isn't in thread mode or parent ids don't match, the draft is ignored. */ loadDraft(draft: DraftResponse) { if ( From cd5397a5d058896f7229b196f7e6143cd3435437 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Sun, 19 Oct 2025 15:17:09 +0200 Subject: [PATCH 5/7] Fix integration issue --- .../message-input.component.spec.ts | 21 +++++++------------ .../message-input/message-input.component.ts | 8 +++++-- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts b/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts index 30754d04..98c39837 100644 --- a/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts @@ -1364,11 +1364,10 @@ describe('MessageInputComponent', () => { const channel = mockActiveChannel$.getValue(); fixture.detectChanges(); component.loadDraft({ - channel: { cid: `not${channel.cid}` } as any as ChannelResponse, message: { text: 'Hello, world!', } as any as DraftMessage, - channel_cid: 'messaging:123', + channel_cid: `not${channel.cid}`, created_at: new Date().toISOString(), }); fixture.detectChanges(); @@ -1382,12 +1381,11 @@ describe('MessageInputComponent', () => { mockActiveParentMessageId$.next(parentMessageId); fixture.detectChanges(); component.loadDraft({ - channel: { cid: `not${channel.cid}` } as any as ChannelResponse, message: { text: 'Hello, world!', parent_id: 'not' + parentMessageId, } as any as DraftMessage, - channel_cid: 'messaging:123', + channel_cid: `not${channel.cid}`, created_at: new Date().toISOString(), }); fixture.detectChanges(); @@ -1403,11 +1401,10 @@ describe('MessageInputComponent', () => { component.ngOnChanges({ message: {} as any as SimpleChange }); fixture.detectChanges(); component.loadDraft({ - channel: { cid: channel.cid } as any as ChannelResponse, message: { text: 'Hello, world!', } as any as DraftMessage, - channel_cid: 'messaging:123', + channel_cid: channel.cid, created_at: new Date().toISOString(), }); fixture.detectChanges(); @@ -1422,11 +1419,10 @@ describe('MessageInputComponent', () => { selectMessageToQuoteSpy.calls.reset(); component.loadDraft({ - channel: { cid: channel.cid } as any as ChannelResponse, message: { text: 'Hello, world!', } as any as DraftMessage, - channel_cid: 'messaging:123', + channel_cid: channel.cid, created_at: new Date().toISOString(), quoted_message: mockQuotedMessage as any as MessageResponse, }); @@ -1442,11 +1438,10 @@ describe('MessageInputComponent', () => { const channel = mockActiveChannel$.getValue(); component.loadDraft({ - channel: { cid: channel.cid } as any as ChannelResponse, message: { text: 'Hello, world!', } as any as DraftMessage, - channel_cid: 'messaging:123', + channel_cid: channel.cid, created_at: new Date().toISOString(), quoted_message: undefined, }); @@ -1458,14 +1453,13 @@ describe('MessageInputComponent', () => { it(`should set all fields from draft`, () => { const channel = mockActiveChannel$.getValue(); const draft = { - channel: { cid: channel.cid } as any as ChannelResponse, message: { text: 'Hello, world!', mentioned_users: ['user1', 'user2'], poll_id: 'poll1', attachments: [{ type: 'file', url: 'url' }], } as any as DraftMessage, - channel_cid: 'messaging:123', + channel_cid: channel.cid, created_at: new Date().toISOString(), }; attachmentService.createFromAttachments.calls.reset(); @@ -1497,14 +1491,13 @@ describe('MessageInputComponent', () => { ]); }); const draft = { - channel: { cid: channel.cid } as any as ChannelResponse, message: { text: 'Hello, world!', mentioned_users: ['user1', 'user2'], poll_id: 'poll1', attachments: [{ type: 'file', url: 'url' }], } as any as DraftMessage, - channel_cid: 'messaging:123', + channel_cid: channel.cid, created_at: new Date().toISOString(), }; const spy = jasmine.createSpy(); 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 bf88c267..63f5e01e 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 @@ -248,6 +248,7 @@ export class MessageInputComponent ); this.subscriptions.push( this.channelService.channelSwitchState$.subscribe((state) => { + console.log('channelSwitchState', state); this.isChannelChangeResetInProgress = state === 'start'; }) ); @@ -654,23 +655,26 @@ export class MessageInputComponent * - If a thread message is loaded, and the input isn't in thread mode or parent ids don't match, the draft is ignored. */ loadDraft(draft: DraftResponse) { + console.log('input load draft', draft); if ( - this.channel?.cid !== draft.channel?.cid || + this.channel?.cid !== draft.channel_cid || draft?.message?.parent_id !== this.parentMessageId || this.isUpdate ) { return; } this.isLoadingDraft = true; - this.channelService.selectMessageToQuote(draft.quoted_message); this.textareaValue = draft.message?.text || ''; this.mentionedUsers = draft?.message?.mentioned_users?.map((id) => ({ id })) || []; this.pollId = draft?.message?.poll_id; + this.attachmentService.resetAttachmentUploads(); this.attachmentService.createFromAttachments( draft?.message?.attachments || [] ); + console.log('textareaValue', this.textareaValue); + this.channelService.selectMessageToQuote(draft.quoted_message); this.isLoadingDraft = false; } From b8e03ec6da285f5e7c533ce710ebb946cded1190 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Mon, 20 Oct 2025 11:09:03 +0200 Subject: [PATCH 6/7] fix lint issues --- .../src/lib/message-input/message-input.component.spec.ts | 1 - .../src/lib/message-input/message-input.component.ts | 3 --- 2 files changed, 4 deletions(-) diff --git a/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts b/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts index 98c39837..9bb75abe 100644 --- a/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts @@ -13,7 +13,6 @@ import { BehaviorSubject, Subject, of } from 'rxjs'; import { Attachment, Channel, - ChannelResponse, DraftMessage, MessageResponse, UserResponse, 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 63f5e01e..b199b7ca 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 @@ -248,7 +248,6 @@ export class MessageInputComponent ); this.subscriptions.push( this.channelService.channelSwitchState$.subscribe((state) => { - console.log('channelSwitchState', state); this.isChannelChangeResetInProgress = state === 'start'; }) ); @@ -655,7 +654,6 @@ export class MessageInputComponent * - If a thread message is loaded, and the input isn't in thread mode or parent ids don't match, the draft is ignored. */ loadDraft(draft: DraftResponse) { - console.log('input load draft', draft); if ( this.channel?.cid !== draft.channel_cid || draft?.message?.parent_id !== this.parentMessageId || @@ -673,7 +671,6 @@ export class MessageInputComponent this.attachmentService.createFromAttachments( draft?.message?.attachments || [] ); - console.log('textareaValue', this.textareaValue); this.channelService.selectMessageToQuote(draft.quoted_message); this.isLoadingDraft = false; } From 333414374a6290e0f6275b92c785ffb7caa1e675 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Mon, 20 Oct 2025 11:17:54 +0200 Subject: [PATCH 7/7] update docs comment --- .../src/lib/message-input/message-input.component.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 b199b7ca..0c8a43dd 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 @@ -126,12 +126,16 @@ export class MessageInputComponent }>(); /** * Emits the messsage draft whenever the composed message changes. - * - If the user clears the message input, or sends the message, undefined is emitted. - * - If active channel changes, nothing is emitted. + * - If the user clears the message input, or sends the message, undefined is emitted. + * - If active channel changes, nothing is emitted. * * To save and fetch message drafts, you can use the [Stream message drafts API](https://getstream.io/chat/docs/javascript/drafts/). * * Message draft only works for new messages, nothing is emitted when input is in edit mode (if `message` input is set). + * + * To load a message draft into the input, use the `loadDraft` method. + * - If channel id doesn't match the active channel id, the draft is ignored. + * - If a thread message is loaded, and the input isn't in thread mode or parent ids don't match, the draft is ignored. */ @Output() readonly messageDraftChange = new EventEmitter< DraftMessagePayload | undefined