UNPKG

@progress/kendo-angular-conversational-ui

Version:

Kendo UI for Angular Conversational UI components

618 lines (617 loc) 29.3 kB
/**----------------------------------------------------------------------------------------- * 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: ` @for (group of view; track trackGroup($index, group); let lastGroup = $last) { @switch (group.type) { @case ('date-marker') { <div class="k-timestamp" > @if (timestampTemplate?.templateRef) { <ng-container [ngTemplateOutlet]="timestampTemplate.templateRef" [ngTemplateOutletContext]="{ $implicit: group.timestamp }" > </ng-container> } @if (!timestampTemplate?.templateRef) { {{ formatTimeStamp(group.timestamp) }} } </div> } @case ('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])" > @if (!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> } @if (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> @if (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"> @if (showGroupAuthor(group)) { <p class="k-message-author">{{ group.author.name }}</p> } @for (msg of group.messages; track msg; let firstMessage = $first; let lastMessage = $last) { @if (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> @if (msg.attachments && msg.attachments.length === 1) { <kendo-chat-attachment [attachment]="msg.attachments[0]" [template]="attachmentTemplate" > </kendo-chat-attachment> } } </div> </div> } @case ('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> } @case ('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: ` @for (group of view; track trackGroup($index, group); let lastGroup = $last) { @switch (group.type) { @case ('date-marker') { <div class="k-timestamp" > @if (timestampTemplate?.templateRef) { <ng-container [ngTemplateOutlet]="timestampTemplate.templateRef" [ngTemplateOutletContext]="{ $implicit: group.timestamp }" > </ng-container> } @if (!timestampTemplate?.templateRef) { {{ formatTimeStamp(group.timestamp) }} } </div> } @case ('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])" > @if (!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> } @if (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> @if (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"> @if (showGroupAuthor(group)) { <p class="k-message-author">{{ group.author.name }}</p> } @for (msg of group.messages; track msg; let firstMessage = $first; let lastMessage = $last) { @if (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> @if (msg.attachments && msg.attachments.length === 1) { <kendo-chat-attachment [attachment]="msg.attachments[0]" [template]="attachmentTemplate" > </kendo-chat-attachment> } } </div> </div> } @case ('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> } @case ('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'] }] } });