Skip to content

Commit ff0de6d

Browse files
committed
Add messageDraftChange output to message input
1 parent 06fb077 commit ff0de6d

3 files changed

Lines changed: 293 additions & 8 deletions

File tree

projects/stream-chat-angular/src/lib/message-input/message-input.component.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,9 +171,9 @@
171171
[autoFocus]="autoFocus"
172172
[placeholder]="textareaPlaceholder"
173173
[(value)]="textareaValue"
174-
(valueChange)="typingStart$.next()"
174+
(valueChange)="typingStart$.next(); updateMessageDraft()"
175175
(send)="messageSent()"
176-
(userMentions)="mentionedUsers = $event"
176+
(userMentions)="userMentionsChanged($event)"
177177
(pasteFromClipboard)="itemsPasted($event)"
178178
></ng-container>
179179
<ng-container *ngIf="emojiPickerTemplate" data-testid="emoji-picker">

projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts

Lines changed: 197 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import { CustomTemplatesService } from '../custom-templates.service';
3636
import { MessageInputConfigService } from './message-input-config.service';
3737
import { MessageTextComponent } from '../message-text/message-text.component';
3838

39-
describe('MessageInputComponent', () => {
39+
fdescribe('MessageInputComponent', () => {
4040
let nativeElement: HTMLElement;
4141
let component: MessageInputComponent;
4242
let fixture: ComponentFixture<MessageInputComponent>;
@@ -1144,4 +1144,200 @@ describe('MessageInputComponent', () => {
11441144
expect(queryFileInput()?.disabled).toBe(true);
11451145
expect(queryVoiceRecorderButton()?.disabled).toBe(true);
11461146
});
1147+
1148+
describe('message draft output', () => {
1149+
it('should emit undefined when all message fields are cleared', () => {
1150+
// Parent id doesn't count here
1151+
component.mode = 'thread';
1152+
mockActiveParentMessageId$.next('parentMessageId');
1153+
attachmentService.mapToAttachments.and.returnValue([]);
1154+
1155+
attachmentService.resetAttachmentUploads();
1156+
component.quotedMessage = undefined;
1157+
component.textareaValue = '';
1158+
component['pollId'] = undefined;
1159+
component.mentionedUsers = [];
1160+
1161+
const messageDraftSpy = jasmine.createSpy();
1162+
component.messageDraftChange.subscribe(messageDraftSpy);
1163+
messageDraftSpy.calls.reset();
1164+
component.updateMessageDraft();
1165+
1166+
expect(messageDraftSpy).toHaveBeenCalledWith(undefined);
1167+
});
1168+
1169+
it('should emit message draft when textarea value changes', () => {
1170+
const messageDraftSpy = jasmine.createSpy();
1171+
component.messageDraftChange.subscribe(messageDraftSpy);
1172+
messageDraftSpy.calls.reset();
1173+
queryTextarea()?.valueChange.next('Hello, world!');
1174+
1175+
expect(messageDraftSpy).toHaveBeenCalledWith({
1176+
text: 'Hello, world!',
1177+
attachments: undefined,
1178+
mentioned_users: [],
1179+
parent_id: undefined,
1180+
quoted_message_id: undefined,
1181+
poll_id: undefined,
1182+
});
1183+
});
1184+
1185+
it('should emit message draft when mentioned users change', () => {
1186+
const messageDraftSpy = jasmine.createSpy();
1187+
component.messageDraftChange.subscribe(messageDraftSpy);
1188+
messageDraftSpy.calls.reset();
1189+
queryTextarea()?.userMentions.next([{ id: 'user1', name: 'User 1' }]);
1190+
fixture.detectChanges();
1191+
1192+
expect(messageDraftSpy).toHaveBeenCalledWith(
1193+
jasmine.objectContaining({
1194+
mentioned_users: ['user1'],
1195+
})
1196+
);
1197+
});
1198+
1199+
it('should emit message draft when poll is added', () => {
1200+
const messageDraftSpy = jasmine.createSpy();
1201+
component.messageDraftChange.subscribe(messageDraftSpy);
1202+
messageDraftSpy.calls.reset();
1203+
component.addPoll('poll1');
1204+
fixture.detectChanges();
1205+
1206+
expect(messageDraftSpy).toHaveBeenCalledWith(
1207+
jasmine.objectContaining({
1208+
poll_id: 'poll1',
1209+
})
1210+
);
1211+
});
1212+
1213+
it('should emit message draft when attachment is added', () => {
1214+
const messageDraftSpy = jasmine.createSpy();
1215+
component.messageDraftChange.subscribe(messageDraftSpy);
1216+
messageDraftSpy.calls.reset();
1217+
attachmentService.mapToAttachments.and.returnValue([{ type: 'file' }]);
1218+
attachmentService.attachmentUploads$.next([
1219+
{
1220+
type: 'file',
1221+
state: 'success',
1222+
url: 'url',
1223+
file: { name: 'file.pdf', type: 'application/pdf' } as File,
1224+
} as AttachmentUpload,
1225+
]);
1226+
1227+
expect(messageDraftSpy).toHaveBeenCalledWith(
1228+
jasmine.objectContaining({
1229+
attachments: [{ type: 'file' }],
1230+
})
1231+
);
1232+
});
1233+
1234+
it('should not emit if attachment upload is in progress', () => {
1235+
const messageDraftSpy = jasmine.createSpy();
1236+
component.messageDraftChange.subscribe(messageDraftSpy);
1237+
messageDraftSpy.calls.reset();
1238+
attachmentService.mapToAttachments.and.returnValue([]);
1239+
attachmentService.attachmentUploads$.next([
1240+
{
1241+
type: 'file',
1242+
state: 'uploading',
1243+
url: 'url',
1244+
file: { name: 'file.pdf', type: 'application/pdf' } as File,
1245+
} as AttachmentUpload,
1246+
]);
1247+
1248+
expect(messageDraftSpy).not.toHaveBeenCalled();
1249+
});
1250+
1251+
it('should emit message draft when custom attachment is added', () => {
1252+
const messageDraftSpy = jasmine.createSpy();
1253+
component.messageDraftChange.subscribe(messageDraftSpy);
1254+
messageDraftSpy.calls.reset();
1255+
const customAttachment = {
1256+
type: 'image',
1257+
image_url: 'url',
1258+
};
1259+
attachmentService.mapToAttachments.and.returnValue([customAttachment]);
1260+
attachmentService.customAttachments$.next([customAttachment]);
1261+
1262+
expect(messageDraftSpy).toHaveBeenCalledWith(
1263+
jasmine.objectContaining({
1264+
attachments: [customAttachment],
1265+
})
1266+
);
1267+
});
1268+
1269+
it('should emit undefined if message is sent', async () => {
1270+
const messageDraftSpy = jasmine.createSpy();
1271+
component.messageDraftChange.subscribe(messageDraftSpy);
1272+
queryTextarea()?.valueChange.next('Hello');
1273+
messageDraftSpy.calls.reset();
1274+
await component.messageSent();
1275+
fixture.detectChanges();
1276+
1277+
expect(messageDraftSpy).toHaveBeenCalledOnceWith(undefined);
1278+
});
1279+
1280+
it('should not emit undefined even if message request fails (users can retry from preview added to message list)', async () => {
1281+
const messageDraftSpy = jasmine.createSpy();
1282+
component.messageDraftChange.subscribe(messageDraftSpy);
1283+
queryTextarea()?.valueChange.next('Hello');
1284+
messageDraftSpy.calls.reset();
1285+
sendMessageSpy.and.throwError('error');
1286+
await component.messageSent();
1287+
fixture.detectChanges();
1288+
1289+
expect(messageDraftSpy).toHaveBeenCalledOnceWith(undefined);
1290+
});
1291+
1292+
it('should emit if quoted message changes', () => {
1293+
const messageDraftSpy = jasmine.createSpy();
1294+
component.messageDraftChange.subscribe(messageDraftSpy);
1295+
messageDraftSpy.calls.reset();
1296+
const quotedMessage = mockMessage();
1297+
mockMessageToQuote$.next(quotedMessage);
1298+
fixture.detectChanges();
1299+
1300+
expect(messageDraftSpy).toHaveBeenCalledWith(
1301+
jasmine.objectContaining({
1302+
quoted_message_id: quotedMessage.id,
1303+
})
1304+
);
1305+
});
1306+
1307+
it(`shouldn't emit if in edit mode`, () => {
1308+
component.message = mockMessage();
1309+
fixture.detectChanges();
1310+
const messageDraftSpy = jasmine.createSpy();
1311+
component.messageDraftChange.subscribe(messageDraftSpy);
1312+
messageDraftSpy.calls.reset();
1313+
queryTextarea()?.valueChange.next('Hello');
1314+
fixture.detectChanges();
1315+
1316+
expect(messageDraftSpy).not.toHaveBeenCalled();
1317+
});
1318+
1319+
it('should not emit if active channel changes', () => {
1320+
const messageDraftSpy = jasmine.createSpy();
1321+
component.messageDraftChange.subscribe(messageDraftSpy);
1322+
queryTextarea()?.valueChange.next('Hello');
1323+
messageDraftSpy.calls.reset();
1324+
mockActiveChannel$.next({
1325+
...mockActiveChannel$.getValue(),
1326+
id: 'new-channel',
1327+
} as any as Channel);
1328+
fixture.detectChanges();
1329+
1330+
expect(messageDraftSpy).not.toHaveBeenCalled();
1331+
});
1332+
1333+
it(`shouldn't emit if parent message id changes (it's basically same as active channel changes)`, () => {
1334+
const messageDraftSpy = jasmine.createSpy();
1335+
component.messageDraftChange.subscribe(messageDraftSpy);
1336+
messageDraftSpy.calls.reset();
1337+
mockActiveParentMessageId$.next('parentMessageId');
1338+
fixture.detectChanges();
1339+
1340+
expect(messageDraftSpy).not.toHaveBeenCalled();
1341+
});
1342+
});
11471343
});

projects/stream-chat-angular/src/lib/message-input/message-input.component.ts

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,13 @@ import {
2121
ViewChild,
2222
} from '@angular/core';
2323
import { combineLatest, Observable, Subject, Subscription, timer } from 'rxjs';
24-
import { first, map, take, tap } from 'rxjs/operators';
25-
import { Attachment, Channel, UserResponse } from 'stream-chat';
24+
import { distinctUntilChanged, first, map, take, tap } from 'rxjs/operators';
25+
import {
26+
Attachment,
27+
Channel,
28+
DraftMessagePayload,
29+
UserResponse,
30+
} from 'stream-chat';
2631
import { AttachmentService } from '../attachment.service';
2732
import { ChannelService } from '../channel.service';
2833
import { textareaInjectionToken } from '../injection-tokens';
@@ -118,6 +123,18 @@ export class MessageInputComponent
118123
@Output() readonly messageUpdate = new EventEmitter<{
119124
message: StreamMessage;
120125
}>();
126+
/**
127+
* Emits the messsage draft whenever the composed message changes.
128+
* - If the user clears the message input, or sends the message, undefined is emitted.
129+
* - If active channel changes, nothing is emitted.
130+
*
131+
* To save and fetch message drafts, you can use the [Stream message drafts API](https://getstream.io/chat/docs/javascript/drafts/).
132+
*
133+
* Message draft only works for new messages, nothing is emitted when input is in edit mode.
134+
*/
135+
@Output() readonly messageDraftChange = new EventEmitter<
136+
DraftMessagePayload | undefined
137+
>();
121138
@ContentChild(TemplateRef) voiceRecorderRef:
122139
| TemplateRef<{ service: VoiceRecorderService }>
123140
| undefined;
@@ -159,6 +176,8 @@ export class MessageInputComponent
159176
private readonly slowModeTextareaPlaceholder = 'streamChat.Slow Mode ON';
160177
private messageToEdit?: StreamMessage;
161178
private pollId: string | undefined;
179+
private isChannelChangeResetInProgress = false;
180+
private isSendingMessage = false;
162181

163182
constructor(
164183
private channelService: ChannelService,
@@ -186,13 +205,35 @@ export class MessageInputComponent
186205
}
187206
)
188207
);
208+
this.subscriptions.push(
209+
this.attachmentService.attachmentUploads$
210+
.pipe(
211+
distinctUntilChanged(
212+
(prev, current) =>
213+
prev.filter((v) => v.state === 'success').length ===
214+
current.filter((v) => v.state === 'success').length
215+
)
216+
)
217+
.subscribe(() => {
218+
this.updateMessageDraft();
219+
})
220+
);
221+
this.subscriptions.push(
222+
this.attachmentService.customAttachments$.subscribe(() => {
223+
this.updateMessageDraft();
224+
})
225+
);
189226
this.subscriptions.push(
190227
this.channelService.activeChannel$.subscribe((channel) => {
191228
if (channel && this.channel && channel.id !== this.channel.id) {
229+
this.isChannelChangeResetInProgress = true;
192230
this.textareaValue = '';
193231
this.attachmentService.resetAttachmentUploads();
194232
this.pollId = undefined;
195233
this.voiceRecorderService.isRecorderVisible$.next(false);
234+
// Preemptively deselect quoted message, to avoid unwanted draft emission
235+
this.channelService.selectMessageToQuote(undefined);
236+
this.isChannelChangeResetInProgress = false;
196237
}
197238
const capabilities = channel?.data?.own_capabilities as string[];
198239
if (capabilities) {
@@ -209,11 +250,13 @@ export class MessageInputComponent
209250
this.channelService.messageToQuote$.subscribe((m) => {
210251
const isThreadReply = m && m.parent_id;
211252
if (
212-
(this.mode === 'thread' && isThreadReply) ||
213-
(this.mode === 'thread' && this.quotedMessage && !m) ||
214-
(this.mode === 'main' && !isThreadReply)
253+
((this.mode === 'thread' && isThreadReply) ||
254+
(this.mode === 'thread' && this.quotedMessage && !m) ||
255+
(this.mode === 'main' && !isThreadReply)) &&
256+
(!!m || !!this.quotedMessage)
215257
) {
216258
this.quotedMessage = m;
259+
this.updateMessageDraft();
217260
}
218261
})
219262
);
@@ -366,6 +409,7 @@ export class MessageInputComponent
366409
if (this.isCooldownInProgress) {
367410
return;
368411
}
412+
this.isSendingMessage = true;
369413
let attachmentUploadInProgressCounter!: number;
370414
this.attachmentService.attachmentUploadInProgressCounter$
371415
.pipe(first())
@@ -428,11 +472,15 @@ export class MessageInputComponent
428472
this.attachmentService.resetAttachmentUploads();
429473
}
430474
} catch (error) {
475+
this.isSendingMessage = false;
431476
if (this.isUpdate) {
432477
this.notificationService.addTemporaryNotification(
433478
'streamChat.Edit message request failed'
434479
);
435480
}
481+
} finally {
482+
this.isSendingMessage = false;
483+
this.updateMessageDraft();
436484
}
437485
void this.channelService.typingStopped(this.parentMessageId);
438486
if (this.quotedMessage) {
@@ -534,9 +582,50 @@ export class MessageInputComponent
534582
addPoll = (pollId: string) => {
535583
this.isComposerOpen = false;
536584
this.pollId = pollId;
585+
this.updateMessageDraft();
537586
void this.messageSent();
538587
};
539588

589+
userMentionsChanged(userMentions: UserResponse[]) {
590+
if (
591+
userMentions.map((u) => u.id).join(',') !==
592+
this.mentionedUsers.map((u) => u.id).join(',')
593+
) {
594+
this.mentionedUsers = userMentions;
595+
this.updateMessageDraft();
596+
}
597+
}
598+
599+
updateMessageDraft() {
600+
if (
601+
this.isSendingMessage ||
602+
this.isChannelChangeResetInProgress ||
603+
this.isUpdate
604+
) {
605+
return;
606+
}
607+
const attachments = this.attachmentService.mapToAttachments();
608+
609+
if (
610+
!this.textareaValue &&
611+
!this.mentionedUsers.length &&
612+
!attachments?.length &&
613+
!this.pollId &&
614+
!this.quotedMessage?.id
615+
) {
616+
this.messageDraftChange.emit(undefined);
617+
} else {
618+
this.messageDraftChange.emit({
619+
text: this.textareaValue,
620+
attachments: this.attachmentService.mapToAttachments(),
621+
mentioned_users: this.mentionedUsers.map((user) => user.id),
622+
poll_id: this.pollId,
623+
parent_id: this.parentMessageId,
624+
quoted_message_id: this.quotedMessage?.id,
625+
});
626+
}
627+
}
628+
540629
async voiceRecordingReady(recording: AudioRecording) {
541630
try {
542631
await this.attachmentService.uploadVoiceRecording(recording);

0 commit comments

Comments
 (0)