Skip to content

Commit e8f5c49

Browse files
committed
feat: custom autocompletes
1 parent 6ab7009 commit e8f5c49

4 files changed

Lines changed: 177 additions & 40 deletions

File tree

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

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,34 @@
1515
(paste)="pasteFromClipboard.emit($event)"
1616
></textarea>
1717
<ng-template #autocompleteItem let-item="item">
18-
<div class="rta rta__item str-chat__emojisearch__item" [ngSwitch]="item.type">
19-
<div *ngSwitchCase="'mention'" class="rta__entity">
20-
<ng-container
21-
*ngTemplateOutlet="
22-
mentionAutocompleteItemTemplate || defaultMentionTemplate;
23-
context: { item: item }
24-
"
25-
></ng-container>
18+
<ng-container *ngIf="item.templateRef; else builtinItem">
19+
<ng-container
20+
*ngTemplateOutlet="item.templateRef; context: { item: item }"
21+
></ng-container>
22+
</ng-container>
23+
<ng-template #builtinItem>
24+
<div
25+
class="rta rta__item str-chat__emojisearch__item"
26+
[ngSwitch]="item.type"
27+
>
28+
<div *ngSwitchCase="'mention'" class="rta__entity">
29+
<ng-container
30+
*ngTemplateOutlet="
31+
mentionAutocompleteItemTemplate || defaultMentionTemplate;
32+
context: { item: item }
33+
"
34+
></ng-container>
35+
</div>
36+
<div *ngSwitchCase="'command'" class="rta__entity">
37+
<ng-container
38+
*ngTemplateOutlet="
39+
commandAutocompleteItemTemplate || defaultCommandTemplate;
40+
context: { item: item }
41+
"
42+
></ng-container>
43+
</div>
2644
</div>
27-
<div *ngSwitchCase="'command'" class="rta__entity">
28-
<ng-container
29-
*ngTemplateOutlet="
30-
commandAutocompleteItemTemplate || defaultCommandTemplate;
31-
context: { item: item }
32-
"
33-
></ng-container>
34-
</div>
35-
</div>
45+
</ng-template>
3646
</ng-template>
3747

3848
<ng-template #defaultCommandTemplate let-item="item">

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

Lines changed: 102 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,11 @@ import { UserResponse } from 'stream-chat';
2424
import { ChannelService } from '../../channel.service';
2525
import { TextareaInterface } from '../textarea.interface';
2626
import { ChatClientService } from '../../chat-client.service';
27-
import { debounceTime } from 'rxjs/operators';
27+
import { debounceTime, filter } from 'rxjs/operators';
2828
import { TransliterationService } from '../../transliteration.service';
2929
import { EmojiInputService } from '../emoji-input.service';
3030
import { CustomTemplatesService } from '../../custom-templates.service';
31+
import { MessageInputConfigService } from '../message-input-config.service';
3132

