@progress/kendo-angular-conversational-ui
Version:
Kendo UI for Angular Conversational UI components
618 lines (617 loc) • 29.3 kB
JavaScript
/**-----------------------------------------------------------------------------------------
* Copyright © 2025 Progress Software Corporation. All rights reserved.
* Licensed under commercial license. See LICENSE.md in the project root for more information
*-------------------------------------------------------------------------------------------*/
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ChangeDetectorRef, Component, ElementRef, EventEmitter, HostBinding, Input, Output, QueryList, Renderer2, ViewChildren } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { ExecuteActionEvent } from './api';
import { isPresent, Keys, normalizeKeys, ResizeSensorComponent } from '@progress/kendo-angular-common';
import { IntlService } from '@progress/kendo-angular-intl';
import { LocalizationService } from '@progress/kendo-angular-l10n';
import { closest, DOWNLOAD_ALL_SELECTOR, FILE_ACTION_BTN_SELECTOR } from './common/utils';
import { ChatItem } from './chat-item';
import { chatView, isAuthor } from './chat-view';
import { AttachmentTemplateDirective } from './templates/attachment-template.directive';
import { MessageContentTemplateDirective } from './templates/message-content-template.directive';
import { ChatTimestampTemplateDirective } from './templates/timestamp-template.directive';
import { Subscription } from 'rxjs';
import { SuggestedActionsComponent } from './suggested-actions.component';
import { MessageComponent } from './message.component';
import { MessageAttachmentsComponent } from './message-attachments.component';
import { AttachmentComponent } from './attachment.component';
import { ChatService } from './common/chat.service';
import { ChatStatusTemplateDirective } from './templates/status-template.directive';
import { ChatUserStatusTemplateDirective } from './templates/user-status-template.directive';
import { MessageTemplateDirective } from './templates/message-template.directive';
import { AuthorMessageContentTemplateDirective } from './templates/author-message-content-template.directive';
import { ReceiverMessageContentTemplateDirective } from './templates/receiver-message-content-template.directive';
import { AuthorMessageTemplateDirective } from './templates/author-message-template.directive';
import { ReceiverMessageTemplateDirective } from './templates/receiver-message-template.directive';
import * as i0 from "@angular/core";
import * as i1 from "@progress/kendo-angular-intl";
import * as i2 from "./common/chat.service";
/**
* @hidden
*/
export class MessageListComponent {
element;
intl;
renderer;
chatService;
cdr;
set messages(value) {
const data = value || [];
this.view = chatView(data);
this._messages = data;
}
get messages() {
return this._messages;
}
attachmentTemplate;
authorMessageContentTemplate;
receiverMessageContentTemplate;
messageContentTemplate;
authorMessageTemplate;
receiverMessageTemplate;
messageTemplate;
timestampTemplate;
statusTemplate;
userStatusTemplate;
localization;
authorId;
executeAction = new EventEmitter();
navigate = new EventEmitter();
resize = new EventEmitter();
items;
cssClass = true;
view = [];
_messages;
subs = new Subscription();
selectedItem;
keyActions = {
[Keys.Home]: (_) => this.onHomeOrEndKeyDown('home'),
[Keys.End]: (_) => this.onHomeOrEndKeyDown('end'),
[Keys.ArrowUp]: (e) => this.navigateTo(e, -1),
[Keys.ArrowDown]: (e) => this.navigateTo(e, 1),
[Keys.Tab]: (e) => this.onTabKeyDown(e),
[Keys.F10]: (e) => e.shiftKey && this.openContextMenu(e),
};
constructor(element, intl, renderer, chatService, cdr) {
this.element = element;
this.intl = intl;
this.renderer = renderer;
this.chatService = chatService;
this.cdr = cdr;
}
ngOnInit() {
const elRef = this.element.nativeElement;
this.subs.add(this.renderer.listen(elRef, 'keydown', event => this.onKeydown(event)));
this.subs.add(this.renderer.listen(elRef, 'focusout', event => this.onBlur(event)));
this.subs.add(this.renderer.listen(elRef, 'click', event => {
const messageComponent = this.findMessageComponentFromEvent(event);
if (messageComponent) {
this.select(messageComponent, event);
}
}));
this.subs.add(this.renderer.listen(elRef, 'contextmenu', event => {
event.preventDefault();
const messageComponent = this.findMessageComponentFromEvent(event);
if (messageComponent) {
this.onContextMenuClick(messageComponent.message, event, messageComponent);
}
}));
this.subs.add(this.chatService.replyReferenceClick$.subscribe((messageId) => {
this.scrollToAndSelectMessage(messageId);
}));
this.subs.add(this.chatService.contextMenuVisibilityChange$.subscribe((isVisible) => {
this.handleMenuClose(isVisible);
}));
}
ngAfterViewInit() {
this.selectedItem = this.items.last;
}
ngOnDestroy() {
this.subs.unsubscribe();
}
onResize() {
this.resize.emit();
}
onClick(message, event) {
this.select(message, event);
}
onContextMenuClick(message, event, messageElement) {
this.chatService.calculateContextMenuActions(this.isOwnMessage(message));
if (this.chatService.calculatedContextMenuActions.length > 0) {
this.chatService.messagesContextMenu.show({
left: event.pageX,
top: event.pageY,
});
this.chatService.activeMessageElement = messageElement;
this.chatService.activeMessage = message;
this.chatService.active = true;
this.chatService.selectOnMenuClose = this.chatService.activeMessageElement?.selected;
this.chatService.emit('contextMenuVisibilityChange', true);
}
}
formatTimeStamp(date) {
return this.intl.formatDate(date, { date: 'full' });
}
calculateMessageWidthMode(message) {
const isOwn = this.isOwnMessage(message);
const messageSettings = isOwn ? this.chatService.authorMessageSettings : this.chatService.receiverMessageSettings;
if (messageSettings?.messageWidthMode) {
return messageSettings.messageWidthMode === 'full';
}
return this.chatService.messageWidthMode === 'full';
}
onKeydown(e) {
// On some keyboards Numpad keys are used for Home/End/PageUp/PageDown.
const code = normalizeKeys(e);
const action = this.keyActions[code];
if (action) {
action(e);
}
}
onBlur(args) {
const next = args.relatedTarget || document.activeElement;
const outside = !closest(next, (node) => node === this.element.nativeElement);
const isActionClick = closest(next, (node) => node?.classList?.contains('k-context-menu-popup')) || closest(next, (node) => node?.classList?.contains('k-menu-popup'));
if (outside && !isActionClick) {
this.select(null);
}
}
isOwnMessage(msg) {
return isAuthor(this.authorId, msg);
}
showGroupAuthor(group) {
const messageSettings = this.isOwnMessage(group.messages[0]) ?
this.chatService.authorMessageSettings : this.chatService.receiverMessageSettings;
if (isPresent(messageSettings?.showUsername)) {
return messageSettings.showUsername && group.author.name;
}
return this.chatService.showUsername && group.author.name;
}
showGroupAvatar(group) {
const messageSettings = this.isOwnMessage(group.messages[0]) ?
this.chatService.authorMessageSettings : this.chatService.receiverMessageSettings;
if (isPresent(messageSettings?.showAvatar)) {
return messageSettings.showAvatar && group.author.avatarUrl;
}
return this.chatService.showAvatar && group.author.avatarUrl;
}
dispatchAction(action, message) {
const args = new ExecuteActionEvent(action, message);
this.executeAction.emit(args);
}
trackGroup(_index, item) {
return item.trackBy;
}
select(item, event) {
if (event) {
const target = event.target;
if (target.classList.contains('k-suggestion') || target.closest(DOWNLOAD_ALL_SELECTOR) || target.closest(FILE_ACTION_BTN_SELECTOR)) {
return;
}
}
if (!this.chatService.toggleMessageState) {
const prevItem = this.selectedItem;
if (prevItem) {
prevItem.selected = false;
}
if (item) {
item.selected = true;
this.selectedItem = item;
}
this.cdr.detectChanges();
}
this.chatService.toggleMessageState = false;
}
last(items) {
if (!items || items.length === 0) {
return;
}
const messageGroups = items.filter((item) => item.type === 'message-group');
const lastMessageGroup = messageGroups[messageGroups.length - 1].messages;
return lastMessageGroup[lastMessageGroup.length - 1];
}
handleMenuClose(state) {
if (!state) {
this.select(null);
}
}
textFor(key) {
return this.localization.get(key);
}
onHomeOrEndKeyDown(key) {
const items = this.items.toArray();
if (key === 'home') {
items[0].focus();
}
else {
items[items.length - 1].focus();
}
}
onTabKeyDown(event) {
const item = this.items.toArray()[this.items.length - 1];
const isLastItemQuickReply = item instanceof SuggestedActionsComponent;
const isLastItemMessage = item instanceof MessageComponent;
event.target.blur();
if (isLastItemQuickReply || isLastItemMessage) {
this.select(item);
item.focus();
this.navigate.emit();
}
}
navigateTo(e, offset) {
const items = this.items.toArray();
const prevItem = this.selectedItem;
const prevIndex = items.indexOf(prevItem);
const nextIndex = Math.max(0, Math.min(prevIndex + offset, this.items.length - 1));
const nextItem = items[nextIndex];
const isNextItemQuickReply = nextItem instanceof SuggestedActionsComponent;
if (nextItem !== prevItem) {
if (isNextItemQuickReply) {
nextItem.items.toArray()[0]?.nativeElement.focus();
}
this.select(nextItem);
nextItem.focus();
this.navigate.emit();
e.preventDefault();
}
}
scrollToAndSelectMessage(messageId) {
this.chatService.scrollToMessage(messageId);
const message = this.chatService.getMessageById(messageId);
const chatItem = this.items.find(item => item instanceof MessageComponent &&
item.message === message);
if (chatItem) {
this.select(chatItem);
}
}
openContextMenu(event) {
event.preventDefault();
const messageComponent = this.findMessageComponentFromActiveElement();
if (messageComponent) {
const messageContentElement = messageComponent.element.nativeElement.querySelector('.k-chat-bubble');
if (messageContentElement) {
const rect = messageContentElement?.getBoundingClientRect();
this.onContextMenuClick(messageComponent.message, {
pageX: rect.left + (rect.width / 2) + window.scrollX,
pageY: rect.top + (rect.height / 2) + window.scrollY
}, messageComponent);
}
else {
const messageElement = messageComponent.element.nativeElement;
const rect = messageElement?.getBoundingClientRect();
this.onContextMenuClick(messageComponent.message, {
pageX: rect.left + (rect.width / 2) + window.scrollX,
pageY: rect.top + (rect.height / 2) + window.scrollY
}, messageComponent);
}
}
}
findMessageComponentFromActiveElement() {
const activeElement = document.activeElement;
const messageComponents = this.items.filter(item => item instanceof MessageComponent);
return messageComponents.find(component => {
const componentElement = component.element?.nativeElement;
return componentElement && (componentElement === activeElement || componentElement.contains(activeElement));
});
}
findMessageComponentFromEvent(event) {
const target = event.target;
const clickedElement = target?.closest('.k-message');
if (!clickedElement)
return undefined;
const messageComponents = this.items.filter(item => item instanceof MessageComponent);
return messageComponents.find(component => {
const componentElement = component.element?.nativeElement;
return componentElement && (componentElement === clickedElement || componentElement.contains(clickedElement));
});
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: MessageListComponent, deps: [{ token: i0.ElementRef }, { token: i1.IntlService }, { token: i0.Renderer2 }, { token: i2.ChatService }, { token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: MessageListComponent, isStandalone: true, selector: "kendo-chat-message-list", inputs: { messages: "messages", attachmentTemplate: "attachmentTemplate", authorMessageContentTemplate: "authorMessageContentTemplate", receiverMessageContentTemplate: "receiverMessageContentTemplate", messageContentTemplate: "messageContentTemplate", authorMessageTemplate: "authorMessageTemplate", receiverMessageTemplate: "receiverMessageTemplate", messageTemplate: "messageTemplate", timestampTemplate: "timestampTemplate", statusTemplate: "statusTemplate", userStatusTemplate: "userStatusTemplate", localization: "localization", authorId: "authorId" }, outputs: { executeAction: "executeAction", navigate: "navigate", resize: "resize" }, host: { properties: { "class.k-message-list-content": "this.cssClass" } }, viewQueries: [{ propertyName: "items", predicate: ChatItem, descendants: true }], ngImport: i0, template: `
(group of view; track trackGroup($index, group); let lastGroup = $last) {
(group.type) {
('date-marker') {
<div
class="k-timestamp"
>
(timestampTemplate?.templateRef) {
<ng-container
[ngTemplateOutlet]="timestampTemplate.templateRef"
[ngTemplateOutletContext]="{ $implicit: group.timestamp }"
>
</ng-container>
}
(!timestampTemplate?.templateRef) {
{{ formatTimeStamp(group.timestamp) }}
}
</div>
}
('message-group') {
<div
class="k-message-group"
[class.k-message-group-sender]="isOwnMessage(group.messages[0])"
[class.k-message-group-receiver]="!isOwnMessage(group.messages[0])"
[class.k-no-avatar]="!showGroupAvatar(group)"
[class.k-message-group-full-width]="calculateMessageWidthMode(group.messages[0])"
>
(!userStatusTemplate?.templateRef && showGroupAvatar(group)) {
<div
class="k-avatar k-avatar-md k-avatar-solid k-avatar-solid-primary k-rounded-full"
>
<span class="k-avatar-image">
<img
[attr.src]="group.author.avatarUrl"
[alt]="group.author.avatarAltText"
/>
</span>
</div>
}
(showGroupAvatar(group) && userStatusTemplate?.templateRef) {
<div class="k-chat-user-status-wrapper">
<div
class="k-avatar k-avatar-md k-avatar-solid k-avatar-solid-primary k-rounded-full"
>
<span class="k-avatar-image">
<img
[attr.src]="group.author.avatarUrl"
[alt]="group.author.avatarAltText"
/>
</span>
</div>
(userStatusTemplate?.templateRef) {
<div class="k-chat-user-status">
<ng-template
[ngTemplateOutlet]="userStatusTemplate.templateRef"
[ngTemplateOutletContext]="{ $implicit: group.messages.at(-1) }"
>
</ng-template>
</div>
}
</div>
}
<div class="k-message-group-content">
(showGroupAuthor(group)) {
<p class="k-message-author">{{ group.author.name }}</p>
}
(msg of group.messages; track msg; let firstMessage = $first; let lastMessage = $last) {
(msg.user?.avatarUrl) {
<div
class="k-avatar k-avatar-md k-avatar-solid k-avatar-solid-primary k-rounded-full"
>
<span class="k-avatar-image">
<img [src]="msg.user?.avatarUrl">
</span>
</div>
}
<kendo-chat-message #message
[message]="msg"
[tabbable]="lastGroup && lastMessage"
[authorMessageContentTemplate]="authorMessageContentTemplate"
[receiverMessageContentTemplate]="receiverMessageContentTemplate"
[messageContentTemplate]="messageContentTemplate"
[authorMessageTemplate]="authorMessageTemplate"
[receiverMessageTemplate]="receiverMessageTemplate"
[messageTemplate]="messageTemplate"
[statusTemplate]="statusTemplate"
[authorId]="authorId"
>
</kendo-chat-message>
(msg.attachments && msg.attachments.length === 1) {
<kendo-chat-attachment
[attachment]="msg.attachments[0]"
[template]="attachmentTemplate"
>
</kendo-chat-attachment>
}
}
</div>
</div>
}
('attachment-group') {
<kendo-chat-message-attachments #attachments
[attachments]="group.attachments"
[layout]="group.attachmentLayout"
[localization]="localization"
[tabbable]="lastGroup"
[template]="attachmentTemplate"
(click)="select(attachments)"
(focus)="select(attachments)"
>
</kendo-chat-message-attachments>
}
('action-group') {
<kendo-chat-suggested-actions #actions
[actions]="group.actions"
type="action"
[tabbable]="lastGroup"
(dispatchAction)="dispatchAction($event, last(view))"
(click)="select(actions, $event)"
(focus)="select(actions, $event)"
>
</kendo-chat-suggested-actions>
}
}
}
<kendo-resize-sensor (resize)="onResize()">
</kendo-resize-sensor>
`, isInline: true, dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: MessageComponent, selector: "kendo-chat-message", inputs: ["message", "tabbable", "authorMessageContentTemplate", "receiverMessageContentTemplate", "messageContentTemplate", "authorMessageTemplate", "receiverMessageTemplate", "messageTemplate", "statusTemplate", "showMessageTime", "authorId"] }, { kind: "component", type: AttachmentComponent, selector: "kendo-chat-attachment", inputs: ["attachment", "template"] }, { kind: "component", type: MessageAttachmentsComponent, selector: "kendo-chat-message-attachments", inputs: ["attachments", "layout", "tabbable", "template", "localization"] }, { kind: "component", type: SuggestedActionsComponent, selector: "kendo-chat-suggested-actions", inputs: ["actions", "suggestions", "tabbable", "type", "suggestionTemplate"], outputs: ["dispatchAction", "dispatchSuggestion"] }, { kind: "component", type: ResizeSensorComponent, selector: "kendo-resize-sensor", inputs: ["rateLimit"], outputs: ["resize"] }] });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: MessageListComponent, decorators: [{
type: Component,
args: [{
selector: 'kendo-chat-message-list',
template: `
(group of view; track trackGroup($index, group); let lastGroup = $last) {
(group.type) {
('date-marker') {
<div
class="k-timestamp"
>
(timestampTemplate?.templateRef) {
<ng-container
[ngTemplateOutlet]="timestampTemplate.templateRef"
[ngTemplateOutletContext]="{ $implicit: group.timestamp }"
>
</ng-container>
}
(!timestampTemplate?.templateRef) {
{{ formatTimeStamp(group.timestamp) }}
}
</div>
}
('message-group') {
<div
class="k-message-group"
[class.k-message-group-sender]="isOwnMessage(group.messages[0])"
[class.k-message-group-receiver]="!isOwnMessage(group.messages[0])"
[class.k-no-avatar]="!showGroupAvatar(group)"
[class.k-message-group-full-width]="calculateMessageWidthMode(group.messages[0])"
>
(!userStatusTemplate?.templateRef && showGroupAvatar(group)) {
<div
class="k-avatar k-avatar-md k-avatar-solid k-avatar-solid-primary k-rounded-full"
>
<span class="k-avatar-image">
<img
[attr.src]="group.author.avatarUrl"
[alt]="group.author.avatarAltText"
/>
</span>
</div>
}
(showGroupAvatar(group) && userStatusTemplate?.templateRef) {
<div class="k-chat-user-status-wrapper">
<div
class="k-avatar k-avatar-md k-avatar-solid k-avatar-solid-primary k-rounded-full"
>
<span class="k-avatar-image">
<img
[attr.src]="group.author.avatarUrl"
[alt]="group.author.avatarAltText"
/>
</span>
</div>
(userStatusTemplate?.templateRef) {
<div class="k-chat-user-status">
<ng-template
[ngTemplateOutlet]="userStatusTemplate.templateRef"
[ngTemplateOutletContext]="{ $implicit: group.messages.at(-1) }"
>
</ng-template>
</div>
}
</div>
}
<div class="k-message-group-content">
(showGroupAuthor(group)) {
<p class="k-message-author">{{ group.author.name }}</p>
}
(msg of group.messages; track msg; let firstMessage = $first; let lastMessage = $last) {
(msg.user?.avatarUrl) {
<div
class="k-avatar k-avatar-md k-avatar-solid k-avatar-solid-primary k-rounded-full"
>
<span class="k-avatar-image">
<img [src]="msg.user?.avatarUrl">
</span>
</div>
}
<kendo-chat-message #message
[message]="msg"
[tabbable]="lastGroup && lastMessage"
[authorMessageContentTemplate]="authorMessageContentTemplate"
[receiverMessageContentTemplate]="receiverMessageContentTemplate"
[messageContentTemplate]="messageContentTemplate"
[authorMessageTemplate]="authorMessageTemplate"
[receiverMessageTemplate]="receiverMessageTemplate"
[messageTemplate]="messageTemplate"
[statusTemplate]="statusTemplate"
[authorId]="authorId"
>
</kendo-chat-message>
(msg.attachments && msg.attachments.length === 1) {
<kendo-chat-attachment
[attachment]="msg.attachments[0]"
[template]="attachmentTemplate"
>
</kendo-chat-attachment>
}
}
</div>
</div>
}
('attachment-group') {
<kendo-chat-message-attachments #attachments
[attachments]="group.attachments"
[layout]="group.attachmentLayout"
[localization]="localization"
[tabbable]="lastGroup"
[template]="attachmentTemplate"
(click)="select(attachments)"
(focus)="select(attachments)"
>
</kendo-chat-message-attachments>
}
('action-group') {
<kendo-chat-suggested-actions #actions
[actions]="group.actions"
type="action"
[tabbable]="lastGroup"
(dispatchAction)="dispatchAction($event, last(view))"
(click)="select(actions, $event)"
(focus)="select(actions, $event)"
>
</kendo-chat-suggested-actions>
}
}
}
<kendo-resize-sensor (resize)="onResize()">
</kendo-resize-sensor>
`,
standalone: true,
imports: [NgTemplateOutlet, MessageComponent, AttachmentComponent, MessageAttachmentsComponent, SuggestedActionsComponent, ResizeSensorComponent]
}]
}], ctorParameters: () => [{ type: i0.ElementRef }, { type: i1.IntlService }, { type: i0.Renderer2 }, { type: i2.ChatService }, { type: i0.ChangeDetectorRef }], propDecorators: { messages: [{
type: Input
}], attachmentTemplate: [{
type: Input
}], authorMessageContentTemplate: [{
type: Input
}], receiverMessageContentTemplate: [{
type: Input
}], messageContentTemplate: [{
type: Input
}], authorMessageTemplate: [{
type: Input
}], receiverMessageTemplate: [{
type: Input
}], messageTemplate: [{
type: Input
}], timestampTemplate: [{
type: Input
}], statusTemplate: [{
type: Input
}], userStatusTemplate: [{
type: Input
}], localization: [{
type: Input
}], authorId: [{
type: Input
}], executeAction: [{
type: Output
}], navigate: [{
type: Output
}], resize: [{
type: Output
}], items: [{
type: ViewChildren,
args: [ChatItem]
}], cssClass: [{
type: HostBinding,
args: ['class.k-message-list-content']
}] } });