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 3b5c1562..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'); } /** @@ -1138,7 +1154,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.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..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 @@ -10,7 +10,13 @@ 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, + DraftMessage, + MessageResponse, + UserResponse, +} from 'stream-chat'; import { AttachmentService } from '../attachment.service'; import { ChannelService } from '../channel.service'; import { ChatClientService } from '../chat-client.service'; @@ -51,6 +57,7 @@ describe('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: { @@ -120,6 +127,7 @@ describe('MessageInputComponent', () => { ], }, }); + channelSwitchState$ = new BehaviorSubject<'start' | 'end'>('end'); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), StreamAvatarModule], declarations: [ @@ -144,6 +152,7 @@ describe('MessageInputComponent', () => { typingStarted: typingStartedSpy, typingStopped: typingStoppedSpy, latestMessageDateByUserByChannels$, + channelSwitchState$: channelSwitchState$, }, }, { @@ -1144,4 +1153,359 @@ describe('MessageInputComponent', () => { expect(queryFileInput()?.disabled).toBe(true); expect(queryVoiceRecorderButton()?.disabled).toBe(true); }); + + describe('message draft change', () => { + 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(); + component.ngOnChanges({ message: {} as any as SimpleChange }); + 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', () => { + 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(); + }); + + 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(); + }); + }); + + 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({ + message: { + text: 'Hello, world!', + } as any as DraftMessage, + channel_cid: `not${channel.cid}`, + 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({ + message: { + text: 'Hello, world!', + parent_id: 'not' + parentMessageId, + } as any as DraftMessage, + channel_cid: `not${channel.cid}`, + 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({ + message: { + text: 'Hello, world!', + } as any as DraftMessage, + channel_cid: channel.cid, + 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({ + message: { + text: 'Hello, world!', + } as any as DraftMessage, + channel_cid: channel.cid, + 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({ + message: { + text: 'Hello, world!', + } as any as DraftMessage, + channel_cid: channel.cid, + 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 = { + message: { + text: 'Hello, world!', + mentioned_users: ['user1', 'user2'], + poll_id: 'poll1', + attachments: [{ type: 'file', url: 'url' }], + } as any as DraftMessage, + channel_cid: channel.cid, + 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)`, () => { + 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 = { + message: { + text: 'Hello, world!', + mentioned_users: ['user1', 'user2'], + poll_id: 'poll1', + attachments: [{ type: 'file', url: 'url' }], + } as any as DraftMessage, + channel_cid: channel.cid, + 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 0c8e2634..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 @@ -21,8 +21,14 @@ 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, + DraftResponse, + UserResponse, +} from 'stream-chat'; import { AttachmentService } from '../attachment.service'; import { ChannelService } from '../channel.service'; import { textareaInjectionToken } from '../injection-tokens'; @@ -118,6 +124,22 @@ 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 (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 + >(); @ContentChild(TemplateRef) voiceRecorderRef: | TemplateRef<{ service: VoiceRecorderService }> | undefined; @@ -159,6 +181,9 @@ export class MessageInputComponent private readonly slowModeTextareaPlaceholder = 'streamChat.Slow Mode ON'; private messageToEdit?: StreamMessage; private pollId: string | undefined; + private isChannelChangeResetInProgress = false; + private isSendingMessage = false; + private isLoadingDraft = false; constructor( private channelService: ChannelService, @@ -186,6 +211,24 @@ 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) { @@ -193,6 +236,8 @@ export class MessageInputComponent this.attachmentService.resetAttachmentUploads(); this.pollId = undefined; this.voiceRecorderService.isRecorderVisible$.next(false); + // Preemptively deselect quoted message, to avoid unwanted draft emission + this.channelService.selectMessageToQuote(undefined); } const capabilities = channel?.data?.own_capabilities as string[]; if (capabilities) { @@ -205,6 +250,11 @@ 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; @@ -214,6 +264,7 @@ export class MessageInputComponent (this.mode === 'main' && !isThreadReply) ) { this.quotedMessage = m; + this.updateMessageDraft(); } }) ); @@ -366,6 +417,7 @@ export class MessageInputComponent if (this.isCooldownInProgress) { return; } + this.isSendingMessage = true; let attachmentUploadInProgressCounter!: number; this.attachmentService.attachmentUploadInProgressCounter$ .pipe(first()) @@ -428,11 +480,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 +590,51 @@ 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.isLoadingDraft || + 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); @@ -552,6 +650,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 in thread mode or parent ids don't match, the draft is ignored. + */ + loadDraft(draft: DraftResponse) { + if ( + this.channel?.cid !== draft.channel_cid || + draft?.message?.parent_id !== this.parentMessageId || + this.isUpdate + ) { + return; + } + this.isLoadingDraft = true; + + 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 || [] + ); + this.channelService.selectMessageToQuote(draft.quoted_message); + this.isLoadingDraft = false; + } + private deleteUpload(upload: AttachmentUpload) { if (this.isUpdate) { // Delay delete to avoid modal detecting this click as outside click