3233
/**
3334
* The `AutocompleteTextarea` component is used by the [`MessageInput`](./MessageInputComponent.mdx) component to display the input HTML element where users can type their message.
@@ -137,13 +138,21 @@ export class AutocompleteTextareaComponent
137138
private transliterationService: TransliterationService,
138139
private emojiInputService: EmojiInputService,
139140
private customTemplatesService: CustomTemplatesService,
140-
private cdRef: ChangeDetectorRef
141+
private cdRef: ChangeDetectorRef,
142+
private messageInputConfigService: MessageInputConfigService
141143
) {
142-
this.searchTerm$.pipe(debounceTime(300)).subscribe((searchTerm) => {
143-
if (searchTerm.startsWith(this.mentionTriggerChar)) {
144-
void this.updateMentionOptions(searchTerm);
145-
}
146-
});
144+
this.searchTerm$
145+
.pipe(
146+
filter((searchTerm) => searchTerm.length !== 1),
147+
debounceTime(300)
148+
)
149+
.subscribe((searchTerm) => {
150+
if (searchTerm.startsWith(this.mentionTriggerChar)) {
151+
void this.updateMentionOptions(searchTerm);
152+
} else {
153+
void this.updateCustomAutocompleteOptions(searchTerm);
154+
}
155+
});
147156
this.subscriptions.push(
148157
this.channelService.activeChannel$.subscribe((channel) => {
149158
const commands = channel?.getConfig()?.commands || [];
@@ -155,6 +164,7 @@ export class AutocompleteTextareaComponent
155164
this.mentionedUsers = [];
156165
this.userMentions.next([...this.mentionedUsers]);
157166
void this.updateMentionOptions(this.searchTerm$.getValue());
167+
void this.updateCustomAutocompleteOptions(this.searchTerm$.getValue());
158168
})
159169
);
160170
this.subscriptions.push(
@@ -183,20 +193,60 @@ export class AutocompleteTextareaComponent
183193
this.userMentionConfig,
184194
this.slashCommandConfig,
185195
];
196+
this.subscriptions.push(
197+
this.messageInputConfigService.customAutocompletes$.subscribe(
198+
(customConfigs) => {
199+
const builtInItems =
200+
this.autocompleteConfig.mentions?.filter(
201+
(m) =>
202+
m === this.userMentionConfig || m === this.slashCommandConfig
203+
) ?? [];
204+
const transformedCustomConfigs = customConfigs.map((c) => {
205+
const copy: Mentions = {
206+
items: c.options.map((o) => ({
207+
...o,
208+
templateRef: c.templateRef,
209+
})),
210+
triggerChar: c.triggerCharacter,
211+
dropUp: true,
212+
labelKey: this.autocompleteKey,
213+
returnTrigger: true,
214+
allowSpace: c.allowSpace,
215+
mentionFilter: (
216+
searchString: string,
217+
items: { autocompleteLabel: string }[]
218+
) => this.filter(searchString, items),
219+
mentionSelect: (item, triggerChar) =>
220+
this.itemSelectedFromAutocompleteList(
221+
item as MentionAutcompleteListItem,
222+
triggerChar
223+
),
224+
};
225+
226+
return copy;
227+
});
228+
229+
this.autocompleteConfig.mentions = [
230+
...builtInItems,
231+
...transformedCustomConfigs,
232+
];
233+
this.autocompleteConfig = { ...this.autocompleteConfig };
234+
}
235+
)
236+
);
186237
}
187238

188239
ngOnChanges(changes: SimpleChanges): void {
189240
if (changes.areMentionsEnabled) {
190-
if (this.areMentionsEnabled) {
191-
this.autocompleteConfig.mentions = [
192-
this.userMentionConfig,
193-
this.slashCommandConfig,
194-
];
195-
this.autocompleteConfig = { ...this.autocompleteConfig };
196-
} else {
197-
this.autocompleteConfig.mentions = [this.slashCommandConfig];
198-
this.autocompleteConfig = { ...this.autocompleteConfig };
199-
}
241+
this.autocompleteConfig.mentions =
242+
this.autocompleteConfig?.mentions?.filter((c) => {
243+
if (c !== this.userMentionConfig) {
244+
return true;
245+
} else {
246+
return this.areMentionsEnabled;
247+
}
248+
}) ?? [];
249+
this.autocompleteConfig = { ...this.autocompleteConfig };
200250
}
201251
if (changes.mentionScope) {
202252
void this.updateMentionOptions(this.searchTerm$.getValue());
@@ -258,11 +308,14 @@ export class AutocompleteTextareaComponent
258308
}
259309

260310
autcompleteSearchTermChanged(searchTerm: string) {
261-
if (searchTerm === this.mentionTriggerChar) {
262-
void this.updateMentionOptions();
263-
} else {
264-
this.searchTerm$.next(searchTerm);
311+
if (searchTerm.length === 1) {
312+
if (searchTerm === this.mentionTriggerChar) {
313+
void this.updateMentionOptions();
314+
} else {
315+
void this.updateCustomAutocompleteOptions(searchTerm);
316+
}
265317
}
318+
this.searchTerm$.next(searchTerm);
266319
}
267320

268321
inputChanged() {
@@ -326,8 +379,7 @@ export class AutocompleteTextareaComponent
326379
);
327380
this.userMentionConfig.items = items;
328381
this.autocompleteConfig.mentions = [
329-
this.userMentionConfig,
330-
this.slashCommandConfig,
382+
...(this.autocompleteConfig?.mentions ?? []),
331383
];
332384
this.autocompleteConfig = { ...this.autocompleteConfig };
333385
this.cdRef.detectChanges();
@@ -346,4 +398,31 @@ export class AutocompleteTextareaComponent
346398
this.mentionedUsers = updatedMentionedUsers;
347399
}
348400
}
401+
402+
private async updateCustomAutocompleteOptions(searchTerm: string) {
403+
if (
404+
this.messageInputConfigService.customAutocompletes$.getValue().length ===
405+
0
406+
) {
407+
return;
408+
}
409+
const customMentionConfig = this.autocompleteConfig.mentions?.find(
410+
(c) => c.triggerChar && searchTerm.startsWith(c.triggerChar)
411+
);
412+
const customAutocompleteConfig = customMentionConfig
413+
? this.messageInputConfigService.customAutocompletes$
414+
.getValue()
415+
.find((c) => c.triggerCharacter === customMentionConfig?.triggerChar)
416+
: undefined;
417+
if (customMentionConfig && customAutocompleteConfig?.updateOptions) {
418+
const newOptions = await customAutocompleteConfig.updateOptions(
419+
searchTerm.replace(customMentionConfig.triggerChar || '', '')
420+
);
421+
customMentionConfig.items = newOptions.map((o) => ({
422+
...o,
423+
templateRef: customAutocompleteConfig.templateRef,
424+
}));
425+
this.autocompleteConfig = { ...this.autocompleteConfig };
426+
}
427+
}
349428
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { Injectable } from '@angular/core';
22
import { MessageInputComponent } from './message-input.component';
3+
import { BehaviorSubject } from 'rxjs';
4+
import { CustomAutocomplete } from '../types';
35

46
/**
57
* The `MessageInputConfigService` is used to keep a consistent configuration among the different [`MessageInput`](../components/MessageInputComponent.mdx) components if your UI has more than one input component.
@@ -47,6 +49,12 @@ export class MessageInputConfigService {
4749
event: ClipboardEvent,
4850
inputComponent: MessageInputComponent
4951
) => void;
52+
/**
53+
* Add custom autocomplete configurations to the message input
54+
*
55+
* Only works when using StreamAutocompleteTextareaModule
56+
*/
57+
customAutocompletes$ = new BehaviorSubject<CustomAutocomplete[]>([]);
5058

5159
constructor() {}
5260
}

projects/stream-chat-angular/src/lib/types.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,3 +500,43 @@ export type ThreadReplyButtonContext<
500500
> = {
501501
message: StreamMessage<T>;
502502
};
503+
504+
export type CustomAutocompleteItemContext = {
505+
item: CustomAutocompleteItem;
506+
};
507+
508+
export type CustomAutocompleteItem = {
509+
/**
510+
* This is the text that will be inserted into the message input once a user selects an option (appended after the trigger character)
511+
*/
512+
autocompleteLabel: string;
513+
};
514+
515+
export type CustomAutocomplete = {
516+
/**
517+
* The character that will trigger the autocomplete (for example #)
518+
*
519+
* The SDK supports @ and / by default, so you can't use those
520+
*/
521+
triggerCharacter: string;
522+
/**
523+
* The HTML template to display an item in the autocomplete list
524+
*/
525+
templateRef: TemplateRef<{ item: CustomAutocompleteItem }>;
526+
/**
527+
* Set to `true` if space characters can be part of the `autocompleteLabel`
528+
*/
529+
allowSpace: boolean;
530+
/**
531+
* The options to choose from
532+
*
533+
* In case you want to use dynamic/server-side filtering, use `updateOptions` instead
534+
*/
535+
options: CustomAutocompleteItem[];
536+
/**
537+
* If you want to have dynamic/server-side filtering provide a method that will be called any time the autocomplete options should be filtered
538+
* @param searchTerm the text to filter by (without the trigger character), can be an empty string
539+
* @returns a promise that will resolve to the options, you should take care of error handling
540+
*/
541+
updateOptions?: (searchTerm: string) => Promise<CustomAutocompleteItem[]>;
542+
};

0 commit comments

Comments
 (0)