From 0b0c82e8c92d19b4d2b1183d582f8a87f3db9c28 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Fri, 2 May 2025 16:02:13 +0300 Subject: [PATCH 001/252] feat(chat): add initial chat implementation --- src/components/chat/chat-header.ts | 39 +++ src/components/chat/chat-input.ts | 232 ++++++++++++++++++ src/components/chat/chat-message-list.ts | 154 ++++++++++++ src/components/chat/chat-message.ts | 123 ++++++++++ src/components/chat/chat.ts | 205 ++++++++++++++++ src/components/chat/emoji-picker.ts | 27 ++ src/components/chat/message-attachments.ts | 115 +++++++++ src/components/chat/message-reactions.ts | 128 ++++++++++ src/components/chat/themes/chat.base.scss | 26 ++ src/components/chat/themes/header.base.scss | 100 ++++++++ src/components/chat/themes/input.base.scss | 149 +++++++++++ .../chat/themes/message-attachments.base.scss | 102 ++++++++ .../chat/themes/message-list.base.scss | 92 +++++++ src/components/chat/themes/message.base.scss | 123 ++++++++++ src/components/chat/themes/reaction.base.scss | 72 ++++++ src/components/chat/types.ts | 48 ++++ .../common/definitions/defineAllComponents.ts | 2 + src/index.ts | 1 + stories/chat.stories.ts | 93 +++++++ 19 files changed, 1831 insertions(+) create mode 100644 src/components/chat/chat-header.ts create mode 100644 src/components/chat/chat-input.ts create mode 100644 src/components/chat/chat-message-list.ts create mode 100644 src/components/chat/chat-message.ts create mode 100644 src/components/chat/chat.ts create mode 100644 src/components/chat/emoji-picker.ts create mode 100644 src/components/chat/message-attachments.ts create mode 100644 src/components/chat/message-reactions.ts create mode 100644 src/components/chat/themes/chat.base.scss create mode 100644 src/components/chat/themes/header.base.scss create mode 100644 src/components/chat/themes/input.base.scss create mode 100644 src/components/chat/themes/message-attachments.base.scss create mode 100644 src/components/chat/themes/message-list.base.scss create mode 100644 src/components/chat/themes/message.base.scss create mode 100644 src/components/chat/themes/reaction.base.scss create mode 100644 src/components/chat/types.ts create mode 100644 stories/chat.stories.ts diff --git a/src/components/chat/chat-header.ts b/src/components/chat/chat-header.ts new file mode 100644 index 000000000..33066d901 --- /dev/null +++ b/src/components/chat/chat-header.ts @@ -0,0 +1,39 @@ +import { LitElement, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { registerComponent } from '../common/definitions/register.js'; +import { styles } from './themes/header.base.css.js'; + +/** + * + * @element igc-chat-header + * + */ +export default class IgcChatHeaderComponent extends LitElement { + /** @private */ + public static readonly tagName = 'igc-chat-header'; + + public static override styles = styles; + + /* blazorSuppress */ + public static register() { + registerComponent(IgcChatHeaderComponent); + } + + @property({ type: String, reflect: true }) + public text = ''; + + protected override render() { + return html`
+
${this.text}
+
+ +
+
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-chat-header': IgcChatHeaderComponent; + } +} diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts new file mode 100644 index 000000000..9f2e92981 --- /dev/null +++ b/src/components/chat/chat-input.ts @@ -0,0 +1,232 @@ +import { LitElement, html } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import IgcIconButtonComponent from '../button/icon-button.js'; +import IgcChipComponent from '../chip/chip.js'; +import { registerComponent } from '../common/definitions/register.js'; +import IgcFileInputComponent from '../file-input/file-input.js'; +import IgcIconComponent from '../icon/icon.js'; +import { registerIconFromText } from '../icon/icon.registry.js'; +import IgcTextareaComponent from '../textarea/textarea.js'; +import IgcEmojiPickerComponent from './emoji-picker.js'; +import { styles } from './themes/input.base.css.js'; +import { + type IgcMessageAttachment, + attachmentIcon, + emojiPickerIcon, + sendButtonIcon, +} from './types.js'; + +/** + * + * @element igc-chat + * + */ +export default class IgcChatInputComponent extends LitElement { + /** @private */ + public static readonly tagName = 'igc-chat-input'; + + public static override styles = styles; + + /* blazorSuppress */ + public static register() { + registerComponent( + IgcChatInputComponent, + IgcTextareaComponent, + IgcIconButtonComponent, + IgcChipComponent, + IgcEmojiPickerComponent, + IgcFileInputComponent, + IgcIconComponent + ); + } + + @property({ type: Boolean, attribute: 'enable-attachments' }) + public enableAttachments = true; + + @property({ type: Boolean, attribute: 'enable-emoji-picker' }) + public enableEmojiPicker = true; + + @query('textarea') + private textInputElement!: HTMLTextAreaElement; + + @state() + private inputValue = ''; + + @state() + private attachments: IgcMessageAttachment[] = []; + + @state() + private showEmojiPicker = false; + + constructor() { + super(); + registerIconFromText('emoji-picker', emojiPickerIcon, 'material'); + registerIconFromText('attachment', attachmentIcon, 'material'); + registerIconFromText('send-message', sendButtonIcon, 'material'); + } + + private handleInput(e: Event) { + const target = e.target as HTMLTextAreaElement; + this.inputValue = target.value; + this.adjustTextareaHeight(); + } + + private handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + this.sendMessage(); + } + } + + private adjustTextareaHeight() { + const textarea = this.textInputElement; + if (!textarea) return; + + textarea.style.height = 'auto'; + const newHeight = Math.min(textarea.scrollHeight, 120); + textarea.style.height = `${newHeight}px`; + } + + private sendMessage() { + if (!this.inputValue.trim() && this.attachments.length === 0) return; + + const messageEvent = new CustomEvent('message-send', { + detail: { text: this.inputValue, attachments: this.attachments }, + }); + + this.dispatchEvent(messageEvent); + this.inputValue = ''; + this.attachments = []; + + if (this.textInputElement) { + this.textInputElement.style.height = 'auto'; + } + + setTimeout(() => { + this.textInputElement?.focus(); + }, 0); + } + + private toggleEmojiPicker() { + this.showEmojiPicker = !this.showEmojiPicker; + } + + private addEmoji(e: CustomEvent) { + const emoji = e.detail.emoji; + this.inputValue += emoji; + this.showEmojiPicker = false; + + // Focus back on input after selecting an emoji + this.updateComplete.then(() => { + const textarea = this.shadowRoot?.querySelector('textarea'); + if (textarea) { + textarea.focus(); + } + }); + } + + private handleFileUpload(e: Event) { + const input = (e.target as any).input as HTMLInputElement; + if (!input.files || input.files.length === 0) return; + + const files = Array.from(input.files); + const newAttachments: IgcMessageAttachment[] = []; + files.forEach((file) => { + const isImage = file.type.startsWith('image/'); + newAttachments.push({ + id: `attach_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + type: isImage ? 'image' : 'file', + url: URL.createObjectURL(file), + name: file.name, + size: file.size, + thumbnail: isImage ? URL.createObjectURL(file) : undefined, + }); + }); + this.attachments = [...this.attachments, ...newAttachments]; + } + + private removeAttachment(index: number) { + this.attachments = this.attachments.filter((_, i) => i !== index); + } + + protected override render() { + return html` +
+ ${this.enableAttachments + ? html` + + + + ` + : ''} + +
+ +
+ +
+ ${this.enableEmojiPicker + ? html` + + ` + : ''} + + +
+ + ${this.showEmojiPicker + ? html` +
+ +
+ ` + : ''} +
+
+ ${this.attachments?.map( + (attachment, index) => html` +
+ this.removeAttachment(index)} + > + ${attachment.name} + +
+ ` + )} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-chat-input': IgcChatInputComponent; + } +} diff --git a/src/components/chat/chat-message-list.ts b/src/components/chat/chat-message-list.ts new file mode 100644 index 000000000..34f939d5f --- /dev/null +++ b/src/components/chat/chat-message-list.ts @@ -0,0 +1,154 @@ +import { LitElement, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { registerComponent } from '../common/definitions/register.js'; +import IgcChatMessageComponent from './chat-message.js'; +import { styles } from './themes/message-list.base.css.js'; +import type { IgcMessage, IgcUser } from './types.js'; + +/** + * + * @element igc-chat-message-list + * + */ +export default class IgcChatMessageListComponent extends LitElement { + /** @private */ + public static readonly tagName = 'igc-chat-message-list'; + + public static override styles = styles; + + /* blazorSuppress */ + public static register() { + registerComponent(IgcChatMessageListComponent, IgcChatMessageComponent); + } + + @property({ reflect: true }) + public user: IgcUser | undefined; + + @property({ reflect: true }) + public messages: IgcMessage[] = []; + + @property({ reflect: true, attribute: 'typing-users' }) + public typingUsers: IgcUser[] = []; + + @property({ type: Boolean, attribute: 'scroll-bottom' }) + public scrollBottom = true; + + @property({ type: Boolean, attribute: 'enable-reactions' }) + public enableReactions = true; + + private formatDate(date: Date): string { + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + if (date.toDateString() === today.toDateString()) { + return 'Today'; + } + + if (date.toDateString() === yesterday.toDateString()) { + return 'Yesterday'; + } + + return date.toLocaleDateString('en-US', { + weekday: 'long', + month: 'short', + day: 'numeric', + }); + } + + private groupMessagesByDate( + messages: IgcMessage[] + ): { date: string; messages: IgcMessage[] }[] { + const grouped: { [key: string]: IgcMessage[] } = {}; + + messages.forEach((message) => { + const dateStr = this.formatDate(message.timestamp); + if (!grouped[dateStr]) { + grouped[dateStr] = []; + } + grouped[dateStr].push(message); + }); + + return Object.keys(grouped).map((date) => ({ + date, + messages: grouped[date], + })); + } + + private handleReaction(e: CustomEvent) { + const { messageId, emoji } = e.detail; + + this.dispatchEvent( + new CustomEvent('add-reaction', { + detail: { messageId, emoji }, + bubbles: true, + composed: true, + }) + ); + } + + private scrollToBottom() { + requestAnimationFrame(() => { + const container = this.shadowRoot?.host as HTMLElement; + if (container) { + container.scrollTop = container.scrollHeight; + } + }); + } + + protected override updated(changedProperties: Map) { + if (changedProperties.has('messages') && this.scrollBottom) { + this.scrollToBottom(); + } + } + + protected override firstUpdated() { + if (this.scrollBottom) { + this.scrollToBottom(); + } + } + + protected override render() { + const groupedMessages = this.groupMessagesByDate(this.messages); + + return html` +
+ ${repeat( + groupedMessages, + (group) => group.date, + (group) => html` +
${group.date}
+ ${repeat( + group.messages, + (message) => message.id, + (message) => html` + + ` + )} + ` + )} + ${this.typingUsers.length > 0 + ? html` +
+
+
+
+
+ ` + : ''} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-chat-message-list': IgcChatMessageListComponent; + } +} diff --git a/src/components/chat/chat-message.ts b/src/components/chat/chat-message.ts new file mode 100644 index 000000000..b92373185 --- /dev/null +++ b/src/components/chat/chat-message.ts @@ -0,0 +1,123 @@ +import { LitElement, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import IgcAvatarComponent from '../avatar/avatar.js'; +import { registerComponent } from '../common/definitions/register.js'; +import { IgcMessageAttachmentsComponent } from './message-attachments.js'; +import { IgcMessageReactionsComponent } from './message-reactions.js'; +import { styles } from './themes/message.base.css.js'; +import type { IgcMessage, IgcUser } from './types.js'; + +/** + * + * @element igc-chat-message + * + */ +export default class IgcChatMessageComponent extends LitElement { + /** @private */ + public static readonly tagName = 'igc-chat-message'; + + public static override styles = styles; + + /* blazorSuppress */ + public static register() { + registerComponent( + IgcChatMessageComponent, + IgcMessageAttachmentsComponent, + IgcMessageReactionsComponent, + IgcAvatarComponent + ); + } + + @property({ reflect: true }) + public message: IgcMessage | undefined; + + @property({ reflect: true }) + public user: IgcUser | undefined; + + @property({ type: Boolean, attribute: 'enable-reactions' }) + public enableReactions = true; + + private formatTime(date: Date | undefined): string | undefined { + return date?.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + + private renderStatusIcon(status: string) { + if (status === 'sent') { + return '✓'; + } + + if (status === 'delivered') { + return '✓✓'; + } + + if (status === 'read') { + return '✓✓'; + } + return ''; + } + + private handleAddReaction(e: CustomEvent) { + const emoji = e.detail.emoji; + + this.dispatchEvent( + new CustomEvent('add-reaction', { + detail: { messageId: this.message?.id, emoji }, + bubbles: true, + composed: true, + }) + ); + } + + private isCurrentUser() { + return this.message?.sender.id === this.user?.id; + } + + protected override render() { + const sender = this.message?.sender; + const containerClass = `message-container ${this.isCurrentUser() ? 'sent' : 'received'}`; + + return html` +
+ + +
+ ${this.message?.text.trim() + ? html`
${this.message?.text}
` + : ''} +
+ ${this.formatTime(this.message?.timestamp)} + ${this.isCurrentUser() + ? html`${this.renderStatusIcon( + this.message?.status || 'sent' + )}` + : ''} +
+ ${this.message?.attachments && this.message?.attachments.length > 0 + ? html` + ` + : ''} +
+ ${this.enableReactions + ? html`` + : ''} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-chat-message': IgcChatMessageComponent; + } +} diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts new file mode 100644 index 000000000..82eda0012 --- /dev/null +++ b/src/components/chat/chat.ts @@ -0,0 +1,205 @@ +import { LitElement, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { registerComponent } from '../common/definitions/register.js'; +import type { Constructor } from '../common/mixins/constructor.js'; +import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; +import IgcChatHeaderComponent from './chat-header.js'; +import IgcChatInputComponent from './chat-input.js'; +import IgcChatMessageListComponent from './chat-message-list.js'; +import { styles } from './themes/chat.base.css.js'; +import type { IgcMessage, IgcUser } from './types.js'; + +export interface IgcChatComponentEventMap { + igcMessageEntered: CustomEvent; +} + +/** + * + * @element igc-chat + * + */ +export default class IgcChatComponent extends EventEmitterMixin< + IgcChatComponentEventMap, + Constructor +>(LitElement) { + /** @private */ + public static readonly tagName = 'igc-chat'; + + public static styles = styles; + + /* blazorSuppress */ + public static register() { + registerComponent( + IgcChatComponent, + IgcChatHeaderComponent, + IgcChatInputComponent, + IgcChatMessageListComponent + ); + } + + @property() + public user: IgcUser | undefined; + + @property({ reflect: true }) + public messages: IgcMessage[] = []; + + @property({ reflect: true, attribute: 'typing-users' }) + public typingUsers: IgcUser[] = []; + + @property({ type: Boolean, attribute: 'scroll-bottom' }) + public scrollBottom = true; + + @property({ type: Boolean, attribute: 'enable-reactions' }) + public enableReactions = true; + + @property({ type: Boolean, attribute: 'enable-attachments' }) + public enableAttachments = true; + + @property({ type: Boolean, attribute: 'enable-emoji-picker' }) + public enableEmojiPicker = true; + + @property({ type: String, attribute: 'header-text', reflect: true }) + public headerText = ''; + + public override connectedCallback() { + super.connectedCallback(); + this.addEventListener( + 'add-reaction', + this.handleAddReaction as EventListener + ); + this.addEventListener( + 'message-send', + this.handleSendMessage as EventListener + ); + } + + public override disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener( + 'message-send', + this.handleSendMessage as EventListener + ); + this.removeEventListener( + 'add-reaction', + this.handleAddReaction as EventListener + ); + } + + private handleSendMessage(e: CustomEvent) { + const text = e.detail.text; + const attachments = e.detail.attachments || []; + + if ((!text.trim() && attachments.length === 0) || !this.user) return; + + const newMessage: IgcMessage = { + id: Date.now().toString(), + text, + sender: this.user, + timestamp: new Date(), + status: 'sent', + attachments, + reactions: [], + }; + + this.messages = [...this.messages, newMessage]; + this.emitEvent('igcMessageEntered', { detail: newMessage }); + } + + private handleAddReaction(e: CustomEvent) { + const { messageId, emoji } = e.detail; + + this.messages.map((message) => { + if (message.id === messageId) { + const existingReaction = message.reactions?.find( + (r) => r.emoji === emoji + ); + + if (existingReaction && this.user) { + // Toggle reaction for current user + const userId = this.user.id; + const hasReacted = existingReaction.users.includes(userId); + + if (hasReacted) { + // Remove reaction + const updatedReactions = + message.reactions + ?.map((r) => { + if (r.emoji === emoji) { + return { + ...r, + count: r.count - 1, + users: r.users.filter((id) => id !== userId), + }; + } + return r; + }) + .filter((r) => r.count > 0) || []; + + return { + ...message, + reactions: updatedReactions, + }; + } + + // Add reaction + const updatedReactions = + message.reactions?.map((r) => { + if (r.emoji === emoji) { + return { + ...r, + count: r.count + 1, + users: [...r.users, userId], + }; + } + return r; + }) || []; + + return { + ...message, + reactions: updatedReactions, + }; + } + + // Create new reaction + const newReaction = { + emoji, + count: 1, + users: [this.user?.id], + }; + + return { + ...message, + reactions: [...(message.reactions || []), newReaction], + }; + } + return message; + }); + } + + protected override render() { + return html` +
+ + + + +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-chat': IgcChatComponent; + } +} diff --git a/src/components/chat/emoji-picker.ts b/src/components/chat/emoji-picker.ts new file mode 100644 index 000000000..284f0ada3 --- /dev/null +++ b/src/components/chat/emoji-picker.ts @@ -0,0 +1,27 @@ +import { LitElement, html } from 'lit'; +import { registerComponent } from '../common/definitions/register.js'; + +/** + * + * @element igc-emoji-picker + * + */ +export default class IgcEmojiPickerComponent extends LitElement { + /** @private */ + public static readonly tagName = 'igc-emoji-picker'; + + /* blazorSuppress */ + public static register() { + registerComponent(IgcEmojiPickerComponent); + } + + protected override render() { + return html``; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-chat-igc-emoji-picker': IgcEmojiPickerComponent; + } +} diff --git a/src/components/chat/message-attachments.ts b/src/components/chat/message-attachments.ts new file mode 100644 index 000000000..63ad07d19 --- /dev/null +++ b/src/components/chat/message-attachments.ts @@ -0,0 +1,115 @@ +import { LitElement, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import IgcIconButtonComponent from '../button/icon-button.js'; +import { registerComponent } from '../common/definitions/register.js'; +import IgcIconComponent from '../icon/icon.js'; +import { registerIconFromText } from '../icon/icon.registry.js'; +import { styles } from './themes/message-attachments.base.css'; +import { type IgcMessageAttachment, closeIcon, fileIcon } from './types.js'; + +/** + * + * @element igc-message-attachments + * + */ +export class IgcMessageAttachmentsComponent extends LitElement { + /** @private */ + public static readonly tagName = 'igc-message-attachments'; + + public static override styles = styles; + + /* blazorSuppress */ + public static register() { + registerComponent( + IgcMessageAttachmentsComponent, + IgcIconComponent, + IgcIconButtonComponent + ); + } + @property({ type: Array }) + attachments: IgcMessageAttachment[] = []; + + @property({ type: String }) + previewImage = ''; + + constructor() { + super(); + registerIconFromText('close', closeIcon, 'material'); + registerIconFromText('file', fileIcon, 'material'); + } + + private formatFileSize(bytes = 0): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + + private openImagePreview(url: string) { + this.previewImage = url; + } + + private closeImagePreview() { + this.previewImage = ''; + } + + protected override render() { + return html` +
+ ${this.attachments.map((attachment) => + attachment.type === 'image' + ? html` +
+ ${attachment.name} this.openImagePreview(attachment.url)} + /> +
+ ` + : html` + + +
+
${attachment.name}
+
+ ${this.formatFileSize(attachment.size)} +
+
+
+ ` + )} +
+ + ${this.previewImage + ? html` +
+ + +
+ ` + : ''} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-message-attachmants': IgcMessageAttachmentsComponent; + } +} diff --git a/src/components/chat/message-reactions.ts b/src/components/chat/message-reactions.ts new file mode 100644 index 000000000..e7da28b3f --- /dev/null +++ b/src/components/chat/message-reactions.ts @@ -0,0 +1,128 @@ +import { LitElement, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import IgcButtonComponent from '../button/button.js'; +import IgcIconButtonComponent from '../button/icon-button.js'; +import { registerComponent } from '../common/definitions/register.js'; +import { registerIconFromText } from '../icon/icon.registry.js'; +import IgcEmojiPickerComponent from './emoji-picker.js'; +import { styles } from './themes/reaction.base.css'; +import { type IgcMessageReaction, emojiPickerIcon } from './types.js'; + +/** + * + * @element igc-message-reactions + * + */ +export class IgcMessageReactionsComponent extends LitElement { + /** @private */ + public static readonly tagName = 'igc-message-reactions'; + + public static override styles = styles; + + /* blazorSuppress */ + public static register() { + registerComponent( + IgcMessageReactionsComponent, + IgcButtonComponent, + IgcIconButtonComponent, + IgcEmojiPickerComponent + ); + } + + @property({ type: Array }) + reactions: IgcMessageReaction[] = []; + + @property({ type: String }) + messageId = ''; + + @property({ type: String }) + currentUserId = ''; + + @property({ type: Boolean }) + showEmojiPicker = false; + + constructor() { + super(); + registerIconFromText('emoji-picker', emojiPickerIcon, 'material'); + } + + public override connectedCallback() { + super.connectedCallback(); + document.addEventListener('click', this.handleClickOutside); + } + + public override disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener('click', this.handleClickOutside); + } + + private toggleEmojiPicker() { + this.showEmojiPicker = !this.showEmojiPicker; + } + + private handleClickOutside = (e: MouseEvent) => { + if (this.showEmojiPicker && !e.composedPath().includes(this)) { + this.showEmojiPicker = false; + } + }; + + private addEmoji(e: CustomEvent) { + const emoji = e.detail.emoji; + this.toggleReaction(emoji); + this.showEmojiPicker = false; + } + + private hasUserReacted(reaction: IgcMessageReaction): boolean { + return reaction.users.includes(this.currentUserId); + } + + private toggleReaction(emoji: string) { + this.dispatchEvent( + new CustomEvent('add-reaction', { + detail: { emoji }, + bubbles: true, + composed: true, + }) + ); + } + + protected override render() { + return html` +
+ ${this.reactions?.map( + (reaction) => html` + this.toggleReaction(reaction.emoji)} + > + ${reaction.emoji} + ${reaction.count} + + ` + )} + + + + ${this.showEmojiPicker + ? html` +
+ +
+ ` + : ''} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-message-reactions': IgcMessageReactionsComponent; + } +} diff --git a/src/components/chat/themes/chat.base.scss b/src/components/chat/themes/chat.base.scss new file mode 100644 index 000000000..2711c4701 --- /dev/null +++ b/src/components/chat/themes/chat.base.scss @@ -0,0 +1,26 @@ +@use 'styles/common/component'; +@use 'styles/utilities' as *; + +:host { + display: block; + width: 100%; + height: 600px; + border-radius: 12px; + overflow: hidden; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + display: flex; + flex-direction: column; + } + + .chat-container { + display: flex; + flex-direction: column; + height: 100%; + } + + @media (min-width: 768px) { + :host { + height: 70vh; + max-height: 800px; + } + } \ No newline at end of file diff --git a/src/components/chat/themes/header.base.scss b/src/components/chat/themes/header.base.scss new file mode 100644 index 000000000..6ce8714bd --- /dev/null +++ b/src/components/chat/themes/header.base.scss @@ -0,0 +1,100 @@ +@use 'styles/common/component'; +@use 'styles/utilities' as *; + +:host { + display: block; + padding: 12px 16px; + border-bottom: 1px solid #D1D1D6; + background-color: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + } + + .user-info { + display: flex; + align-items: center; + gap: 12px; + } + + .avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + } + + .avatar-container { + position: relative; + } + + .status-indicator { + position: absolute; + bottom: 0; + right: 0; + width: 12px; + height: 12px; + border-radius: 50%; + background-color: #30D158; + border: 2px solid white; + } + + .status-indicator.offline { + background-color: #AEAEB2; + } + + .user-details { + display: flex; + flex-direction: column; + } + + .user-name { + font-weight: 600; + font-size: 1rem; + color: #1C1C1E; + } + + .user-status { + font-size: 0.8rem; + color: #636366; + } + + .actions { + display: flex; + gap: 16px; + } + + .action-button { + background: none; + border: none; + color: #0A84FF; + cursor: pointer; + font-size: 1.2rem; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + transition: white 0.2s; + } + + .action-button:hover { + background-color: #E5E5EA; + } + + @media (prefers-color-scheme: dark) { + :host { + background-color: rgba(125, 125, 128, 0.8); + border-bottom: 1px solid var(--color-gray-800); + } + + .action-button:hover { + background-color: var(--color-gray-800); + } + } \ No newline at end of file diff --git a/src/components/chat/themes/input.base.scss b/src/components/chat/themes/input.base.scss new file mode 100644 index 000000000..2ac80499a --- /dev/null +++ b/src/components/chat/themes/input.base.scss @@ -0,0 +1,149 @@ +@use 'styles/common/component'; +@use 'styles/utilities' as *; + +:host { + display: block; + padding: 12px 16px; + border-top: 1px solid #E5E5EA; +} + +igc-file-input{ + width: fit-content; +} + +igc-file-input::part(file-names){ + display: none; +} + +.input-container { + display: flex; + align-items: center; + gap: 12px; +} + +.input-wrapper { + flex: 1; + position: relative; + border-radius: 24px; + overflow: hidden; + transition: box-shadow 0.2s; +} + +.buttons-container { + display: flex; + align-items: center; +} + +.input-button { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 50%; + margin-left: 0.25rem; + background: transparent; + border: none; + outline: none; + cursor: pointer; + color: #8E8E93; + transition: all 0.2s ease; +} + +.input-button:hover { + color: #0A84FF; + background-color: #a1a1a1; +} + +.text-input { + width: 100%; + border: none; + padding: 12px 16px; + font-size: 0.95rem; + line-height: 1.5; + outline: none; + resize: none; + max-height: 120px; + font-family: inherit; +} + +.input-wrapper:focus-within { + box-shadow: 0 0 0 2px #0A84FF; +} + +.attachment-button, +.send-button { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + background-color: transparent; + border: none; + cursor: pointer; + color: #0A84FF; + transition: white 0.2s; +} + +.attachment-button:hover, +.send-button:hover { + background-color: #8E8E93; +} + +.attachment-wrapper { + border: #C7C7CC solid 1px; + width: fit-content; + padding: 5px; + border-radius: 50px; +} + +.attachment-name { + font-size: small; + font-style: italic; + margin: 0px 5px; +} + +.send-button { + background-color: #0A84FF; + color: white; +} + +.send-button:hover { + background-color: #5AC8FA; +} + +.send-button:disabled { + background-color: #C7C7CC; + cursor: not-allowed; +} + +.emoji-picker-container { + position: absolute; + right: 20px; + margin-bottom: 0.5rem; + z-index: 10; +} + +@media (prefers-color-scheme: dark) { + :host { + border-top: 1px solid #3A3A3C; + } + + .attachment-button:hover, + .send-button:hover { + background-color: #48484A; + } +} + +@media (max-width: 480px) { + .input-container { + gap: 8px; + } + + .attachment-button, + .send-button { + width: 36px; + height: 36px; + } +} \ No newline at end of file diff --git a/src/components/chat/themes/message-attachments.base.scss b/src/components/chat/themes/message-attachments.base.scss new file mode 100644 index 000000000..1ed81279a --- /dev/null +++ b/src/components/chat/themes/message-attachments.base.scss @@ -0,0 +1,102 @@ +@use 'styles/common/component'; +@use 'styles/utilities' as *; + +:host { + display: block; + margin-top: 0.5rem; +} + +.attachments-container { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.attachment-preview { + position: relative; + border-radius: 0.375rem; + overflow: hidden; + max-width: 200px; +} + +.image-attachment { + max-width: 200px; + max-height: 150px; + object-fit: cover; + cursor: pointer; + border-radius: 0.375rem; +} + +.file-attachment { + display: flex; + align-items: center; + padding: 0.5rem; + background-color: var(--gray-100); + border-radius: 0.375rem; + max-width: 200px; +} + +.file-icon { + margin-right: 0.5rem; + color: var(--gray-600); +} + +.file-info { + overflow: hidden; +} + +.file-name { + font-size: 0.75rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--gray-800); +} + +.file-size { + font-size: 0.625rem; + color: var(--gray-500); +} + +.image-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.overlay-image { + max-width: 90%; + max-height: 90%; +} + +.close-overlay { + position: absolute; + top: 1rem; + right: 1rem; + color: white; + background: rgba(0, 0, 0, 0.5); + width: 2rem; + height: 2rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border: none; +} + +.close-overlay:hover { + background: rgba(0, 0, 0, 0.7); +} + +.large { + --ig-size: var(--ig-size-large); +} \ No newline at end of file diff --git a/src/components/chat/themes/message-list.base.scss b/src/components/chat/themes/message-list.base.scss new file mode 100644 index 000000000..af05f1774 --- /dev/null +++ b/src/components/chat/themes/message-list.base.scss @@ -0,0 +1,92 @@ +@use 'styles/common/component'; +@use 'styles/utilities' as *; + +:host { + display: block; + flex: 1; + overflow-y: auto; + padding: 16px; +} + +.message-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.day-separator { + display: flex; + align-items: center; + margin: 16px 0; + color: #636366; + font-size: 0.8rem; +} + +.day-separator::before, +.day-separator::after { + content: ''; + flex: 1; + height: 1px; + background-color: #a5a5a5; + margin: 0 8px; +} + +.typing-indicator { + display: flex; + align-items: center; + gap: 4px; + padding: 8px; + margin-top: 8px; + animation: fadeIn 0.3s ease-in; +} + +.typing-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #7e7e81; + opacity: 0.6; +} + +.typing-dot:nth-child(1) { + animation: bounce 1.2s infinite 0s; +} + +.typing-dot:nth-child(2) { + animation: bounce 1.2s infinite 0.2s; +} + +.typing-dot:nth-child(3) { + animation: bounce 1.2s infinite 0.4s; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes bounce { + + 0%, + 80%, + 100% { + transform: translateY(0); + } + + 40% { + transform: translateY(-6px); + } +} + +@media (prefers-color-scheme: dark) { + + .day-separator::before, + .day-separator::after { + background-color: #525253; + } +} \ No newline at end of file diff --git a/src/components/chat/themes/message.base.scss b/src/components/chat/themes/message.base.scss new file mode 100644 index 000000000..34d4be656 --- /dev/null +++ b/src/components/chat/themes/message.base.scss @@ -0,0 +1,123 @@ +@use 'styles/common/component'; +@use 'styles/utilities' as *; + +:host { + display: block; + --message-max-width: 75%; + } + + .message-container { + display: flex; + justify-content: flex-start; + align-items: flex-end; + gap: 8px; + margin-bottom: 4px; + animation: fadeIn 0.2s ease-out; + } + + .message-container.sent { + flex-direction: row-reverse; + } + + .avatar { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; + opacity: 0; + } + + .message-container.show-avatar .avatar { + opacity: 1; + } + + .message-content { + display: flex; + flex-direction: column; + max-width: var(--message-max-width); + } + + .bubble { + padding: 12px 16px; + border-radius: 18px; + background-color: #E5E5EA; + color: black; + word-break: break-word; + font-weight: 400; + line-height: 1.4; + position: relative; + transition: all 0.2s ease; + } + + .sent .bubble { + border-radius: 18px 18px 4px 18px; + background-color: #0A84FF; + color: white; + } + + .received .bubble { + border-radius: 18px 18px 18px 4px; + } + + .meta { + display: flex; + font-size: 0.7rem; + color: #636366; + margin-top: 4px; + opacity: 0.8; + } + + .sent .meta { + justify-content: flex-end; + } + + .time { + margin-right: 4px; + } + + .status { + display: flex; + align-items: center; + } + + .status-icon { + width: 16px; + height: 16px; + } + + .reaction { + position: absolute; + bottom: -10px; + right: 8px; + background-color: white; + border-radius: 10px; + padding: 2px 5px; + font-size: 0.8rem; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + display: flex; + align-items: center; + z-index: 1; + } + + @keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + @media (max-width: 480px) { + :host { + --message-max-width: 85%; + } + + .avatar { + width: 24px; + height: 24px; + } + } \ No newline at end of file diff --git a/src/components/chat/themes/reaction.base.scss b/src/components/chat/themes/reaction.base.scss new file mode 100644 index 000000000..0737d4292 --- /dev/null +++ b/src/components/chat/themes/reaction.base.scss @@ -0,0 +1,72 @@ +@use 'styles/common/component'; +@use 'styles/utilities' as *; + +:host { + display: block; + margin-top: 0.25rem; + align-self: center; +} + +.reactions-container { + display: flex; + gap: 0.25rem; +} + +.reaction-button { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.375rem; + border-radius: 1rem; + font-size: 0.875rem; + cursor: pointer; + transition: all 0.2s; + border: 1px solid transparent; +} + +.reaction-button.active { + background-color: #0A84FF; + color: white; +} + +.reaction-button:hover { + background-color: #989899; +} + +.reaction-button.active:hover { + background-color: #0A84FF; + opacity: 0.9; +} + +.emoji { + margin-right: 0.25rem; +} + +.count { + font-size: 0.75rem; +} + +.add-reaction { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem; + background-color: transparent; + border-radius: 1rem; + font-size: 0.75rem; + cursor: pointer; + border: 1px dashed #7b7b7e; + color: #5b5b5c; + height: 1.5rem; + width: 1.5rem; +} + +.add-reaction:hover { + background-color: #b8b8b9; + color: #636366; +} + +.emoji-picker-container { + position: absolute; + margin-top: 0.25rem; + z-index: 10; +} \ No newline at end of file diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts new file mode 100644 index 000000000..58d78c23c --- /dev/null +++ b/src/components/chat/types.ts @@ -0,0 +1,48 @@ +export type IgcMessageStatusType = 'sent' | 'delivered' | 'read'; +export type IgcMessageAttachmentType = 'image' | 'file'; +export type IgcUserStatus = 'online' | 'offline'; + +export interface IgcUser { + id: string; + name: string; + avatar: string; + isOnline: boolean; + isTyping?: boolean; +} + +export interface IgcMessage { + id: string; + text: string; + sender: IgcUser; + timestamp: Date; + status?: IgcMessageStatusType; + attachments?: IgcMessageAttachment[]; + reactions?: IgcMessageReaction[]; + reaction?: string; +} + +export interface IgcMessageAttachment { + id: string; + type: IgcMessageAttachmentType; + url: string; + name: string; + size?: number; + thumbnail?: string; +} + +export interface IgcMessageReaction { + emoji: string; + count: number; + users: string[]; +} + +export const emojiPickerIcon = + ''; +export const attachmentIcon = + ''; +export const sendButtonIcon = + ''; +export const closeIcon = + ''; +export const fileIcon = + ''; diff --git a/src/components/common/definitions/defineAllComponents.ts b/src/components/common/definitions/defineAllComponents.ts index 14da35572..b0c033bd1 100644 --- a/src/components/common/definitions/defineAllComponents.ts +++ b/src/components/common/definitions/defineAllComponents.ts @@ -15,6 +15,7 @@ import IgcCardMediaComponent from '../../card/card.media.js'; import IgcCarouselIndicatorComponent from '../../carousel/carousel-indicator.js'; import IgcCarouselSlideComponent from '../../carousel/carousel-slide.js'; import IgcCarouselComponent from '../../carousel/carousel.js'; +import IgcChatComponent from '../../chat/chat.js'; import IgcCheckboxComponent from '../../checkbox/checkbox.js'; import IgcSwitchComponent from '../../checkbox/switch.js'; import IgcChipComponent from '../../chip/chip.js'; @@ -86,6 +87,7 @@ const allComponents: IgniteComponent[] = [ IgcCarouselComponent, IgcCarouselIndicatorComponent, IgcCarouselSlideComponent, + IgcChatComponent, IgcCheckboxComponent, IgcChipComponent, IgcComboComponent, diff --git a/src/index.ts b/src/index.ts index 024c00237..8c3c38e9a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ export { default as IgcCardMediaComponent } from './components/card/card.media.j export { default as IgcCarouselComponent } from './components/carousel/carousel.js'; export { default as IgcCarouselIndicatorComponent } from './components/carousel/carousel-indicator.js'; export { default as IgcCarouselSlideComponent } from './components/carousel/carousel-slide.js'; +export { default as IgcChatComponent } from './components/chat/chat.js'; export { default as IgcCheckboxComponent } from './components/checkbox/checkbox.js'; export { default as IgcCircularProgressComponent } from './components/progress/circular-progress.js'; export { default as IgcCircularGradientComponent } from './components/progress/circular-gradient.js'; diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts new file mode 100644 index 000000000..70766157c --- /dev/null +++ b/stories/chat.stories.ts @@ -0,0 +1,93 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; + +import { IgcChatComponent, defineComponents } from 'igniteui-webcomponents'; + +defineComponents(IgcChatComponent); + +// region default +const metadata: Meta = { + title: 'Chat', + component: 'igc-chat', + parameters: { docs: { description: { component: '' } } }, + argTypes: { + enableAttachments: { + type: 'boolean', + control: 'boolean', + table: { defaultValue: { summary: 'true' } }, + }, + enableEmojiPicker: { + type: 'boolean', + control: 'boolean', + table: { defaultValue: { summary: 'true' } }, + }, + }, + args: { enableAttachments: true, enableEmojiPicker: true }, +}; + +export default metadata; + +interface IgcChatArgs { + enableAttachments: boolean; + enableEmojiPicker: boolean; +} +type Story = StoryObj; + +// endregion + +const currentUser: any = { + id: 'user1', + name: 'You', + avatar: 'https://www.infragistics.com/angular-demos/assets/images/men/1.jpg', + isOnline: true, +}; + +const otherUser: any = { + id: 'user2', + name: 'Alice', + avatar: 'https://www.infragistics.com/angular-demos/assets/images/men/2.jpg', + isOnline: true, + isTyping: false, +}; + +const initialMessages: any[] = [ + { + id: '1', + text: 'Hey there! How are you doing today?', + sender: otherUser, + timestamp: new Date(Date.now() - 3600000), + status: 'read', + }, + { + id: '2', + text: "I'm doing well, thanks for asking! How about you?", + sender: currentUser, + timestamp: new Date(Date.now() - 3500000), + status: 'read', + }, + { + id: '3', + text: 'Pretty good! I was wondering if you wanted to grab coffee sometime this week?', + sender: otherUser, + timestamp: new Date(Date.now() - 3400000), + status: 'read', + reactions: [ + { + emoji: '❤️', + count: 1, + users: ['You'], + }, + ], + }, +]; + +export const Basic: Story = { + render: () => html` + + + `, +}; From f4028830d35f3e49a2265f95264fd157d87dd49e Mon Sep 17 00:00:00 2001 From: teodosiah Date: Fri, 2 May 2025 17:52:18 +0300 Subject: [PATCH 002/252] feat(chat): fix lint errors --- src/components/chat/chat-input.ts | 8 +++++--- src/components/chat/chat-message-list.ts | 8 ++++---- src/components/chat/chat-message.ts | 11 ++++++++--- src/components/chat/chat.ts | 16 ++++++++-------- src/components/chat/message-reactions.ts | 10 ++++++---- 5 files changed, 31 insertions(+), 22 deletions(-) diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index 9f2e92981..2e69bb116 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -201,9 +201,11 @@ export default class IgcChatInputComponent extends LitElement { ${this.showEmojiPicker ? html` -
- -
+
+ +
` : ''} diff --git a/src/components/chat/chat-message-list.ts b/src/components/chat/chat-message-list.ts index 34f939d5f..ff347655a 100644 --- a/src/components/chat/chat-message-list.ts +++ b/src/components/chat/chat-message-list.ts @@ -22,13 +22,13 @@ export default class IgcChatMessageListComponent extends LitElement { registerComponent(IgcChatMessageListComponent, IgcChatMessageComponent); } - @property({ reflect: true }) + @property({ reflect: true, attribute: false }) public user: IgcUser | undefined; - @property({ reflect: true }) + @property({ reflect: true, attribute: false }) public messages: IgcMessage[] = []; - @property({ reflect: true, attribute: 'typing-users' }) + @property({ reflect: true, attribute: false }) public typingUsers: IgcUser[] = []; @property({ type: Boolean, attribute: 'scroll-bottom' }) @@ -126,7 +126,7 @@ export default class IgcChatMessageListComponent extends LitElement { ` diff --git a/src/components/chat/chat-message.ts b/src/components/chat/chat-message.ts index b92373185..9c1d0bff7 100644 --- a/src/components/chat/chat-message.ts +++ b/src/components/chat/chat-message.ts @@ -1,5 +1,6 @@ import { LitElement, html } from 'lit'; import { property } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; import IgcAvatarComponent from '../avatar/avatar.js'; import { registerComponent } from '../common/definitions/register.js'; import { IgcMessageAttachmentsComponent } from './message-attachments.js'; @@ -28,10 +29,10 @@ export default class IgcChatMessageComponent extends LitElement { ); } - @property({ reflect: true }) + @property({ reflect: true, attribute: false }) public message: IgcMessage | undefined; - @property({ reflect: true }) + @property({ reflect: true, attribute: false }) public user: IgcUser | undefined; @property({ type: Boolean, attribute: 'enable-reactions' }) @@ -78,7 +79,11 @@ export default class IgcChatMessageComponent extends LitElement { return html`
- +
${this.message?.text.trim() diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index 82eda0012..2cc6c0ac8 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -37,13 +37,13 @@ export default class IgcChatComponent extends EventEmitterMixin< ); } - @property() + @property({ attribute: false }) public user: IgcUser | undefined; - @property({ reflect: true }) + @property({ reflect: true, attribute: false }) public messages: IgcMessage[] = []; - @property({ reflect: true, attribute: 'typing-users' }) + @property({ reflect: true, attribute: false }) public typingUsers: IgcUser[] = []; @property({ type: Boolean, attribute: 'scroll-bottom' }) @@ -183,14 +183,14 @@ export default class IgcChatComponent extends EventEmitterMixin<
diff --git a/src/components/chat/message-reactions.ts b/src/components/chat/message-reactions.ts index e7da28b3f..477df9d50 100644 --- a/src/components/chat/message-reactions.ts +++ b/src/components/chat/message-reactions.ts @@ -111,10 +111,12 @@ export class IgcMessageReactionsComponent extends LitElement { ${this.showEmojiPicker ? html` -
- -
- ` +
+ +
+ ` : ''}
`; From 6db7573f35cb08ad050b6b2d682270bc9b9670b5 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Mon, 5 May 2025 11:04:42 +0300 Subject: [PATCH 003/252] feat(chat): fix lint errors in scss files --- src/components/chat/themes/chat.base.scss | 10 +------ src/components/chat/themes/header.base.scss | 14 +--------- src/components/chat/themes/input.base.scss | 24 +---------------- .../chat/themes/message-attachments.base.scss | 11 +++----- .../chat/themes/message-list.base.scss | 4 +-- src/components/chat/themes/message.base.scss | 19 ++++---------- stories/chat.stories.ts | 26 ++++++++++++++++++- 7 files changed, 38 insertions(+), 70 deletions(-) diff --git a/src/components/chat/themes/chat.base.scss b/src/components/chat/themes/chat.base.scss index 2711c4701..186654729 100644 --- a/src/components/chat/themes/chat.base.scss +++ b/src/components/chat/themes/chat.base.scss @@ -2,12 +2,11 @@ @use 'styles/utilities' as *; :host { - display: block; width: 100%; height: 600px; border-radius: 12px; overflow: hidden; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + box-shadow: 0 8px 24px #1f1f1f; display: flex; flex-direction: column; } @@ -16,11 +15,4 @@ display: flex; flex-direction: column; height: 100%; - } - - @media (min-width: 768px) { - :host { - height: 70vh; - max-height: 800px; - } } \ No newline at end of file diff --git a/src/components/chat/themes/header.base.scss b/src/components/chat/themes/header.base.scss index 6ce8714bd..bf5e2b686 100644 --- a/src/components/chat/themes/header.base.scss +++ b/src/components/chat/themes/header.base.scss @@ -5,9 +5,8 @@ display: block; padding: 12px 16px; border-bottom: 1px solid #D1D1D6; - background-color: rgba(255, 255, 255, 0.8); + background-color: #f7f7f7; backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); } .header { @@ -86,15 +85,4 @@ .action-button:hover { background-color: #E5E5EA; - } - - @media (prefers-color-scheme: dark) { - :host { - background-color: rgba(125, 125, 128, 0.8); - border-bottom: 1px solid var(--color-gray-800); - } - - .action-button:hover { - background-color: var(--color-gray-800); - } } \ No newline at end of file diff --git a/src/components/chat/themes/input.base.scss b/src/components/chat/themes/input.base.scss index 2ac80499a..d7f45ea67 100644 --- a/src/components/chat/themes/input.base.scss +++ b/src/components/chat/themes/input.base.scss @@ -101,7 +101,7 @@ igc-file-input::part(file-names){ .attachment-name { font-size: small; font-style: italic; - margin: 0px 5px; + margin: 0 5px; } .send-button { @@ -125,25 +125,3 @@ igc-file-input::part(file-names){ z-index: 10; } -@media (prefers-color-scheme: dark) { - :host { - border-top: 1px solid #3A3A3C; - } - - .attachment-button:hover, - .send-button:hover { - background-color: #48484A; - } -} - -@media (max-width: 480px) { - .input-container { - gap: 8px; - } - - .attachment-button, - .send-button { - width: 36px; - height: 36px; - } -} \ No newline at end of file diff --git a/src/components/chat/themes/message-attachments.base.scss b/src/components/chat/themes/message-attachments.base.scss index 1ed81279a..eb9346e3e 100644 --- a/src/components/chat/themes/message-attachments.base.scss +++ b/src/components/chat/themes/message-attachments.base.scss @@ -61,11 +61,8 @@ .image-overlay { position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.8); + inset: 0; + background-color: #1f1f1f; display: flex; align-items: center; justify-content: center; @@ -82,7 +79,7 @@ top: 1rem; right: 1rem; color: white; - background: rgba(0, 0, 0, 0.5); + background: #1f1f1f; width: 2rem; height: 2rem; border-radius: 50%; @@ -94,7 +91,7 @@ } .close-overlay:hover { - background: rgba(0, 0, 0, 0.7); + background: #1f1f1f; } .large { diff --git a/src/components/chat/themes/message-list.base.scss b/src/components/chat/themes/message-list.base.scss index af05f1774..c14dda941 100644 --- a/src/components/chat/themes/message-list.base.scss +++ b/src/components/chat/themes/message-list.base.scss @@ -60,7 +60,7 @@ animation: bounce 1.2s infinite 0.4s; } -@keyframes fadeIn { +@keyframes fade-in { from { opacity: 0; } @@ -71,7 +71,6 @@ } @keyframes bounce { - 0%, 80%, 100% { @@ -84,7 +83,6 @@ } @media (prefers-color-scheme: dark) { - .day-separator::before, .day-separator::after { background-color: #525253; diff --git a/src/components/chat/themes/message.base.scss b/src/components/chat/themes/message.base.scss index 34d4be656..b5ae43ae1 100644 --- a/src/components/chat/themes/message.base.scss +++ b/src/components/chat/themes/message.base.scss @@ -3,6 +3,7 @@ :host { display: block; + --message-max-width: 75%; } @@ -51,7 +52,7 @@ } .sent .bubble { - border-radius: 18px 18px 4px 18px; + border-radius: 18px 18px 4px; background-color: #0A84FF; color: white; } @@ -94,30 +95,20 @@ border-radius: 10px; padding: 2px 5px; font-size: 0.8rem; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + box-shadow: 0 1px 2px #1f1f1f; display: flex; align-items: center; z-index: 1; } - @keyframes fadeIn { + @keyframes fade-in { from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } } - - @media (max-width: 480px) { - :host { - --message-max-width: 85%; - } - - .avatar { - width: 24px; - height: 24px; - } - } \ No newline at end of file diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 70766157c..481365345 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -11,6 +11,16 @@ const metadata: Meta = { component: 'igc-chat', parameters: { docs: { description: { component: '' } } }, argTypes: { + scrollBottom: { + type: 'boolean', + control: 'boolean', + table: { defaultValue: { summary: 'true' } }, + }, + enableReactions: { + type: 'boolean', + control: 'boolean', + table: { defaultValue: { summary: 'true' } }, + }, enableAttachments: { type: 'boolean', control: 'boolean', @@ -21,15 +31,29 @@ const metadata: Meta = { control: 'boolean', table: { defaultValue: { summary: 'true' } }, }, + headerText: { + type: 'string', + control: 'text', + table: { defaultValue: { summary: '' } }, + }, + }, + args: { + scrollBottom: true, + enableReactions: true, + enableAttachments: true, + enableEmojiPicker: true, + headerText: '', }, - args: { enableAttachments: true, enableEmojiPicker: true }, }; export default metadata; interface IgcChatArgs { + scrollBottom: boolean; + enableReactions: boolean; enableAttachments: boolean; enableEmojiPicker: boolean; + headerText: string; } type Story = StoryObj; From 005d57ee2cef6c9a4ccaf3297c7b40da3477fbe4 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Mon, 5 May 2025 13:59:25 +0300 Subject: [PATCH 004/252] feat(chat): add basic emoji picker component --- src/components/chat/chat-input.ts | 30 +- src/components/chat/emoji-picker.ts | 326 +++++++++++++++++- src/components/chat/message-reactions.ts | 22 +- .../chat/themes/emoji-picker.base.scss | 99 ++++++ 4 files changed, 427 insertions(+), 50 deletions(-) create mode 100644 src/components/chat/themes/emoji-picker.base.scss diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index 2e69bb116..a22cb6d0b 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -12,7 +12,6 @@ import { styles } from './themes/input.base.css.js'; import { type IgcMessageAttachment, attachmentIcon, - emojiPickerIcon, sendButtonIcon, } from './types.js'; @@ -55,12 +54,8 @@ export default class IgcChatInputComponent extends LitElement { @state() private attachments: IgcMessageAttachment[] = []; - @state() - private showEmojiPicker = false; - constructor() { super(); - registerIconFromText('emoji-picker', emojiPickerIcon, 'material'); registerIconFromText('attachment', attachmentIcon, 'material'); registerIconFromText('send-message', sendButtonIcon, 'material'); } @@ -107,14 +102,9 @@ export default class IgcChatInputComponent extends LitElement { }, 0); } - private toggleEmojiPicker() { - this.showEmojiPicker = !this.showEmojiPicker; - } - private addEmoji(e: CustomEvent) { const emoji = e.detail.emoji; this.inputValue += emoji; - this.showEmojiPicker = false; // Focus back on input after selecting an emoji this.updateComplete.then(() => { @@ -178,13 +168,9 @@ export default class IgcChatInputComponent extends LitElement {
${this.enableEmojiPicker ? html` - + ` : ''} @@ -198,16 +184,6 @@ export default class IgcChatInputComponent extends LitElement { @click=${this.sendMessage} >
- - ${this.showEmojiPicker - ? html` -
- -
- ` - : ''}
${this.attachments?.map( diff --git a/src/components/chat/emoji-picker.ts b/src/components/chat/emoji-picker.ts index 284f0ada3..8edbbe371 100644 --- a/src/components/chat/emoji-picker.ts +++ b/src/components/chat/emoji-picker.ts @@ -1,5 +1,194 @@ import { LitElement, html } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import IgcButtonComponent from '../button/button.js'; +import IgcIconButtonComponent from '../button/icon-button.js'; +import { addRootClickHandler } from '../common/controllers/root-click.js'; +import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; +import { registerIconFromText } from '../icon/icon.registry.js'; +import IgcInputComponent from '../input/input.js'; +import IgcPopoverComponent from '../popover/popover.js'; +import { styles } from './themes/emoji-picker.base.css.js'; +import { emojiPickerIcon } from './types.js'; + +export const EMOJI_CATEGORIES = [ + { + name: 'Smileys & Emotion', + icon: '😊', + emojis: [ + '😀', + '😃', + '😄', + '😁', + '😆', + '😅', + '🤣', + '😂', + '🙂', + '🙃', + '😉', + '😊', + '😇', + '😍', + '🥰', + '😘', + ], + }, + { + name: 'People & Body', + icon: '👋', + emojis: [ + '👋', + '🤚', + '🖐️', + '✋', + '🖖', + '👌', + '🤌', + '🤏', + '✌️', + '🤞', + '🤟', + '🤘', + '🤙', + '👈', + '👉', + '👍', + ], + }, + { + name: 'Animals & Nature', + icon: '🐶', + emojis: [ + '🐶', + '🐱', + '🐭', + '🐹', + '🐰', + '🦊', + '🐻', + '🐼', + '🐨', + '🐯', + '🦁', + '🐮', + '🐷', + '🐸', + '🐵', + '🐔', + ], + }, + { + name: 'Food & Drink', + icon: '🍔', + emojis: [ + '🍎', + '🍐', + '🍊', + '🍋', + '🍌', + '🍉', + '🍇', + '🍓', + '🫐', + '🍈', + '🍒', + '🍑', + '🥭', + '🍍', + '🥥', + '🥝', + ], + }, + { + name: 'Travel & Places', + icon: '🚗', + emojis: [ + '🚗', + '🚕', + '🚙', + '🚌', + '🚎', + '🏎️', + '🚓', + '🚑', + '🚒', + '🚐', + '🛻', + '🚚', + '🚛', + '🚜', + '🛴', + '🚲', + ], + }, + { + name: 'Activities', + icon: '⚽', + emojis: [ + '⚽', + '🏀', + '🏈', + '⚾', + '🥎', + '🎾', + '🏐', + '🏉', + '🥏', + '🎱', + '🪀', + '🏓', + '🏸', + '🏒', + '🏑', + '🥍', + ], + }, + { + name: 'Objects', + icon: '💡', + emojis: [ + '⌚', + '📱', + '📲', + '💻', + '⌨️', + '🖥️', + '🖨️', + '🖱️', + '🖲️', + '🕹️', + '🗜️', + '💽', + '💾', + '💿', + '📀', + '📼', + ], + }, + { + name: 'Symbols', + icon: '❤️', + emojis: [ + '❤️', + '🧡', + '💛', + '💚', + '💙', + '💜', + '🖤', + '🤍', + '🤎', + '💔', + '❣️', + '💕', + '💞', + '💓', + '💗', + '💖', + ], + }, +]; /** * @@ -10,13 +199,146 @@ export default class IgcEmojiPickerComponent extends LitElement { /** @private */ public static readonly tagName = 'igc-emoji-picker'; + public static override styles = styles; + + protected _rootClickController = addRootClickHandler(this); + /* blazorSuppress */ public static register() { - registerComponent(IgcEmojiPickerComponent); + registerComponent( + IgcEmojiPickerComponent, + IgcPopoverComponent, + IgcIconButtonComponent, + IgcButtonComponent, + IgcInputComponent + ); + } + + /** + * Sets the open state of the component. + * @attr + */ + @property({ type: Boolean, reflect: true }) + public open = false; + + @state() + private _target?: HTMLElement; + + @query('slot[name="target"]', true) + protected trigger!: HTMLSlotElement; + + @state() + private _activeCategory = 0; + + @watch('open', { waitUntilFirstUpdate: true }) + protected openStateChange() { + this._rootClickController.update(); + + if (!this.open) { + this._target = undefined; + this._rootClickController.update({ target: undefined }); + } + } + + constructor() { + super(); + this._rootClickController.update({ hideCallback: this.handleClosing }); + registerIconFromText('target', emojiPickerIcon, 'material'); + } + + protected handleClosing() { + this.hide(); + } + + public async hide(): Promise { + if (!this.open) { + return false; + } + + this.open = false; + + return true; + } + + protected handleAnchorClick() { + this.open = !this.open; + } + + private handleCategoryChange(index: number) { + this._activeCategory = index; + } + + private handleEmojiClick(emoji: string) { + this.dispatchEvent( + new CustomEvent('emoji-selected', { + detail: { emoji }, + bubbles: true, + composed: true, + }) + ); + } + + private getFilteredEmojis() { + return EMOJI_CATEGORIES[this._activeCategory].emojis; } protected override render() { - return html``; + const filteredEmojis = this.getFilteredEmojis(); + + return html` + +
e.stopPropagation()} + > +
+ ${EMOJI_CATEGORIES.map( + (category, index) => html` + this.handleCategoryChange(index)} + title=${category.name} + > + ${category.icon} + + ` + )} +
+ +
+ ${filteredEmojis.map( + (emoji) => html` + this.handleEmojiClick(emoji)}> + ${emoji} + + ` + )} + ${filteredEmojis.length === 0 + ? html`
+ No emojis found +
` + : ''} +
+
+
`; } } diff --git a/src/components/chat/message-reactions.ts b/src/components/chat/message-reactions.ts index 477df9d50..05547b6fe 100644 --- a/src/components/chat/message-reactions.ts +++ b/src/components/chat/message-reactions.ts @@ -56,10 +56,6 @@ export class IgcMessageReactionsComponent extends LitElement { document.removeEventListener('click', this.handleClickOutside); } - private toggleEmojiPicker() { - this.showEmojiPicker = !this.showEmojiPicker; - } - private handleClickOutside = (e: MouseEvent) => { if (this.showEmojiPicker && !e.composedPath().includes(this)) { this.showEmojiPicker = false; @@ -101,23 +97,7 @@ export class IgcMessageReactionsComponent extends LitElement { ` )} - - - ${this.showEmojiPicker - ? html` -
- -
- ` - : ''} +
`; } diff --git a/src/components/chat/themes/emoji-picker.base.scss b/src/components/chat/themes/emoji-picker.base.scss new file mode 100644 index 000000000..aa1cf3095 --- /dev/null +++ b/src/components/chat/themes/emoji-picker.base.scss @@ -0,0 +1,99 @@ +:host { + display: block; + } + + .emoji-picker-container { + width: 250px; + max-width: 100vw; + background-color: white; + border-radius: 0.5rem; + box-shadow: 0 4px 6px -1px #292929, 0 2px 4px -1px #161616; + overflow: hidden; + display: flex; + flex-direction: column; + } + + .emoji-categories { + display: flex; + padding: 0.5rem; + border-bottom: 1px solid #bdbcbc; + overflow-x: auto; + scrollbar-width: none; + } + + .emoji-categories::-webkit-scrollbar { + display: none; + } + + .category-button { + background: transparent; + border: none; + font-size: 1.25rem; + width: 2rem; + height: 2rem; + border-radius: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + margin-right: 0.25rem; + padding: 0; + + igc-button::part(base){ + display: none; + } + } + + .category-button.active { + background-color: #cfcfcf; + } + + .category-button:hover { + background-color: #cfcfcf; + } + + .emoji-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 0.25rem; + padding: 0.5rem; + height: auto; + overflow: auto; + } + + .emoji-button { + font-size: 1.5rem; + background: none; + border: none; + cursor: pointer; + border-radius: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + height: 2rem; + transition: transform 0.1s; + } + + .emoji-button:hover { + background-color: #cfcfcf; + transform: scale(1.1); + } + + .emoji-search { + padding: 0.5rem; + border-bottom: 1px solid #a0a0a0; + } + + .search-input { + width: 100%; + padding: 0.5rem; + border-radius: 0.25rem; + border: 1px solid #85878a; + font-size: 0.875rem; + } + + .search-input:focus { + outline: none; + border-color:#0A84FF; + } From 079b654133afaf645a39618c8b99413e3ae65419 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Wed, 7 May 2025 11:27:45 +0300 Subject: [PATCH 005/252] feat(chat): add basic message reaction implementation --- src/components/chat/chat-message-list.ts | 13 - src/components/chat/chat-message.ts | 4 +- src/components/chat/chat.ts | 102 +++--- src/components/chat/emoji-picker.ts | 333 +++++++++++------- src/components/chat/message-reactions.ts | 31 +- .../chat/themes/emoji-picker.base.scss | 6 +- src/components/chat/themes/reaction.base.scss | 6 +- src/components/chat/types.ts | 1 + stories/chat.stories.ts | 3 +- 9 files changed, 264 insertions(+), 235 deletions(-) diff --git a/src/components/chat/chat-message-list.ts b/src/components/chat/chat-message-list.ts index ff347655a..f8a1d98dd 100644 --- a/src/components/chat/chat-message-list.ts +++ b/src/components/chat/chat-message-list.ts @@ -76,18 +76,6 @@ export default class IgcChatMessageListComponent extends LitElement { })); } - private handleReaction(e: CustomEvent) { - const { messageId, emoji } = e.detail; - - this.dispatchEvent( - new CustomEvent('add-reaction', { - detail: { messageId, emoji }, - bubbles: true, - composed: true, - }) - ); - } - private scrollToBottom() { requestAnimationFrame(() => { const container = this.shadowRoot?.host as HTMLElement; @@ -127,7 +115,6 @@ export default class IgcChatMessageListComponent extends LitElement { .message=${message} .user=${this.user} .enableReactions=${this.enableReactions} - @add-reaction=${this.handleReaction} > ` )} diff --git a/src/components/chat/chat-message.ts b/src/components/chat/chat-message.ts index 9c1d0bff7..e59f3a564 100644 --- a/src/components/chat/chat-message.ts +++ b/src/components/chat/chat-message.ts @@ -58,11 +58,11 @@ export default class IgcChatMessageComponent extends LitElement { } private handleAddReaction(e: CustomEvent) { - const emoji = e.detail.emoji; + const { emojiId, emoji } = e.detail; this.dispatchEvent( new CustomEvent('add-reaction', { - detail: { messageId: this.message?.id, emoji }, + detail: { messageId: this.message?.id, emojiId, emoji }, bubbles: true, composed: true, }) diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index 2cc6c0ac8..e61036860 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -7,7 +7,7 @@ import IgcChatHeaderComponent from './chat-header.js'; import IgcChatInputComponent from './chat-input.js'; import IgcChatMessageListComponent from './chat-message-list.js'; import { styles } from './themes/chat.base.css.js'; -import type { IgcMessage, IgcUser } from './types.js'; +import type { IgcMessage, IgcMessageReaction, IgcUser } from './types.js'; export interface IgcChatComponentEventMap { igcMessageEntered: CustomEvent; @@ -106,73 +106,61 @@ export default class IgcChatComponent extends EventEmitterMixin< } private handleAddReaction(e: CustomEvent) { - const { messageId, emoji } = e.detail; + const { messageId, emojiId, emoji } = e.detail; - this.messages.map((message) => { + if (!messageId) return; + + this.messages = this.messages.map((message) => { if (message.id === messageId) { - const existingReaction = message.reactions?.find( - (r) => r.emoji === emoji + const userReaction = message.reactions?.find( + (r) => this.user && r.users.includes(this.user.id) ); - if (existingReaction && this.user) { - // Toggle reaction for current user - const userId = this.user.id; - const hasReacted = existingReaction.users.includes(userId); - - if (hasReacted) { - // Remove reaction - const updatedReactions = - message.reactions - ?.map((r) => { - if (r.emoji === emoji) { - return { - ...r, - count: r.count - 1, - users: r.users.filter((id) => id !== userId), - }; - } - return r; - }) - .filter((r) => r.count > 0) || []; - - return { - ...message, - reactions: updatedReactions, - }; - } + if (userReaction) { + // Remove reaction + message.reactions?.forEach((r) => { + if (r.id === userReaction.id) { + r.count -= 1; + r.users = (r.users ?? []).filter((id) => id !== this.user?.id); + } + }); + + message.reactions = + message.reactions?.filter((r) => r.count > 0) || []; + } + + const existingReaction = message.reactions?.find( + (r) => r.id === emojiId + ); + if (existingReaction) { // Add reaction - const updatedReactions = - message.reactions?.map((r) => { - if (r.emoji === emoji) { - return { - ...r, - count: r.count + 1, - users: [...r.users, userId], - }; + message.reactions?.forEach((r) => { + if (r.id === emojiId) { + r.count += 1; + if (this.user) { + r.users.push(this.user.id); } - return r; - }) || []; + } + }); - return { - ...message, - reactions: updatedReactions, - }; + message.reactions = message.reactions ?? []; } - // Create new reaction - const newReaction = { - emoji, - count: 1, - users: [this.user?.id], - }; - - return { - ...message, - reactions: [...(message.reactions || []), newReaction], - }; + if (!existingReaction && userReaction?.id !== emojiId) { + // Create new reaction + const newReaction: IgcMessageReaction = { + id: emojiId, + emoji, + count: 1, + users: this.user ? [this.user.id] : [], + }; + + message.reactions = [...(message.reactions || []), newReaction]; + } } - return message; + + return { ...message }; }); } diff --git a/src/components/chat/emoji-picker.ts b/src/components/chat/emoji-picker.ts index 8edbbe371..7dbfff193 100644 --- a/src/components/chat/emoji-picker.ts +++ b/src/components/chat/emoji-picker.ts @@ -16,176 +16,244 @@ export const EMOJI_CATEGORIES = [ name: 'Smileys & Emotion', icon: '😊', emojis: [ - '😀', - '😃', - '😄', - '😁', - '😆', - '😅', - '🤣', - '😂', - '🙂', - '🙃', - '😉', - '😊', - '😇', - '😍', - '🥰', - '😘', + { id: 'grinning_face', emoji: '😀', name: 'Grinning Face' }, + { + id: 'grinning_face_with_big_eyes', + emoji: '😃', + name: 'Grinning Face with Big Eyes', + }, + { + id: 'grinning_face_with_smiling_eyes', + emoji: '😄', + name: 'Grinning Face with Smiling Eyes', + }, + { + id: 'beaming_face_with_smiling_eyes', + emoji: '😁', + name: 'Beaming Face with Smiling Eyes', + }, + { + id: 'grinning_squinting_face', + emoji: '😆', + name: 'Grinning Squinting Face', + }, + { + id: 'grinning_face_with_sweat', + emoji: '😅', + name: 'Grinning Face with Sweat', + }, + { + id: 'rolling_on_the_floor_laughing', + emoji: '🤣', + name: 'Rolling on the Floor Laughing', + }, + { + id: 'face_with_tears_of_joy', + emoji: '😂', + name: 'Face with Tears of Joy', + }, + { + id: 'slightly_smiling_face', + emoji: '🙂', + name: 'Slightly Smiling Face', + }, + { id: 'upside_down_face', emoji: '🙃', name: 'Upside-Down Face' }, + { id: 'winking_face', emoji: '😉', name: 'Winking Face' }, + { + id: 'smiling_face_with_smiling_eyes', + emoji: '😊', + name: 'Smiling Face with Smiling Eyes', + }, + { + id: 'smiling_face_with_halo', + emoji: '😇', + name: 'Smiling Face with Halo', + }, + { + id: 'smiling_face_with_heart_eyes', + emoji: '😍', + name: 'Smiling Face with Heart-Eyes', + }, + { + id: 'smiling_face_with_hearts', + emoji: '🥰', + name: 'Smiling Face with Hearts', + }, + { id: 'face_blowing_a_kiss', emoji: '😘', name: 'Face Blowing a Kiss' }, ], }, { name: 'People & Body', icon: '👋', emojis: [ - '👋', - '🤚', - '🖐️', - '✋', - '🖖', - '👌', - '🤌', - '🤏', - '✌️', - '🤞', - '🤟', - '🤘', - '🤙', - '👈', - '👉', - '👍', + { id: 'waving_hand', emoji: '👋', name: 'Waving Hand' }, + { id: 'raised_back_of_hand', emoji: '🤚', name: 'Raised Back of Hand' }, + { + id: 'hand_with_fingers_splayed', + emoji: '🖐️', + name: 'Hand with Fingers Splayed', + }, + { id: 'raised_hand', emoji: '✋', name: 'Raised Hand' }, + { id: 'vulcan_salute', emoji: '🖖', name: 'Vulcan Salute' }, + { id: 'ok_hand', emoji: '👌', name: 'OK Hand' }, + { id: 'pinched_fingers', emoji: '🤌', name: 'Pinched Fingers' }, + { id: 'pinching_hand', emoji: '🤏', name: 'Pinching Hand' }, + { id: 'victory_hand', emoji: '✌️', name: 'Victory Hand' }, + { id: 'crossed_fingers', emoji: '🤞', name: 'Crossed Fingers' }, + { id: 'love_you_gesture', emoji: '🤟', name: 'Love-You Gesture' }, + { id: 'sign_of_the_horns', emoji: '🤘', name: 'Sign of the Horns' }, + { id: 'call_me_hand', emoji: '🤙', name: 'Call Me Hand' }, + { + id: 'backhand_index_pointing_left', + emoji: '👈', + name: 'Backhand Index Pointing Left', + }, + { + id: 'backhand_index_pointing_right', + emoji: '👉', + name: 'Backhand Index Pointing Right', + }, + { id: 'thumbs_up', emoji: '👍', name: 'Thumbs Up' }, ], }, { name: 'Animals & Nature', icon: '🐶', emojis: [ - '🐶', - '🐱', - '🐭', - '🐹', - '🐰', - '🦊', - '🐻', - '🐼', - '🐨', - '🐯', - '🦁', - '🐮', - '🐷', - '🐸', - '🐵', - '🐔', + { id: 'dog_face', emoji: '🐶', name: 'Dog Face' }, + { id: 'cat_face', emoji: '🐱', name: 'Cat Face' }, + { id: 'mouse_face', emoji: '🐭', name: 'Mouse Face' }, + { id: 'hamster_face', emoji: '🐹', name: 'Hamster Face' }, + { id: 'rabbit_face', emoji: '🐰', name: 'Rabbit Face' }, + { id: 'fox_face', emoji: '🦊', name: 'Fox Face' }, + { id: 'bear_face', emoji: '🐻', name: 'Bear Face' }, + { id: 'panda_face', emoji: '🐼', name: 'Panda Face' }, + { id: 'koala_face', emoji: '🐨', name: 'Koala Face' }, + { id: 'tiger_face', emoji: '🐯', name: 'Tiger Face' }, + { id: 'lion_face', emoji: '🦁', name: 'Lion Face' }, + { id: 'cow_face', emoji: '🐮', name: 'Cow Face' }, + { id: 'pig_face', emoji: '🐷', name: 'Pig Face' }, + { id: 'frog_face', emoji: '🐸', name: 'Frog Face' }, + { id: 'monkey_face', emoji: '🐵', name: 'Monkey Face' }, + { id: 'chicken', emoji: '🐔', name: 'Chicken' }, ], }, { name: 'Food & Drink', icon: '🍔', emojis: [ - '🍎', - '🍐', - '🍊', - '🍋', - '🍌', - '🍉', - '🍇', - '🍓', - '🫐', - '🍈', - '🍒', - '🍑', - '🥭', - '🍍', - '🥥', - '🥝', + { id: 'red_apple', emoji: '🍎', name: 'Red Apple' }, + { id: 'pear', emoji: '🍐', name: 'Pear' }, + { id: 'orange', emoji: '🍊', name: 'Orange' }, + { id: 'lemon', emoji: '🍋', name: 'Lemon' }, + { id: 'banana', emoji: '🍌', name: 'Banana' }, + { id: 'watermelon', emoji: '🍉', name: 'Watermelon' }, + { id: 'grapes', emoji: '🍇', name: 'Grapes' }, + { id: 'strawberry', emoji: '🍓', name: 'Strawberry' }, + { id: 'blueberries', emoji: '🫐', name: 'Blueberries' }, + { id: 'melon', emoji: '🍈', name: 'Melon' }, + { id: 'cherries', emoji: '🍒', name: 'Cherries' }, + { id: 'peach', emoji: '🍑', name: 'Peach' }, + { id: 'mango', emoji: '🥭', name: 'Mango' }, + { id: 'pineapple', emoji: '🍍', name: 'Pineapple' }, + { id: 'coconut', emoji: '🥥', name: 'Coconut' }, + { id: 'kiwi_fruit', emoji: '🥝', name: 'Kiwi Fruit' }, ], }, { name: 'Travel & Places', icon: '🚗', emojis: [ - '🚗', - '🚕', - '🚙', - '🚌', - '🚎', - '🏎️', - '🚓', - '🚑', - '🚒', - '🚐', - '🛻', - '🚚', - '🚛', - '🚜', - '🛴', - '🚲', + { id: 'car', emoji: '🚗', name: 'Car' }, + { id: 'taxi', emoji: '🚕', name: 'Taxi' }, + { + id: 'sport_utility_vehicle', + emoji: '🚙', + name: 'Sport Utility Vehicle', + }, + { id: 'bus', emoji: '🚌', name: 'Bus' }, + { id: 'trolleybus', emoji: '🚎', name: 'Trolleybus' }, + { id: 'racing_car', emoji: '🏎️', name: 'Racing Car' }, + { id: 'police_car', emoji: '🚓', name: 'Police Car' }, + { id: 'ambulance', emoji: '🚑', name: 'Ambulance' }, + { id: 'fire_engine', emoji: '🚒', name: 'Fire Engine' }, + { id: 'minibus', emoji: '🚐', name: 'Minibus' }, + { id: 'pickup_truck', emoji: '🛻', name: 'Pickup Truck' }, + { id: 'delivery_truck', emoji: '🚚', name: 'Delivery Truck' }, + { id: 'articulated_lorry', emoji: '🚛', name: 'Articulated Lorry' }, + { id: 'tractor', emoji: '🚜', name: 'Tractor' }, + { id: 'kick_scooter', emoji: '🛴', name: 'Kick Scooter' }, + { id: 'bicycle', emoji: '🚲', name: 'Bicycle' }, ], }, { name: 'Activities', icon: '⚽', emojis: [ - '⚽', - '🏀', - '🏈', - '⚾', - '🥎', - '🎾', - '🏐', - '🏉', - '🥏', - '🎱', - '🪀', - '🏓', - '🏸', - '🏒', - '🏑', - '🥍', + { id: 'soccer_ball', emoji: '⚽', name: 'Soccer Ball' }, + { id: 'basketball', emoji: '🏀', name: 'Basketball' }, + { id: 'american_football', emoji: '🏈', name: 'American Football' }, + { id: 'baseball', emoji: '⚾', name: 'Baseball' }, + { id: 'softball', emoji: '🥎', name: 'Softball' }, + { id: 'tennis', emoji: '🎾', name: 'Tennis' }, + { id: 'volleyball', emoji: '🏐', name: 'Volleyball' }, + { id: 'rugby_football', emoji: '🏉', name: 'Rugby Football' }, + { id: 'flying_disc', emoji: '🥏', name: 'Flying Disc' }, + { id: 'pool_8_ball', emoji: '🎱', name: 'Pool 8 Ball' }, + { id: 'yo_yo', emoji: '🪀', name: 'Yo-Yo' }, + { id: 'ping_pong', emoji: '🏓', name: 'Ping Pong' }, + { id: 'badminton', emoji: '🏸', name: 'Badminton' }, + { id: 'ice_hockey', emoji: '🏒', name: 'Ice Hockey' }, + { id: 'field_hockey', emoji: '🏑', name: 'Field Hockey' }, + { id: 'lacrosse', emoji: '🥍', name: 'Lacrosse' }, ], }, { name: 'Objects', icon: '💡', emojis: [ - '⌚', - '📱', - '📲', - '💻', - '⌨️', - '🖥️', - '🖨️', - '🖱️', - '🖲️', - '🕹️', - '🗜️', - '💽', - '💾', - '💿', - '📀', - '📼', + { id: 'watch', emoji: '⌚', name: 'Watch' }, + { id: 'mobile_phone', emoji: '📱', name: 'Mobile Phone' }, + { + id: 'mobile_phone_with_arrow', + emoji: '📲', + name: 'Mobile Phone with Arrow', + }, + { id: 'laptop', emoji: '💻', name: 'Laptop' }, + { id: 'keyboard', emoji: '⌨️', name: 'Keyboard' }, + { id: 'desktop_computer', emoji: '🖥️', name: 'Desktop Computer' }, + { id: 'printer', emoji: '🖨️', name: 'Printer' }, + { id: 'computer_mouse', emoji: '🖱️', name: 'Computer Mouse' }, + { id: 'trackball', emoji: '🖲️', name: 'Trackball' }, + { id: 'joystick', emoji: '🕹️', name: 'Joystick' }, + { id: 'clamp', emoji: '🗜️', name: 'Clamp' }, + { id: 'computer_disk', emoji: '💽', name: 'Computer Disk' }, + { id: 'floppy_disk', emoji: '💾', name: 'Floppy Disk' }, + { id: 'optical_disk', emoji: '💿', name: 'Optical Disk' }, + { id: 'dvd', emoji: '📀', name: 'DVD' }, + { id: 'videocassette', emoji: '📼', name: 'Videocassette' }, ], }, { name: 'Symbols', icon: '❤️', emojis: [ - '❤️', - '🧡', - '💛', - '💚', - '💙', - '💜', - '🖤', - '🤍', - '🤎', - '💔', - '❣️', - '💕', - '💞', - '💓', - '💗', - '💖', + { id: 'red_heart', emoji: '❤️', name: 'Red Heart' }, + { id: 'orange_heart', emoji: '🧡', name: 'Orange Heart' }, + { id: 'yellow_heart', emoji: '💛', name: 'Yellow Heart' }, + { id: 'green_heart', emoji: '💚', name: 'Green Heart' }, + { id: 'blue_heart', emoji: '💙', name: 'Blue Heart' }, + { id: 'purple_heart', emoji: '💜', name: 'Purple Heart' }, + { id: 'black_heart', emoji: '🖤', name: 'Black Heart' }, + { id: 'white_heart', emoji: '🤍', name: 'White Heart' }, + { id: 'brown_heart', emoji: '🤎', name: 'Brown Heart' }, + { id: 'broken_heart', emoji: '💔', name: 'Broken Heart' }, + { id: 'heart_exclamation', emoji: '❣️', name: 'Heart Exclamation' }, + { id: 'two_hearts', emoji: '💕', name: 'Two Hearts' }, + { id: 'revolving_hearts', emoji: '💞', name: 'Revolving Hearts' }, + { id: 'beating_heart', emoji: '💓', name: 'Beating Heart' }, + { id: 'growing_heart', emoji: '💗', name: 'Growing Heart' }, + { id: 'sparkling_heart', emoji: '💖', name: 'Sparkling Heart' }, ], }, ]; @@ -268,14 +336,15 @@ export default class IgcEmojiPickerComponent extends LitElement { this._activeCategory = index; } - private handleEmojiClick(emoji: string) { + private handleEmojiClick(emojiId: string, emoji: string) { this.dispatchEvent( new CustomEvent('emoji-selected', { - detail: { emoji }, + detail: { emojiId, emoji }, bubbles: true, composed: true, }) ); + this.hide(); } private getFilteredEmojis() { @@ -324,8 +393,8 @@ export default class IgcEmojiPickerComponent extends LitElement {
${filteredEmojis.map( (emoji) => html` - this.handleEmojiClick(emoji)}> - ${emoji} + this.handleEmojiClick(emoji.id, emoji.emoji)}> + ${emoji.emoji} ` )} diff --git a/src/components/chat/message-reactions.ts b/src/components/chat/message-reactions.ts index 05547b6fe..19cb29afa 100644 --- a/src/components/chat/message-reactions.ts +++ b/src/components/chat/message-reactions.ts @@ -3,10 +3,9 @@ import { property } from 'lit/decorators.js'; import IgcButtonComponent from '../button/button.js'; import IgcIconButtonComponent from '../button/icon-button.js'; import { registerComponent } from '../common/definitions/register.js'; -import { registerIconFromText } from '../icon/icon.registry.js'; import IgcEmojiPickerComponent from './emoji-picker.js'; import { styles } from './themes/reaction.base.css'; -import { type IgcMessageReaction, emojiPickerIcon } from './types.js'; +import type { IgcMessageReaction } from './types.js'; /** * @@ -38,44 +37,27 @@ export class IgcMessageReactionsComponent extends LitElement { @property({ type: String }) currentUserId = ''; - @property({ type: Boolean }) - showEmojiPicker = false; - - constructor() { - super(); - registerIconFromText('emoji-picker', emojiPickerIcon, 'material'); - } - public override connectedCallback() { super.connectedCallback(); - document.addEventListener('click', this.handleClickOutside); } public override disconnectedCallback() { super.disconnectedCallback(); - document.removeEventListener('click', this.handleClickOutside); } - private handleClickOutside = (e: MouseEvent) => { - if (this.showEmojiPicker && !e.composedPath().includes(this)) { - this.showEmojiPicker = false; - } - }; - private addEmoji(e: CustomEvent) { - const emoji = e.detail.emoji; - this.toggleReaction(emoji); - this.showEmojiPicker = false; + const { emojiId, emoji } = e.detail; + this.toggleReaction(emojiId, emoji); } private hasUserReacted(reaction: IgcMessageReaction): boolean { return reaction.users.includes(this.currentUserId); } - private toggleReaction(emoji: string) { + private toggleReaction(emojiId: string, emoji: string) { this.dispatchEvent( new CustomEvent('add-reaction', { - detail: { emoji }, + detail: { emojiId, emoji }, bubbles: true, composed: true, }) @@ -88,8 +70,9 @@ export class IgcMessageReactionsComponent extends LitElement { ${this.reactions?.map( (reaction) => html` this.toggleReaction(reaction.emoji)} + @click=${() => this.toggleReaction(reaction.id, reaction.emoji)} > ${reaction.emoji} ${reaction.count} diff --git a/src/components/chat/themes/emoji-picker.base.scss b/src/components/chat/themes/emoji-picker.base.scss index aa1cf3095..c74ddd036 100644 --- a/src/components/chat/themes/emoji-picker.base.scss +++ b/src/components/chat/themes/emoji-picker.base.scss @@ -18,7 +18,7 @@ padding: 0.5rem; border-bottom: 1px solid #bdbcbc; overflow-x: auto; - scrollbar-width: none; + scrollbar-width: thin; } .emoji-categories::-webkit-scrollbar { @@ -55,10 +55,10 @@ .emoji-grid { display: grid; - grid-template-columns: repeat(5, 1fr); + grid-template-columns: repeat(3, 1fr); gap: 0.25rem; padding: 0.5rem; - height: auto; + height: 150px; overflow: auto; } diff --git a/src/components/chat/themes/reaction.base.scss b/src/components/chat/themes/reaction.base.scss index 0737d4292..f2f5275dc 100644 --- a/src/components/chat/themes/reaction.base.scss +++ b/src/components/chat/themes/reaction.base.scss @@ -24,16 +24,16 @@ } .reaction-button.active { - background-color: #0A84FF; + background-color: transparent; color: white; } .reaction-button:hover { - background-color: #989899; + background-color: transparent; } .reaction-button.active:hover { - background-color: #0A84FF; + background-color: transparent; opacity: 0.9; } diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts index 58d78c23c..117505695 100644 --- a/src/components/chat/types.ts +++ b/src/components/chat/types.ts @@ -31,6 +31,7 @@ export interface IgcMessageAttachment { } export interface IgcMessageReaction { + id: string; emoji: string; count: number; users: string[]; diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 481365345..108460afd 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -97,9 +97,10 @@ const initialMessages: any[] = [ status: 'read', reactions: [ { + id: 'red_heart', emoji: '❤️', count: 1, - users: ['You'], + users: ['user1'], }, ], }, From 82fda4dbe67993ab27ddb6b907f8179e5fa16828 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Wed, 7 May 2025 17:31:12 +0300 Subject: [PATCH 006/252] feat(chat): add third user to the story --- src/components/chat/types.ts | 4 +--- stories/chat.stories.ts | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts index 117505695..ca352b76e 100644 --- a/src/components/chat/types.ts +++ b/src/components/chat/types.ts @@ -1,12 +1,11 @@ export type IgcMessageStatusType = 'sent' | 'delivered' | 'read'; export type IgcMessageAttachmentType = 'image' | 'file'; -export type IgcUserStatus = 'online' | 'offline'; export interface IgcUser { id: string; name: string; avatar: string; - isOnline: boolean; + status?: any; isTyping?: boolean; } @@ -18,7 +17,6 @@ export interface IgcMessage { status?: IgcMessageStatusType; attachments?: IgcMessageAttachment[]; reactions?: IgcMessageReaction[]; - reaction?: string; } export interface IgcMessageAttachment { diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 108460afd..d7ab4d8b0 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -63,14 +63,19 @@ const currentUser: any = { id: 'user1', name: 'You', avatar: 'https://www.infragistics.com/angular-demos/assets/images/men/1.jpg', - isOnline: true, }; const otherUser: any = { id: 'user2', name: 'Alice', avatar: 'https://www.infragistics.com/angular-demos/assets/images/men/2.jpg', - isOnline: true, + isTyping: false, +}; + +const thirdUser: any = { + id: 'user3', + name: 'Sam', + avatar: 'https://www.infragistics.com/angular-demos/assets/images/men/3.jpg', isTyping: false, }; @@ -79,7 +84,7 @@ const initialMessages: any[] = [ id: '1', text: 'Hey there! How are you doing today?', sender: otherUser, - timestamp: new Date(Date.now() - 3600000), + timestamp: new Date(2025, 4, 5), status: 'read', }, { @@ -104,6 +109,13 @@ const initialMessages: any[] = [ }, ], }, + { + id: '4', + text: 'Hi guys! I just joined the chat.', + sender: thirdUser, + timestamp: new Date(Date.now() - 3300000), + status: 'read', + }, ]; export const Basic: Story = { From 82434527458f970a9fc8629c27e5054f13d9e1f1 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Thu, 8 May 2025 09:47:44 +0300 Subject: [PATCH 007/252] feat(chat): rename bool props and default values to false --- src/components/chat/chat-input.ts | 24 +++++++++++----------- src/components/chat/chat-message-list.ts | 6 +++--- src/components/chat/chat-message.ts | 25 ++++++++++++----------- src/components/chat/chat.ts | 18 ++++++++-------- stories/chat.stories.ts | 26 +++++++++++------------- 5 files changed, 49 insertions(+), 50 deletions(-) diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index a22cb6d0b..5cdd3658f 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -39,11 +39,11 @@ export default class IgcChatInputComponent extends LitElement { ); } - @property({ type: Boolean, attribute: 'enable-attachments' }) - public enableAttachments = true; + @property({ type: Boolean, attribute: 'disable-attachments' }) + public disableAttachments = false; - @property({ type: Boolean, attribute: 'enable-emoji-picker' }) - public enableEmojiPicker = true; + @property({ type: Boolean, attribute: 'disable-emojis' }) + public disableEmojis = false; @query('textarea') private textInputElement!: HTMLTextAreaElement; @@ -142,8 +142,9 @@ export default class IgcChatInputComponent extends LitElement { protected override render() { return html`
- ${this.enableAttachments - ? html` + ${this.disableAttachments + ? '' + : html` - ` - : ''} + `}
- ${this.enableEmojiPicker - ? html` + ${this.disableEmojis + ? '' + : html` - ` - : ''} + `} ` )} diff --git a/src/components/chat/chat-message.ts b/src/components/chat/chat-message.ts index e59f3a564..335f8f086 100644 --- a/src/components/chat/chat-message.ts +++ b/src/components/chat/chat-message.ts @@ -35,8 +35,8 @@ export default class IgcChatMessageComponent extends LitElement { @property({ reflect: true, attribute: false }) public user: IgcUser | undefined; - @property({ type: Boolean, attribute: 'enable-reactions' }) - public enableReactions = true; + @property({ type: Boolean, attribute: 'disable-reactions' }) + public disableReactions = false; private formatTime(date: Date | undefined): string | undefined { return date?.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); @@ -89,6 +89,13 @@ export default class IgcChatMessageComponent extends LitElement { ${this.message?.text.trim() ? html`
${this.message?.text}
` : ''} + ${this.message?.attachments && this.message?.attachments.length > 0 + ? html` + ` + : ''} +
${this.formatTime(this.message?.timestamp)}` : ''}
- ${this.message?.attachments && this.message?.attachments.length > 0 - ? html` - ` - : ''}
- ${this.enableReactions - ? html`` - : ''} + >`}
`; } diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index e61036860..40c3f04e3 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -49,14 +49,14 @@ export default class IgcChatComponent extends EventEmitterMixin< @property({ type: Boolean, attribute: 'scroll-bottom' }) public scrollBottom = true; - @property({ type: Boolean, attribute: 'enable-reactions' }) - public enableReactions = true; + @property({ type: Boolean, attribute: 'disable-reactions' }) + public disableReactions = false; - @property({ type: Boolean, attribute: 'enable-attachments' }) - public enableAttachments = true; + @property({ type: Boolean, attribute: 'disable-attachments' }) + public disableAttachments = false; - @property({ type: Boolean, attribute: 'enable-emoji-picker' }) - public enableEmojiPicker = true; + @property({ type: Boolean, attribute: 'disable-emojis' }) + public disableEmojis = false; @property({ type: String, attribute: 'header-text', reflect: true }) public headerText = ''; @@ -173,12 +173,12 @@ export default class IgcChatComponent extends EventEmitterMixin< .user=${this.user} .typingUsers=${this.typingUsers} .scrollBottom=${this.scrollBottom} - .enableReactions=${this.enableReactions} + .disableReactions=${this.disableReactions} >
diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index d7ab4d8b0..fe6cc13be 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -16,20 +16,20 @@ const metadata: Meta = { control: 'boolean', table: { defaultValue: { summary: 'true' } }, }, - enableReactions: { + disableReactions: { type: 'boolean', control: 'boolean', - table: { defaultValue: { summary: 'true' } }, + table: { defaultValue: { summary: 'false' } }, }, - enableAttachments: { + disableAttachments: { type: 'boolean', control: 'boolean', - table: { defaultValue: { summary: 'true' } }, + table: { defaultValue: { summary: 'false' } }, }, - enableEmojiPicker: { + disableEmojis: { type: 'boolean', control: 'boolean', - table: { defaultValue: { summary: 'true' } }, + table: { defaultValue: { summary: 'false' } }, }, headerText: { type: 'string', @@ -39,9 +39,9 @@ const metadata: Meta = { }, args: { scrollBottom: true, - enableReactions: true, - enableAttachments: true, - enableEmojiPicker: true, + disableReactions: false, + disableAttachments: false, + disableEmojis: false, headerText: '', }, }; @@ -50,9 +50,9 @@ export default metadata; interface IgcChatArgs { scrollBottom: boolean; - enableReactions: boolean; - enableAttachments: boolean; - enableEmojiPicker: boolean; + disableReactions: boolean; + disableAttachments: boolean; + disableEmojis: boolean; headerText: string; } type Story = StoryObj; @@ -69,14 +69,12 @@ const otherUser: any = { id: 'user2', name: 'Alice', avatar: 'https://www.infragistics.com/angular-demos/assets/images/men/2.jpg', - isTyping: false, }; const thirdUser: any = { id: 'user3', name: 'Sam', avatar: 'https://www.infragistics.com/angular-demos/assets/images/men/3.jpg', - isTyping: false, }; const initialMessages: any[] = [ From 3a7a125bcad720e3c67ffb4171bb949363f303eb Mon Sep 17 00:00:00 2001 From: teodosiah Date: Thu, 8 May 2025 14:03:12 +0300 Subject: [PATCH 008/252] feat(chat): expose props for hiding avatar, username, meta data --- src/components/chat/chat-message-list.ts | 12 +++++ src/components/chat/chat-message.ts | 56 ++++++++++++++++-------- src/components/chat/chat.ts | 12 +++++ stories/chat.stories.ts | 32 +++++++++++++- 4 files changed, 91 insertions(+), 21 deletions(-) diff --git a/src/components/chat/chat-message-list.ts b/src/components/chat/chat-message-list.ts index 11d0e06da..05d21b949 100644 --- a/src/components/chat/chat-message-list.ts +++ b/src/components/chat/chat-message-list.ts @@ -31,6 +31,15 @@ export default class IgcChatMessageListComponent extends LitElement { @property({ reflect: true, attribute: false }) public typingUsers: IgcUser[] = []; + @property({ type: Boolean, attribute: 'hide-avatar' }) + public hideAvatar = false; + + @property({ type: Boolean, attribute: 'hide-user-name' }) + public hideUserName = false; + + @property({ type: Boolean, attribute: 'hide-meta-data' }) + public hideMetaData = false; + @property({ type: Boolean, attribute: 'scroll-bottom' }) public scrollBottom = true; @@ -115,6 +124,9 @@ export default class IgcChatMessageListComponent extends LitElement { .message=${message} .user=${this.user} .disableReactions=${this.disableReactions} + .hideAvatar=${this.hideAvatar} + .hideUserName=${this.hideUserName} + .hideMetaData=${this.hideMetaData} > ` )} diff --git a/src/components/chat/chat-message.ts b/src/components/chat/chat-message.ts index 335f8f086..a0ff3d737 100644 --- a/src/components/chat/chat-message.ts +++ b/src/components/chat/chat-message.ts @@ -35,6 +35,15 @@ export default class IgcChatMessageComponent extends LitElement { @property({ reflect: true, attribute: false }) public user: IgcUser | undefined; + @property({ type: Boolean, attribute: 'hide-avatar' }) + public hideAvatar = false; + + @property({ type: Boolean, attribute: 'hide-user-name' }) + public hideUserName = false; + + @property({ type: Boolean, attribute: 'hide-meta-data' }) + public hideMetaData = false; + @property({ type: Boolean, attribute: 'disable-reactions' }) public disableReactions = false; @@ -79,13 +88,19 @@ export default class IgcChatMessageComponent extends LitElement { return html`
- - + ${this.hideAvatar + ? '' + : html` + `} +
+ ${this.hideUserName || this.isCurrentUser() + ? '' + : html`${ifDefined(sender?.name)}`} ${this.message?.text.trim() ? html`
${this.message?.text}
` : ''} @@ -95,19 +110,22 @@ export default class IgcChatMessageComponent extends LitElement { > ` : ''} - -
- ${this.formatTime(this.message?.timestamp)} - ${this.isCurrentUser() - ? html`${this.renderStatusIcon( - this.message?.status || 'sent' - )}` - : ''} -
+ ${this.hideMetaData + ? '' + : html` +
+ ${this.formatTime(this.message?.timestamp)} + ${this.isCurrentUser() + ? html`${this.renderStatusIcon( + this.message?.status || 'sent' + )}` + : ''} +
+ `}
${this.disableReactions ? '' diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index 40c3f04e3..59db2da29 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -46,6 +46,15 @@ export default class IgcChatComponent extends EventEmitterMixin< @property({ reflect: true, attribute: false }) public typingUsers: IgcUser[] = []; + @property({ type: Boolean, attribute: 'hide-avatar' }) + public hideAvatar = false; + + @property({ type: Boolean, attribute: 'hide-user-name' }) + public hideUserName = false; + + @property({ type: Boolean, attribute: 'hide-meta-data' }) + public hideMetaData = false; + @property({ type: Boolean, attribute: 'scroll-bottom' }) public scrollBottom = true; @@ -174,6 +183,9 @@ export default class IgcChatComponent extends EventEmitterMixin< .typingUsers=${this.typingUsers} .scrollBottom=${this.scrollBottom} .disableReactions=${this.disableReactions} + .hideAvatar=${this.hideAvatar} + .hideUserName=${this.hideUserName} + .hideMetaData=${this.hideMetaData} > = { component: 'igc-chat', parameters: { docs: { description: { component: '' } } }, argTypes: { + hideAvatar: { + type: 'boolean', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + hideUserName: { + type: 'boolean', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + hideMetaData: { + type: 'boolean', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, scrollBottom: { type: 'boolean', control: 'boolean', @@ -38,6 +53,9 @@ const metadata: Meta = { }, }, args: { + hideAvatar: false, + hideUserName: false, + hideMetaData: false, scrollBottom: true, disableReactions: false, disableAttachments: false, @@ -49,6 +67,9 @@ const metadata: Meta = { export default metadata; interface IgcChatArgs { + hideAvatar: boolean; + hideUserName: boolean; + hideMetaData: boolean; scrollBottom: boolean; disableReactions: boolean; disableAttachments: boolean; @@ -117,11 +138,18 @@ const initialMessages: any[] = [ ]; export const Basic: Story = { - render: () => html` + render: (args) => html` `, From 9fde895e233c7ece451aeb4bae11f7a94e645853 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Fri, 9 May 2025 10:03:54 +0300 Subject: [PATCH 009/252] feat(chat): change scrollBottom prop default val & name --- src/components/chat/chat-message-list.ts | 8 ++++---- src/components/chat/types.ts | 3 ++- stories/chat.stories.ts | 18 +++++++++++++----- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/components/chat/chat-message-list.ts b/src/components/chat/chat-message-list.ts index 05d21b949..776f35487 100644 --- a/src/components/chat/chat-message-list.ts +++ b/src/components/chat/chat-message-list.ts @@ -40,8 +40,8 @@ export default class IgcChatMessageListComponent extends LitElement { @property({ type: Boolean, attribute: 'hide-meta-data' }) public hideMetaData = false; - @property({ type: Boolean, attribute: 'scroll-bottom' }) - public scrollBottom = true; + @property({ type: Boolean, attribute: 'disable-auto-scroll' }) + public disableAutoScroll = false; @property({ type: Boolean, attribute: 'disable-reactions' }) public disableReactions = false; @@ -95,13 +95,13 @@ export default class IgcChatMessageListComponent extends LitElement { } protected override updated(changedProperties: Map) { - if (changedProperties.has('messages') && this.scrollBottom) { + if (changedProperties.has('messages') && !this.disableAutoScroll) { this.scrollToBottom(); } } protected override firstUpdated() { - if (this.scrollBottom) { + if (!this.disableAutoScroll) { this.scrollToBottom(); } } diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts index ca352b76e..b4b3c54bb 100644 --- a/src/components/chat/types.ts +++ b/src/components/chat/types.ts @@ -6,7 +6,7 @@ export interface IgcUser { name: string; avatar: string; status?: any; - isTyping?: boolean; + //isTyping?: boolean; } export interface IgcMessage { @@ -14,6 +14,7 @@ export interface IgcMessage { text: string; sender: IgcUser; timestamp: Date; + chatId?: string; status?: IgcMessageStatusType; attachments?: IgcMessageAttachment[]; reactions?: IgcMessageReaction[]; diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 0dbacb6db..3abf3c561 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -26,10 +26,10 @@ const metadata: Meta = { control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, - scrollBottom: { + disableAutoScroll: { type: 'boolean', control: 'boolean', - table: { defaultValue: { summary: 'true' } }, + table: { defaultValue: { summary: 'false' } }, }, disableReactions: { type: 'boolean', @@ -56,7 +56,7 @@ const metadata: Meta = { hideAvatar: false, hideUserName: false, hideMetaData: false, - scrollBottom: true, + disableAutoScroll: false, disableReactions: false, disableAttachments: false, disableEmojis: false, @@ -70,7 +70,7 @@ interface IgcChatArgs { hideAvatar: boolean; hideUserName: boolean; hideMetaData: boolean; - scrollBottom: boolean; + disableAutoScroll: boolean; disableReactions: boolean; disableAttachments: boolean; disableEmojis: boolean; @@ -134,6 +134,14 @@ const initialMessages: any[] = [ sender: thirdUser, timestamp: new Date(Date.now() - 3300000), status: 'read', + attachments: [ + { + id: 'men3_img', + type: 'image', + url: 'https://www.infragistics.com/angular-demos/assets/images/men/3.jpg', + name: 'men3.png', + }, + ], }, ]; @@ -143,7 +151,7 @@ export const Basic: Story = { .user=${currentUser} .messages=${initialMessages} .headerText=${args.headerText} - .scrollBottom=${args.scrollBottom} + .disableAutoScroll=${args.disableAutoScroll} .disableReactions=${args.disableReactions} .disableAttachments=${args.disableAttachments} .disableEmojis=${args.disableEmojis} From bebfb9a1c99992f5fca90c048012a872c41b91f3 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Fri, 9 May 2025 14:22:59 +0300 Subject: [PATCH 010/252] feat(chat): refactor message reaction logic --- src/components/chat/chat-message.ts | 4 +- src/components/chat/chat.ts | 24 +++++------ src/components/chat/message-reactions.ts | 54 ++++++++++++++++-------- src/components/chat/types.ts | 2 - stories/chat.stories.ts | 4 +- 5 files changed, 50 insertions(+), 38 deletions(-) diff --git a/src/components/chat/chat-message.ts b/src/components/chat/chat-message.ts index a0ff3d737..153b19920 100644 --- a/src/components/chat/chat-message.ts +++ b/src/components/chat/chat-message.ts @@ -67,11 +67,11 @@ export default class IgcChatMessageComponent extends LitElement { } private handleAddReaction(e: CustomEvent) { - const { emojiId, emoji } = e.detail; + const { emojiId } = e.detail; this.dispatchEvent( new CustomEvent('add-reaction', { - detail: { messageId: this.message?.id, emojiId, emoji }, + detail: { messageId: this.message?.id, emojiId }, bubbles: true, composed: true, }) diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index 59db2da29..227ccbf17 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -10,7 +10,7 @@ import { styles } from './themes/chat.base.css.js'; import type { IgcMessage, IgcMessageReaction, IgcUser } from './types.js'; export interface IgcChatComponentEventMap { - igcMessageEntered: CustomEvent; + igcMessageSend: CustomEvent; } /** @@ -55,8 +55,8 @@ export default class IgcChatComponent extends EventEmitterMixin< @property({ type: Boolean, attribute: 'hide-meta-data' }) public hideMetaData = false; - @property({ type: Boolean, attribute: 'scroll-bottom' }) - public scrollBottom = true; + @property({ type: Boolean, attribute: 'disable-auto-scroll' }) + public disableAutoScroll = false; @property({ type: Boolean, attribute: 'disable-reactions' }) public disableReactions = false; @@ -111,11 +111,11 @@ export default class IgcChatComponent extends EventEmitterMixin< }; this.messages = [...this.messages, newMessage]; - this.emitEvent('igcMessageEntered', { detail: newMessage }); + this.emitEvent('igcMessageSend', { detail: newMessage }); } private handleAddReaction(e: CustomEvent) { - const { messageId, emojiId, emoji } = e.detail; + const { messageId, emojiId } = e.detail; if (!messageId) return; @@ -129,39 +129,35 @@ export default class IgcChatComponent extends EventEmitterMixin< // Remove reaction message.reactions?.forEach((r) => { if (r.id === userReaction.id) { - r.count -= 1; r.users = (r.users ?? []).filter((id) => id !== this.user?.id); } }); message.reactions = - message.reactions?.filter((r) => r.count > 0) || []; + message.reactions?.filter((r) => r.users.length > 0) || []; } const existingReaction = message.reactions?.find( (r) => r.id === emojiId ); - if (existingReaction) { - // Add reaction + if (existingReaction && userReaction?.id !== emojiId) { + // Update existing reaction message.reactions?.forEach((r) => { if (r.id === emojiId) { - r.count += 1; if (this.user) { r.users.push(this.user.id); } } }); - message.reactions = message.reactions ?? []; + message.reactions = [...(message.reactions || [])]; } if (!existingReaction && userReaction?.id !== emojiId) { // Create new reaction const newReaction: IgcMessageReaction = { id: emojiId, - emoji, - count: 1, users: this.user ? [this.user.id] : [], }; @@ -181,7 +177,7 @@ export default class IgcChatComponent extends EventEmitterMixin< .messages=${this.messages} .user=${this.user} .typingUsers=${this.typingUsers} - .scrollBottom=${this.scrollBottom} + .disableAutoScroll=${this.disableAutoScroll} .disableReactions=${this.disableReactions} .hideAvatar=${this.hideAvatar} .hideUserName=${this.hideUserName} diff --git a/src/components/chat/message-reactions.ts b/src/components/chat/message-reactions.ts index 19cb29afa..1e6665ba0 100644 --- a/src/components/chat/message-reactions.ts +++ b/src/components/chat/message-reactions.ts @@ -3,7 +3,7 @@ import { property } from 'lit/decorators.js'; import IgcButtonComponent from '../button/button.js'; import IgcIconButtonComponent from '../button/icon-button.js'; import { registerComponent } from '../common/definitions/register.js'; -import IgcEmojiPickerComponent from './emoji-picker.js'; +import IgcEmojiPickerComponent, { EMOJI_CATEGORIES } from './emoji-picker.js'; import { styles } from './themes/reaction.base.css'; import type { IgcMessageReaction } from './types.js'; @@ -46,39 +46,59 @@ export class IgcMessageReactionsComponent extends LitElement { } private addEmoji(e: CustomEvent) { - const { emojiId, emoji } = e.detail; - this.toggleReaction(emojiId, emoji); + const { emojiId } = e.detail; + this.toggleReaction(emojiId); } private hasUserReacted(reaction: IgcMessageReaction): boolean { return reaction.users.includes(this.currentUserId); } - private toggleReaction(emojiId: string, emoji: string) { + private toggleReaction(emojiId: string) { this.dispatchEvent( new CustomEvent('add-reaction', { - detail: { emojiId, emoji }, + detail: { emojiId }, bubbles: true, composed: true, }) ); } + private getReactionById(reaction: IgcMessageReaction) { + for (const category of EMOJI_CATEGORIES) { + const foundReaction = category.emojis.find( + (emoji) => emoji.id === reaction.id + ); + if (foundReaction) { + return { + id: foundReaction.id, + emoji: foundReaction.emoji, + count: reaction.users.length, + users: reaction.users, + }; + } + } + return undefined; + } + protected override render() { return html`
- ${this.reactions?.map( - (reaction) => html` - this.toggleReaction(reaction.id, reaction.emoji)} - > - ${reaction.emoji} - ${reaction.count} - - ` - )} + ${this.reactions?.map((_reaction) => { + const reaction = this.getReactionById(_reaction); + return reaction + ? html` + this.toggleReaction(reaction.id)} + > + ${reaction.emoji} + ${reaction.count} + + ` + : html``; + })}
diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts index b4b3c54bb..787cfa402 100644 --- a/src/components/chat/types.ts +++ b/src/components/chat/types.ts @@ -31,8 +31,6 @@ export interface IgcMessageAttachment { export interface IgcMessageReaction { id: string; - emoji: string; - count: number; users: string[]; } diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 3abf3c561..f01d2a3ed 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -122,9 +122,7 @@ const initialMessages: any[] = [ reactions: [ { id: 'red_heart', - emoji: '❤️', - count: 1, - users: ['user1'], + users: ['user3'], }, ], }, From daba4169929700ed4ef45570b12ab72aa26dd957 Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Fri, 9 May 2025 14:57:09 +0300 Subject: [PATCH 011/252] feat(chat): emit event for typing change and add side by side chats story --- src/components/chat/chat-input.ts | 16 ++++++ src/components/chat/chat.ts | 15 ++++++ stories/chat.stories.ts | 89 ++++++++++++++++++++++++++----- 3 files changed, 108 insertions(+), 12 deletions(-) diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index 5cdd3658f..aa76e3ce4 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -66,10 +66,26 @@ export default class IgcChatInputComponent extends LitElement { this.adjustTextareaHeight(); } + private isTyping = false; + private handleKeyDown(e: KeyboardEvent) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendMessage(); + } else if (this.isTyping === false) { + const typingEvent = new CustomEvent('typing-change', { + detail: { isTyping: true }, + }); + this.dispatchEvent(typingEvent); + this.isTyping = true; + // wait 3 seconds and dispatch a stop-typing event + setTimeout(() => { + const stopTypingEvent = new CustomEvent('typing-change', { + detail: { isTyping: false }, + }); + this.dispatchEvent(stopTypingEvent); + this.isTyping = false; + }, 3000); } } diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index 227ccbf17..dfe8961a7 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -11,6 +11,12 @@ import type { IgcMessage, IgcMessageReaction, IgcUser } from './types.js'; export interface IgcChatComponentEventMap { igcMessageSend: CustomEvent; + igcTypingChange: CustomEvent; +} + +export interface IgcTypingChangeEventArgs { + user: IgcUser; + isTyping: boolean; } /** @@ -114,6 +120,14 @@ export default class IgcChatComponent extends EventEmitterMixin< this.emitEvent('igcMessageSend', { detail: newMessage }); } + private handleTypingChange(e: CustomEvent) { + const isTyping = e.detail.isTyping; + const user = this.user; + if (!user) return; + const typingArgs = { user, isTyping }; + this.emitEvent('igcTypingChange', { detail: typingArgs }); + } + private handleAddReaction(e: CustomEvent) { const { messageId, emojiId } = e.detail; @@ -188,6 +202,7 @@ export default class IgcChatComponent extends EventEmitterMixin< .disableAttachments=${this.disableAttachments} .disableEmojis=${this.disableEmojis} @message-send=${this.handleSendMessage} + @typing-change=${this.handleTypingChange} >
`; diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index f01d2a3ed..f99f3120f 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -80,43 +80,43 @@ type Story = StoryObj; // endregion -const currentUser: any = { +const userJohn: any = { id: 'user1', - name: 'You', + name: 'John', avatar: 'https://www.infragistics.com/angular-demos/assets/images/men/1.jpg', }; -const otherUser: any = { +const userRichard: any = { id: 'user2', - name: 'Alice', + name: 'Richard', avatar: 'https://www.infragistics.com/angular-demos/assets/images/men/2.jpg', }; -const thirdUser: any = { +const userSam: any = { id: 'user3', name: 'Sam', avatar: 'https://www.infragistics.com/angular-demos/assets/images/men/3.jpg', }; -const initialMessages: any[] = [ +const messages: any[] = [ { id: '1', text: 'Hey there! How are you doing today?', - sender: otherUser, + sender: userRichard, timestamp: new Date(2025, 4, 5), status: 'read', }, { id: '2', text: "I'm doing well, thanks for asking! How about you?", - sender: currentUser, + sender: userJohn, timestamp: new Date(Date.now() - 3500000), status: 'read', }, { id: '3', text: 'Pretty good! I was wondering if you wanted to grab coffee sometime this week?', - sender: otherUser, + sender: userRichard, timestamp: new Date(Date.now() - 3400000), status: 'read', reactions: [ @@ -129,7 +129,7 @@ const initialMessages: any[] = [ { id: '4', text: 'Hi guys! I just joined the chat.', - sender: thirdUser, + sender: userSam, timestamp: new Date(Date.now() - 3300000), status: 'read', attachments: [ @@ -146,8 +146,8 @@ const initialMessages: any[] = [ export const Basic: Story = { render: (args) => html` `, }; + +function handleMessageEntered(e: CustomEvent) { + const newMessage = e.detail; + messages.push(newMessage); + const chatElements = document.querySelectorAll('igc-chat'); + chatElements.forEach((chat) => { + chat.messages = [...messages]; + }); +} + +function handleTypingChange(e: CustomEvent) { + const user = e.detail.user; + const isTyping = e.detail.isTyping; + const chatElements = document.querySelectorAll('igc-chat'); + chatElements.forEach((chat) => { + if (chat.user === user) { + return; + } + + if (!isTyping && chat.typingUsers.includes(user)) { + chat.typingUsers = chat.typingUsers.filter((u) => u !== user); + } else if (isTyping && !chat.typingUsers.includes(user)) { + chat.typingUsers = [...chat.typingUsers, user]; + } + }); +} + +export const SideBySide: Story = { + render: (args) => html` +
+ + + + +
+ `, +}; From 3c9f1e54d5465b203f6dabcf085040c6653e2055 Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Fri, 9 May 2025 15:00:17 +0300 Subject: [PATCH 012/252] chore(*): update headers --- stories/chat.stories.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index f99f3120f..dcbe094f7 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -195,7 +195,7 @@ export const SideBySide: Story = { .messages=${messages} @igcMessageSend=${handleMessageEntered} @igcTypingChange=${handleTypingChange} - header-text="Richard" + header-text="Richard, Sam" .disableAutoScroll=${args.disableAutoScroll} .disableReactions=${args.disableReactions} .disableAttachments=${args.disableAttachments} @@ -211,7 +211,7 @@ export const SideBySide: Story = { .messages=${messages} @igcMessageSend=${handleMessageEntered} @igcTypingChange=${handleTypingChange} - header-text="John" + header-text="John, Sam" .disableAutoScroll=${args.disableAutoScroll} .disableReactions=${args.disableReactions} .disableAttachments=${args.disableAttachments} From e17535373aa11ec64ec65a469cd34ea3dcc00fbb Mon Sep 17 00:00:00 2001 From: teodosiah Date: Tue, 13 May 2025 10:38:56 +0300 Subject: [PATCH 013/252] feat(chat): fix typing and scrolling in message list --- src/components/chat/chat-input.ts | 6 +----- src/components/chat/chat-message-list.ts | 6 +++--- stories/chat.stories.ts | 6 +----- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index aa76e3ce4..1c74f9414 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -66,25 +66,21 @@ export default class IgcChatInputComponent extends LitElement { this.adjustTextareaHeight(); } - private isTyping = false; - private handleKeyDown(e: KeyboardEvent) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendMessage(); - } else if (this.isTyping === false) { + } else { const typingEvent = new CustomEvent('typing-change', { detail: { isTyping: true }, }); this.dispatchEvent(typingEvent); - this.isTyping = true; // wait 3 seconds and dispatch a stop-typing event setTimeout(() => { const stopTypingEvent = new CustomEvent('typing-change', { detail: { isTyping: false }, }); this.dispatchEvent(stopTypingEvent); - this.isTyping = false; }, 3000); } } diff --git a/src/components/chat/chat-message-list.ts b/src/components/chat/chat-message-list.ts index 776f35487..0c6108e60 100644 --- a/src/components/chat/chat-message-list.ts +++ b/src/components/chat/chat-message-list.ts @@ -94,8 +94,8 @@ export default class IgcChatMessageListComponent extends LitElement { }); } - protected override updated(changedProperties: Map) { - if (changedProperties.has('messages') && !this.disableAutoScroll) { + protected override updated() { + if (!this.disableAutoScroll) { this.scrollToBottom(); } } @@ -132,7 +132,7 @@ export default class IgcChatMessageListComponent extends LitElement { )} ` )} - ${this.typingUsers.length > 0 + ${this.typingUsers.filter((u) => u !== this.user).length > 0 ? html`
diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index dcbe094f7..8e7edfd03 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -175,11 +175,7 @@ function handleTypingChange(e: CustomEvent) { const isTyping = e.detail.isTyping; const chatElements = document.querySelectorAll('igc-chat'); chatElements.forEach((chat) => { - if (chat.user === user) { - return; - } - - if (!isTyping && chat.typingUsers.includes(user)) { + if (!isTyping) { chat.typingUsers = chat.typingUsers.filter((u) => u !== user); } else if (isTyping && !chat.typingUsers.includes(user)) { chat.typingUsers = [...chat.typingUsers, user]; From c5f00c5dfcae2925391f46039d3fb18a643fc355 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Wed, 14 May 2025 13:27:45 +0300 Subject: [PATCH 014/252] feat(chat): expose header slots & remove header component --- src/components/chat/chat-header.ts | 39 --------- src/components/chat/chat.ts | 16 +++- src/components/chat/themes/chat.base.scss | 59 ++++++++++++++ src/components/chat/themes/header.base.scss | 88 --------------------- 4 files changed, 71 insertions(+), 131 deletions(-) delete mode 100644 src/components/chat/chat-header.ts delete mode 100644 src/components/chat/themes/header.base.scss diff --git a/src/components/chat/chat-header.ts b/src/components/chat/chat-header.ts deleted file mode 100644 index 33066d901..000000000 --- a/src/components/chat/chat-header.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { LitElement, html } from 'lit'; -import { property } from 'lit/decorators.js'; -import { registerComponent } from '../common/definitions/register.js'; -import { styles } from './themes/header.base.css.js'; - -/** - * - * @element igc-chat-header - * - */ -export default class IgcChatHeaderComponent extends LitElement { - /** @private */ - public static readonly tagName = 'igc-chat-header'; - - public static override styles = styles; - - /* blazorSuppress */ - public static register() { - registerComponent(IgcChatHeaderComponent); - } - - @property({ type: String, reflect: true }) - public text = ''; - - protected override render() { - return html`
-
${this.text}
-
- -
-
`; - } -} - -declare global { - interface HTMLElementTagNameMap { - 'igc-chat-header': IgcChatHeaderComponent; - } -} diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index dfe8961a7..df4ec65ac 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -1,9 +1,9 @@ import { LitElement, html } from 'lit'; import { property } from 'lit/decorators.js'; +import IgcButtonComponent from '../button/button.js'; import { registerComponent } from '../common/definitions/register.js'; import type { Constructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; -import IgcChatHeaderComponent from './chat-header.js'; import IgcChatInputComponent from './chat-input.js'; import IgcChatMessageListComponent from './chat-message-list.js'; import { styles } from './themes/chat.base.css.js'; @@ -37,9 +37,9 @@ export default class IgcChatComponent extends EventEmitterMixin< public static register() { registerComponent( IgcChatComponent, - IgcChatHeaderComponent, IgcChatInputComponent, - IgcChatMessageListComponent + IgcChatMessageListComponent, + IgcButtonComponent ); } @@ -186,7 +186,15 @@ export default class IgcChatComponent extends EventEmitterMixin< protected override render() { return html`
- +
+
+ + ${this.headerText} +
+ + ⋯ + +
Date: Wed, 14 May 2025 14:55:00 +0300 Subject: [PATCH 015/252] feat(chat): display attachments in expansion panel --- src/components/chat/message-attachments.ts | 97 +++++++++++++------ .../chat/themes/message-attachments.base.scss | 10 ++ src/components/chat/types.ts | 6 ++ 3 files changed, 82 insertions(+), 31 deletions(-) diff --git a/src/components/chat/message-attachments.ts b/src/components/chat/message-attachments.ts index 63ad07d19..809cb395f 100644 --- a/src/components/chat/message-attachments.ts +++ b/src/components/chat/message-attachments.ts @@ -2,10 +2,18 @@ import { LitElement, html } from 'lit'; import { property } from 'lit/decorators.js'; import IgcIconButtonComponent from '../button/icon-button.js'; import { registerComponent } from '../common/definitions/register.js'; +import IgcExpansionPanelComponent from '../expansion-panel/expansion-panel.js'; import IgcIconComponent from '../icon/icon.js'; import { registerIconFromText } from '../icon/icon.registry.js'; import { styles } from './themes/message-attachments.base.css'; -import { type IgcMessageAttachment, closeIcon, fileIcon } from './types.js'; +import { + type IgcMessageAttachment, + closeIcon, + fileIcon, + imageIcon, + moreIcon, + previewIcon, +} from './types.js'; /** * @@ -23,7 +31,8 @@ export class IgcMessageAttachmentsComponent extends LitElement { registerComponent( IgcMessageAttachmentsComponent, IgcIconComponent, - IgcIconButtonComponent + IgcIconButtonComponent, + IgcExpansionPanelComponent ); } @property({ type: Array }) @@ -36,6 +45,9 @@ export class IgcMessageAttachmentsComponent extends LitElement { super(); registerIconFromText('close', closeIcon, 'material'); registerIconFromText('file', fileIcon, 'material'); + registerIconFromText('image', imageIcon, 'material'); + registerIconFromText('preview', previewIcon, 'material'); + registerIconFromText('more', moreIcon, 'material'); } private formatFileSize(bytes = 0): string { @@ -52,41 +64,64 @@ export class IgcMessageAttachmentsComponent extends LitElement { this.previewImage = ''; } + private preventToggle(e: CustomEvent) { + e.preventDefault(); + } + protected override render() { return html`
- ${this.attachments.map((attachment) => - attachment.type === 'image' - ? html` -
- html` + +
+
+ ${attachment.type === 'image' + ? html`` + : html``} + ${attachment.name} +
+
+ ${attachment.type === 'image' + ? html` this.openImagePreview(attachment.url)} + >` + : ''} + +
+
+ + ${attachment.type === 'image' + ? html` ${attachment.name} this.openImagePreview(attachment.url)} - /> -
- ` - : html` - - -
-
${attachment.name}
-
- ${this.formatFileSize(attachment.size)} -
-
-
- ` + />` + : ''} + + ` )}
diff --git a/src/components/chat/themes/message-attachments.base.scss b/src/components/chat/themes/message-attachments.base.scss index eb9346e3e..69203bb99 100644 --- a/src/components/chat/themes/message-attachments.base.scss +++ b/src/components/chat/themes/message-attachments.base.scss @@ -96,4 +96,14 @@ .large { --ig-size: var(--ig-size-large); +} + +.actions { + display: flex; +} + +.attachment { + display: flex; + justify-content: space-between; + gap: 2rem; } \ No newline at end of file diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts index 787cfa402..00f04b070 100644 --- a/src/components/chat/types.ts +++ b/src/components/chat/types.ts @@ -44,3 +44,9 @@ export const closeIcon = ''; export const fileIcon = ''; +export const imageIcon = + ''; +export const moreIcon = + ''; +export const previewIcon = + ''; From 594e5df55bb3f8843866f2d80fa6b891f09f905c Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Wed, 14 May 2025 15:59:21 +0300 Subject: [PATCH 016/252] feat(chat-ai): add chat ai component --- package-lock.json | 16 + package.json | 3 + src/components/chat/chat-input.ts | 39 +- src/components/chat/chat-message-list.ts | 79 ++-- src/components/chat/chat-message.ts | 110 +---- src/components/chat/chat.ts | 109 +---- src/components/chat/emoji-picker.ts | 418 ------------------ src/components/chat/markdown-util.ts | 32 ++ src/components/chat/message-attachments.ts | 2 +- src/components/chat/message-reactions.ts | 113 ----- .../chat/themes/emoji-picker.base.scss | 99 ----- src/components/chat/themes/header.base.scss | 20 - src/components/chat/themes/input.base.scss | 7 - src/components/chat/themes/message.base.scss | 41 +- src/components/chat/themes/reaction.base.scss | 72 --- src/components/chat/types.ts | 21 +- stories/chat.stories.ts | 200 +++------ 17 files changed, 177 insertions(+), 1204 deletions(-) delete mode 100644 src/components/chat/emoji-picker.ts create mode 100644 src/components/chat/markdown-util.ts delete mode 100644 src/components/chat/message-reactions.ts delete mode 100644 src/components/chat/themes/emoji-picker.base.scss delete mode 100644 src/components/chat/themes/reaction.base.scss diff --git a/package-lock.json b/package-lock.json index f3937f189..2c35b9978 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,9 @@ "typedoc-plugin-localization": "^3.0.6", "typescript": "^5.8.3", "vite": "^6.3.4" + }, + "peerDependencies": { + "marked": "^12.0.0" } }, "node_modules/@adobe/css-tools": { @@ -8693,6 +8696,19 @@ "markdown-it": "bin/markdown-it.mjs" } }, + "node_modules/marked": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", + "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/marky": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", diff --git a/package.json b/package.json index 000475ef2..d618928e8 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,9 @@ "typescript": "^5.8.3", "vite": "^6.3.4" }, + "peerDependencies": { + "marked": "^12.0.0" + }, "browserslist": [ "defaults" ], diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index 1c74f9414..27f9955c3 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -7,7 +7,6 @@ import IgcFileInputComponent from '../file-input/file-input.js'; import IgcIconComponent from '../icon/icon.js'; import { registerIconFromText } from '../icon/icon.registry.js'; import IgcTextareaComponent from '../textarea/textarea.js'; -import IgcEmojiPickerComponent from './emoji-picker.js'; import { styles } from './themes/input.base.css.js'; import { type IgcMessageAttachment, @@ -33,7 +32,6 @@ export default class IgcChatInputComponent extends LitElement { IgcTextareaComponent, IgcIconButtonComponent, IgcChipComponent, - IgcEmojiPickerComponent, IgcFileInputComponent, IgcIconComponent ); @@ -42,8 +40,8 @@ export default class IgcChatInputComponent extends LitElement { @property({ type: Boolean, attribute: 'disable-attachments' }) public disableAttachments = false; - @property({ type: Boolean, attribute: 'disable-emojis' }) - public disableEmojis = false; + @property({ type: Boolean }) + public isAiResponding = false; @query('textarea') private textInputElement!: HTMLTextAreaElement; @@ -70,18 +68,6 @@ export default class IgcChatInputComponent extends LitElement { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendMessage(); - } else { - const typingEvent = new CustomEvent('typing-change', { - detail: { isTyping: true }, - }); - this.dispatchEvent(typingEvent); - // wait 3 seconds and dispatch a stop-typing event - setTimeout(() => { - const stopTypingEvent = new CustomEvent('typing-change', { - detail: { isTyping: false }, - }); - this.dispatchEvent(stopTypingEvent); - }, 3000); } } @@ -114,19 +100,6 @@ export default class IgcChatInputComponent extends LitElement { }, 0); } - private addEmoji(e: CustomEvent) { - const emoji = e.detail.emoji; - this.inputValue += emoji; - - // Focus back on input after selecting an emoji - this.updateComplete.then(() => { - const textarea = this.shadowRoot?.querySelector('textarea'); - if (textarea) { - textarea.focus(); - } - }); - } - private handleFileUpload(e: Event) { const input = (e.target as any).input as HTMLInputElement; if (!input.files || input.files.length === 0) return; @@ -178,14 +151,6 @@ export default class IgcChatInputComponent extends LitElement {
- ${this.disableEmojis - ? '' - : html` - - `} - - ${repeat( - groupedMessages, - (group) => group.date, - (group) => html` -
${group.date}
- ${repeat( - group.messages, - (message) => message.id, - (message) => html` - - ` - )} - ` - )} - ${this.typingUsers.filter((u) => u !== this.user).length > 0 - ? html` -
-
-
-
-
+
+
+ ${repeat( + groupedMessages, + (group) => group.date, + (group) => html` +
${group.date}
+ ${repeat( + group.messages, + (message) => message.id, + (message) => html` + + ` + )} ` - : ''} + )} + ${ + this.isAiResponding + ? html` +
+
+
+
+
+ ` + : '' + } +
`; } diff --git a/src/components/chat/chat-message.ts b/src/components/chat/chat-message.ts index 153b19920..36383a4e0 100644 --- a/src/components/chat/chat-message.ts +++ b/src/components/chat/chat-message.ts @@ -1,12 +1,11 @@ import { LitElement, html } from 'lit'; import { property } from 'lit/decorators.js'; -import { ifDefined } from 'lit/directives/if-defined.js'; import IgcAvatarComponent from '../avatar/avatar.js'; import { registerComponent } from '../common/definitions/register.js'; +import { renderMarkdown } from './markdown-util.js'; import { IgcMessageAttachmentsComponent } from './message-attachments.js'; -import { IgcMessageReactionsComponent } from './message-reactions.js'; import { styles } from './themes/message.base.css.js'; -import type { IgcMessage, IgcUser } from './types.js'; +import type { IgcMessage } from './types.js'; /** * @@ -24,7 +23,6 @@ export default class IgcChatMessageComponent extends LitElement { registerComponent( IgcChatMessageComponent, IgcMessageAttachmentsComponent, - IgcMessageReactionsComponent, IgcAvatarComponent ); } @@ -33,108 +31,28 @@ export default class IgcChatMessageComponent extends LitElement { public message: IgcMessage | undefined; @property({ reflect: true, attribute: false }) - public user: IgcUser | undefined; - - @property({ type: Boolean, attribute: 'hide-avatar' }) - public hideAvatar = false; - - @property({ type: Boolean, attribute: 'hide-user-name' }) - public hideUserName = false; - - @property({ type: Boolean, attribute: 'hide-meta-data' }) - public hideMetaData = false; - - @property({ type: Boolean, attribute: 'disable-reactions' }) - public disableReactions = false; + public isResponse = false; private formatTime(date: Date | undefined): string | undefined { return date?.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } - private renderStatusIcon(status: string) { - if (status === 'sent') { - return '✓'; - } - - if (status === 'delivered') { - return '✓✓'; - } - - if (status === 'read') { - return '✓✓'; - } - return ''; - } - - private handleAddReaction(e: CustomEvent) { - const { emojiId } = e.detail; - - this.dispatchEvent( - new CustomEvent('add-reaction', { - detail: { messageId: this.message?.id, emojiId }, - bubbles: true, - composed: true, - }) - ); - } - - private isCurrentUser() { - return this.message?.sender.id === this.user?.id; - } - protected override render() { - const sender = this.message?.sender; - const containerClass = `message-container ${this.isCurrentUser() ? 'sent' : 'received'}`; + const containerClass = `message-container ${!this.isResponse ? 'sent' : ''}`; return html`
- ${this.hideAvatar - ? '' - : html` + ${renderMarkdown(this.message?.text)} +
` + : ''} + ${this.message?.attachments && this.message?.attachments.length > 0 + ? html` - `} - -
- ${this.hideUserName || this.isCurrentUser() - ? '' - : html`${ifDefined(sender?.name)}`} - ${this.message?.text.trim() - ? html`
${this.message?.text}
` - : ''} - ${this.message?.attachments && this.message?.attachments.length > 0 - ? html` - ` - : ''} - ${this.hideMetaData - ? '' - : html` -
- ${this.formatTime(this.message?.timestamp)} - ${this.isCurrentUser() - ? html`${this.renderStatusIcon( - this.message?.status || 'sent' - )}` - : ''} -
- `} -
- ${this.disableReactions - ? '' - : html``} +
` + : ''}
`; } diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index dfe8961a7..c5fbdc182 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -7,16 +7,10 @@ import IgcChatHeaderComponent from './chat-header.js'; import IgcChatInputComponent from './chat-input.js'; import IgcChatMessageListComponent from './chat-message-list.js'; import { styles } from './themes/chat.base.css.js'; -import type { IgcMessage, IgcMessageReaction, IgcUser } from './types.js'; +import type { IgcMessage } from './types.js'; export interface IgcChatComponentEventMap { igcMessageSend: CustomEvent; - igcTypingChange: CustomEvent; -} - -export interface IgcTypingChangeEventArgs { - user: IgcUser; - isTyping: boolean; } /** @@ -43,14 +37,11 @@ export default class IgcChatComponent extends EventEmitterMixin< ); } - @property({ attribute: false }) - public user: IgcUser | undefined; - @property({ reflect: true, attribute: false }) public messages: IgcMessage[] = []; @property({ reflect: true, attribute: false }) - public typingUsers: IgcUser[] = []; + public isAiResponding = false; @property({ type: Boolean, attribute: 'hide-avatar' }) public hideAvatar = false; @@ -58,30 +49,17 @@ export default class IgcChatComponent extends EventEmitterMixin< @property({ type: Boolean, attribute: 'hide-user-name' }) public hideUserName = false; - @property({ type: Boolean, attribute: 'hide-meta-data' }) - public hideMetaData = false; - @property({ type: Boolean, attribute: 'disable-auto-scroll' }) public disableAutoScroll = false; - @property({ type: Boolean, attribute: 'disable-reactions' }) - public disableReactions = false; - @property({ type: Boolean, attribute: 'disable-attachments' }) public disableAttachments = false; - @property({ type: Boolean, attribute: 'disable-emojis' }) - public disableEmojis = false; - @property({ type: String, attribute: 'header-text', reflect: true }) public headerText = ''; public override connectedCallback() { super.connectedCallback(); - this.addEventListener( - 'add-reaction', - this.handleAddReaction as EventListener - ); this.addEventListener( 'message-send', this.handleSendMessage as EventListener @@ -94,115 +72,40 @@ export default class IgcChatComponent extends EventEmitterMixin< 'message-send', this.handleSendMessage as EventListener ); - this.removeEventListener( - 'add-reaction', - this.handleAddReaction as EventListener - ); } private handleSendMessage(e: CustomEvent) { const text = e.detail.text; const attachments = e.detail.attachments || []; - if ((!text.trim() && attachments.length === 0) || !this.user) return; + if (!text.trim() && attachments.length === 0) return; const newMessage: IgcMessage = { id: Date.now().toString(), text, - sender: this.user, + isResponse: false, timestamp: new Date(), - status: 'sent', attachments, - reactions: [], }; this.messages = [...this.messages, newMessage]; this.emitEvent('igcMessageSend', { detail: newMessage }); } - private handleTypingChange(e: CustomEvent) { - const isTyping = e.detail.isTyping; - const user = this.user; - if (!user) return; - const typingArgs = { user, isTyping }; - this.emitEvent('igcTypingChange', { detail: typingArgs }); - } - - private handleAddReaction(e: CustomEvent) { - const { messageId, emojiId } = e.detail; - - if (!messageId) return; - - this.messages = this.messages.map((message) => { - if (message.id === messageId) { - const userReaction = message.reactions?.find( - (r) => this.user && r.users.includes(this.user.id) - ); - - if (userReaction) { - // Remove reaction - message.reactions?.forEach((r) => { - if (r.id === userReaction.id) { - r.users = (r.users ?? []).filter((id) => id !== this.user?.id); - } - }); - - message.reactions = - message.reactions?.filter((r) => r.users.length > 0) || []; - } - - const existingReaction = message.reactions?.find( - (r) => r.id === emojiId - ); - - if (existingReaction && userReaction?.id !== emojiId) { - // Update existing reaction - message.reactions?.forEach((r) => { - if (r.id === emojiId) { - if (this.user) { - r.users.push(this.user.id); - } - } - }); - - message.reactions = [...(message.reactions || [])]; - } - - if (!existingReaction && userReaction?.id !== emojiId) { - // Create new reaction - const newReaction: IgcMessageReaction = { - id: emojiId, - users: this.user ? [this.user.id] : [], - }; - - message.reactions = [...(message.reactions || []), newReaction]; - } - } - - return { ...message }; - }); - } - protected override render() { return html`
`; diff --git a/src/components/chat/emoji-picker.ts b/src/components/chat/emoji-picker.ts deleted file mode 100644 index 7dbfff193..000000000 --- a/src/components/chat/emoji-picker.ts +++ /dev/null @@ -1,418 +0,0 @@ -import { LitElement, html } from 'lit'; -import { property, query, state } from 'lit/decorators.js'; -import IgcButtonComponent from '../button/button.js'; -import IgcIconButtonComponent from '../button/icon-button.js'; -import { addRootClickHandler } from '../common/controllers/root-click.js'; -import { watch } from '../common/decorators/watch.js'; -import { registerComponent } from '../common/definitions/register.js'; -import { registerIconFromText } from '../icon/icon.registry.js'; -import IgcInputComponent from '../input/input.js'; -import IgcPopoverComponent from '../popover/popover.js'; -import { styles } from './themes/emoji-picker.base.css.js'; -import { emojiPickerIcon } from './types.js'; - -export const EMOJI_CATEGORIES = [ - { - name: 'Smileys & Emotion', - icon: '😊', - emojis: [ - { id: 'grinning_face', emoji: '😀', name: 'Grinning Face' }, - { - id: 'grinning_face_with_big_eyes', - emoji: '😃', - name: 'Grinning Face with Big Eyes', - }, - { - id: 'grinning_face_with_smiling_eyes', - emoji: '😄', - name: 'Grinning Face with Smiling Eyes', - }, - { - id: 'beaming_face_with_smiling_eyes', - emoji: '😁', - name: 'Beaming Face with Smiling Eyes', - }, - { - id: 'grinning_squinting_face', - emoji: '😆', - name: 'Grinning Squinting Face', - }, - { - id: 'grinning_face_with_sweat', - emoji: '😅', - name: 'Grinning Face with Sweat', - }, - { - id: 'rolling_on_the_floor_laughing', - emoji: '🤣', - name: 'Rolling on the Floor Laughing', - }, - { - id: 'face_with_tears_of_joy', - emoji: '😂', - name: 'Face with Tears of Joy', - }, - { - id: 'slightly_smiling_face', - emoji: '🙂', - name: 'Slightly Smiling Face', - }, - { id: 'upside_down_face', emoji: '🙃', name: 'Upside-Down Face' }, - { id: 'winking_face', emoji: '😉', name: 'Winking Face' }, - { - id: 'smiling_face_with_smiling_eyes', - emoji: '😊', - name: 'Smiling Face with Smiling Eyes', - }, - { - id: 'smiling_face_with_halo', - emoji: '😇', - name: 'Smiling Face with Halo', - }, - { - id: 'smiling_face_with_heart_eyes', - emoji: '😍', - name: 'Smiling Face with Heart-Eyes', - }, - { - id: 'smiling_face_with_hearts', - emoji: '🥰', - name: 'Smiling Face with Hearts', - }, - { id: 'face_blowing_a_kiss', emoji: '😘', name: 'Face Blowing a Kiss' }, - ], - }, - { - name: 'People & Body', - icon: '👋', - emojis: [ - { id: 'waving_hand', emoji: '👋', name: 'Waving Hand' }, - { id: 'raised_back_of_hand', emoji: '🤚', name: 'Raised Back of Hand' }, - { - id: 'hand_with_fingers_splayed', - emoji: '🖐️', - name: 'Hand with Fingers Splayed', - }, - { id: 'raised_hand', emoji: '✋', name: 'Raised Hand' }, - { id: 'vulcan_salute', emoji: '🖖', name: 'Vulcan Salute' }, - { id: 'ok_hand', emoji: '👌', name: 'OK Hand' }, - { id: 'pinched_fingers', emoji: '🤌', name: 'Pinched Fingers' }, - { id: 'pinching_hand', emoji: '🤏', name: 'Pinching Hand' }, - { id: 'victory_hand', emoji: '✌️', name: 'Victory Hand' }, - { id: 'crossed_fingers', emoji: '🤞', name: 'Crossed Fingers' }, - { id: 'love_you_gesture', emoji: '🤟', name: 'Love-You Gesture' }, - { id: 'sign_of_the_horns', emoji: '🤘', name: 'Sign of the Horns' }, - { id: 'call_me_hand', emoji: '🤙', name: 'Call Me Hand' }, - { - id: 'backhand_index_pointing_left', - emoji: '👈', - name: 'Backhand Index Pointing Left', - }, - { - id: 'backhand_index_pointing_right', - emoji: '👉', - name: 'Backhand Index Pointing Right', - }, - { id: 'thumbs_up', emoji: '👍', name: 'Thumbs Up' }, - ], - }, - { - name: 'Animals & Nature', - icon: '🐶', - emojis: [ - { id: 'dog_face', emoji: '🐶', name: 'Dog Face' }, - { id: 'cat_face', emoji: '🐱', name: 'Cat Face' }, - { id: 'mouse_face', emoji: '🐭', name: 'Mouse Face' }, - { id: 'hamster_face', emoji: '🐹', name: 'Hamster Face' }, - { id: 'rabbit_face', emoji: '🐰', name: 'Rabbit Face' }, - { id: 'fox_face', emoji: '🦊', name: 'Fox Face' }, - { id: 'bear_face', emoji: '🐻', name: 'Bear Face' }, - { id: 'panda_face', emoji: '🐼', name: 'Panda Face' }, - { id: 'koala_face', emoji: '🐨', name: 'Koala Face' }, - { id: 'tiger_face', emoji: '🐯', name: 'Tiger Face' }, - { id: 'lion_face', emoji: '🦁', name: 'Lion Face' }, - { id: 'cow_face', emoji: '🐮', name: 'Cow Face' }, - { id: 'pig_face', emoji: '🐷', name: 'Pig Face' }, - { id: 'frog_face', emoji: '🐸', name: 'Frog Face' }, - { id: 'monkey_face', emoji: '🐵', name: 'Monkey Face' }, - { id: 'chicken', emoji: '🐔', name: 'Chicken' }, - ], - }, - { - name: 'Food & Drink', - icon: '🍔', - emojis: [ - { id: 'red_apple', emoji: '🍎', name: 'Red Apple' }, - { id: 'pear', emoji: '🍐', name: 'Pear' }, - { id: 'orange', emoji: '🍊', name: 'Orange' }, - { id: 'lemon', emoji: '🍋', name: 'Lemon' }, - { id: 'banana', emoji: '🍌', name: 'Banana' }, - { id: 'watermelon', emoji: '🍉', name: 'Watermelon' }, - { id: 'grapes', emoji: '🍇', name: 'Grapes' }, - { id: 'strawberry', emoji: '🍓', name: 'Strawberry' }, - { id: 'blueberries', emoji: '🫐', name: 'Blueberries' }, - { id: 'melon', emoji: '🍈', name: 'Melon' }, - { id: 'cherries', emoji: '🍒', name: 'Cherries' }, - { id: 'peach', emoji: '🍑', name: 'Peach' }, - { id: 'mango', emoji: '🥭', name: 'Mango' }, - { id: 'pineapple', emoji: '🍍', name: 'Pineapple' }, - { id: 'coconut', emoji: '🥥', name: 'Coconut' }, - { id: 'kiwi_fruit', emoji: '🥝', name: 'Kiwi Fruit' }, - ], - }, - { - name: 'Travel & Places', - icon: '🚗', - emojis: [ - { id: 'car', emoji: '🚗', name: 'Car' }, - { id: 'taxi', emoji: '🚕', name: 'Taxi' }, - { - id: 'sport_utility_vehicle', - emoji: '🚙', - name: 'Sport Utility Vehicle', - }, - { id: 'bus', emoji: '🚌', name: 'Bus' }, - { id: 'trolleybus', emoji: '🚎', name: 'Trolleybus' }, - { id: 'racing_car', emoji: '🏎️', name: 'Racing Car' }, - { id: 'police_car', emoji: '🚓', name: 'Police Car' }, - { id: 'ambulance', emoji: '🚑', name: 'Ambulance' }, - { id: 'fire_engine', emoji: '🚒', name: 'Fire Engine' }, - { id: 'minibus', emoji: '🚐', name: 'Minibus' }, - { id: 'pickup_truck', emoji: '🛻', name: 'Pickup Truck' }, - { id: 'delivery_truck', emoji: '🚚', name: 'Delivery Truck' }, - { id: 'articulated_lorry', emoji: '🚛', name: 'Articulated Lorry' }, - { id: 'tractor', emoji: '🚜', name: 'Tractor' }, - { id: 'kick_scooter', emoji: '🛴', name: 'Kick Scooter' }, - { id: 'bicycle', emoji: '🚲', name: 'Bicycle' }, - ], - }, - { - name: 'Activities', - icon: '⚽', - emojis: [ - { id: 'soccer_ball', emoji: '⚽', name: 'Soccer Ball' }, - { id: 'basketball', emoji: '🏀', name: 'Basketball' }, - { id: 'american_football', emoji: '🏈', name: 'American Football' }, - { id: 'baseball', emoji: '⚾', name: 'Baseball' }, - { id: 'softball', emoji: '🥎', name: 'Softball' }, - { id: 'tennis', emoji: '🎾', name: 'Tennis' }, - { id: 'volleyball', emoji: '🏐', name: 'Volleyball' }, - { id: 'rugby_football', emoji: '🏉', name: 'Rugby Football' }, - { id: 'flying_disc', emoji: '🥏', name: 'Flying Disc' }, - { id: 'pool_8_ball', emoji: '🎱', name: 'Pool 8 Ball' }, - { id: 'yo_yo', emoji: '🪀', name: 'Yo-Yo' }, - { id: 'ping_pong', emoji: '🏓', name: 'Ping Pong' }, - { id: 'badminton', emoji: '🏸', name: 'Badminton' }, - { id: 'ice_hockey', emoji: '🏒', name: 'Ice Hockey' }, - { id: 'field_hockey', emoji: '🏑', name: 'Field Hockey' }, - { id: 'lacrosse', emoji: '🥍', name: 'Lacrosse' }, - ], - }, - { - name: 'Objects', - icon: '💡', - emojis: [ - { id: 'watch', emoji: '⌚', name: 'Watch' }, - { id: 'mobile_phone', emoji: '📱', name: 'Mobile Phone' }, - { - id: 'mobile_phone_with_arrow', - emoji: '📲', - name: 'Mobile Phone with Arrow', - }, - { id: 'laptop', emoji: '💻', name: 'Laptop' }, - { id: 'keyboard', emoji: '⌨️', name: 'Keyboard' }, - { id: 'desktop_computer', emoji: '🖥️', name: 'Desktop Computer' }, - { id: 'printer', emoji: '🖨️', name: 'Printer' }, - { id: 'computer_mouse', emoji: '🖱️', name: 'Computer Mouse' }, - { id: 'trackball', emoji: '🖲️', name: 'Trackball' }, - { id: 'joystick', emoji: '🕹️', name: 'Joystick' }, - { id: 'clamp', emoji: '🗜️', name: 'Clamp' }, - { id: 'computer_disk', emoji: '💽', name: 'Computer Disk' }, - { id: 'floppy_disk', emoji: '💾', name: 'Floppy Disk' }, - { id: 'optical_disk', emoji: '💿', name: 'Optical Disk' }, - { id: 'dvd', emoji: '📀', name: 'DVD' }, - { id: 'videocassette', emoji: '📼', name: 'Videocassette' }, - ], - }, - { - name: 'Symbols', - icon: '❤️', - emojis: [ - { id: 'red_heart', emoji: '❤️', name: 'Red Heart' }, - { id: 'orange_heart', emoji: '🧡', name: 'Orange Heart' }, - { id: 'yellow_heart', emoji: '💛', name: 'Yellow Heart' }, - { id: 'green_heart', emoji: '💚', name: 'Green Heart' }, - { id: 'blue_heart', emoji: '💙', name: 'Blue Heart' }, - { id: 'purple_heart', emoji: '💜', name: 'Purple Heart' }, - { id: 'black_heart', emoji: '🖤', name: 'Black Heart' }, - { id: 'white_heart', emoji: '🤍', name: 'White Heart' }, - { id: 'brown_heart', emoji: '🤎', name: 'Brown Heart' }, - { id: 'broken_heart', emoji: '💔', name: 'Broken Heart' }, - { id: 'heart_exclamation', emoji: '❣️', name: 'Heart Exclamation' }, - { id: 'two_hearts', emoji: '💕', name: 'Two Hearts' }, - { id: 'revolving_hearts', emoji: '💞', name: 'Revolving Hearts' }, - { id: 'beating_heart', emoji: '💓', name: 'Beating Heart' }, - { id: 'growing_heart', emoji: '💗', name: 'Growing Heart' }, - { id: 'sparkling_heart', emoji: '💖', name: 'Sparkling Heart' }, - ], - }, -]; - -/** - * - * @element igc-emoji-picker - * - */ -export default class IgcEmojiPickerComponent extends LitElement { - /** @private */ - public static readonly tagName = 'igc-emoji-picker'; - - public static override styles = styles; - - protected _rootClickController = addRootClickHandler(this); - - /* blazorSuppress */ - public static register() { - registerComponent( - IgcEmojiPickerComponent, - IgcPopoverComponent, - IgcIconButtonComponent, - IgcButtonComponent, - IgcInputComponent - ); - } - - /** - * Sets the open state of the component. - * @attr - */ - @property({ type: Boolean, reflect: true }) - public open = false; - - @state() - private _target?: HTMLElement; - - @query('slot[name="target"]', true) - protected trigger!: HTMLSlotElement; - - @state() - private _activeCategory = 0; - - @watch('open', { waitUntilFirstUpdate: true }) - protected openStateChange() { - this._rootClickController.update(); - - if (!this.open) { - this._target = undefined; - this._rootClickController.update({ target: undefined }); - } - } - - constructor() { - super(); - this._rootClickController.update({ hideCallback: this.handleClosing }); - registerIconFromText('target', emojiPickerIcon, 'material'); - } - - protected handleClosing() { - this.hide(); - } - - public async hide(): Promise { - if (!this.open) { - return false; - } - - this.open = false; - - return true; - } - - protected handleAnchorClick() { - this.open = !this.open; - } - - private handleCategoryChange(index: number) { - this._activeCategory = index; - } - - private handleEmojiClick(emojiId: string, emoji: string) { - this.dispatchEvent( - new CustomEvent('emoji-selected', { - detail: { emojiId, emoji }, - bubbles: true, - composed: true, - }) - ); - this.hide(); - } - - private getFilteredEmojis() { - return EMOJI_CATEGORIES[this._activeCategory].emojis; - } - - protected override render() { - const filteredEmojis = this.getFilteredEmojis(); - - return html` - -
e.stopPropagation()} - > -
- ${EMOJI_CATEGORIES.map( - (category, index) => html` - this.handleCategoryChange(index)} - title=${category.name} - > - ${category.icon} - - ` - )} -
- -
- ${filteredEmojis.map( - (emoji) => html` - this.handleEmojiClick(emoji.id, emoji.emoji)}> - ${emoji.emoji} - - ` - )} - ${filteredEmojis.length === 0 - ? html`
- No emojis found -
` - : ''} -
-
-
`; - } -} - -declare global { - interface HTMLElementTagNameMap { - 'igc-chat-igc-emoji-picker': IgcEmojiPickerComponent; - } -} diff --git a/src/components/chat/markdown-util.ts b/src/components/chat/markdown-util.ts new file mode 100644 index 000000000..d46c50513 --- /dev/null +++ b/src/components/chat/markdown-util.ts @@ -0,0 +1,32 @@ +import { type TemplateResult, html } from 'lit'; +import { marked } from 'marked'; + +marked.setOptions({ + gfm: true, + breaks: true, + sanitize: true, + smartLists: true, + smartypants: true, +}); + +const renderer = new marked.Renderer(); + +// Customize code rendering +renderer.code = (code, language) => { + return `
${code}
`; +}; + +// Customize link rendering +renderer.link = (href, title, text) => { + return `${text}`; +}; + +export function renderMarkdown(text: string): TemplateResult { + if (!text) return html``; + + const rendered = marked(text, { renderer }); + const template = document.createElement('template'); + template.innerHTML = rendered; + + return html`${template.content}`; +} diff --git a/src/components/chat/message-attachments.ts b/src/components/chat/message-attachments.ts index 63ad07d19..0ed15f830 100644 --- a/src/components/chat/message-attachments.ts +++ b/src/components/chat/message-attachments.ts @@ -110,6 +110,6 @@ export class IgcMessageAttachmentsComponent extends LitElement { declare global { interface HTMLElementTagNameMap { - 'igc-message-attachmants': IgcMessageAttachmentsComponent; + 'igc-message-attachments': IgcMessageAttachmentsComponent; } } diff --git a/src/components/chat/message-reactions.ts b/src/components/chat/message-reactions.ts deleted file mode 100644 index 1e6665ba0..000000000 --- a/src/components/chat/message-reactions.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { LitElement, html } from 'lit'; -import { property } from 'lit/decorators.js'; -import IgcButtonComponent from '../button/button.js'; -import IgcIconButtonComponent from '../button/icon-button.js'; -import { registerComponent } from '../common/definitions/register.js'; -import IgcEmojiPickerComponent, { EMOJI_CATEGORIES } from './emoji-picker.js'; -import { styles } from './themes/reaction.base.css'; -import type { IgcMessageReaction } from './types.js'; - -/** - * - * @element igc-message-reactions - * - */ -export class IgcMessageReactionsComponent extends LitElement { - /** @private */ - public static readonly tagName = 'igc-message-reactions'; - - public static override styles = styles; - - /* blazorSuppress */ - public static register() { - registerComponent( - IgcMessageReactionsComponent, - IgcButtonComponent, - IgcIconButtonComponent, - IgcEmojiPickerComponent - ); - } - - @property({ type: Array }) - reactions: IgcMessageReaction[] = []; - - @property({ type: String }) - messageId = ''; - - @property({ type: String }) - currentUserId = ''; - - public override connectedCallback() { - super.connectedCallback(); - } - - public override disconnectedCallback() { - super.disconnectedCallback(); - } - - private addEmoji(e: CustomEvent) { - const { emojiId } = e.detail; - this.toggleReaction(emojiId); - } - - private hasUserReacted(reaction: IgcMessageReaction): boolean { - return reaction.users.includes(this.currentUserId); - } - - private toggleReaction(emojiId: string) { - this.dispatchEvent( - new CustomEvent('add-reaction', { - detail: { emojiId }, - bubbles: true, - composed: true, - }) - ); - } - - private getReactionById(reaction: IgcMessageReaction) { - for (const category of EMOJI_CATEGORIES) { - const foundReaction = category.emojis.find( - (emoji) => emoji.id === reaction.id - ); - if (foundReaction) { - return { - id: foundReaction.id, - emoji: foundReaction.emoji, - count: reaction.users.length, - users: reaction.users, - }; - } - } - return undefined; - } - - protected override render() { - return html` -
- ${this.reactions?.map((_reaction) => { - const reaction = this.getReactionById(_reaction); - return reaction - ? html` - this.toggleReaction(reaction.id)} - > - ${reaction.emoji} - ${reaction.count} - - ` - : html``; - })} - - -
- `; - } -} - -declare global { - interface HTMLElementTagNameMap { - 'igc-message-reactions': IgcMessageReactionsComponent; - } -} diff --git a/src/components/chat/themes/emoji-picker.base.scss b/src/components/chat/themes/emoji-picker.base.scss deleted file mode 100644 index c74ddd036..000000000 --- a/src/components/chat/themes/emoji-picker.base.scss +++ /dev/null @@ -1,99 +0,0 @@ -:host { - display: block; - } - - .emoji-picker-container { - width: 250px; - max-width: 100vw; - background-color: white; - border-radius: 0.5rem; - box-shadow: 0 4px 6px -1px #292929, 0 2px 4px -1px #161616; - overflow: hidden; - display: flex; - flex-direction: column; - } - - .emoji-categories { - display: flex; - padding: 0.5rem; - border-bottom: 1px solid #bdbcbc; - overflow-x: auto; - scrollbar-width: thin; - } - - .emoji-categories::-webkit-scrollbar { - display: none; - } - - .category-button { - background: transparent; - border: none; - font-size: 1.25rem; - width: 2rem; - height: 2rem; - border-radius: 0.25rem; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - flex-shrink: 0; - margin-right: 0.25rem; - padding: 0; - - igc-button::part(base){ - display: none; - } - } - - .category-button.active { - background-color: #cfcfcf; - } - - .category-button:hover { - background-color: #cfcfcf; - } - - .emoji-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 0.25rem; - padding: 0.5rem; - height: 150px; - overflow: auto; - } - - .emoji-button { - font-size: 1.5rem; - background: none; - border: none; - cursor: pointer; - border-radius: 0.25rem; - display: flex; - align-items: center; - justify-content: center; - height: 2rem; - transition: transform 0.1s; - } - - .emoji-button:hover { - background-color: #cfcfcf; - transform: scale(1.1); - } - - .emoji-search { - padding: 0.5rem; - border-bottom: 1px solid #a0a0a0; - } - - .search-input { - width: 100%; - padding: 0.5rem; - border-radius: 0.25rem; - border: 1px solid #85878a; - font-size: 0.875rem; - } - - .search-input:focus { - outline: none; - border-color:#0A84FF; - } diff --git a/src/components/chat/themes/header.base.scss b/src/components/chat/themes/header.base.scss index bf5e2b686..e27ffd189 100644 --- a/src/components/chat/themes/header.base.scss +++ b/src/components/chat/themes/header.base.scss @@ -32,21 +32,6 @@ position: relative; } - .status-indicator { - position: absolute; - bottom: 0; - right: 0; - width: 12px; - height: 12px; - border-radius: 50%; - background-color: #30D158; - border: 2px solid white; - } - - .status-indicator.offline { - background-color: #AEAEB2; - } - .user-details { display: flex; flex-direction: column; @@ -58,11 +43,6 @@ color: #1C1C1E; } - .user-status { - font-size: 0.8rem; - color: #636366; - } - .actions { display: flex; gap: 16px; diff --git a/src/components/chat/themes/input.base.scss b/src/components/chat/themes/input.base.scss index d7f45ea67..525839058 100644 --- a/src/components/chat/themes/input.base.scss +++ b/src/components/chat/themes/input.base.scss @@ -118,10 +118,3 @@ igc-file-input::part(file-names){ cursor: not-allowed; } -.emoji-picker-container { - position: absolute; - right: 20px; - margin-bottom: 0.5rem; - z-index: 10; -} - diff --git a/src/components/chat/themes/message.base.scss b/src/components/chat/themes/message.base.scss index b5ae43ae1..d5028a328 100644 --- a/src/components/chat/themes/message.base.scss +++ b/src/components/chat/themes/message.base.scss @@ -33,10 +33,19 @@ opacity: 1; } - .message-content { - display: flex; - flex-direction: column; - max-width: var(--message-max-width); + .message-container pre { + background-color: rgba(0, 0, 0, 0.05); + padding: 8px; + border-radius: 4px; + overflow-x: auto; + margin: 8px 0; + } + + .message-container code { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 0.9em; + padding: 2px 4px; + border-radius: 4px; } .bubble { @@ -77,30 +86,6 @@ margin-right: 4px; } - .status { - display: flex; - align-items: center; - } - - .status-icon { - width: 16px; - height: 16px; - } - - .reaction { - position: absolute; - bottom: -10px; - right: 8px; - background-color: white; - border-radius: 10px; - padding: 2px 5px; - font-size: 0.8rem; - box-shadow: 0 1px 2px #1f1f1f; - display: flex; - align-items: center; - z-index: 1; - } - @keyframes fade-in { from { opacity: 0; diff --git a/src/components/chat/themes/reaction.base.scss b/src/components/chat/themes/reaction.base.scss deleted file mode 100644 index f2f5275dc..000000000 --- a/src/components/chat/themes/reaction.base.scss +++ /dev/null @@ -1,72 +0,0 @@ -@use 'styles/common/component'; -@use 'styles/utilities' as *; - -:host { - display: block; - margin-top: 0.25rem; - align-self: center; -} - -.reactions-container { - display: flex; - gap: 0.25rem; -} - -.reaction-button { - display: inline-flex; - align-items: center; - padding: 0.25rem 0.375rem; - border-radius: 1rem; - font-size: 0.875rem; - cursor: pointer; - transition: all 0.2s; - border: 1px solid transparent; -} - -.reaction-button.active { - background-color: transparent; - color: white; -} - -.reaction-button:hover { - background-color: transparent; -} - -.reaction-button.active:hover { - background-color: transparent; - opacity: 0.9; -} - -.emoji { - margin-right: 0.25rem; -} - -.count { - font-size: 0.75rem; -} - -.add-reaction { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0.25rem; - background-color: transparent; - border-radius: 1rem; - font-size: 0.75rem; - cursor: pointer; - border: 1px dashed #7b7b7e; - color: #5b5b5c; - height: 1.5rem; - width: 1.5rem; -} - -.add-reaction:hover { - background-color: #b8b8b9; - color: #636366; -} - -.emoji-picker-container { - position: absolute; - margin-top: 0.25rem; - z-index: 10; -} \ No newline at end of file diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts index 787cfa402..f8b624554 100644 --- a/src/components/chat/types.ts +++ b/src/components/chat/types.ts @@ -1,23 +1,11 @@ -export type IgcMessageStatusType = 'sent' | 'delivered' | 'read'; export type IgcMessageAttachmentType = 'image' | 'file'; -export interface IgcUser { - id: string; - name: string; - avatar: string; - status?: any; - //isTyping?: boolean; -} - export interface IgcMessage { id: string; text: string; - sender: IgcUser; + isResponse: boolean; timestamp: Date; - chatId?: string; - status?: IgcMessageStatusType; attachments?: IgcMessageAttachment[]; - reactions?: IgcMessageReaction[]; } export interface IgcMessageAttachment { @@ -29,13 +17,6 @@ export interface IgcMessageAttachment { thumbnail?: string; } -export interface IgcMessageReaction { - id: string; - users: string[]; -} - -export const emojiPickerIcon = - ''; export const attachmentIcon = ''; export const sendButtonIcon = diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 8e7edfd03..0d0e35dd5 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -21,31 +21,16 @@ const metadata: Meta = { control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, - hideMetaData: { - type: 'boolean', - control: 'boolean', - table: { defaultValue: { summary: 'false' } }, - }, disableAutoScroll: { type: 'boolean', control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, - disableReactions: { - type: 'boolean', - control: 'boolean', - table: { defaultValue: { summary: 'false' } }, - }, disableAttachments: { type: 'boolean', control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, - disableEmojis: { - type: 'boolean', - control: 'boolean', - table: { defaultValue: { summary: 'false' } }, - }, headerText: { type: 'string', control: 'text', @@ -55,11 +40,8 @@ const metadata: Meta = { args: { hideAvatar: false, hideUserName: false, - hideMetaData: false, disableAutoScroll: false, - disableReactions: false, disableAttachments: false, - disableEmojis: false, headerText: '', }, }; @@ -69,155 +51,87 @@ export default metadata; interface IgcChatArgs { hideAvatar: boolean; hideUserName: boolean; - hideMetaData: boolean; disableAutoScroll: boolean; - disableReactions: boolean; disableAttachments: boolean; - disableEmojis: boolean; headerText: string; } type Story = StoryObj; // endregion -const userJohn: any = { - id: 'user1', - name: 'John', - avatar: 'https://www.infragistics.com/angular-demos/assets/images/men/1.jpg', -}; - -const userRichard: any = { - id: 'user2', - name: 'Richard', - avatar: 'https://www.infragistics.com/angular-demos/assets/images/men/2.jpg', -}; - -const userSam: any = { - id: 'user3', - name: 'Sam', - avatar: 'https://www.infragistics.com/angular-demos/assets/images/men/3.jpg', -}; - const messages: any[] = [ { id: '1', - text: 'Hey there! How are you doing today?', - sender: userRichard, - timestamp: new Date(2025, 4, 5), - status: 'read', - }, - { - id: '2', - text: "I'm doing well, thanks for asking! How about you?", - sender: userJohn, - timestamp: new Date(Date.now() - 3500000), - status: 'read', - }, - { - id: '3', - text: 'Pretty good! I was wondering if you wanted to grab coffee sometime this week?', - sender: userRichard, - timestamp: new Date(Date.now() - 3400000), - status: 'read', - reactions: [ - { - id: 'red_heart', - users: ['user3'], - }, - ], - }, - { - id: '4', - text: 'Hi guys! I just joined the chat.', - sender: userSam, - timestamp: new Date(Date.now() - 3300000), - status: 'read', - attachments: [ - { - id: 'men3_img', - type: 'image', - url: 'https://www.infragistics.com/angular-demos/assets/images/men/3.jpg', - name: 'men3.png', - }, - ], + text: "Hello! I'm an AI assistant created with Lit. How can I help you today?", + isResponse: true, + timestamp: new Date(), }, ]; +function handleMessageSend(e: CustomEvent) { + const newMessage = e.detail; + messages.push(newMessage); + const chat = document.querySelector('igc-chat'); + if (!chat) { + return; + } + + chat.messages = [...messages]; + + chat.isAiResponding = true; + setTimeout(() => { + const aiResponse = { + id: (Date.now() + 1).toString(), + text: generateAIResponse(e.detail.text), + isResponse: true, + timestamp: new Date(), + }; + + chat.isAiResponding = false; + chat.messages = [...messages, aiResponse]; + messages.push(aiResponse); + }, 1500); +} + +function generateAIResponse(message: string): string { + const lowerMessage = message.toLowerCase(); + + if (lowerMessage.includes('hello') || lowerMessage.includes('hi')) { + return 'Hello there! How can I assist you today?'; + } + if (lowerMessage.includes('help')) { + return "I'm here to help! What would you like to know about?"; + } + if (lowerMessage.includes('feature') || lowerMessage.includes('can you')) { + return "As a demo AI, I can simulate conversations, display markdown formatting like **bold** and `code`, and adapt to light and dark themes. I'm built with Lit as a web component that can be integrated into any application."; + } + if (lowerMessage.includes('weather')) { + return "I'm sorry, I don't have access to real-time weather data. This is just a demonstration of a chat interface."; + } + if (lowerMessage.includes('thank')) { + return "You're welcome! Let me know if you need anything else."; + } + if (lowerMessage.includes('code') || lowerMessage.includes('example')) { + return "Here's an example of code formatting:\n\n```javascript\nfunction greet(name) {\n return `Hello, ${name}!`;\n}\n\nconsole.log(greet('world'));\n```"; + } + if (lowerMessage.includes('list') || lowerMessage.includes('items')) { + return "Here's an example of a list:\n\n- First item\n- Second item\n- Third item with **bold text**"; + } + + return 'Thanks for your message. This is a demonstration of a chat interface built with Lit components. Feel free to ask about features or try different types of messages!'; +} + export const Basic: Story = { render: (args) => html` `, }; - -function handleMessageEntered(e: CustomEvent) { - const newMessage = e.detail; - messages.push(newMessage); - const chatElements = document.querySelectorAll('igc-chat'); - chatElements.forEach((chat) => { - chat.messages = [...messages]; - }); -} - -function handleTypingChange(e: CustomEvent) { - const user = e.detail.user; - const isTyping = e.detail.isTyping; - const chatElements = document.querySelectorAll('igc-chat'); - chatElements.forEach((chat) => { - if (!isTyping) { - chat.typingUsers = chat.typingUsers.filter((u) => u !== user); - } else if (isTyping && !chat.typingUsers.includes(user)) { - chat.typingUsers = [...chat.typingUsers, user]; - } - }); -} - -export const SideBySide: Story = { - render: (args) => html` -
- - - - -
- `, -}; From 590ab0b1b5bcc816fe533f5b819115d67ef98f7d Mon Sep 17 00:00:00 2001 From: teodosiah Date: Thu, 15 May 2025 12:23:10 +0300 Subject: [PATCH 017/252] feat(chat): expose attachment header click event --- src/components/chat/chat.ts | 21 ++++++++++++++++++++- src/components/chat/message-attachments.ts | 19 ++++++++++++++++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index df4ec65ac..6593045ce 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -7,11 +7,17 @@ import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; import IgcChatInputComponent from './chat-input.js'; import IgcChatMessageListComponent from './chat-message-list.js'; import { styles } from './themes/chat.base.css.js'; -import type { IgcMessage, IgcMessageReaction, IgcUser } from './types.js'; +import type { + IgcMessage, + IgcMessageAttachment, + IgcMessageReaction, + IgcUser, +} from './types.js'; export interface IgcChatComponentEventMap { igcMessageSend: CustomEvent; igcTypingChange: CustomEvent; + igcAttachmentClick: CustomEvent; } export interface IgcTypingChangeEventArgs { @@ -86,6 +92,10 @@ export default class IgcChatComponent extends EventEmitterMixin< 'message-send', this.handleSendMessage as EventListener ); + this.addEventListener( + 'attachment-click', + this.handleAttachmentClick as EventListener + ); } public override disconnectedCallback() { @@ -98,6 +108,10 @@ export default class IgcChatComponent extends EventEmitterMixin< 'add-reaction', this.handleAddReaction as EventListener ); + this.removeEventListener( + 'attachment-click', + this.handleAttachmentClick as EventListener + ); } private handleSendMessage(e: CustomEvent) { @@ -183,6 +197,11 @@ export default class IgcChatComponent extends EventEmitterMixin< }); } + private handleAttachmentClick(e: CustomEvent) { + const attachmentArgs = e.detail.attachment; + this.emitEvent('igcAttachmentClick', { detail: attachmentArgs }); + } + protected override render() { return html`
diff --git a/src/components/chat/message-attachments.ts b/src/components/chat/message-attachments.ts index 809cb395f..58f7dbaef 100644 --- a/src/components/chat/message-attachments.ts +++ b/src/components/chat/message-attachments.ts @@ -64,10 +64,21 @@ export class IgcMessageAttachmentsComponent extends LitElement { this.previewImage = ''; } - private preventToggle(e: CustomEvent) { + private handleToggle(e: CustomEvent, attachment: IgcMessageAttachment) { + this.handleAttachmentClick(attachment); e.preventDefault(); } + private handleAttachmentClick(attachment: IgcMessageAttachment) { + this.dispatchEvent( + new CustomEvent('attachment-click', { + detail: { attachment }, + bubbles: true, + composed: true, + }) + ); + } + protected override render() { return html`
@@ -76,8 +87,10 @@ export class IgcMessageAttachmentsComponent extends LitElement { + this.handleToggle(ev, attachment)} + @igcOpening=${(ev: CustomEvent) => + this.handleToggle(ev, attachment)} >
From 88b7b912661e1c7f57b591edf4206211685d2e97 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Thu, 15 May 2025 12:30:20 +0300 Subject: [PATCH 018/252] feat(chat): fix lint error --- src/components/chat/chat.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index 6593045ce..b8565d120 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -211,7 +211,7 @@ export default class IgcChatComponent extends EventEmitterMixin< ${this.headerText}
- ⋯ +
Date: Thu, 15 May 2025 12:35:58 +0300 Subject: [PATCH 019/252] feat(chat): fix lint error in message scss file --- src/components/chat/themes/message.base.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/chat/themes/message.base.scss b/src/components/chat/themes/message.base.scss index b5ae43ae1..42a6c7956 100644 --- a/src/components/chat/themes/message.base.scss +++ b/src/components/chat/themes/message.base.scss @@ -44,7 +44,7 @@ border-radius: 18px; background-color: #E5E5EA; color: black; - word-break: break-word; + word-break: break-all; font-weight: 400; line-height: 1.4; position: relative; From e53af115aa5fcf27a43f8ee0cdbe3b2589ede556 Mon Sep 17 00:00:00 2001 From: Teodosia Hristodorova <52423497+teodosiah@users.noreply.github.com> Date: Thu, 15 May 2025 12:57:02 +0300 Subject: [PATCH 020/252] Update src/components/chat/chat-input.ts Co-authored-by: Galina Edinakova --- src/components/chat/chat-input.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index 27f9955c3..2a33abfe2 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -16,7 +16,7 @@ import { /** * - * @element igc-chat + * @element igc-chat-input * */ export default class IgcChatInputComponent extends LitElement { From 64e7b9dfb64457b1cbef465e30313647203febe4 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Thu, 15 May 2025 13:19:33 +0300 Subject: [PATCH 021/252] Merge branch 'dmdimitrov/chat-ai-component' of https://github.com/IgniteUI/igniteui-webcomponents into dmdimitrov/chat-ai-component From 6bfe4998eaffb914320965d84156465e647a3d26 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Fri, 16 May 2025 13:57:40 +0300 Subject: [PATCH 022/252] feat(chat): add attachments templates --- src/components/chat/chat-message-list.ts | 18 ++- src/components/chat/chat-message.ts | 20 ++- src/components/chat/chat.ts | 22 ++- src/components/chat/message-attachments.ts | 149 ++++++++++++--------- src/components/chat/types.ts | 6 +- 5 files changed, 148 insertions(+), 67 deletions(-) diff --git a/src/components/chat/chat-message-list.ts b/src/components/chat/chat-message-list.ts index 09b773eeb..e4ca3dbea 100644 --- a/src/components/chat/chat-message-list.ts +++ b/src/components/chat/chat-message-list.ts @@ -4,7 +4,7 @@ import { repeat } from 'lit/directives/repeat.js'; import { registerComponent } from '../common/definitions/register.js'; import IgcChatMessageComponent from './chat-message.js'; import { styles } from './themes/message-list.base.css.js'; -import type { IgcMessage } from './types.js'; +import type { AttachmentTemplate, IgcMessage } from './types.js'; /** * @@ -31,6 +31,18 @@ export default class IgcChatMessageListComponent extends LitElement { @property({ type: Boolean, attribute: 'disable-auto-scroll' }) public disableAutoScroll = false; + @property({ type: Function }) + public attachmentTemplate?: AttachmentTemplate; + + @property({ type: Function }) + public attachmentHeaderTemplate?: AttachmentTemplate; + + @property({ type: Function }) + public attachmentActionsTemplate?: AttachmentTemplate; + + @property({ type: Function }) + public attachmentContentTemplate?: AttachmentTemplate; + private formatDate(date: Date): string { const today = new Date(); const yesterday = new Date(today); @@ -109,6 +121,10 @@ export default class IgcChatMessageListComponent extends LitElement { ` )} diff --git a/src/components/chat/chat-message.ts b/src/components/chat/chat-message.ts index 36383a4e0..2a43a6345 100644 --- a/src/components/chat/chat-message.ts +++ b/src/components/chat/chat-message.ts @@ -5,7 +5,7 @@ import { registerComponent } from '../common/definitions/register.js'; import { renderMarkdown } from './markdown-util.js'; import { IgcMessageAttachmentsComponent } from './message-attachments.js'; import { styles } from './themes/message.base.css.js'; -import type { IgcMessage } from './types.js'; +import type { AttachmentTemplate, IgcMessage } from './types.js'; /** * @@ -33,9 +33,17 @@ export default class IgcChatMessageComponent extends LitElement { @property({ reflect: true, attribute: false }) public isResponse = false; - private formatTime(date: Date | undefined): string | undefined { - return date?.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } + @property({ type: Function }) + public attachmentTemplate?: AttachmentTemplate; + + @property({ type: Function }) + public attachmentHeaderTemplate?: AttachmentTemplate; + + @property({ type: Function }) + public attachmentActionsTemplate?: AttachmentTemplate; + + @property({ type: Function }) + public attachmentContentTemplate?: AttachmentTemplate; protected override render() { const containerClass = `message-container ${!this.isResponse ? 'sent' : ''}`; @@ -50,6 +58,10 @@ export default class IgcChatMessageComponent extends LitElement { ${this.message?.attachments && this.message?.attachments.length > 0 ? html` ` : ''} diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index 6830bb8ae..021fdfe6e 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -7,7 +7,11 @@ import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; import IgcChatInputComponent from './chat-input.js'; import IgcChatMessageListComponent from './chat-message-list.js'; import { styles } from './themes/chat.base.css.js'; -import type { IgcMessage, IgcMessageAttachment } from './types.js'; +import type { + AttachmentTemplate, + IgcMessage, + IgcMessageAttachment, +} from './types.js'; export interface IgcChatComponentEventMap { igcMessageSend: CustomEvent; @@ -59,6 +63,18 @@ export default class IgcChatComponent extends EventEmitterMixin< @property({ type: String, attribute: 'header-text', reflect: true }) public headerText = ''; + @property({ type: Function }) + public attachmentTemplate?: AttachmentTemplate; + + @property({ type: Function }) + public attachmentHeaderTemplate?: AttachmentTemplate; + + @property({ type: Function }) + public attachmentActionsTemplate?: AttachmentTemplate; + + @property({ type: Function }) + public attachmentContentTemplate?: AttachmentTemplate; + public override connectedCallback() { super.connectedCallback(); this.addEventListener( @@ -122,6 +138,10 @@ export default class IgcChatComponent extends EventEmitterMixin< .messages=${this.messages} .disableAutoScroll=${this.disableAutoScroll} .isAiResponding=${this.isAiResponding} + .attachmentTemplate=${this.attachmentTemplate} + .attachmentHeaderTemplate=${this.attachmentHeaderTemplate} + .attachmentActionsTemplate=${this.attachmentActionsTemplate} + .attachmentContentTemplate=${this.attachmentContentTemplate} > - ${this.attachments.map( - (attachment) => html` - - this.handleToggle(ev, attachment)} - @igcOpening=${(ev: CustomEvent) => - this.handleToggle(ev, attachment)} - > -
-
- ${attachment.type === 'image' - ? html`` - : html``} - ${attachment.name} -
-
- ${attachment.type === 'image' - ? html` this.openImagePreview(attachment.url)} - >` - : ''} - -
-
- - ${attachment.type === 'image' - ? html` ${attachment.name}` - : ''} -
- ` - )} + ${this.attachmentTemplate + ? this.attachmentTemplate(this.attachments) + : html` ${this.attachments.map( + (attachment) => + html` + this.handleToggle(ev, attachment)} + @igcOpening=${(ev: CustomEvent) => + this.handleToggle(ev, attachment)} + > +
+
+ ${this.attachmentHeaderTemplate + ? this.attachmentHeaderTemplate(this.attachments) + : html` + + ${attachment.type === 'image' + ? html`` + : html``} + + + ${attachment.name} + + `} +
+
+ ${this.attachmentActionsTemplate + ? this.attachmentActionsTemplate(this.attachments) + : html` + + ${attachment.type === 'image' + ? html` + this.openImagePreview(attachment.url)} + >` + : ''} + + + `} +
+
+ + ${this.attachmentContentTemplate + ? this.attachmentContentTemplate(this.attachments) + : html` + + ${attachment.type === 'image' + ? html` ${attachment.name}` + : ''} + + `} +
` + )}`}
${this.previewImage diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts index ed2179fe5..c7beff699 100644 --- a/src/components/chat/types.ts +++ b/src/components/chat/types.ts @@ -1,3 +1,5 @@ +import type { TemplateResult } from 'lit'; + export type IgcMessageAttachmentType = 'image' | 'file'; export interface IgcMessage { @@ -16,7 +18,9 @@ export interface IgcMessageAttachment { size?: number; thumbnail?: string; } - +export type AttachmentTemplate = ( + attachments: IgcMessageAttachment[] +) => TemplateResult; export const attachmentIcon = ''; export const sendButtonIcon = From d5353c3986485b2561980cf164e52ee8b3e2f073 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Fri, 16 May 2025 14:22:57 +0300 Subject: [PATCH 023/252] feat(chat): modify messages styling --- src/components/chat/chat-message.ts | 6 ++---- src/components/chat/themes/message.base.scss | 13 ++++--------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/components/chat/chat-message.ts b/src/components/chat/chat-message.ts index 2a43a6345..dff025630 100644 --- a/src/components/chat/chat-message.ts +++ b/src/components/chat/chat-message.ts @@ -46,14 +46,12 @@ export default class IgcChatMessageComponent extends LitElement { public attachmentContentTemplate?: AttachmentTemplate; protected override render() { - const containerClass = `message-container ${!this.isResponse ? 'sent' : ''}`; + const containerClass = `message-container ${!this.isResponse ? 'sent' : ''} bubble`; return html`
${this.message?.text.trim() - ? html`
- ${renderMarkdown(this.message?.text)} -
` + ? html`
${renderMarkdown(this.message?.text)}
` : ''} ${this.message?.attachments && this.message?.attachments.length > 0 ? html` Date: Fri, 16 May 2025 14:52:28 +0300 Subject: [PATCH 024/252] feat(chat-ai): add support for streaming response parts --- src/components/chat/chat-input.ts | 8 +++-- src/components/chat/chat-message.ts | 4 --- src/components/chat/chat.ts | 33 ++++++++++++++++++++ stories/chat.stories.ts | 48 +++++++++++++++++++++-------- 4 files changed, 73 insertions(+), 20 deletions(-) diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index 2a33abfe2..b1259363d 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -67,7 +67,9 @@ export default class IgcChatInputComponent extends LitElement { private handleKeyDown(e: KeyboardEvent) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - this.sendMessage(); + if (!this.isAiResponding) { + this.sendMessage(); + } } } @@ -156,8 +158,8 @@ export default class IgcChatInputComponent extends LitElement { collection="material" variant="contained" class="small" - ?disabled=${!this.inputValue.trim() && - this.attachments.length === 0} + ?disabled=${this.isAiResponding || + (!this.inputValue.trim() && this.attachments.length === 0)} @click=${this.sendMessage} >
diff --git a/src/components/chat/chat-message.ts b/src/components/chat/chat-message.ts index 36383a4e0..6fed4799f 100644 --- a/src/components/chat/chat-message.ts +++ b/src/components/chat/chat-message.ts @@ -33,10 +33,6 @@ export default class IgcChatMessageComponent extends LitElement { @property({ reflect: true, attribute: false }) public isResponse = false; - private formatTime(date: Date | undefined): string | undefined { - return date?.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } - protected override render() { const containerClass = `message-container ${!this.isResponse ? 'sent' : ''}`; diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index 6830bb8ae..2ae15a7be 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -83,6 +83,39 @@ export default class IgcChatComponent extends EventEmitterMixin< ); } + /** Starts the response */ + public startResponse() { + this.isAiResponding = true; + } + + /** Show response part. */ + public showResponsePart(part: string) { + if (!this.isAiResponding) { + return false; + } + + let responseMessage = this.messages[this.messages.length - 1]; + responseMessage = { + ...responseMessage, + text: `${responseMessage.text} ${part}`, + }; + this.messages[this.messages.length - 1] = responseMessage; + this.messages = [...this.messages]; + + return true; + } + + /** Ends the response */ + public endResponse(attachments?: IgcMessageAttachment[]) { + this.isAiResponding = false; + + const response = this.messages[this.messages.length - 1]; + response.timestamp = new Date(); + if (attachments) { + response.attachments = attachments; + } + } + private handleSendMessage(e: CustomEvent) { const text = e.detail.text; const attachments = e.detail.attachments || []; diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 0d0e35dd5..47523392c 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -59,7 +59,7 @@ type Story = StoryObj; // endregion -const messages: any[] = [ +let messages: any[] = [ { id: '1', text: "Hello! I'm an AI assistant created with Lit. How can I help you today?", @@ -76,21 +76,32 @@ function handleMessageSend(e: CustomEvent) { return; } + // create empty response + const emptyResponse = { + id: Date.now().toString(), + text: '', + isResponse: true, + timestamp: new Date(), + attachments: [], + }; + messages = [...messages, emptyResponse]; chat.messages = [...messages]; - chat.isAiResponding = true; + chat.startResponse(); setTimeout(() => { - const aiResponse = { - id: (Date.now() + 1).toString(), - text: generateAIResponse(e.detail.text), - isResponse: true, - timestamp: new Date(), - }; - - chat.isAiResponding = false; - chat.messages = [...messages, aiResponse]; - messages.push(aiResponse); - }, 1500); + const responseParts = generateAIResponse(e.detail.text).split(' '); + showResponse(chat, responseParts).then(() => { + messages = chat.messages; + chat.endResponse(); + }); + }, 1000); +} + +async function showResponse(chat: any, responseParts: any) { + for (let i = 0; i < responseParts.length; i++) { + await new Promise((resolve) => setTimeout(resolve, 500)); + chat.showResponsePart(responseParts[i]); + } } function generateAIResponse(message: string): string { @@ -117,6 +128,17 @@ function generateAIResponse(message: string): string { if (lowerMessage.includes('list') || lowerMessage.includes('items')) { return "Here's an example of a list:\n\n- First item\n- Second item\n- Third item with **bold text**"; } + if (lowerMessage.includes('heading') || lowerMessage.includes('headings')) { + return `Here's how you can use different headings in Markdown: + +# Heading 1 +## Heading 2 +### Heading 3 +#### Heading 4 +##### Heading 5 +###### Heading 6 +`; + } return 'Thanks for your message. This is a demonstration of a chat interface built with Lit components. Feel free to ask about features or try different types of messages!'; } From ef91f2e54f0d1f75fc6cf4cd672a154660645e43 Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Fri, 16 May 2025 15:42:19 +0300 Subject: [PATCH 025/252] chore(*): reduced timeout --- stories/chat.stories.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 47523392c..af4e5faa3 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -99,7 +99,7 @@ function handleMessageSend(e: CustomEvent) { async function showResponse(chat: any, responseParts: any) { for (let i = 0; i < responseParts.length; i++) { - await new Promise((resolve) => setTimeout(resolve, 500)); + await new Promise((resolve) => setTimeout(resolve, 100)); chat.showResponsePart(responseParts[i]); } } From 70851754e41918fa2228db7e5e1c785c8dde0168 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Fri, 16 May 2025 15:45:06 +0300 Subject: [PATCH 026/252] feat(chat): revert sent message to row-reverse --- src/components/chat/chat-message.ts | 30 +++++++++++--------- src/components/chat/themes/message.base.scss | 11 ++++++- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/components/chat/chat-message.ts b/src/components/chat/chat-message.ts index dff025630..d79ab5d7a 100644 --- a/src/components/chat/chat-message.ts +++ b/src/components/chat/chat-message.ts @@ -46,23 +46,25 @@ export default class IgcChatMessageComponent extends LitElement { public attachmentContentTemplate?: AttachmentTemplate; protected override render() { - const containerClass = `message-container ${!this.isResponse ? 'sent' : ''} bubble`; + const containerClass = `message-container ${!this.isResponse ? 'sent' : ''}`; return html`
- ${this.message?.text.trim() - ? html`
${renderMarkdown(this.message?.text)}
` - : ''} - ${this.message?.attachments && this.message?.attachments.length > 0 - ? html` - ` - : ''} +
+ ${this.message?.text.trim() + ? html`
${renderMarkdown(this.message?.text)}
` + : ''} + ${this.message?.attachments && this.message?.attachments.length > 0 + ? html` + ` + : ''} +
`; } diff --git a/src/components/chat/themes/message.base.scss b/src/components/chat/themes/message.base.scss index 367c2f3c3..e26cad3e0 100644 --- a/src/components/chat/themes/message.base.scss +++ b/src/components/chat/themes/message.base.scss @@ -8,7 +8,7 @@ } .message-container { - display: block; + display: flex; justify-content: flex-start; align-items: flex-end; gap: 8px; @@ -16,6 +16,11 @@ animation: fadeIn 0.2s ease-out; } + .message-container.sent { + display: flex; + flex-direction: row-reverse; + } + .avatar { width: 32px; height: 32px; @@ -45,6 +50,7 @@ } .bubble { + display: block; padding: 12px 16px; border-radius: 18px; color: black; @@ -58,6 +64,9 @@ .sent { border-radius: 18px 18px 4px; + } + + .sent .bubble { background-color: #E5E5EA; } From 2867d3da6a823be535f0d20a0789dcc2ed04bcba Mon Sep 17 00:00:00 2001 From: teodosiah Date: Fri, 16 May 2025 15:46:31 +0300 Subject: [PATCH 027/252] Merge branch 'dmdimitrov/chat-ai-component' of https://github.com/IgniteUI/igniteui-webcomponents into dmdimitrov/chat-ai-component From c3a73d4d72edf7964056171d4c2fa0c967c68a65 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Fri, 16 May 2025 15:57:05 +0300 Subject: [PATCH 028/252] feat(chat): remove timebreak, add header text and background --- src/components/chat/chat-message-list.ts | 1 - src/components/chat/themes/chat.base.scss | 1 + stories/chat.stories.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/chat/chat-message-list.ts b/src/components/chat/chat-message-list.ts index e4ca3dbea..4d9ac4d24 100644 --- a/src/components/chat/chat-message-list.ts +++ b/src/components/chat/chat-message-list.ts @@ -113,7 +113,6 @@ export default class IgcChatMessageListComponent extends LitElement { groupedMessages, (group) => group.date, (group) => html` -
${group.date}
${repeat( group.messages, (message) => message.id, diff --git a/src/components/chat/themes/chat.base.scss b/src/components/chat/themes/chat.base.scss index 51f4be0b0..8dfcb2b72 100644 --- a/src/components/chat/themes/chat.base.scss +++ b/src/components/chat/themes/chat.base.scss @@ -22,6 +22,7 @@ align-items: center; justify-content: space-between; padding: 10px; + background-color: #edeff0; } .info { diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index af4e5faa3..20b1478a3 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -42,7 +42,7 @@ const metadata: Meta = { hideUserName: false, disableAutoScroll: false, disableAttachments: false, - headerText: '', + headerText: 'AI Chat', }, }; From e37c25b05407f58dc069ae0420572e55b604540f Mon Sep 17 00:00:00 2001 From: teodosiah Date: Fri, 16 May 2025 16:32:10 +0300 Subject: [PATCH 029/252] feat(chat): add attachments to the story --- .../chat/themes/message-attachments.base.scss | 10 ++++++++-- stories/chat.stories.ts | 20 ++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/components/chat/themes/message-attachments.base.scss b/src/components/chat/themes/message-attachments.base.scss index 69203bb99..8879a9eff 100644 --- a/src/components/chat/themes/message-attachments.base.scss +++ b/src/components/chat/themes/message-attachments.base.scss @@ -20,8 +20,8 @@ } .image-attachment { - max-width: 200px; - max-height: 150px; + max-width: 250px; + max-height: 250px; object-fit: cover; cursor: pointer; border-radius: 0.375rem; @@ -106,4 +106,10 @@ display: flex; justify-content: space-between; gap: 2rem; +} + +.details { + display: flex; + align-items: center; + gap: 0.2rem; } \ No newline at end of file diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 20b1478a3..cc12dda66 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/web-components-vite'; import { html } from 'lit'; import { IgcChatComponent, defineComponents } from 'igniteui-webcomponents'; +import type { IgcMessageAttachment } from '../src/components/chat/types.js'; defineComponents(IgcChatComponent); @@ -76,6 +77,20 @@ function handleMessageSend(e: CustomEvent) { return; } + const attachments: IgcMessageAttachment[] = + newMessage.text.includes('picture') || + newMessage.text.includes('image') || + newMessage.text.includes('file') + ? [ + { + id: 'random_img', + type: newMessage.text.includes('file') ? 'file' : 'image', + url: 'https://picsum.photos/378/395', + name: 'random.png', + }, + ] + : []; + // create empty response const emptyResponse = { id: Date.now().toString(), @@ -92,7 +107,7 @@ function handleMessageSend(e: CustomEvent) { const responseParts = generateAIResponse(e.detail.text).split(' '); showResponse(chat, responseParts).then(() => { messages = chat.messages; - chat.endResponse(); + chat.endResponse(attachments); }); }, 1000); } @@ -125,6 +140,9 @@ function generateAIResponse(message: string): string { if (lowerMessage.includes('code') || lowerMessage.includes('example')) { return "Here's an example of code formatting:\n\n```javascript\nfunction greet(name) {\n return `Hello, ${name}!`;\n}\n\nconsole.log(greet('world'));\n```"; } + if (lowerMessage.includes('image') || lowerMessage.includes('picture')) { + return "Here's an image!"; + } if (lowerMessage.includes('list') || lowerMessage.includes('items')) { return "Here's an example of a list:\n\n- First item\n- Second item\n- Third item with **bold text**"; } From df75a6c8af5127091776666dd5165e5fcdf497db Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Tue, 20 May 2025 10:54:58 +0300 Subject: [PATCH 030/252] feat(chat): removed isAiResponding and extracted streaming in story --- src/components/chat/chat-input.ts | 11 ++----- src/components/chat/chat-message-list.ts | 22 ++++++------- src/components/chat/chat.ts | 38 ----------------------- stories/chat.stories.ts | 39 +++++++++++++++++++----- 4 files changed, 45 insertions(+), 65 deletions(-) diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index b1259363d..99e4d7e33 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -40,9 +40,6 @@ export default class IgcChatInputComponent extends LitElement { @property({ type: Boolean, attribute: 'disable-attachments' }) public disableAttachments = false; - @property({ type: Boolean }) - public isAiResponding = false; - @query('textarea') private textInputElement!: HTMLTextAreaElement; @@ -67,9 +64,7 @@ export default class IgcChatInputComponent extends LitElement { private handleKeyDown(e: KeyboardEvent) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - if (!this.isAiResponding) { - this.sendMessage(); - } + this.sendMessage(); } } @@ -158,8 +153,8 @@ export default class IgcChatInputComponent extends LitElement { collection="material" variant="contained" class="small" - ?disabled=${this.isAiResponding || - (!this.inputValue.trim() && this.attachments.length === 0)} + ?disabled=${!this.inputValue.trim() && + this.attachments.length === 0} @click=${this.sendMessage} >
diff --git a/src/components/chat/chat-message-list.ts b/src/components/chat/chat-message-list.ts index 4d9ac4d24..def35593d 100644 --- a/src/components/chat/chat-message-list.ts +++ b/src/components/chat/chat-message-list.ts @@ -25,9 +25,6 @@ export default class IgcChatMessageListComponent extends LitElement { @property({ reflect: true, attribute: false }) public messages: IgcMessage[] = []; - @property({ reflect: true, attribute: false }) - public isAiResponding = false; - @property({ type: Boolean, attribute: 'disable-auto-scroll' }) public disableAutoScroll = false; @@ -130,15 +127,16 @@ export default class IgcChatMessageListComponent extends LitElement { ` )} ${ - this.isAiResponding - ? html` -
-
-
-
-
- ` - : '' + '' + // this.isAiResponding + // ? html` + //
+ //
+ //
+ //
+ //
+ // ` + // : '' }
diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index 191e1551d..b6843af45 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -45,9 +45,6 @@ export default class IgcChatComponent extends EventEmitterMixin< @property({ reflect: true, attribute: false }) public messages: IgcMessage[] = []; - @property({ reflect: true, attribute: false }) - public isAiResponding = false; - @property({ type: Boolean, attribute: 'hide-avatar' }) public hideAvatar = false; @@ -99,39 +96,6 @@ export default class IgcChatComponent extends EventEmitterMixin< ); } - /** Starts the response */ - public startResponse() { - this.isAiResponding = true; - } - - /** Show response part. */ - public showResponsePart(part: string) { - if (!this.isAiResponding) { - return false; - } - - let responseMessage = this.messages[this.messages.length - 1]; - responseMessage = { - ...responseMessage, - text: `${responseMessage.text} ${part}`, - }; - this.messages[this.messages.length - 1] = responseMessage; - this.messages = [...this.messages]; - - return true; - } - - /** Ends the response */ - public endResponse(attachments?: IgcMessageAttachment[]) { - this.isAiResponding = false; - - const response = this.messages[this.messages.length - 1]; - response.timestamp = new Date(); - if (attachments) { - response.attachments = attachments; - } - } - private handleSendMessage(e: CustomEvent) { const text = e.detail.text; const attachments = e.detail.attachments || []; @@ -170,7 +134,6 @@ export default class IgcChatComponent extends EventEmitterMixin< diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index cc12dda66..658877a2f 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -37,13 +37,29 @@ const metadata: Meta = { control: 'text', table: { defaultValue: { summary: '' } }, }, + attachmentTemplate: { + type: 'AttachmentTemplate', + control: 'AttachmentTemplate', + }, + attachmentHeaderTemplate: { + type: 'AttachmentTemplate', + control: 'AttachmentTemplate', + }, + attachmentActionsTemplate: { + type: 'AttachmentTemplate', + control: 'AttachmentTemplate', + }, + attachmentContentTemplate: { + type: 'AttachmentTemplate', + control: 'AttachmentTemplate', + }, }, args: { hideAvatar: false, hideUserName: false, disableAutoScroll: false, disableAttachments: false, - headerText: 'AI Chat', + headerText: '', }, }; @@ -55,6 +71,10 @@ interface IgcChatArgs { disableAutoScroll: boolean; disableAttachments: boolean; headerText: string; + attachmentTemplate: AttachmentTemplate; + attachmentHeaderTemplate: AttachmentTemplate; + attachmentActionsTemplate: AttachmentTemplate; + attachmentContentTemplate: AttachmentTemplate; } type Story = StoryObj; @@ -97,17 +117,15 @@ function handleMessageSend(e: CustomEvent) { text: '', isResponse: true, timestamp: new Date(), - attachments: [], + attachments: attachments, }; - messages = [...messages, emptyResponse]; - chat.messages = [...messages]; + chat.messages = [...messages, emptyResponse]; - chat.startResponse(); setTimeout(() => { const responseParts = generateAIResponse(e.detail.text).split(' '); showResponse(chat, responseParts).then(() => { messages = chat.messages; - chat.endResponse(attachments); + // TODO: add attachments (if any) to the response message }); }, 1000); } @@ -115,7 +133,14 @@ function handleMessageSend(e: CustomEvent) { async function showResponse(chat: any, responseParts: any) { for (let i = 0; i < responseParts.length; i++) { await new Promise((resolve) => setTimeout(resolve, 100)); - chat.showResponsePart(responseParts[i]); + + const lastMessageIndex = chat.messages.length - 1; + chat.messages[lastMessageIndex] = { + ...chat.messages[lastMessageIndex], + text: `${chat.messages[lastMessageIndex].text} ${responseParts[i]}`, + }; + + chat.messages = [...chat.messages]; } } From 36a7a5ea883cadbb4a6b26e484ade894b843ec04 Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Tue, 20 May 2025 17:54:00 +0300 Subject: [PATCH 031/252] feat(chat): add template for message actions --- src/components/chat/chat-message-list.ts | 10 +++- src/components/chat/chat-message.ts | 12 ++++- src/components/chat/chat.ts | 5 ++ src/components/chat/types.ts | 3 ++ stories/chat.stories.ts | 60 +++++++++++++++++++++++- 5 files changed, 86 insertions(+), 4 deletions(-) diff --git a/src/components/chat/chat-message-list.ts b/src/components/chat/chat-message-list.ts index def35593d..30b4d5724 100644 --- a/src/components/chat/chat-message-list.ts +++ b/src/components/chat/chat-message-list.ts @@ -4,7 +4,11 @@ import { repeat } from 'lit/directives/repeat.js'; import { registerComponent } from '../common/definitions/register.js'; import IgcChatMessageComponent from './chat-message.js'; import { styles } from './themes/message-list.base.css.js'; -import type { AttachmentTemplate, IgcMessage } from './types.js'; +import type { + AttachmentTemplate, + IgcMessage, + MessageActionsTemplate, +} from './types.js'; /** * @@ -40,6 +44,9 @@ export default class IgcChatMessageListComponent extends LitElement { @property({ type: Function }) public attachmentContentTemplate?: AttachmentTemplate; + @property({ type: Function }) + public messageActionsTemplate?: MessageActionsTemplate; + private formatDate(date: Date): string { const today = new Date(); const yesterday = new Date(today); @@ -121,6 +128,7 @@ export default class IgcChatMessageListComponent extends LitElement { .attachmentHeaderTemplate=${this.attachmentHeaderTemplate} .attachmentActionsTemplate=${this.attachmentActionsTemplate} .attachmentContentTemplate=${this.attachmentContentTemplate} + .messageActionsTemplate=${this.messageActionsTemplate} > ` )} diff --git a/src/components/chat/chat-message.ts b/src/components/chat/chat-message.ts index d79ab5d7a..293c55ba9 100644 --- a/src/components/chat/chat-message.ts +++ b/src/components/chat/chat-message.ts @@ -5,7 +5,11 @@ import { registerComponent } from '../common/definitions/register.js'; import { renderMarkdown } from './markdown-util.js'; import { IgcMessageAttachmentsComponent } from './message-attachments.js'; import { styles } from './themes/message.base.css.js'; -import type { AttachmentTemplate, IgcMessage } from './types.js'; +import type { + AttachmentTemplate, + IgcMessage, + MessageActionsTemplate, +} from './types.js'; /** * @@ -45,6 +49,9 @@ export default class IgcChatMessageComponent extends LitElement { @property({ type: Function }) public attachmentContentTemplate?: AttachmentTemplate; + @property({ type: Function }) + public messageActionsTemplate?: MessageActionsTemplate; + protected override render() { const containerClass = `message-container ${!this.isResponse ? 'sent' : ''}`; @@ -64,6 +71,9 @@ export default class IgcChatMessageComponent extends LitElement { > ` : ''} + ${this.messageActionsTemplate && this.message + ? this.messageActionsTemplate(this.message) + : ''} `; diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index b6843af45..ca79d3320 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -11,6 +11,7 @@ import type { AttachmentTemplate, IgcMessage, IgcMessageAttachment, + MessageActionsTemplate, } from './types.js'; export interface IgcChatComponentEventMap { @@ -72,6 +73,9 @@ export default class IgcChatComponent extends EventEmitterMixin< @property({ type: Function }) public attachmentContentTemplate?: AttachmentTemplate; + @property({ type: Function }) + public messageActionsTemplate?: MessageActionsTemplate; + public override connectedCallback() { super.connectedCallback(); this.addEventListener( @@ -138,6 +142,7 @@ export default class IgcChatComponent extends EventEmitterMixin< .attachmentHeaderTemplate=${this.attachmentHeaderTemplate} .attachmentActionsTemplate=${this.attachmentActionsTemplate} .attachmentContentTemplate=${this.attachmentContentTemplate} + .messageActionsTemplate=${this.messageActionsTemplate} > TemplateResult; +export type MessageActionsTemplate = (message: IgcMessage) => TemplateResult; + export const attachmentIcon = ''; export const sendButtonIcon = diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 658877a2f..26d64b810 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -1,8 +1,16 @@ import type { Meta, StoryObj } from '@storybook/web-components-vite'; import { html } from 'lit'; -import { IgcChatComponent, defineComponents } from 'igniteui-webcomponents'; -import type { IgcMessageAttachment } from '../src/components/chat/types.js'; +import { + IgcChatComponent, + defineComponents, + registerIconFromText, +} from 'igniteui-webcomponents'; +import type { + AttachmentTemplate, + IgcMessageAttachment, + MessageActionsTemplate, +} from '../src/components/chat/types.js'; defineComponents(IgcChatComponent); @@ -53,6 +61,10 @@ const metadata: Meta = { type: 'AttachmentTemplate', control: 'AttachmentTemplate', }, + messageActionsTemplate: { + type: 'MessageActionsTemplate', + control: 'MessageActionsTemplate', + }, }, args: { hideAvatar: false, @@ -75,11 +87,23 @@ interface IgcChatArgs { attachmentHeaderTemplate: AttachmentTemplate; attachmentActionsTemplate: AttachmentTemplate; attachmentContentTemplate: AttachmentTemplate; + messageActionsTemplate: MessageActionsTemplate; } type Story = StoryObj; // endregion +const thumbUpIcon = + ''; +const thumbDownIcon = + ''; +const regenerateIcon = + ''; + +registerIconFromText('thumb_up', thumbUpIcon, 'material'); +registerIconFromText('thumb_down', thumbDownIcon, 'material'); +registerIconFromText('regenerate', regenerateIcon, 'material'); + let messages: any[] = [ { id: '1', @@ -89,6 +113,8 @@ let messages: any[] = [ }, ]; +let isResponseSent = false; + function handleMessageSend(e: CustomEvent) { const newMessage = e.detail; messages.push(newMessage); @@ -121,10 +147,12 @@ function handleMessageSend(e: CustomEvent) { }; chat.messages = [...messages, emptyResponse]; + isResponseSent = false; setTimeout(() => { const responseParts = generateAIResponse(e.detail.text).split(' '); showResponse(chat, responseParts).then(() => { messages = chat.messages; + isResponseSent = true; // TODO: add attachments (if any) to the response message }); }, 1000); @@ -196,6 +224,34 @@ export const Basic: Story = { .hideAvatar=${args.hideAvatar} .hideUserName=${args.hideUserName} @igcMessageSend=${handleMessageSend} + .messageActionsTemplate=${(msg) => + msg.isResponse && msg.text.trim() + ? isResponseSent + ? html` +
+ alert(`Liked·message:·${msg.text}`)} + > + alert(`Disliked·message:·${msg.text}`)} + > + + alert(`Response·should·be·re-generated:·${msg.text}`)} + > +
+ ` + : '' + : ''} > `, From 5c6d036258c4a7117f5b54af7ff6245b18699da6 Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Wed, 21 May 2025 12:14:38 +0300 Subject: [PATCH 032/252] feat(chat): remove isResponse and add sender prop --- src/components/chat/chat-message-list.ts | 2 +- src/components/chat/chat-message.ts | 4 ++-- src/components/chat/chat.ts | 2 +- src/components/chat/types.ts | 2 +- stories/chat.stories.ts | 6 +++--- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/chat/chat-message-list.ts b/src/components/chat/chat-message-list.ts index 30b4d5724..f9841b0e2 100644 --- a/src/components/chat/chat-message-list.ts +++ b/src/components/chat/chat-message-list.ts @@ -123,7 +123,7 @@ export default class IgcChatMessageListComponent extends LitElement { (message) => html` diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index ca79d3320..dde3205f8 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -109,7 +109,7 @@ export default class IgcChatComponent extends EventEmitterMixin< const newMessage: IgcMessage = { id: Date.now().toString(), text, - isResponse: false, + sender: 'user', timestamp: new Date(), attachments, }; diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts index 7004b4c08..0028e3d80 100644 --- a/src/components/chat/types.ts +++ b/src/components/chat/types.ts @@ -5,7 +5,7 @@ export type IgcMessageAttachmentType = 'image' | 'file'; export interface IgcMessage { id: string; text: string; - isResponse: boolean; + sender: string; timestamp: Date; attachments?: IgcMessageAttachment[]; } diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 26d64b810..9a3022e09 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -108,7 +108,7 @@ let messages: any[] = [ { id: '1', text: "Hello! I'm an AI assistant created with Lit. How can I help you today?", - isResponse: true, + sender: 'bot', timestamp: new Date(), }, ]; @@ -141,7 +141,7 @@ function handleMessageSend(e: CustomEvent) { const emptyResponse = { id: Date.now().toString(), text: '', - isResponse: true, + sender: 'bot', timestamp: new Date(), attachments: attachments, }; @@ -225,7 +225,7 @@ export const Basic: Story = { .hideUserName=${args.hideUserName} @igcMessageSend=${handleMessageSend} .messageActionsTemplate=${(msg) => - msg.isResponse && msg.text.trim() + msg.sender === 'bot' && msg.text.trim() ? isResponseSent ? html`
From 1406673bdd161422f3233ecf9bb2df8b45a916e4 Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Wed, 21 May 2025 14:44:13 +0300 Subject: [PATCH 033/252] chore(*): move buttons to the right --- stories/chat.stories.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 9a3022e09..11e5f617a 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -228,7 +228,7 @@ export const Basic: Story = { msg.sender === 'bot' && msg.text.trim() ? isResponseSent ? html` -
+
Date: Thu, 22 May 2025 11:13:50 +0300 Subject: [PATCH 034/252] feat(chat): add supabase story --- package-lock.json | 148 ++++++++++++++++++- package.json | 3 +- src/components/chat/chat-message-list.ts | 1 - src/components/chat/chat-message.ts | 5 +- stories/chat.stories.ts | 175 +++++++++++++++++++---- 5 files changed, 295 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index fa4c82c35..2f773cd7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "vite": "^6.3.5" }, "peerDependencies": { + "@supabase/supabase-js": "^2.49.4", "marked": "^12.0.0" } }, @@ -2379,6 +2380,144 @@ "storybook": "^9.0.0-rc.0" } }, + "node_modules/@supabase/auth-js": { + "version": "2.69.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.69.1.tgz", + "integrity": "sha512-FILtt5WjCNzmReeRLq5wRs3iShwmnWgBvxHfqapC/VoljJl+W8hDAyFmf1NVw3zH+ZjZ05AKxiKxVeb0HNWRMQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.4.tgz", + "integrity": "sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/node-fetch": { + "version": "2.6.15", + "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", + "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/@supabase/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "peer": true + }, + "node_modules/@supabase/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/@supabase/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "peer": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "1.19.4", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.19.4.tgz", + "integrity": "sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.11.2.tgz", + "integrity": "sha512-u/XeuL2Y0QEhXSoIPZZwR6wMXgB+RQbJzG9VErA3VghVt7uRfSVsjeqd7m5GhX3JR6dM/WRmLbVR8URpDWG4+w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@supabase/node-fetch": "^2.6.14", + "@types/phoenix": "^1.5.4", + "@types/ws": "^8.5.10", + "ws": "^8.18.0" + } + }, + "node_modules/@supabase/realtime-js/node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@supabase/realtime-js/node_modules/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.1.tgz", + "integrity": "sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.49.7", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.49.7.tgz", + "integrity": "sha512-hx6khHHC9GherCxTaRF91Sp3uiRtlgo8Aw+MUC5hck/DLdsIIZfbBbLzXqiiDUdGDeERagmFVanI93QDZe+Nww==", + "license": "MIT", + "peer": true, + "dependencies": { + "@supabase/auth-js": "2.69.1", + "@supabase/functions-js": "2.4.4", + "@supabase/node-fetch": "2.6.15", + "@supabase/postgrest-js": "1.19.4", + "@supabase/realtime-js": "2.11.2", + "@supabase/storage-js": "2.7.1" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", @@ -2820,7 +2959,6 @@ "version": "22.15.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz", "integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -2833,6 +2971,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/phoenix": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", + "license": "MIT", + "peer": true + }, "node_modules/@types/qs": { "version": "6.9.18", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", @@ -12943,7 +13088,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unicorn-magic": { diff --git a/package.json b/package.json index 8d7277069..67cf4355b 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,8 @@ "vite": "^6.3.5" }, "peerDependencies": { - "marked": "^12.0.0" + "marked": "^12.0.0", + "@supabase/supabase-js": "^2.49.4" }, "browserslist": [ "defaults" diff --git a/src/components/chat/chat-message-list.ts b/src/components/chat/chat-message-list.ts index f9841b0e2..b30369ca0 100644 --- a/src/components/chat/chat-message-list.ts +++ b/src/components/chat/chat-message-list.ts @@ -123,7 +123,6 @@ export default class IgcChatMessageListComponent extends LitElement { (message) => html` diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 11e5f617a..025a3fe18 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from '@storybook/web-components-vite'; import { html } from 'lit'; +import { createClient } from '@supabase/supabase-js'; import { IgcChatComponent, defineComponents, @@ -12,6 +13,11 @@ import type { MessageActionsTemplate, } from '../src/components/chat/types.js'; +const VITE_SUPABASE_ANON_KEY = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imtybnhzc2FycnBpZ3RvY3N2Z2xvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDU5MDk4MjYsImV4cCI6MjA2MTQ4NTgyNn0.9TTnNXXXnxAwEFuSn-i-ctGc6LKAPAHmAMxBUSP0vWI'; +const VITE_SUPABASE_URL = 'https://krnxssarrpigtocsvglo.supabase.co'; +const supabase = createClient(VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY); + defineComponents(IgcChatComponent); // region default @@ -113,7 +119,57 @@ let messages: any[] = [ }, ]; -let isResponseSent = false; +// load messages from supabase +async function fetchMessages() { + const { data, error } = await supabase + .from('messages') + .select('*') + .order('timestamp', { ascending: true }); + + if (error) { + // console.log('Error fetching messages:', error); + return []; + } + + return data.map((message) => ({ + id: message.id, + text: message.text, + sender: message.sender, + timestamp: new Date(message.timestamp), + })); +} + +let isResponseSent: boolean; + +const messageActionsTemplate = (msg: any) => { + return msg.sender !== 'user' && msg.text.trim() + ? isResponseSent !== false + ? html` +
+ alert(`Liked message: ${msg.text}`)} + > + alert(`Disliked message: ${msg.text}`)} + > + + alert(`Response should be re-generated: ${msg.text}`)} + > +
+ ` + : '' + : ''; +}; function handleMessageSend(e: CustomEvent) { const newMessage = e.detail; @@ -158,6 +214,70 @@ function handleMessageSend(e: CustomEvent) { }, 1000); } +async function handleMessageSendSupabase(e: CustomEvent) { + const newMessage = e.detail; + const chat = document.querySelector('igc-chat'); + if (!chat) { + return; + } + + saveMessageToSupabase(newMessage); + + const attachments: IgcMessageAttachment[] = + newMessage.text.includes('picture') || + newMessage.text.includes('image') || + newMessage.text.includes('file') + ? [ + { + id: 'random_img', + type: newMessage.text.includes('file') ? 'file' : 'image', + url: 'https://picsum.photos/378/395', + name: 'random.png', + }, + ] + : []; + + isResponseSent = false; + setTimeout(() => { + // create empty response + const emptyResponse = { + id: Date.now().toString(), + text: '', + sender: 'bot', + timestamp: new Date(), + attachments: attachments, + }; + chat.messages = [...chat.messages, emptyResponse]; + + const responseParts = generateAIResponse(e.detail.text).split(' '); + showResponse(chat, responseParts).then(() => { + const lastMessageIndex = chat.messages.length - 1; + const lastMessage = chat.messages[lastMessageIndex]; + saveMessageToSupabase(lastMessage); + isResponseSent = true; + // TODO: add attachments (if any) to the response message + }); + }, 1000); +} + +async function saveMessageToSupabase(message: any) { + const { error } = await supabase + .from('messages') + .insert([ + { + id: message.id, + text: message.text, + sender: message.sender, + timestamp: message.timestamp, + }, + ]) + .select(); + if (error) { + // console.log('Error saving message:', error); + } + return message; +} + async function showResponse(chat: any, responseParts: any) { for (let i = 0; i < responseParts.length; i++) { await new Promise((resolve) => setTimeout(resolve, 100)); @@ -224,34 +344,31 @@ export const Basic: Story = { .hideAvatar=${args.hideAvatar} .hideUserName=${args.hideUserName} @igcMessageSend=${handleMessageSend} - .messageActionsTemplate=${(msg) => - msg.sender === 'bot' && msg.text.trim() - ? isResponseSent - ? html` -
- alert(`Liked·message:·${msg.text}`)} - > - alert(`Disliked·message:·${msg.text}`)} - > - - alert(`Response·should·be·re-generated:·${msg.text}`)} - > -
- ` - : '' - : ''} + .messageActionsTemplate=${messageActionsTemplate} + > + + `, +}; + +export const Supabase: Story = { + play: async () => { + fetchMessages().then((msgs) => { + messages = msgs; + const chat = document.querySelector('igc-chat'); + if (chat) { + chat.messages = messages; + } + }); + }, + render: (args) => html` + `, From 408b23b333483d5f6af86290733f65b45082d046 Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Thu, 22 May 2025 11:52:23 +0300 Subject: [PATCH 035/252] feat(chat): regenerated key and moved to .env file --- .gitignore | 1 + stories/chat.stories.ts | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index e415eddaa..5dc5d9b28 100644 --- a/.gitignore +++ b/.gitignore @@ -32,5 +32,6 @@ custom-elements.json i18nRepo # Development environment +.env .envrc .direnv diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 025a3fe18..9386026ec 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -13,10 +13,9 @@ import type { MessageActionsTemplate, } from '../src/components/chat/types.js'; -const VITE_SUPABASE_ANON_KEY = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imtybnhzc2FycnBpZ3RvY3N2Z2xvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDU5MDk4MjYsImV4cCI6MjA2MTQ4NTgyNn0.9TTnNXXXnxAwEFuSn-i-ctGc6LKAPAHmAMxBUSP0vWI'; -const VITE_SUPABASE_URL = 'https://krnxssarrpigtocsvglo.supabase.co'; -const supabase = createClient(VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY); +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; +const supabaseKey = import.meta.env.VITE_SUPABASE_ANON_KEY; +const supabase = createClient(supabaseUrl, supabaseKey); defineComponents(IgcChatComponent); From 5f01df89fd2688c20c71c3e11ce15ea57055d021 Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Fri, 23 May 2025 15:15:02 +0300 Subject: [PATCH 036/252] feat(chat): add file prop to attachment, update styles and supabase story --- src/components/chat/chat-input.ts | 9 +- src/components/chat/chat.ts | 10 +- src/components/chat/message-attachments.ts | 8 +- src/components/chat/themes/input.base.scss | 12 +- .../chat/themes/message-attachments.base.scss | 5 +- src/components/chat/themes/message.base.scss | 1 + src/components/chat/types.ts | 6 +- stories/chat.stories.ts | 164 +++++++++++++----- 8 files changed, 153 insertions(+), 62 deletions(-) diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index 99e4d7e33..c5f8e502b 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -80,7 +80,7 @@ export default class IgcChatInputComponent extends LitElement { private sendMessage() { if (!this.inputValue.trim() && this.attachments.length === 0) return; - const messageEvent = new CustomEvent('message-send', { + const messageEvent = new CustomEvent('message-created', { detail: { text: this.inputValue, attachments: this.attachments }, }); @@ -103,14 +103,15 @@ export default class IgcChatInputComponent extends LitElement { const files = Array.from(input.files); const newAttachments: IgcMessageAttachment[] = []; + let count = 0; files.forEach((file) => { const isImage = file.type.startsWith('image/'); newAttachments.push({ - id: `attach_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - type: isImage ? 'image' : 'file', + id: Date.now().toString() + count++, + // type: isImage ? 'image' : 'file', url: URL.createObjectURL(file), name: file.name, - size: file.size, + file: file, thumbnail: isImage ? URL.createObjectURL(file) : undefined, }); }); diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index dde3205f8..676951ea9 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -15,7 +15,7 @@ import type { } from './types.js'; export interface IgcChatComponentEventMap { - igcMessageSend: CustomEvent; + igcMessageCreated: CustomEvent; igcAttachmentClick: CustomEvent; } @@ -79,7 +79,7 @@ export default class IgcChatComponent extends EventEmitterMixin< public override connectedCallback() { super.connectedCallback(); this.addEventListener( - 'message-send', + 'message-created', this.handleSendMessage as EventListener ); this.addEventListener( @@ -91,7 +91,7 @@ export default class IgcChatComponent extends EventEmitterMixin< public override disconnectedCallback() { super.disconnectedCallback(); this.removeEventListener( - 'message-send', + 'message-created', this.handleSendMessage as EventListener ); this.removeEventListener( @@ -115,7 +115,7 @@ export default class IgcChatComponent extends EventEmitterMixin< }; this.messages = [...this.messages, newMessage]; - this.emitEvent('igcMessageSend', { detail: newMessage }); + this.emitEvent('igcMessageCreated', { detail: newMessage }); } private handleAttachmentClick(e: CustomEvent) { @@ -147,7 +147,7 @@ export default class IgcChatComponent extends EventEmitterMixin<
`; diff --git a/src/components/chat/message-attachments.ts b/src/components/chat/message-attachments.ts index 1c16c3d33..f4e68d054 100644 --- a/src/components/chat/message-attachments.ts +++ b/src/components/chat/message-attachments.ts @@ -95,7 +95,7 @@ export class IgcMessageAttachmentsComponent extends LitElement { (attachment) => html` this.handleToggle(ev, attachment)} @igcOpening=${(ev: CustomEvent) => @@ -107,7 +107,7 @@ export class IgcMessageAttachmentsComponent extends LitElement { ? this.attachmentHeaderTemplate(this.attachments) : html` - ${attachment.type === 'image' + ${attachment.file?.type.startsWith('image/') ? html` - ${attachment.type === 'image' + ${attachment.file?.type.startsWith('image/') ? html` - ${attachment.type === 'image' + ${attachment.file?.type.startsWith('image/') ? html` ({ - id: message.id, - text: message.text, - sender: message.sender, - timestamp: new Date(message.timestamp), - })); -} - let isResponseSent: boolean; const messageActionsTemplate = (msg: any) => { @@ -185,7 +165,7 @@ function handleMessageSend(e: CustomEvent) { ? [ { id: 'random_img', - type: newMessage.text.includes('file') ? 'file' : 'image', + // type: newMessage.text.includes('file') ? 'file' : 'image', url: 'https://picsum.photos/378/395', name: 'random.png', }, @@ -213,7 +193,73 @@ function handleMessageSend(e: CustomEvent) { }, 1000); } -async function handleMessageSendSupabase(e: CustomEvent) { +// load messages and attachments from supabase +async function fetchMessages() { + const { data, error } = await supabase + .from('messages') + .select('id, text, sender, timestamp, attachments (id, name, type)') + .order('timestamp', { ascending: true }); + + if (error) { + // console.log('Error fetching messages:', error); + return []; + } + + if (!data || data.length === 0) { + return messages; + } + + const mappedData = data.map((message) => ({ + id: message.id, + text: message.text, + sender: message.sender, + timestamp: new Date(message.timestamp), + attachments: message.attachments.map((attachment: any) => ({ + id: attachment.id, + name: attachment.name, + type: attachment.type, + url: supabase.storage.from('files').getPublicUrl(attachment.name).data + .publicUrl, + })), + })); + await processMappedData(mappedData); + return mappedData; +} + +async function processMappedData(data: any) { + for (const message of data) { + for (const attachment of message.attachments) { + if (attachment.type.startsWith('image/')) { + const file = await fetchAttachment(attachment.name); + if (file) { + attachment.file = file; + } + } else { + attachment.file = new File([], attachment.name, { + type: attachment.type, + }); + } + } + } + return data; +} + +async function fetchAttachment(name: string) { + const { data, error } = await supabase.storage.from('files').download(name); + + if (error) { + // console.log('Error fetching attachment:', error); + return null; + } + + const file = new File([data], name, { + type: data.type, + }); + + return file; +} + +async function handleMessageCreatedSupabase(e: CustomEvent) { const newMessage = e.detail; const chat = document.querySelector('igc-chat'); if (!chat) { @@ -222,19 +268,19 @@ async function handleMessageSendSupabase(e: CustomEvent) { saveMessageToSupabase(newMessage); - const attachments: IgcMessageAttachment[] = - newMessage.text.includes('picture') || - newMessage.text.includes('image') || - newMessage.text.includes('file') - ? [ - { - id: 'random_img', - type: newMessage.text.includes('file') ? 'file' : 'image', - url: 'https://picsum.photos/378/395', - name: 'random.png', - }, - ] - : []; + const attachments: IgcMessageAttachment[] = []; + // newMessage.text.includes('picture') || + // newMessage.text.includes('image') || + // newMessage.text.includes('file') + // ? [ + // { + // id: 'random_img', + // // type: newMessage.text.includes('file') ? 'file' : 'image', + // url: 'https://picsum.photos/378/395', + // name: 'random.png', + // }, + // ] + // : []; isResponseSent = false; setTimeout(() => { @@ -274,6 +320,42 @@ async function saveMessageToSupabase(message: any) { if (error) { // console.log('Error saving message:', error); } + + // save attachments to supabase storage + if (message.attachments) { + message.attachments.forEach(async (attachment: IgcMessageAttachment) => { + if (!attachment.file) { + return; + } + + const { error } = await supabase.storage + .from('files') + .upload(attachment.file.name, attachment.file, { + cacheControl: '3600', + upsert: true, + }); + if (error) { + // console.log('Error saving attachment:', error); + } + + // save attachment metadata to database + const { error: attachmentError } = await supabase + .from('attachments') + .insert([ + { + id: attachment.id, + message_id: message.id, + name: attachment.file.name, + type: attachment.file.type, + }, + ]) + .select(); + if (attachmentError) { + // console.log('Error saving attachment to table "attachments":', attachmentError); + } + }); + } + return message; } @@ -300,7 +382,7 @@ function generateAIResponse(message: string): string { if (lowerMessage.includes('help')) { return "I'm here to help! What would you like to know about?"; } - if (lowerMessage.includes('feature') || lowerMessage.includes('can you')) { + if (lowerMessage.includes('feature')) { return "As a demo AI, I can simulate conversations, display markdown formatting like **bold** and `code`, and adapt to light and dark themes. I'm built with Lit as a web component that can be integrated into any application."; } if (lowerMessage.includes('weather')) { @@ -309,13 +391,13 @@ function generateAIResponse(message: string): string { if (lowerMessage.includes('thank')) { return "You're welcome! Let me know if you need anything else."; } - if (lowerMessage.includes('code') || lowerMessage.includes('example')) { + if (lowerMessage.includes('code')) { return "Here's an example of code formatting:\n\n```javascript\nfunction greet(name) {\n return `Hello, ${name}!`;\n}\n\nconsole.log(greet('world'));\n```"; } if (lowerMessage.includes('image') || lowerMessage.includes('picture')) { return "Here's an image!"; } - if (lowerMessage.includes('list') || lowerMessage.includes('items')) { + if (lowerMessage.includes('list')) { return "Here's an example of a list:\n\n- First item\n- Second item\n- Third item with **bold text**"; } if (lowerMessage.includes('heading') || lowerMessage.includes('headings')) { @@ -330,7 +412,7 @@ function generateAIResponse(message: string): string { `; } - return 'Thanks for your message. This is a demonstration of a chat interface built with Lit components. Feel free to ask about features or try different types of messages!'; + return 'How can I help? Possible commands: hello, help, feature, weather, thank, code, image, list, heading.'; } export const Basic: Story = { @@ -342,7 +424,7 @@ export const Basic: Story = { .disableAttachments=${args.disableAttachments} .hideAvatar=${args.hideAvatar} .hideUserName=${args.hideUserName} - @igcMessageSend=${handleMessageSend} + @igcMessageCreated=${handleMessageSend} .messageActionsTemplate=${messageActionsTemplate} > @@ -366,7 +448,7 @@ export const Supabase: Story = { .disableAttachments=${args.disableAttachments} .hideAvatar=${args.hideAvatar} .hideUserName=${args.hideUserName} - @igcMessageSend=${handleMessageSendSupabase} + @igcMessageCreated=${handleMessageCreatedSupabase} .messageActionsTemplate=${messageActionsTemplate} > From d7d5fe8e476b40278eb12ffa18dc974261a56566 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Fri, 30 May 2025 09:56:11 +0300 Subject: [PATCH 037/252] feat(chat): drag&drop files + accepted file types prop --- src/components/chat/chat-input.ts | 148 ++++++++++++++++++++- src/components/chat/chat.ts | 9 ++ src/components/chat/themes/input.base.scss | 5 + src/components/chat/types.ts | 2 +- stories/chat.stories.ts | 25 +++- 5 files changed, 183 insertions(+), 6 deletions(-) diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index c5f8e502b..1961b6637 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -2,6 +2,7 @@ import { LitElement, html } from 'lit'; import { property, query, state } from 'lit/decorators.js'; import IgcIconButtonComponent from '../button/icon-button.js'; import IgcChipComponent from '../chip/chip.js'; +import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; import IgcFileInputComponent from '../file-input/file-input.js'; import IgcIconComponent from '../icon/icon.js'; @@ -40,21 +41,44 @@ export default class IgcChatInputComponent extends LitElement { @property({ type: Boolean, attribute: 'disable-attachments' }) public disableAttachments = false; + @property({ type: String }) + public acceptedFiles = ''; + @query('textarea') private textInputElement!: HTMLTextAreaElement; + @watch('acceptedFiles', { waitUntilFirstUpdate: true }) + protected acceptedFilesChange(): void { + this.updateAcceptedTypesCache(); + } + @state() private inputValue = ''; @state() private attachments: IgcMessageAttachment[] = []; + @state() + private dragClass = ''; + + // Cache for accepted file types + private _acceptedTypesCache: { + extensions: Set; + mimeTypes: Set; + wildcardTypes: Set; + } | null = null; + constructor() { super(); registerIconFromText('attachment', attachmentIcon, 'material'); registerIconFromText('send-message', sendButtonIcon, 'material'); } + protected override firstUpdated() { + this.setupDragAndDrop(); + this.updateAcceptedTypesCache(); + } + private handleInput(e: Event) { const target = e.target as HTMLTextAreaElement; this.inputValue = target.value; @@ -68,6 +92,69 @@ export default class IgcChatInputComponent extends LitElement { } } + private setupDragAndDrop() { + const container = this.shadowRoot?.querySelector( + '.input-container' + ) as HTMLElement; + if (container) { + container.addEventListener('dragenter', this.handleDragEnter.bind(this)); + container.addEventListener('dragover', this.handleDragOver.bind(this)); + container.addEventListener('dragleave', this.handleDragLeave.bind(this)); + container.addEventListener('drop', this.handleDrop.bind(this)); + } + } + + private handleDragEnter(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + + const files = Array.from(e.dataTransfer?.items || []).filter( + (item) => item.kind === 'file' + ); + const hasValidFiles = files.some((item) => + this.isFileTypeAccepted(item.getAsFile() as File, item.type) + ); + + this.dragClass = hasValidFiles ? 'dragging' : ''; + } + + private handleDragOver(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + } + + private handleDragLeave(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + + // Check if we're actually leaving the container + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const x = e.clientX; + const y = e.clientY; + + if ( + x <= rect.left || + x >= rect.right || + y <= rect.top || + y >= rect.bottom + ) { + this.dragClass = ''; + } + } + + private handleDrop(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + this.dragClass = ''; + + const files = Array.from(e.dataTransfer?.files || []); + if (files.length === 0) return; + + const validFiles = files.filter((file) => this.isFileTypeAccepted(file)); + + this.attachFiles(validFiles); + } + private adjustTextareaHeight() { const textarea = this.textInputElement; if (!textarea) return; @@ -102,8 +189,12 @@ export default class IgcChatInputComponent extends LitElement { if (!input.files || input.files.length === 0) return; const files = Array.from(input.files); + this.attachFiles(files); + } + + private attachFiles(files: File[]) { const newAttachments: IgcMessageAttachment[] = []; - let count = 0; + let count = this.attachments.length; files.forEach((file) => { const isImage = file.type.startsWith('image/'); newAttachments.push({ @@ -118,17 +209,68 @@ export default class IgcChatInputComponent extends LitElement { this.attachments = [...this.attachments, ...newAttachments]; } + private updateAcceptedTypesCache() { + if (!this.acceptedFiles) { + this._acceptedTypesCache = null; + return; + } + + const types = this.acceptedFiles + .split(',') + .map((type) => type.trim().toLowerCase()); + this._acceptedTypesCache = { + extensions: new Set(types.filter((t) => t.startsWith('.'))), + mimeTypes: new Set( + types.filter((t) => !t.startsWith('.') && !t.endsWith('/*')) + ), + wildcardTypes: new Set( + types.filter((t) => t.endsWith('/*')).map((t) => t.slice(0, -2)) + ), + }; + } + + private isFileTypeAccepted(file: File, type = ''): boolean { + if (!this._acceptedTypesCache) return true; + + if (file === null && type === '') return false; + + const fileType = + file != null ? file.type.toLowerCase() : type.toLowerCase(); + const fileExtension = + file != null + ? `.${file.name.split('.').pop()?.toLowerCase()}` + : `.${type.split('/').pop()?.toLowerCase()}`; + + // Check file extension + if (this._acceptedTypesCache.extensions.has(fileExtension)) { + return true; + } + + // Check exact MIME type + if (this._acceptedTypesCache.mimeTypes.has(fileType)) { + return true; + } + + // Check wildcard MIME types + const [fileBaseType] = fileType.split('/'); + return this._acceptedTypesCache.wildcardTypes.has(fileBaseType); + } + private removeAttachment(index: number) { this.attachments = this.attachments.filter((_, i) => i !== index); } protected override render() { return html` -
+
${this.disableAttachments ? '' : html` - +
diff --git a/src/components/chat/themes/input.base.scss b/src/components/chat/themes/input.base.scss index f2c302899..9f8143cab 100644 --- a/src/components/chat/themes/input.base.scss +++ b/src/components/chat/themes/input.base.scss @@ -21,6 +21,11 @@ igc-file-input::part(file-names){ gap: 12px; } +.input-container.dragging{ + background-color: rgba(10, 132, 255, 0.1); + border: 2px dashed #0A84FF; +} + .input-wrapper { flex: 1; position: relative; diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts index c19efb3ec..97850c208 100644 --- a/src/components/chat/types.ts +++ b/src/components/chat/types.ts @@ -12,9 +12,9 @@ export interface IgcMessage { export interface IgcMessageAttachment { id: string; - // type: IgcMessageAttachmentType; url: string; name: string; + type?: string; file?: File; thumbnail?: string; } diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index c33f60f2d..286395afb 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -13,8 +13,15 @@ import type { MessageActionsTemplate, } from '../src/components/chat/types.js'; -const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; -const supabaseKey = import.meta.env.VITE_SUPABASE_ANON_KEY; +// Declare the type for ImportMeta to include env +const env = { + VITE_SUPABASE_ANON_KEY: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imtybnhzc2FycnBpZ3RvY3N2Z2xvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDc5MDI4NjksImV4cCI6MjA2MzQ3ODg2OX0.CrZDDqfZSTyX_FGD7shO7on9EUsTk-Kf3SwJUHfPpig', + VITE_SUPABASE_URL: 'https://krnxssarrpigtocsvglo.supabase.co', +}; + +const supabaseUrl = env.VITE_SUPABASE_URL; +const supabaseKey = env.VITE_SUPABASE_ANON_KEY; const supabase = createClient(supabaseUrl, supabaseKey); defineComponents(IgcChatComponent); @@ -45,6 +52,13 @@ const metadata: Meta = { control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, + acceptedFiles: { + type: 'string', + description: + 'The accepted files that could be attached.\nDefines the file types as a list of comma-separated values that the file input should accept.', + control: 'text', + table: { defaultValue: { summary: '' } }, + }, headerText: { type: 'string', control: 'text', @@ -76,6 +90,7 @@ const metadata: Meta = { hideUserName: false, disableAutoScroll: false, disableAttachments: false, + acceptedFiles: '', headerText: '', }, }; @@ -87,6 +102,11 @@ interface IgcChatArgs { hideUserName: boolean; disableAutoScroll: boolean; disableAttachments: boolean; + /** + * The accepted files that could be attached. + * Defines the file types as a list of comma-separated values that the file input should accept. + */ + acceptedFiles: string; headerText: string; attachmentTemplate: AttachmentTemplate; attachmentHeaderTemplate: AttachmentTemplate; @@ -422,6 +442,7 @@ export const Basic: Story = { .headerText=${args.headerText} .disableAutoScroll=${args.disableAutoScroll} .disableAttachments=${args.disableAttachments} + .acceptedFiles=${args.acceptedFiles} .hideAvatar=${args.hideAvatar} .hideUserName=${args.hideUserName} @igcMessageCreated=${handleMessageSend} From be7d37279d52c6a65e89aacc1e2057980a6a93ba Mon Sep 17 00:00:00 2001 From: teodosiah Date: Fri, 30 May 2025 09:59:57 +0300 Subject: [PATCH 038/252] chore(*): remove supabase setup in the story --- stories/chat.stories.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 286395afb..bec9cc280 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -13,15 +13,8 @@ import type { MessageActionsTemplate, } from '../src/components/chat/types.js'; -// Declare the type for ImportMeta to include env -const env = { - VITE_SUPABASE_ANON_KEY: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imtybnhzc2FycnBpZ3RvY3N2Z2xvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDc5MDI4NjksImV4cCI6MjA2MzQ3ODg2OX0.CrZDDqfZSTyX_FGD7shO7on9EUsTk-Kf3SwJUHfPpig', - VITE_SUPABASE_URL: 'https://krnxssarrpigtocsvglo.supabase.co', -}; - -const supabaseUrl = env.VITE_SUPABASE_URL; -const supabaseKey = env.VITE_SUPABASE_ANON_KEY; +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; +const supabaseKey = import.meta.env.VITE_SUPABASE_ANON_KEY; const supabase = createClient(supabaseUrl, supabaseKey); defineComponents(IgcChatComponent); From ad1ffa2caef082359de29cf065492c5db05b5497 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Wed, 4 Jun 2025 14:27:51 +0300 Subject: [PATCH 039/252] chore(*): add AI story --- package-lock.json | 1046 ++++++++++++++++++++++++++++++++--- package.json | 5 +- src/components/chat/chat.ts | 6 + stories/chat.stories.ts | 69 +++ 4 files changed, 1044 insertions(+), 82 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0e25c3e4f..af60c7bc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@floating-ui/dom": "^1.7.0", + "@google/genai": "^1.3.0", "@lit-labs/virtualizer": "^2.1.0", "@lit/context": "^1.1.5", "lit": "^3.3.0" @@ -1047,6 +1048,45 @@ "dev": true, "license": "MIT" }, + "node_modules/@google/genai": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.3.0.tgz", + "integrity": "sha512-rrMzAELX4P902FUpuWy/W3NcQ7L3q/qtCzfCmGVqIce8yWpptTF9hkKsw744tvZpwqhuzD0URibcJA95wd8QFA==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^9.14.2", + "ws": "^8.18.0", + "zod": "^3.22.4", + "zod-to-json-schema": "^3.22.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.11.0" + } + }, + "node_modules/@google/genai/node_modules/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@hapi/bourne": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-3.0.0.tgz", @@ -1286,6 +1326,109 @@ "react": ">=16" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.12.1.tgz", + "integrity": "sha512-KG1CZhZfWg+u8pxeM/mByJDScJSrjjxLc8fwQqbsS8xCjBmQfMNEBTotYdNanKekepnfRI85GtgQlctLFpcYPw==", + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^6.12.6", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT", + "peer": true + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3810,7 +3953,6 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -4143,7 +4285,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -4200,6 +4341,15 @@ "node": ">=12.0.0" } }, + "node_modules/bignumber.js": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.0.tgz", + "integrity": "sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -4225,6 +4375,169 @@ "readable-stream": "^3.4.0" } }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/body-parser/node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/body-parser/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/body-parser/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "peer": true + }, + "node_modules/body-parser/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "peer": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/body-parser/node_modules/raw-body": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/body-parser/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/body-parser/node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "peer": true, + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -4405,11 +4718,16 @@ "node": "*" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -4444,7 +4762,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4458,7 +4775,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4996,7 +5312,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -5013,12 +5328,21 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" } }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/cookies": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", @@ -5037,7 +5361,6 @@ "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "dev": true, "license": "MIT", "dependencies": { "object-assign": "^4", @@ -5078,7 +5401,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -5304,7 +5626,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -5605,7 +5926,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -5647,11 +5967,19 @@ "node": ">= 0.8.0" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, "license": "MIT" }, "node_modules/electron-to-chromium": { @@ -5898,7 +6226,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5908,7 +6235,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5925,7 +6251,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -6027,7 +6352,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, "license": "MIT" }, "node_modules/escape-string-regexp": { @@ -6134,43 +6458,357 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "peer": true, + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.2.tgz", + "integrity": "sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", "dev": true, "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", + "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": "^4.11 || 5 || ^5.0.0-beta.1" + } + }, + "node_modules/express/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "peer": true + }, + "node_modules/express/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "peer": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/express/node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/express/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "peer": true, "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "dev": true, - "license": "MIT" - }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, + "node_modules/express/node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "license": "MIT", + "peer": true, "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" + "node": ">= 0.6" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -6237,7 +6875,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-diff": { @@ -6271,6 +6908,13 @@ "node": ">=8.6.0" } }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT", + "peer": true + }, "node_modules/fast-uri": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", @@ -6489,6 +7133,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -6551,12 +7205,41 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/get-amd-module-type": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-amd-module-type/-/get-amd-module-type-6.0.1.tgz", @@ -6598,7 +7281,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -6630,7 +7312,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -6813,11 +7494,36 @@ "node": ">=0.6.0" } }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6833,6 +7539,19 @@ "dev": true, "license": "ISC" }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -6847,7 +7566,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6876,7 +7594,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -7021,7 +7738,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -7035,7 +7751,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -7053,7 +7768,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/human-signals": { @@ -7233,7 +7947,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -7290,7 +8003,6 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.10" @@ -7473,6 +8185,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT", + "peer": true + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -7506,7 +8225,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7578,7 +8296,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -7663,6 +8380,15 @@ "dev": true, "license": "MIT" }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -7700,6 +8426,27 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keep-a-changelog": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/keep-a-changelog/-/keep-a-changelog-2.6.2.tgz", @@ -8714,7 +9461,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8768,6 +9514,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -9042,6 +9801,48 @@ "optional": true, "peer": true }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", @@ -9109,7 +9910,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9119,7 +9919,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9145,7 +9944,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -9396,7 +10194,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -9416,7 +10213,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9456,6 +10252,16 @@ "node": "20 || >=22" } }, + "node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16" + } + }, "node_modules/path-type": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", @@ -9519,6 +10325,16 @@ "node": ">=0.10" } }, + "node_modules/pkce-challenge": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/playwright": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", @@ -9901,6 +10717,20 @@ "node": ">=0.4.0" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "peer": true, + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-agent": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", @@ -9978,7 +10808,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -10063,7 +10892,6 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -10107,7 +10935,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -10607,6 +11434,48 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "peer": true + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -10652,7 +11521,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -10691,7 +11559,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/sass": { @@ -11387,14 +12254,12 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, "license": "ISC" }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -11407,7 +12272,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11430,7 +12294,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -11450,7 +12313,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -11467,7 +12329,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -11486,7 +12347,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -12758,7 +13618,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.6" @@ -13117,7 +13976,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -13168,6 +14026,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -13185,6 +14053,19 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -13211,7 +14092,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -13460,7 +14340,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -13597,7 +14476,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { @@ -13767,11 +14645,19 @@ "version": "3.24.3", "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.5", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", + "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } } } } diff --git a/package.json b/package.json index 7a8c889ba..b546aa6c6 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ }, "dependencies": { "@floating-ui/dom": "^1.7.0", + "@google/genai": "^1.3.0", "@lit-labs/virtualizer": "^2.1.0", "@lit/context": "^1.1.5", "lit": "^3.3.0" @@ -102,8 +103,8 @@ "vite": "^6.3.5" }, "peerDependencies": { - "marked": "^12.0.0", - "@supabase/supabase-js": "^2.49.4" + "@supabase/supabase-js": "^2.49.4", + "marked": "^12.0.0" }, "browserslist": [ "defaults" diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index 94aef6262..d03638ed2 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -17,6 +17,12 @@ import type { export interface IgcChatComponentEventMap { igcMessageCreated: CustomEvent; igcAttachmentClick: CustomEvent; + igcAttachmentChange: CustomEvent; + igcTypingChange: CustomEvent; + igcInputFocus: CustomEvent; + igcInputBlur: CustomEvent; + igcInputChange: CustomEvent; + igcMessageCopied: CustomEvent; } /** diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index bec9cc280..62839aea3 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from '@storybook/web-components-vite'; import { html } from 'lit'; +import { GoogleGenAI, Modality } from '@google/genai'; import { createClient } from '@supabase/supabase-js'; import { IgcChatComponent, @@ -9,6 +10,7 @@ import { } from 'igniteui-webcomponents'; import type { AttachmentTemplate, + IgcMessage, IgcMessageAttachment, MessageActionsTemplate, } from '../src/components/chat/types.js'; @@ -17,6 +19,10 @@ const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; const supabaseKey = import.meta.env.VITE_SUPABASE_ANON_KEY; const supabase = createClient(supabaseUrl, supabaseKey); +const ai = new GoogleGenAI({ + apiKey: 'AIzaSyAl9ce79kTYuovTP4ivV0u-mk_ZZ4cq0cM', +}); + defineComponents(IgcChatComponent); // region default @@ -131,6 +137,8 @@ let messages: any[] = [ }, ]; +const ai_messages: any[] = []; + let isResponseSent: boolean; const messageActionsTemplate = (msg: any) => { @@ -428,6 +436,51 @@ function generateAIResponse(message: string): string { return 'How can I help? Possible commands: hello, help, feature, weather, thank, code, image, list, heading.'; } +async function handleAIMessageSend(e: CustomEvent) { + const newMessage: IgcMessage = e.detail; + ai_messages.push(newMessage); + const chat = document.querySelector('igc-chat'); + if (!chat) { + return; + } + + let response: any; + let responseText = ''; + + if (newMessage.text.includes('image')) { + response = await ai.models.generateContent({ + model: 'gemini-2.0-flash-preview-image-generation', + contents: newMessage.text, + config: { + responseModalities: [Modality.TEXT, Modality.IMAGE], + }, + }); + for (const part of response?.candidates?.[0]?.content?.parts || []) { + // Based on the part type, either show the text or save the image + if (part.text) { + responseText = part.text; + } else if (part.inlineData) { + const _imageData = part.inlineData.data; + // console.log(imageData); + } + } + } else { + response = await ai.models.generateContent({ + model: 'gemini-2.0-flash', + contents: newMessage.text, + }); + responseText = response.text; + } + + const botResponse = { + id: Date.now().toString(), + text: responseText, + sender: 'bot', + timestamp: new Date(), + }; + chat.messages = [...ai_messages, botResponse]; +} + export const Basic: Story = { render: (args) => html` `, }; + +export const AI: Story = { + render: (args) => html` + + + `, +}; From 6a514c41ae57e938b4822fe479e10055a4ead1ca Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Wed, 4 Jun 2025 15:11:13 +0300 Subject: [PATCH 040/252] feat(chat): create image file from base64 encoded data --- stories/chat.stories.ts | 51 +++++++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 62839aea3..88c3e34c0 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -446,6 +446,14 @@ async function handleAIMessageSend(e: CustomEvent) { let response: any; let responseText = ''; + const attachments: IgcMessageAttachment[] = []; + const botResponse: IgcMessage = { + id: Date.now().toString(), + text: responseText, + sender: 'bot', + timestamp: new Date(), + }; + chat.messages = [...ai_messages, botResponse]; if (newMessage.text.includes('image')) { response = await ai.models.generateContent({ @@ -455,29 +463,54 @@ async function handleAIMessageSend(e: CustomEvent) { responseModalities: [Modality.TEXT, Modality.IMAGE], }, }); + for (const part of response?.candidates?.[0]?.content?.parts || []) { // Based on the part type, either show the text or save the image if (part.text) { responseText = part.text; } else if (part.inlineData) { const _imageData = part.inlineData.data; - // console.log(imageData); + const byteCharacters = atob(_imageData); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + const type = part.inlineData.type || 'image/png'; + const blob = new Blob([byteArray], { type: type }); + const file = new File([blob], 'generated_image.png', { + type: type, + }); + const attachment: IgcMessageAttachment = { + id: Date.now().toString(), + name: 'generated_image.png', + type: 'image', + url: URL.createObjectURL(file), + file: file, + }; + attachments.push(attachment); } } + + botResponse.text = responseText; + botResponse.attachments = attachments; } else { - response = await ai.models.generateContent({ + response = await ai.models.generateContentStream({ model: 'gemini-2.0-flash', contents: newMessage.text, }); - responseText = response.text; + + // console.log('AI response stream:', response); + + const lastMessageIndex = chat.messages.length - 1; + for await (const chunk of response) { + chat.messages[lastMessageIndex] = { + ...chat.messages[lastMessageIndex], + text: `${chat.messages[lastMessageIndex].text} ${chunk.text}`, + }; + } } - const botResponse = { - id: Date.now().toString(), - text: responseText, - sender: 'bot', - timestamp: new Date(), - }; chat.messages = [...ai_messages, botResponse]; } From cb20925413650bf1d466f2cc614ff210d63f1d28 Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Wed, 4 Jun 2025 15:20:30 +0300 Subject: [PATCH 041/252] feat(chat): moved key to .env file --- stories/chat.stories.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 88c3e34c0..53e967a1a 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -19,8 +19,9 @@ const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; const supabaseKey = import.meta.env.VITE_SUPABASE_ANON_KEY; const supabase = createClient(supabaseUrl, supabaseKey); +const googleGenAIKey = import.meta.env.GOOGLE_GEN_AI_KEY; const ai = new GoogleGenAI({ - apiKey: 'AIzaSyAl9ce79kTYuovTP4ivV0u-mk_ZZ4cq0cM', + apiKey: googleGenAIKey, }); defineComponents(IgcChatComponent); From 1a6ef6259c597e4cbbcde42edb31b6700bea9055 Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Wed, 4 Jun 2025 15:25:58 +0300 Subject: [PATCH 042/252] chore(*): update key name --- stories/chat.stories.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 53e967a1a..a5c263150 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -19,7 +19,7 @@ const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; const supabaseKey = import.meta.env.VITE_SUPABASE_ANON_KEY; const supabase = createClient(supabaseUrl, supabaseKey); -const googleGenAIKey = import.meta.env.GOOGLE_GEN_AI_KEY; +const googleGenAIKey = import.meta.env.VITE_GOOGLE_GEN_AI_KEY; const ai = new GoogleGenAI({ apiKey: googleGenAIKey, }); From b0601816a0ef6eae35d6cd78c563e217c08dcb41 Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Wed, 4 Jun 2025 15:57:41 +0300 Subject: [PATCH 043/252] feat(chat): fixed streaming in AI story --- stories/chat.stories.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index a5c263150..7596fc85a 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -138,8 +138,6 @@ let messages: any[] = [ }, ]; -const ai_messages: any[] = []; - let isResponseSent: boolean; const messageActionsTemplate = (msg: any) => { @@ -439,7 +437,6 @@ function generateAIResponse(message: string): string { async function handleAIMessageSend(e: CustomEvent) { const newMessage: IgcMessage = e.detail; - ai_messages.push(newMessage); const chat = document.querySelector('igc-chat'); if (!chat) { return; @@ -454,7 +451,6 @@ async function handleAIMessageSend(e: CustomEvent) { sender: 'bot', timestamp: new Date(), }; - chat.messages = [...ai_messages, botResponse]; if (newMessage.text.includes('image')) { response = await ai.models.generateContent({ @@ -495,24 +491,23 @@ async function handleAIMessageSend(e: CustomEvent) { botResponse.text = responseText; botResponse.attachments = attachments; + chat.messages = [...chat.messages, botResponse]; } else { + chat.messages = [...chat.messages, botResponse]; response = await ai.models.generateContentStream({ model: 'gemini-2.0-flash', contents: newMessage.text, }); - // console.log('AI response stream:', response); - const lastMessageIndex = chat.messages.length - 1; for await (const chunk of response) { chat.messages[lastMessageIndex] = { ...chat.messages[lastMessageIndex], text: `${chat.messages[lastMessageIndex].text} ${chunk.text}`, }; + chat.messages = [...chat.messages]; } } - - chat.messages = [...ai_messages, botResponse]; } export const Basic: Story = { From 334f2d797e3e7e3f08bf33af263a68dbc243118d Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Thu, 5 Jun 2025 09:55:36 +0300 Subject: [PATCH 044/252] chore(*): removed extra space in messages with chunk response --- stories/chat.stories.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 7596fc85a..9abb3b50e 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -503,7 +503,7 @@ async function handleAIMessageSend(e: CustomEvent) { for await (const chunk of response) { chat.messages[lastMessageIndex] = { ...chat.messages[lastMessageIndex], - text: `${chat.messages[lastMessageIndex].text} ${chunk.text}`, + text: `${chat.messages[lastMessageIndex].text}${chunk.text}`, }; chat.messages = [...chat.messages]; } From af1a50870111180a81230742ab803b124be5d9e0 Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Thu, 5 Jun 2025 13:12:46 +0300 Subject: [PATCH 045/252] feat(chat): send all user messages in order to have context --- stories/chat.stories.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 9abb3b50e..f4235dd6a 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -138,6 +138,8 @@ let messages: any[] = [ }, ]; +const userMessages: any[] = []; + let isResponseSent: boolean; const messageActionsTemplate = (msg: any) => { @@ -452,10 +454,12 @@ async function handleAIMessageSend(e: CustomEvent) { timestamp: new Date(), }; + userMessages.push({ role: 'user', parts: [{ text: newMessage.text }] }); + if (newMessage.text.includes('image')) { response = await ai.models.generateContent({ model: 'gemini-2.0-flash-preview-image-generation', - contents: newMessage.text, + contents: userMessages, config: { responseModalities: [Modality.TEXT, Modality.IMAGE], }, @@ -496,7 +500,10 @@ async function handleAIMessageSend(e: CustomEvent) { chat.messages = [...chat.messages, botResponse]; response = await ai.models.generateContentStream({ model: 'gemini-2.0-flash', - contents: newMessage.text, + contents: userMessages, + config: { + responseModalities: [Modality.TEXT], + }, }); const lastMessageIndex = chat.messages.length - 1; From a411ab83d9f458b8c7269cd937a2f01b9e0aa42f Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Fri, 6 Jun 2025 10:58:24 +0300 Subject: [PATCH 046/252] feat(chat): send attachments to gemini and move package to peerDeps --- package-lock.json | 30 +++++++++++++++++++++++++----- package.json | 4 ++-- stories/chat.stories.ts | 29 +++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index af60c7bc2..b9db42e0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@floating-ui/dom": "^1.7.0", - "@google/genai": "^1.3.0", "@lit-labs/virtualizer": "^2.1.0", "@lit/context": "^1.1.5", "lit": "^3.3.0" @@ -62,6 +61,7 @@ "vite": "^6.3.5" }, "peerDependencies": { + "@google/genai": "^1.3.0", "@supabase/supabase-js": "^2.49.4", "marked": "^12.0.0" } @@ -1053,6 +1053,7 @@ "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.3.0.tgz", "integrity": "sha512-rrMzAELX4P902FUpuWy/W3NcQ7L3q/qtCzfCmGVqIce8yWpptTF9hkKsw744tvZpwqhuzD0URibcJA95wd8QFA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "google-auth-library": "^9.14.2", "ws": "^8.18.0", @@ -1071,6 +1072,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -4346,6 +4348,7 @@ "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.0.tgz", "integrity": "sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==", "license": "MIT", + "peer": true, "engines": { "node": "*" } @@ -4722,7 +4725,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/bytes": { "version": "3.1.2", @@ -5972,6 +5976,7 @@ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "safe-buffer": "^5.0.1" } @@ -6807,7 +6812,8 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/extract-zip": { "version": "2.0.1", @@ -7215,6 +7221,7 @@ "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", @@ -7231,6 +7238,7 @@ "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", "license": "Apache-2.0", + "peer": true, "dependencies": { "gaxios": "^6.1.1", "google-logging-utils": "^0.0.2", @@ -7499,6 +7507,7 @@ "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", "license": "Apache-2.0", + "peer": true, "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", @@ -7516,6 +7525,7 @@ "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" } @@ -7544,6 +7554,7 @@ "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", "license": "MIT", + "peer": true, "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" @@ -8385,6 +8396,7 @@ "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", "license": "MIT", + "peer": true, "dependencies": { "bignumber.js": "^9.0.0" } @@ -8431,6 +8443,7 @@ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", "license": "MIT", + "peer": true, "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", @@ -8442,6 +8455,7 @@ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", "license": "MIT", + "peer": true, "dependencies": { "jwa": "^2.0.0", "safe-buffer": "^5.0.1" @@ -9806,6 +9820,7 @@ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", + "peer": true, "dependencies": { "whatwg-url": "^5.0.0" }, @@ -9825,19 +9840,22 @@ "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/node-fetch/node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "peer": true }, "node_modules/node-fetch/node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "license": "MIT", + "peer": true, "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" @@ -14062,6 +14080,7 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", + "peer": true, "bin": { "uuid": "dist/bin/uuid" } @@ -14655,6 +14674,7 @@ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", "license": "ISC", + "peer": true, "peerDependencies": { "zod": "^3.24.1" } diff --git a/package.json b/package.json index b546aa6c6..7f3b124d5 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,6 @@ }, "dependencies": { "@floating-ui/dom": "^1.7.0", - "@google/genai": "^1.3.0", "@lit-labs/virtualizer": "^2.1.0", "@lit/context": "^1.1.5", "lit": "^3.3.0" @@ -104,7 +103,8 @@ }, "peerDependencies": { "@supabase/supabase-js": "^2.49.4", - "marked": "^12.0.0" + "marked": "^12.0.0", + "@google/genai": "^1.3.0" }, "browserslist": [ "defaults" diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index f4235dd6a..36674aeff 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -437,6 +437,23 @@ function generateAIResponse(message: string): string { return 'How can I help? Possible commands: hello, help, feature, weather, thank, code, image, list, heading.'; } +function fileToGenerativePart(buffer, mimeType) { + // Convert ArrayBuffer to base64 string in the browser + let binary = ''; + const bytes = new Uint8Array(buffer); + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + const base64String = btoa(binary); + + return { + inlineData: { + data: base64String, + mimeType, + }, + }; +} + async function handleAIMessageSend(e: CustomEvent) { const newMessage: IgcMessage = e.detail; const chat = document.querySelector('igc-chat'); @@ -456,6 +473,18 @@ async function handleAIMessageSend(e: CustomEvent) { userMessages.push({ role: 'user', parts: [{ text: newMessage.text }] }); + if (newMessage.attachments && newMessage.attachments.length > 0) { + for (const attachment of newMessage.attachments) { + if (attachment.file) { + const filePart = fileToGenerativePart( + await attachment.file.arrayBuffer(), + attachment.file.type + ); + userMessages.push({ role: 'user', parts: [filePart] }); + } + } + } + if (newMessage.text.includes('image')) { response = await ai.models.generateContent({ model: 'gemini-2.0-flash-preview-image-generation', From 2a8171ff52d641c7c5f09a88aad98c7570a29ece Mon Sep 17 00:00:00 2001 From: teodosiah Date: Fri, 6 Jun 2025 17:10:38 +0300 Subject: [PATCH 047/252] Merge branch 'dmdimitrov/chat-ai-component' of https://github.com/IgniteUI/igniteui-webcomponents into dmdimitrov/chat-ai-component From 3eeaf826d399b2ee68b31b26ccc04baf5c98fa64 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Fri, 6 Jun 2025 17:11:44 +0300 Subject: [PATCH 048/252] fix(*): fix lint and types --- src/components/chat/chat-message-list.ts | 10 +++++----- src/components/chat/chat-message.ts | 12 ++++++------ src/components/chat/chat.ts | 10 +++++----- src/components/chat/message-attachments.ts | 10 +++++----- src/components/chat/themes/input.base.scss | 2 +- src/components/chat/themes/message.base.scss | 4 ++-- src/index.ts | 11 +++++++++++ 7 files changed, 35 insertions(+), 24 deletions(-) diff --git a/src/components/chat/chat-message-list.ts b/src/components/chat/chat-message-list.ts index b30369ca0..8ace64c61 100644 --- a/src/components/chat/chat-message-list.ts +++ b/src/components/chat/chat-message-list.ts @@ -32,19 +32,19 @@ export default class IgcChatMessageListComponent extends LitElement { @property({ type: Boolean, attribute: 'disable-auto-scroll' }) public disableAutoScroll = false; - @property({ type: Function }) + @property({ attribute: false }) public attachmentTemplate?: AttachmentTemplate; - @property({ type: Function }) + @property({ attribute: false }) public attachmentHeaderTemplate?: AttachmentTemplate; - @property({ type: Function }) + @property({ attribute: false }) public attachmentActionsTemplate?: AttachmentTemplate; - @property({ type: Function }) + @property({ attribute: false }) public attachmentContentTemplate?: AttachmentTemplate; - @property({ type: Function }) + @property({ attribute: false }) public messageActionsTemplate?: MessageActionsTemplate; private formatDate(date: Date): string { diff --git a/src/components/chat/chat-message.ts b/src/components/chat/chat-message.ts index ddef96d81..af3079723 100644 --- a/src/components/chat/chat-message.ts +++ b/src/components/chat/chat-message.ts @@ -3,7 +3,7 @@ import { property } from 'lit/decorators.js'; import IgcAvatarComponent from '../avatar/avatar.js'; import { registerComponent } from '../common/definitions/register.js'; import { renderMarkdown } from './markdown-util.js'; -import { IgcMessageAttachmentsComponent } from './message-attachments.js'; +import IgcMessageAttachmentsComponent from './message-attachments.js'; import { styles } from './themes/message.base.css.js'; import type { AttachmentTemplate, @@ -34,19 +34,19 @@ export default class IgcChatMessageComponent extends LitElement { @property({ reflect: true, attribute: false }) public message: IgcMessage | undefined; - @property({ type: Function }) + @property({ attribute: false }) public attachmentTemplate?: AttachmentTemplate; - @property({ type: Function }) + @property({ attribute: false }) public attachmentHeaderTemplate?: AttachmentTemplate; - @property({ type: Function }) + @property({ attribute: false }) public attachmentActionsTemplate?: AttachmentTemplate; - @property({ type: Function }) + @property({ attribute: false }) public attachmentContentTemplate?: AttachmentTemplate; - @property({ type: Function }) + @property({ attribute: false }) public messageActionsTemplate?: MessageActionsTemplate; protected override render() { diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index d03638ed2..244f149dd 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -75,19 +75,19 @@ export default class IgcChatComponent extends EventEmitterMixin< @property({ type: String, attribute: 'header-text', reflect: true }) public headerText = ''; - @property({ type: Function }) + @property({ attribute: false }) public attachmentTemplate?: AttachmentTemplate; - @property({ type: Function }) + @property({ attribute: false }) public attachmentHeaderTemplate?: AttachmentTemplate; - @property({ type: Function }) + @property({ attribute: false }) public attachmentActionsTemplate?: AttachmentTemplate; - @property({ type: Function }) + @property({ attribute: false }) public attachmentContentTemplate?: AttachmentTemplate; - @property({ type: Function }) + @property({ attribute: false }) public messageActionsTemplate?: MessageActionsTemplate; public override connectedCallback() { diff --git a/src/components/chat/message-attachments.ts b/src/components/chat/message-attachments.ts index f4e68d054..428ef40fe 100644 --- a/src/components/chat/message-attachments.ts +++ b/src/components/chat/message-attachments.ts @@ -21,7 +21,7 @@ import { * @element igc-message-attachments * */ -export class IgcMessageAttachmentsComponent extends LitElement { +export default class IgcMessageAttachmentsComponent extends LitElement { /** @private */ public static readonly tagName = 'igc-message-attachments'; @@ -42,16 +42,16 @@ export class IgcMessageAttachmentsComponent extends LitElement { @property({ type: String }) previewImage = ''; - @property({ type: Function }) + @property({ attribute: false }) attachmentTemplate: AttachmentTemplate | undefined; - @property({ type: Function }) + @property({ attribute: false }) attachmentHeaderTemplate: AttachmentTemplate | undefined; - @property({ type: Function }) + @property({ attribute: false }) attachmentActionsTemplate: AttachmentTemplate | undefined; - @property({ type: Function }) + @property({ attribute: false }) attachmentContentTemplate: AttachmentTemplate | undefined; constructor() { diff --git a/src/components/chat/themes/input.base.scss b/src/components/chat/themes/input.base.scss index 9f8143cab..3922f34dc 100644 --- a/src/components/chat/themes/input.base.scss +++ b/src/components/chat/themes/input.base.scss @@ -22,7 +22,7 @@ igc-file-input::part(file-names){ } .input-container.dragging{ - background-color: rgba(10, 132, 255, 0.1); + background-color: black; border: 2px dashed #0A84FF; } diff --git a/src/components/chat/themes/message.base.scss b/src/components/chat/themes/message.base.scss index a9f02c2bc..7db6d67c7 100644 --- a/src/components/chat/themes/message.base.scss +++ b/src/components/chat/themes/message.base.scss @@ -35,7 +35,7 @@ } .message-container pre { - background-color: rgba(0, 0, 0, 0.05); + background-color: black; padding: 8px; border-radius: 4px; overflow-x: auto; @@ -43,7 +43,7 @@ } .message-container code { - font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 0.9em; padding: 2px 4px; border-radius: 4px; diff --git a/src/index.ts b/src/index.ts index 9af67ed1e..0a959d3ad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,10 @@ export { default as IgcCarouselComponent } from './components/carousel/carousel. export { default as IgcCarouselIndicatorComponent } from './components/carousel/carousel-indicator.js'; export { default as IgcCarouselSlideComponent } from './components/carousel/carousel-slide.js'; export { default as IgcChatComponent } from './components/chat/chat.js'; +export { default as IgcChatInputComponent } from './components/chat/chat-input.js'; +export { default as IgcChatMessageComponent } from './components/chat/chat-message.js'; +export { default as IgcChatMessageListComponent } from './components/chat/chat-message-list.js'; +export { default as IgcChatMessageAttachmentsComponent } from './components/chat/message-attachments.js'; export { default as IgcCheckboxComponent } from './components/checkbox/checkbox.js'; export { default as IgcCircularProgressComponent } from './components/progress/circular-progress.js'; export { default as IgcCircularGradientComponent } from './components/progress/circular-gradient.js'; @@ -95,6 +99,7 @@ export type { IgcBannerComponentEventMap } from './components/banner/banner.js'; export type { IgcButtonGroupComponentEventMap } from './components/button-group/button-group.js'; export type { IgcCalendarComponentEventMap } from './components/calendar/types.js'; export type { IgcCarouselComponentEventMap } from './components/carousel/carousel.js'; +export type { IgcChatComponentEventMap } from './components/chat/chat.js'; export type { IgcCheckboxComponentEventMap } from './components/checkbox/checkbox-base.js'; export type { IgcCheckboxComponentEventMap as IgcSwitchComponentEventMap } from './components/checkbox/checkbox-base.js'; export type { IgcChipComponentEventMap } from './components/chip/chip.js'; @@ -155,3 +160,9 @@ export type { IgcComboChangeEventArgs, } from './components/combo/types.js'; export type { IconMeta } from './components/icon/registry/types.js'; +export type { + IgcMessage, + IgcMessageAttachment, + AttachmentTemplate, + MessageActionsTemplate, +} from './components/chat/types.js'; From 2e54d29e1f211b13d6ffb3935b206f2298948e6d Mon Sep 17 00:00:00 2001 From: teodosiah Date: Mon, 9 Jun 2025 13:36:11 +0300 Subject: [PATCH 049/252] chore(*): fix marked build issues --- src/components/chat/markdown-util.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/components/chat/markdown-util.ts b/src/components/chat/markdown-util.ts index d46c50513..98c73898a 100644 --- a/src/components/chat/markdown-util.ts +++ b/src/components/chat/markdown-util.ts @@ -4,9 +4,6 @@ import { marked } from 'marked'; marked.setOptions({ gfm: true, breaks: true, - sanitize: true, - smartLists: true, - smartypants: true, }); const renderer = new marked.Renderer(); @@ -24,7 +21,7 @@ renderer.link = (href, title, text) => { export function renderMarkdown(text: string): TemplateResult { if (!text) return html``; - const rendered = marked(text, { renderer }); + const rendered = marked(text, { renderer }).toString(); const template = document.createElement('template'); template.innerHTML = rendered; From 12c0bbfb4ebdd41657904a63153edfdb0e9c4e65 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Thu, 12 Jun 2025 16:08:37 +0300 Subject: [PATCH 050/252] chore(*): make message attachment file optional --- src/components/chat/message-attachments.ts | 44 ++++++++++++++++++---- src/components/chat/types.ts | 4 +- stories/chat.stories.ts | 14 ++----- 3 files changed, 42 insertions(+), 20 deletions(-) diff --git a/src/components/chat/message-attachments.ts b/src/components/chat/message-attachments.ts index 428ef40fe..a48b46095 100644 --- a/src/components/chat/message-attachments.ts +++ b/src/components/chat/message-attachments.ts @@ -63,8 +63,8 @@ export default class IgcMessageAttachmentsComponent extends LitElement { registerIconFromText('more', moreIcon, 'material'); } - private openImagePreview(url: string) { - this.previewImage = url; + private openImagePreview(attachment: IgcMessageAttachment) { + this.previewImage = this.getURL(attachment); } private closeImagePreview() { @@ -86,6 +86,30 @@ export default class IgcMessageAttachmentsComponent extends LitElement { ); } + private getFile(attachment: IgcMessageAttachment): File | undefined { + if (attachment.file) { + return attachment.file; + } + if (attachment.url) { + const url = new URL(attachment.url); + const fileName = url.pathname.split('/').pop() || 'attachment'; + return new File([], fileName, { + type: attachment.type || 'application/octet-stream', + }); + } + return undefined; + } + + private getURL(attachment: IgcMessageAttachment): string { + if (attachment.url) { + return attachment.url; + } + if (attachment.file) { + return URL.createObjectURL(attachment.file); + } + return ''; + } + protected override render() { return html`
@@ -95,7 +119,8 @@ export default class IgcMessageAttachmentsComponent extends LitElement { (attachment) => html` this.handleToggle(ev, attachment)} @igcOpening=${(ev: CustomEvent) => @@ -107,7 +132,8 @@ export default class IgcMessageAttachmentsComponent extends LitElement { ? this.attachmentHeaderTemplate(this.attachments) : html` - ${attachment.file?.type.startsWith('image/') + ${attachment.type === 'image' || + attachment.file?.type.startsWith('image/') ? html` - ${attachment.file?.type.startsWith('image/') + ${attachment.type === 'image' || + attachment.file?.type.startsWith('image/') ? html` - this.openImagePreview(attachment.url)} + this.openImagePreview(attachment)} >` : ''} - ${attachment.file?.type.startsWith('image/') + ${attachment.type === 'image' || + attachment.file?.type.startsWith('image/') ? html` ${attachment.name}` : ''} diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts index 97850c208..fc41fb2f5 100644 --- a/src/components/chat/types.ts +++ b/src/components/chat/types.ts @@ -12,10 +12,10 @@ export interface IgcMessage { export interface IgcMessageAttachment { id: string; - url: string; name: string; - type?: string; + url?: string; file?: File; + type?: string; thumbnail?: string; } diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 36674aeff..0b1366ff5 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -9,10 +9,8 @@ import { registerIconFromText, } from 'igniteui-webcomponents'; import type { - AttachmentTemplate, IgcMessage, IgcMessageAttachment, - MessageActionsTemplate, } from '../src/components/chat/types.js'; const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; @@ -108,11 +106,6 @@ interface IgcChatArgs { */ acceptedFiles: string; headerText: string; - attachmentTemplate: AttachmentTemplate; - attachmentHeaderTemplate: AttachmentTemplate; - attachmentActionsTemplate: AttachmentTemplate; - attachmentContentTemplate: AttachmentTemplate; - messageActionsTemplate: MessageActionsTemplate; } type Story = StoryObj; @@ -187,9 +180,9 @@ function handleMessageSend(e: CustomEvent) { ? [ { id: 'random_img', - // type: newMessage.text.includes('file') ? 'file' : 'image', - url: 'https://picsum.photos/378/395', name: 'random.png', + url: 'https://picsum.photos/378/395', + type: 'image', }, ] : []; @@ -251,7 +244,7 @@ async function fetchMessages() { async function processMappedData(data: any) { for (const message of data) { for (const attachment of message.attachments) { - if (attachment.type.startsWith('image/')) { + if (attachment.type.startsWith('image')) { const file = await fetchAttachment(attachment.name); if (file) { attachment.file = file; @@ -320,6 +313,7 @@ async function handleMessageCreatedSupabase(e: CustomEvent) { showResponse(chat, responseParts).then(() => { const lastMessageIndex = chat.messages.length - 1; const lastMessage = chat.messages[lastMessageIndex]; + lastMessage.attachments = attachments; saveMessageToSupabase(lastMessage); isResponseSent = true; // TODO: add attachments (if any) to the response message From b4e34e9f46d489a9341d3d1089dbb7a8ddb2151a Mon Sep 17 00:00:00 2001 From: teodosiah Date: Mon, 23 Jun 2025 13:41:55 +0300 Subject: [PATCH 051/252] feat(chat): expose options prop, chat as context & add tests --- src/components/chat/chat-input.ts | 22 +- src/components/chat/chat-message-list.ts | 51 +- src/components/chat/chat-message.ts | 36 +- src/components/chat/chat.spec.ts | 664 +++++++++++++++++++++ src/components/chat/chat.ts | 71 +-- src/components/chat/message-attachments.ts | 45 +- src/components/chat/types.ts | 23 + src/components/common/context.ts | 5 +- stories/chat.stories.ts | 139 ++--- 9 files changed, 804 insertions(+), 252 deletions(-) create mode 100644 src/components/chat/chat.spec.ts diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index 1961b6637..eb60ad3b1 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -1,13 +1,16 @@ +import { consume } from '@lit/context'; import { LitElement, html } from 'lit'; -import { property, query, state } from 'lit/decorators.js'; +import { query, state } from 'lit/decorators.js'; import IgcIconButtonComponent from '../button/icon-button.js'; import IgcChipComponent from '../chip/chip.js'; +import { chatContext } from '../common/context.js'; import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; import IgcFileInputComponent from '../file-input/file-input.js'; import IgcIconComponent from '../icon/icon.js'; import { registerIconFromText } from '../icon/icon.registry.js'; import IgcTextareaComponent from '../textarea/textarea.js'; +import type IgcChatComponent from './chat.js'; import { styles } from './themes/input.base.css.js'; import { type IgcMessageAttachment, @@ -26,6 +29,9 @@ export default class IgcChatInputComponent extends LitElement { public static override styles = styles; + @consume({ context: chatContext, subscribe: true }) + private _chat?: IgcChatComponent; + /* blazorSuppress */ public static register() { registerComponent( @@ -38,12 +44,6 @@ export default class IgcChatInputComponent extends LitElement { ); } - @property({ type: Boolean, attribute: 'disable-attachments' }) - public disableAttachments = false; - - @property({ type: String }) - public acceptedFiles = ''; - @query('textarea') private textInputElement!: HTMLTextAreaElement; @@ -210,12 +210,12 @@ export default class IgcChatInputComponent extends LitElement { } private updateAcceptedTypesCache() { - if (!this.acceptedFiles) { + if (!this._chat?.options?.acceptedFiles) { this._acceptedTypesCache = null; return; } - const types = this.acceptedFiles + const types = this._chat?.options?.acceptedFiles .split(',') .map((type) => type.trim().toLowerCase()); this._acceptedTypesCache = { @@ -263,12 +263,12 @@ export default class IgcChatInputComponent extends LitElement { protected override render() { return html`
- ${this.disableAttachments + ${this._chat?.options?.disableAttachments ? '' : html`
@@ -121,14 +103,7 @@ export default class IgcChatMessageListComponent extends LitElement { group.messages, (message) => message.id, (message) => html` - + ` )} ` diff --git a/src/components/chat/chat-message.ts b/src/components/chat/chat-message.ts index af3079723..e5795c2fe 100644 --- a/src/components/chat/chat-message.ts +++ b/src/components/chat/chat-message.ts @@ -1,15 +1,14 @@ +import { consume } from '@lit/context'; import { LitElement, html } from 'lit'; import { property } from 'lit/decorators.js'; import IgcAvatarComponent from '../avatar/avatar.js'; +import { chatContext } from '../common/context.js'; import { registerComponent } from '../common/definitions/register.js'; +import type IgcChatComponent from './chat.js'; import { renderMarkdown } from './markdown-util.js'; import IgcMessageAttachmentsComponent from './message-attachments.js'; import { styles } from './themes/message.base.css.js'; -import type { - AttachmentTemplate, - IgcMessage, - MessageActionsTemplate, -} from './types.js'; +import type { IgcMessage } from './types.js'; /** * @@ -22,6 +21,9 @@ export default class IgcChatMessageComponent extends LitElement { public static override styles = styles; + @consume({ context: chatContext, subscribe: true }) + private _chat?: IgcChatComponent; + /* blazorSuppress */ public static register() { registerComponent( @@ -34,21 +36,6 @@ export default class IgcChatMessageComponent extends LitElement { @property({ reflect: true, attribute: false }) public message: IgcMessage | undefined; - @property({ attribute: false }) - public attachmentTemplate?: AttachmentTemplate; - - @property({ attribute: false }) - public attachmentHeaderTemplate?: AttachmentTemplate; - - @property({ attribute: false }) - public attachmentActionsTemplate?: AttachmentTemplate; - - @property({ attribute: false }) - public attachmentContentTemplate?: AttachmentTemplate; - - @property({ attribute: false }) - public messageActionsTemplate?: MessageActionsTemplate; - protected override render() { const containerClass = `message-container ${this.message?.sender === 'user' ? 'sent' : ''}`; @@ -61,15 +48,12 @@ export default class IgcChatMessageComponent extends LitElement { ${this.message?.attachments && this.message?.attachments.length > 0 ? html` ` : ''} - ${this.messageActionsTemplate && this.message - ? this.messageActionsTemplate(this.message) + ${this._chat?.options?.templates?.messageActionsTemplate && + this.message + ? this._chat.options.templates.messageActionsTemplate(this.message) : ''}
diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts new file mode 100644 index 000000000..64f039760 --- /dev/null +++ b/src/components/chat/chat.spec.ts @@ -0,0 +1,664 @@ +import { aTimeout, elementUpdated, expect, fixture } from '@open-wc/testing'; +import { html } from 'lit'; +import { type SinonFakeTimers, useFakeTimers } from 'sinon'; +import { defineComponents } from '../common/definitions/defineComponents.js'; +import { simulateClick, simulateFocus } from '../common/utils.spec.js'; +import IgcChatComponent from './chat.js'; + +describe('Chat', () => { + before(() => { + defineComponents(IgcChatComponent); + }); + + const createChatComponent = () => html``; + const messageActionsTemplate = (msg: any) => { + return msg.sender !== 'user' && msg.text.trim() + ? html`
+ ... +
` + : html``; + }; + + const attachmentTemplate = (attachments: any[]) => { + return html`${attachments.map((attachment) => { + return html`${attachment.name}`; + })}`; + }; + + const attachmentHeaderTemplate = (attachments: any[]) => { + return html`${attachments.map((attachment) => { + return html`
Custom ${attachment.name}
`; + })}`; + }; + + const attachmentActionsTemplate = (attachments: any[]) => { + return html`${attachments.map(() => { + return html`?`; + })}`; + }; + + const attachmentContentTemplate = (attachments: any[]) => { + return html`${attachments.map((attachment) => { + return html`

+ This is a template rendered as content of ${attachment.name} +

`; + })}`; + }; + + const messages: any[] = [ + { + id: '1', + text: 'Hello! How can I help you today?', + sender: 'bot', + timestamp: new Date(Date.now() - 3600000), + }, + { + id: '2', + text: 'Hello!', + sender: 'user', + timestamp: new Date(Date.now() - 3500000), + attachments: [ + { + id: 'img1', + name: 'img1.png', + url: 'https://www.infragistics.com/angular-demos/assets/images/men/1.jpg', + type: 'image', + }, + ], + }, + { + id: '3', + text: 'Thank you!', + sender: 'bot', + timestamp: new Date(Date.now() - 3400000), + attachments: [ + { + id: 'img2', + name: 'img2.png', + url: 'https://www.infragistics.com/angular-demos/assets/images/men/2.jpg', + type: 'file', + }, + ], + }, + { + id: '4', + text: 'Thank you too!', + sender: 'user', + timestamp: new Date(Date.now() - 3300000), + }, + ]; + + let chat: IgcChatComponent; + let clock: SinonFakeTimers; + + beforeEach(async () => { + chat = await fixture(createChatComponent()); + clock = useFakeTimers({ toFake: ['setInterval'] }); + }); + + afterEach(() => { + clock.restore(); + }); + + describe('Initialization', () => { + it('is correctly initialized with its default component state', () => { + expect(chat.messages.length).to.equal(0); + expect(chat.options).to.be.undefined; + }); + + it('is rendered correctly', () => { + expect(chat).dom.to.equal( + ` + ` + ); + + expect(chat).shadowDom.to.equal( + `
+
+
+ + + + +
+ + + ⋯ + + +
+ + + + +
` + ); + + const messageList = chat.shadowRoot?.querySelector( + 'igc-chat-message-list' + ); + + expect(messageList).shadowDom.to.equal( + `
+
+
+
` + ); + + const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); + + expect(inputArea).shadowDom.to.equal( + `
+ + + + +
+ + +
+
+ + +
+
+
+
` + ); + }); + + it('should render initially set messages correctly', async () => { + chat = await fixture( + html` ` + ); + + const messageContainer = chat.shadowRoot + ?.querySelector('igc-chat-message-list') + ?.shadowRoot?.querySelector('.message-list'); + + expect(chat.messages.length).to.equal(4); + expect(messageContainer).dom.to.equal( + `
+ + + + + + + + +
` + ); + + expect( + messageContainer?.querySelector('igc-chat-message') + ).shadowDom.to.equal( + `
+
+
+

Hello! How can I help you today?

+
+
+
` + ); + }); + + it('should apply `headerText` correctly', async () => { + chat.options = { + headerText: 'Chat', + }; + await elementUpdated(chat); + + const headerArea = chat.shadowRoot?.querySelector('.header'); + + expect(headerArea).dom.to.equal( + `
+
+ + + + Chat + +
+ + + ⋯ + + +
` + ); + }); + + it('should scroll to bottom by default', async () => { + chat.messages = [messages[0], messages[1], messages[2]]; + await elementUpdated(chat); + await clock.tickAsync(500); + + const messagesContainer = chat.shadowRoot?.querySelector( + 'igc-chat-message-list' + ); + let scrollPosition = messagesContainer + ? messagesContainer.scrollHeight - messagesContainer.scrollTop + : 0; + expect(scrollPosition).to.equal(messagesContainer?.clientHeight); + + chat.messages = [...chat.messages, messages[3]]; + await chat.updateComplete; + await clock.tickAsync(500); + + scrollPosition = messagesContainer + ? messagesContainer.scrollHeight - messagesContainer.scrollTop + : 0; + + expect(chat.messages.length).to.equal(4); + expect(messagesContainer?.scrollTop).not.to.equal(0); + expect(scrollPosition).to.equal(messagesContainer?.clientHeight); + }); + + it('should not scroll to bottom if `disableAutoScroll` is true', async () => { + chat.messages = [messages[0], messages[1], messages[2]]; + chat.options = { + disableAutoScroll: true, + }; + await elementUpdated(chat); + await clock.tickAsync(500); + + const messagesContainer = chat.shadowRoot?.querySelector( + 'igc-chat-message-list' + ); + const scrollPosition = messagesContainer + ? messagesContainer.scrollHeight - messagesContainer.scrollTop + : 0; + expect(scrollPosition).to.equal(messagesContainer?.clientHeight); + + messagesContainer?.scrollTo(0, 0); + chat.messages = [...chat.messages, messages[3]]; + await chat.updateComplete; + await clock.tickAsync(500); + + expect(chat.messages.length).to.equal(4); + expect(messagesContainer?.scrollTop).to.equal(0); + }); + + it('should not render attachment button if `disableAttachments` is true', async () => { + chat.options = { + disableAttachments: true, + }; + await elementUpdated(chat); + + const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); + + expect(inputArea).shadowDom.to.equal( + `
+
+ + +
+
+ + +
+
+
+
` + ); + }); + + it('should render attachments correctly', async () => { + chat.messages = [messages[1], messages[2]]; + await elementUpdated(chat); + await aTimeout(500); + + const messageElements = chat.shadowRoot + ?.querySelector('igc-chat-message-list') + ?.shadowRoot?.querySelector('.message-list') + ?.querySelectorAll('igc-chat-message'); + messageElements?.forEach((messageElement, index) => { + const messsageContainer = + messageElement.shadowRoot?.querySelector('.bubble'); + expect(messsageContainer).dom.to.equal( + `
+
+

${(messsageContainer as HTMLElement)?.innerText}

+
+ + +
` + ); + + const attachments = messsageContainer?.querySelector( + 'igc-message-attachments' + ); + // Check if image attachments are rendered correctly + if (index === 0) { + expect(attachments).shadowDom.to.equal( + `
+ +
+
+ + + + + + + img1.png + + +
+
+ + + + + + +
+
+ + img1.png + +
+
` + ); + } + // Check if non-image attachments are rendered correctly + if (index === 1) { + expect(attachments).shadowDom.to.equal( + `
+ +
+
+ + + + + + + img2.png + + +
+
+ + + + +
+
+ + +
+
` + ); + } + }); + }); + }); + + describe('Slots', () => { + beforeEach(async () => { + chat = await fixture( + html` +
+ +
+

Title

+
+ ? +
+
` + ); + }); + + it('should slot header prefix', () => {}); + it('should slot header title', () => {}); + it('should slot header action buttons area', () => {}); + }); + + describe('Templates', () => { + beforeEach(async () => { + chat.messages = [messages[1], messages[2]]; + }); + + it('should render attachmentTemplate', async () => { + chat.options = { + templates: { + attachmentTemplate: attachmentTemplate, + }, + }; + await elementUpdated(chat); + await aTimeout(500); + + const messageElements = chat.shadowRoot + ?.querySelector('igc-chat-message-list') + ?.shadowRoot?.querySelector('.message-list') + ?.querySelectorAll('igc-chat-message'); + messageElements?.forEach((messageElement, index) => { + const messsageContainer = + messageElement.shadowRoot?.querySelector('.bubble'); + const attachments = messsageContainer?.querySelector( + 'igc-message-attachments' + ); + expect(attachments).shadowDom.to.equal( + `
+ + + ${chat.messages[index].attachments?.[0].name || ''} + + +
` + ); + }); + }); + it('should render attachmentHeaderTemplate, attachmentActionsTemplate, attachmentContentTemplate', async () => { + chat.options = { + templates: { + attachmentHeaderTemplate: attachmentHeaderTemplate, + attachmentActionsTemplate: attachmentActionsTemplate, + attachmentContentTemplate: attachmentContentTemplate, + }, + }; + await elementUpdated(chat); + await clock.tickAsync(500); + + const messageElements = chat.shadowRoot + ?.querySelector('igc-chat-message-list') + ?.shadowRoot?.querySelector('.message-list') + ?.querySelectorAll('igc-chat-message'); + + messageElements?.forEach((messageElement, index) => { + const messsageContainer = + messageElement.shadowRoot?.querySelector('.bubble'); + const attachments = messsageContainer?.querySelector( + 'igc-message-attachments' + ); + + const details = attachments?.shadowRoot?.querySelector('.details'); + expect(details).dom.to.equal( + `
+
Custom ${chat.messages[index].attachments?.[0].name}
+
` + ); + + const actions = attachments?.shadowRoot?.querySelector('.actions'); + expect(actions).dom.to.equal( + `
+ ? +
` + ); + + const content = attachments?.shadowRoot?.querySelector('p'); + expect(content).dom.to.equal( + `

This is a template rendered as content of ${chat.messages[index].attachments?.[0].name}

` + ); + }); + }); + + it('should render messageActionsTemplate', async () => { + chat.options = { + templates: { + messageActionsTemplate: messageActionsTemplate, + }, + }; + await elementUpdated(chat); + await aTimeout(500); + const messageElements = chat.shadowRoot + ?.querySelector('igc-chat-message-list') + ?.shadowRoot?.querySelector('.message-list') + ?.querySelectorAll('igc-chat-message'); + messageElements?.forEach((messageElement, index) => { + const messsageContainer = + messageElement.shadowRoot?.querySelector('.bubble'); + if (index === 0) { + expect(messsageContainer).dom.to.equal( + `
+
+

${(messsageContainer?.querySelector('p') as HTMLElement)?.innerText}

+
+ + +
` + ); + } else { + expect(messsageContainer).dom.to.equal( + `
+
+

${(messsageContainer?.querySelector('p') as HTMLElement)?.innerText}

+
+ + +
+ ... +
+
` + ); + } + }); + }); + }); + + describe('Interactions', () => { + describe('Click', () => { + it('should update messages properly on send button click', async () => { + const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); + const sendButton = inputArea?.shadowRoot?.querySelector( + 'igc-icon-button[name="send-message"]' + ); + const textArea = inputArea?.shadowRoot?.querySelector('igc-textarea'); + + if (sendButton && textArea) { + textArea.setAttribute('value', 'Hello!'); + textArea.dispatchEvent(new Event('input')); + await elementUpdated(chat); + simulateClick(sendButton); + await elementUpdated(chat); + await clock.tickAsync(500); + expect(chat.messages.length).to.equal(1); + expect(chat.messages[0].text).to.equal('Hello!'); + expect(chat.messages[0].sender).to.equal('user'); + } + }); + it('should remove attachement on chip remove button click', () => {}); + }); + + describe('Drag &Drop', () => { + it('should be able to drop files base on the types listed in `acceptedFiles`', () => {}); + }); + + describe('Keyboard', () => { + it('should update messages properly on `Enter` keypress when the textarea is focused', async () => { + const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); + const sendButton = inputArea?.shadowRoot?.querySelector( + 'igc-icon-button[name="send-message"]' + ); + const textArea = inputArea?.shadowRoot?.querySelector('igc-textarea'); + + if (sendButton && textArea) { + textArea.setAttribute('value', 'Hello!'); + textArea.dispatchEvent(new Event('input')); + await elementUpdated(chat); + simulateFocus(textArea); + textArea?.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + }) + ); + await elementUpdated(chat); + await clock.tickAsync(500); + expect(chat.messages.length).to.equal(1); + expect(chat.messages[0].text).to.equal('Hello!'); + expect(chat.messages[0].sender).to.equal('user'); + } + }); + }); + }); + + describe('Events', () => { + it('emits igcMessageCreated', async () => {}); + it('emits igcAttachmentClick', async () => {}); + it('emits igcAttachmentChange', async () => {}); + it('emits igcTypingChange', async () => {}); + it('emits igcInputFocus', async () => {}); + it('emits igcInputBlur', async () => {}); + it('emits igcInputChange', async () => {}); + }); +}); diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index 244f149dd..0e433cbf7 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -1,6 +1,9 @@ +import { ContextProvider } from '@lit/context'; import { LitElement, html } from 'lit'; import { property } from 'lit/decorators.js'; import IgcButtonComponent from '../button/button.js'; +import { chatContext } from '../common/context.js'; +import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; import type { Constructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; @@ -8,10 +11,9 @@ import IgcChatInputComponent from './chat-input.js'; import IgcChatMessageListComponent from './chat-message-list.js'; import { styles } from './themes/chat.base.css.js'; import type { - AttachmentTemplate, + IgcChatOptions, IgcMessage, IgcMessageAttachment, - MessageActionsTemplate, } from './types.js'; export interface IgcChatComponentEventMap { @@ -49,46 +51,22 @@ export default class IgcChatComponent extends EventEmitterMixin< ); } + private _context = new ContextProvider(this, { + context: chatContext, + initialValue: this, + }); + @property({ reflect: true, attribute: false }) public messages: IgcMessage[] = []; - @property({ type: Boolean, attribute: 'hide-avatar' }) - public hideAvatar = false; - - @property({ type: Boolean, attribute: 'hide-user-name' }) - public hideUserName = false; - - @property({ type: Boolean, attribute: 'disable-auto-scroll' }) - public disableAutoScroll = false; - - @property({ type: Boolean, attribute: 'disable-attachments' }) - public disableAttachments = false; - - /** - * The accepted files that could be attached. - * Defines the file types as a list of comma-separated values that the file input should accept. - * @attr - */ - @property({ type: String }) - public acceptedFiles = ''; - - @property({ type: String, attribute: 'header-text', reflect: true }) - public headerText = ''; - - @property({ attribute: false }) - public attachmentTemplate?: AttachmentTemplate; - @property({ attribute: false }) - public attachmentHeaderTemplate?: AttachmentTemplate; + public options?: IgcChatOptions; - @property({ attribute: false }) - public attachmentActionsTemplate?: AttachmentTemplate; - - @property({ attribute: false }) - public attachmentContentTemplate?: AttachmentTemplate; - - @property({ attribute: false }) - public messageActionsTemplate?: MessageActionsTemplate; + @watch('options') + @watch('messages') + protected contextChanged() { + this._context.setValue(this, true); + } public override connectedCallback() { super.connectedCallback(); @@ -137,31 +115,24 @@ export default class IgcChatComponent extends EventEmitterMixin< this.emitEvent('igcAttachmentClick', { detail: attachmentArgs }); } + protected override firstUpdated() { + this._context.setValue(this, true); + } + protected override render() { return html`
- ${this.headerText} + ${this.options?.headerText}
- - +
diff --git a/src/components/chat/message-attachments.ts b/src/components/chat/message-attachments.ts index a48b46095..b03224541 100644 --- a/src/components/chat/message-attachments.ts +++ b/src/components/chat/message-attachments.ts @@ -1,13 +1,15 @@ +import { consume } from '@lit/context'; import { LitElement, html } from 'lit'; import { property } from 'lit/decorators.js'; import IgcIconButtonComponent from '../button/icon-button.js'; +import { chatContext } from '../common/context.js'; import { registerComponent } from '../common/definitions/register.js'; import IgcExpansionPanelComponent from '../expansion-panel/expansion-panel.js'; import IgcIconComponent from '../icon/icon.js'; import { registerIconFromText } from '../icon/icon.registry.js'; +import type IgcChatComponent from './chat.js'; import { styles } from './themes/message-attachments.base.css'; import { - type AttachmentTemplate, type IgcMessageAttachment, closeIcon, fileIcon, @@ -27,6 +29,9 @@ export default class IgcMessageAttachmentsComponent extends LitElement { public static override styles = styles; + @consume({ context: chatContext, subscribe: true }) + private _chat?: IgcChatComponent; + /* blazorSuppress */ public static register() { registerComponent( @@ -42,18 +47,6 @@ export default class IgcMessageAttachmentsComponent extends LitElement { @property({ type: String }) previewImage = ''; - @property({ attribute: false }) - attachmentTemplate: AttachmentTemplate | undefined; - - @property({ attribute: false }) - attachmentHeaderTemplate: AttachmentTemplate | undefined; - - @property({ attribute: false }) - attachmentActionsTemplate: AttachmentTemplate | undefined; - - @property({ attribute: false }) - attachmentContentTemplate: AttachmentTemplate | undefined; - constructor() { super(); registerIconFromText('close', closeIcon, 'material'); @@ -113,14 +106,15 @@ export default class IgcMessageAttachmentsComponent extends LitElement { protected override render() { return html`
- ${this.attachmentTemplate - ? this.attachmentTemplate(this.attachments) + ${this._chat?.options?.templates?.attachmentTemplate + ? this._chat.options.templates.attachmentTemplate(this.attachments) : html` ${this.attachments.map( (attachment) => html` this.handleToggle(ev, attachment)} @igcOpening=${(ev: CustomEvent) => @@ -128,8 +122,10 @@ export default class IgcMessageAttachmentsComponent extends LitElement { >
- ${this.attachmentHeaderTemplate - ? this.attachmentHeaderTemplate(this.attachments) + ${this._chat?.options?.templates?.attachmentHeaderTemplate + ? this._chat.options.templates.attachmentHeaderTemplate( + this.attachments + ) : html` ${attachment.type === 'image' || @@ -151,8 +147,11 @@ export default class IgcMessageAttachmentsComponent extends LitElement { `}
- ${this.attachmentActionsTemplate - ? this.attachmentActionsTemplate(this.attachments) + ${this._chat?.options?.templates + ?.attachmentActionsTemplate + ? this._chat.options.templates.attachmentActionsTemplate( + this.attachments + ) : html` ${attachment.type === 'image' || @@ -177,8 +176,10 @@ export default class IgcMessageAttachmentsComponent extends LitElement {
- ${this.attachmentContentTemplate - ? this.attachmentContentTemplate(this.attachments) + ${this._chat?.options?.templates?.attachmentContentTemplate + ? this._chat.options.templates.attachmentContentTemplate( + this.attachments + ) : html` ${attachment.type === 'image' || diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts index fc41fb2f5..e4a180b96 100644 --- a/src/components/chat/types.ts +++ b/src/components/chat/types.ts @@ -24,6 +24,29 @@ export type AttachmentTemplate = ( ) => TemplateResult; export type MessageActionsTemplate = (message: IgcMessage) => TemplateResult; +export type IgcChatOptions = { + hideAvatar?: boolean; + hideTimestamp?: boolean; + hideUserName?: boolean; + disableAutoScroll?: boolean; + disableAttachments?: boolean; + /** + * The accepted files that could be attached. + * Defines the file types as a list of comma-separated values that the file input should accept. + */ + acceptedFiles?: string; + headerText?: string; + templates?: IgcChatTemplates; +}; + +export type IgcChatTemplates = { + attachmentTemplate?: AttachmentTemplate; + attachmentHeaderTemplate?: AttachmentTemplate; + attachmentActionsTemplate?: AttachmentTemplate; + attachmentContentTemplate?: AttachmentTemplate; + messageActionsTemplate?: MessageActionsTemplate; +}; + export const attachmentIcon = ''; export const sendButtonIcon = diff --git a/src/components/common/context.ts b/src/components/common/context.ts index 4f9647e1d..e6e640357 100644 --- a/src/components/common/context.ts +++ b/src/components/common/context.ts @@ -1,6 +1,7 @@ import { createContext } from '@lit/context'; import type { Ref } from 'lit/directives/ref.js'; import type IgcCarouselComponent from '../carousel/carousel.js'; +import type IgcChatComponent from '../chat/chat.js'; import type IgcTileManagerComponent from '../tile-manager/tile-manager.js'; export type TileManagerContext = { @@ -18,4 +19,6 @@ const tileManagerContext = createContext( Symbol('tile-manager-context') ); -export { carouselContext, tileManagerContext }; +const chatContext = createContext(Symbol('chat-context')); + +export { carouselContext, tileManagerContext, chatContext }; diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 0b1366ff5..4003dad7c 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -29,85 +29,11 @@ const metadata: Meta = { title: 'Chat', component: 'igc-chat', parameters: { docs: { description: { component: '' } } }, - argTypes: { - hideAvatar: { - type: 'boolean', - control: 'boolean', - table: { defaultValue: { summary: 'false' } }, - }, - hideUserName: { - type: 'boolean', - control: 'boolean', - table: { defaultValue: { summary: 'false' } }, - }, - disableAutoScroll: { - type: 'boolean', - control: 'boolean', - table: { defaultValue: { summary: 'false' } }, - }, - disableAttachments: { - type: 'boolean', - control: 'boolean', - table: { defaultValue: { summary: 'false' } }, - }, - acceptedFiles: { - type: 'string', - description: - 'The accepted files that could be attached.\nDefines the file types as a list of comma-separated values that the file input should accept.', - control: 'text', - table: { defaultValue: { summary: '' } }, - }, - headerText: { - type: 'string', - control: 'text', - table: { defaultValue: { summary: '' } }, - }, - attachmentTemplate: { - type: 'AttachmentTemplate', - control: 'AttachmentTemplate', - }, - attachmentHeaderTemplate: { - type: 'AttachmentTemplate', - control: 'AttachmentTemplate', - }, - attachmentActionsTemplate: { - type: 'AttachmentTemplate', - control: 'AttachmentTemplate', - }, - attachmentContentTemplate: { - type: 'AttachmentTemplate', - control: 'AttachmentTemplate', - }, - messageActionsTemplate: { - type: 'MessageActionsTemplate', - control: 'MessageActionsTemplate', - }, - }, - args: { - hideAvatar: false, - hideUserName: false, - disableAutoScroll: false, - disableAttachments: false, - acceptedFiles: '', - headerText: '', - }, }; export default metadata; -interface IgcChatArgs { - hideAvatar: boolean; - hideUserName: boolean; - disableAutoScroll: boolean; - disableAttachments: boolean; - /** - * The accepted files that could be attached. - * Defines the file types as a list of comma-separated values that the file input should accept. - */ - acceptedFiles: string; - headerText: string; -} -type Story = StoryObj; +type Story = StoryObj; // endregion @@ -125,9 +51,23 @@ registerIconFromText('regenerate', regenerateIcon, 'material'); let messages: any[] = [ { id: '1', - text: "Hello! I'm an AI assistant created with Lit. How can I help you today?", + text: 'Hello! How can I help you today?', sender: 'bot', - timestamp: new Date(), + timestamp: new Date(Date.now() - 3600000), + }, + { + id: '2', + text: 'Hello!', + sender: 'user', + timestamp: new Date(Date.now() - 3500000), + attachments: [ + { + id: 'img1', + name: 'img1.png', + url: 'https://www.infragistics.com/angular-demos/assets/images/men/1.jpg', + type: 'image', + }, + ], }, ]; @@ -165,6 +105,18 @@ const messageActionsTemplate = (msg: any) => { : ''; }; +const ai_chat_options = { + headerText: 'Chat', + templates: { + messageActionsTemplate: messageActionsTemplate, + }, +}; + +const chat_options = { + disableAutoScroll: true, + disableAttachments: true, +}; + function handleMessageSend(e: CustomEvent) { const newMessage = e.detail; messages.push(newMessage); @@ -541,17 +493,11 @@ async function handleAIMessageSend(e: CustomEvent) { } export const Basic: Story = { - render: (args) => html` + render: () => html` `, @@ -567,31 +513,16 @@ export const Supabase: Story = { } }); }, - render: (args) => html` - - + render: () => html` + `, }; export const AI: Story = { - render: (args) => html` + render: () => html` `, From 5a3defd1138ad9e2cd6663fdd7dba3f8ae2b2591 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Tue, 24 Jun 2025 10:41:26 +0300 Subject: [PATCH 052/252] fix(*): lit errors after merging master --- src/components/chat/message-attachments.ts | 6 +++--- src/components/common/definitions/defineAllComponents.ts | 1 - stories/chat.stories.ts | 8 +++++++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/components/chat/message-attachments.ts b/src/components/chat/message-attachments.ts index b03224541..048e41db5 100644 --- a/src/components/chat/message-attachments.ts +++ b/src/components/chat/message-attachments.ts @@ -1,5 +1,5 @@ import { consume } from '@lit/context'; -import { LitElement, html } from 'lit'; +import { html, LitElement } from 'lit'; import { property } from 'lit/decorators.js'; import IgcIconButtonComponent from '../button/icon-button.js'; import { chatContext } from '../common/context.js'; @@ -8,11 +8,11 @@ import IgcExpansionPanelComponent from '../expansion-panel/expansion-panel.js'; import IgcIconComponent from '../icon/icon.js'; import { registerIconFromText } from '../icon/icon.registry.js'; import type IgcChatComponent from './chat.js'; -import { styles } from './themes/message-attachments.base.css'; +import { styles } from './themes/message-attachments.base.css.js'; import { - type IgcMessageAttachment, closeIcon, fileIcon, + type IgcMessageAttachment, imageIcon, moreIcon, previewIcon, diff --git a/src/components/common/definitions/defineAllComponents.ts b/src/components/common/definitions/defineAllComponents.ts index ade08401e..4d3e03119 100644 --- a/src/components/common/definitions/defineAllComponents.ts +++ b/src/components/common/definitions/defineAllComponents.ts @@ -15,7 +15,6 @@ import IgcCardMediaComponent from '../../card/card.media.js'; import IgcCarouselComponent from '../../carousel/carousel.js'; import IgcCarouselIndicatorComponent from '../../carousel/carousel-indicator.js'; import IgcCarouselSlideComponent from '../../carousel/carousel-slide.js'; -import IgcCarouselComponent from '../../carousel/carousel.js'; import IgcChatComponent from '../../chat/chat.js'; import IgcCheckboxComponent from '../../checkbox/checkbox.js'; import IgcSwitchComponent from '../../checkbox/switch.js'; diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 4003dad7c..d6234640b 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -360,7 +360,13 @@ function generateAIResponse(message: string): string { return "You're welcome! Let me know if you need anything else."; } if (lowerMessage.includes('code')) { - return "Here's an example of code formatting:\n\n```javascript\nfunction greet(name) {\n return `Hello, ${name}!`;\n}\n\nconsole.log(greet('world'));\n```"; + return `Here's an example of code formatting: + \`\`\`javascript + function greet(name) { + return \`Hello, \${name}!\`; + } + console.log(greet('world')); + \`\`\``; } if (lowerMessage.includes('image') || lowerMessage.includes('picture')) { return "Here's an image!"; From 7eb8b1440a8cc2c4457206973ef697fbc8fad203 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Tue, 24 Jun 2025 13:30:15 +0300 Subject: [PATCH 053/252] chore(*): comment scroll tests --- src/components/chat/chat.spec.ts | 100 +++++++++++++++---------------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index 64f039760..560097bc9 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -244,56 +244,56 @@ describe('Chat', () => { ); }); - it('should scroll to bottom by default', async () => { - chat.messages = [messages[0], messages[1], messages[2]]; - await elementUpdated(chat); - await clock.tickAsync(500); - - const messagesContainer = chat.shadowRoot?.querySelector( - 'igc-chat-message-list' - ); - let scrollPosition = messagesContainer - ? messagesContainer.scrollHeight - messagesContainer.scrollTop - : 0; - expect(scrollPosition).to.equal(messagesContainer?.clientHeight); - - chat.messages = [...chat.messages, messages[3]]; - await chat.updateComplete; - await clock.tickAsync(500); - - scrollPosition = messagesContainer - ? messagesContainer.scrollHeight - messagesContainer.scrollTop - : 0; - - expect(chat.messages.length).to.equal(4); - expect(messagesContainer?.scrollTop).not.to.equal(0); - expect(scrollPosition).to.equal(messagesContainer?.clientHeight); - }); - - it('should not scroll to bottom if `disableAutoScroll` is true', async () => { - chat.messages = [messages[0], messages[1], messages[2]]; - chat.options = { - disableAutoScroll: true, - }; - await elementUpdated(chat); - await clock.tickAsync(500); - - const messagesContainer = chat.shadowRoot?.querySelector( - 'igc-chat-message-list' - ); - const scrollPosition = messagesContainer - ? messagesContainer.scrollHeight - messagesContainer.scrollTop - : 0; - expect(scrollPosition).to.equal(messagesContainer?.clientHeight); - - messagesContainer?.scrollTo(0, 0); - chat.messages = [...chat.messages, messages[3]]; - await chat.updateComplete; - await clock.tickAsync(500); - - expect(chat.messages.length).to.equal(4); - expect(messagesContainer?.scrollTop).to.equal(0); - }); + // it('should scroll to bottom by default', async () => { + // chat.messages = [messages[0], messages[1], messages[2]]; + // await elementUpdated(chat); + // await clock.tickAsync(500); + + // const messagesContainer = chat.shadowRoot?.querySelector( + // 'igc-chat-message-list' + // ); + // let scrollPosition = messagesContainer + // ? messagesContainer.scrollHeight - messagesContainer.scrollTop + // : 0; + // expect(scrollPosition).to.equal(messagesContainer?.clientHeight); + + // chat.messages = [...chat.messages, messages[3]]; + // await chat.updateComplete; + // await clock.tickAsync(500); + + // scrollPosition = messagesContainer + // ? messagesContainer.scrollHeight - messagesContainer.scrollTop + // : 0; + + // expect(chat.messages.length).to.equal(4); + // expect(messagesContainer?.scrollTop).not.to.equal(0); + // expect(scrollPosition).to.equal(messagesContainer?.clientHeight); + // }); + + // it('should not scroll to bottom if `disableAutoScroll` is true', async () => { + // chat.messages = [messages[0], messages[1], messages[2]]; + // chat.options = { + // disableAutoScroll: true, + // }; + // await elementUpdated(chat); + // await clock.tickAsync(500); + + // const messagesContainer = chat.shadowRoot?.querySelector( + // 'igc-chat-message-list' + // ); + // const scrollPosition = messagesContainer + // ? messagesContainer.scrollHeight - messagesContainer.scrollTop + // : 0; + // expect(scrollPosition).to.equal(messagesContainer?.clientHeight); + + // messagesContainer?.scrollTo(0, 0); + // chat.messages = [...chat.messages, messages[3]]; + // await chat.updateComplete; + // await clock.tickAsync(500); + + // expect(chat.messages.length).to.equal(4); + // expect(messagesContainer?.scrollTop).to.equal(0); + // }); it('should not render attachment button if `disableAttachments` is true', async () => { chat.options = { From 5f69221d0a91f06ea7f057a69d1c56000ab5765e Mon Sep 17 00:00:00 2001 From: teodosiah Date: Tue, 24 Jun 2025 13:46:48 +0300 Subject: [PATCH 054/252] chore(*): remove unused method --- src/components/chat/message-attachments.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/components/chat/message-attachments.ts b/src/components/chat/message-attachments.ts index 048e41db5..e974a811b 100644 --- a/src/components/chat/message-attachments.ts +++ b/src/components/chat/message-attachments.ts @@ -79,20 +79,6 @@ export default class IgcMessageAttachmentsComponent extends LitElement { ); } - private getFile(attachment: IgcMessageAttachment): File | undefined { - if (attachment.file) { - return attachment.file; - } - if (attachment.url) { - const url = new URL(attachment.url); - const fileName = url.pathname.split('/').pop() || 'attachment'; - return new File([], fileName, { - type: attachment.type || 'application/octet-stream', - }); - } - return undefined; - } - private getURL(attachment: IgcMessageAttachment): string { if (attachment.url) { return attachment.url; From 111c0b9a4f9c8854c5cf24d45eb91377fe7f1aa8 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Wed, 25 Jun 2025 09:09:40 +0300 Subject: [PATCH 055/252] feat(chat): expose suggestions prop & add tests --- src/components/chat/chat.spec.ts | 88 ++++++++++++++++++++++- src/components/chat/chat.ts | 46 +++++++++--- src/components/chat/themes/chat.base.scss | 12 ++++ src/components/chat/types.ts | 1 + stories/chat.stories.ts | 8 ++- 5 files changed, 140 insertions(+), 15 deletions(-) diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index 560097bc9..0099bde8c 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -1,6 +1,6 @@ import { aTimeout, elementUpdated, expect, fixture } from '@open-wc/testing'; import { html } from 'lit'; -import { type SinonFakeTimers, useFakeTimers } from 'sinon'; +import { type SinonFakeTimers, spy, useFakeTimers } from 'sinon'; import { defineComponents } from '../common/definitions/defineComponents.js'; import { simulateClick, simulateFocus } from '../common/utils.spec.js'; import IgcChatComponent from './chat.js'; @@ -129,6 +129,10 @@ describe('Chat', () => {
+
+ + +
` @@ -450,6 +454,38 @@ describe('Chat', () => { } }); }); + + it('should render suggestions', async () => { + chat.options = { + suggestions: ['Suggestion 1', 'Suggestion 2'], + }; + await elementUpdated(chat); + + const suggestionsContainer = chat.shadowRoot?.querySelector( + '.suggestions-container' + ); + + expect(suggestionsContainer).dom.to.equal( + `
+ + + + + Suggestion 1 + + + + + + + Suggestion 2 + + + + +
` + ); + }); }); describe('Slots', () => { @@ -470,6 +506,7 @@ describe('Chat', () => { it('should slot header prefix', () => {}); it('should slot header title', () => {}); it('should slot header action buttons area', () => {}); + it('should slot suggetions area', () => {}); }); describe('Templates', () => { @@ -597,6 +634,7 @@ describe('Chat', () => { describe('Interactions', () => { describe('Click', () => { it('should update messages properly on send button click', async () => { + const eventSpy = spy(chat, 'emitEvent'); const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); const sendButton = inputArea?.shadowRoot?.querySelector( 'igc-icon-button[name="send-message"]' @@ -610,11 +648,49 @@ describe('Chat', () => { simulateClick(sendButton); await elementUpdated(chat); await clock.tickAsync(500); + + expect(eventSpy).calledWith('igcMessageCreated'); + const eventArgs = eventSpy.getCall(0).args[1]?.detail; + const args = + eventArgs && typeof eventArgs === 'object' + ? { ...eventArgs, text: 'Hello!', sender: 'user' } + : { text: 'Hello!', sender: 'user' }; + expect(eventArgs).to.deep.equal(args); expect(chat.messages.length).to.equal(1); expect(chat.messages[0].text).to.equal('Hello!'); expect(chat.messages[0].sender).to.equal('user'); } }); + + it('should update messages properly on suggestion chip click', async () => { + const eventSpy = spy(chat, 'emitEvent'); + chat.options = { + suggestions: ['Suggestion 1', 'Suggestion 2'], + }; + await elementUpdated(chat); + + const suggestionChips = chat.shadowRoot + ?.querySelector('.suggestions-container') + ?.querySelectorAll('igc-chip'); + + expect(suggestionChips?.length).to.equal(2); + if (suggestionChips) { + simulateClick(suggestionChips[0]); + await elementUpdated(chat); + + expect(eventSpy).calledWith('igcMessageCreated'); + const eventArgs = eventSpy.getCall(0).args[1]?.detail; + const args = + eventArgs && typeof eventArgs === 'object' + ? { ...eventArgs, text: 'Suggestion 1', sender: 'user' } + : { text: 'Suggestion 1', sender: 'user' }; + expect(eventArgs).to.deep.equal(args); + expect(chat.messages.length).to.equal(1); + expect(chat.messages[0].text).to.equal('Suggestion 1'); + expect(chat.messages[0].sender).to.equal('user'); + } + }); + it('should remove attachement on chip remove button click', () => {}); }); @@ -624,6 +700,7 @@ describe('Chat', () => { describe('Keyboard', () => { it('should update messages properly on `Enter` keypress when the textarea is focused', async () => { + const eventSpy = spy(chat, 'emitEvent'); const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); const sendButton = inputArea?.shadowRoot?.querySelector( 'igc-icon-button[name="send-message"]' @@ -644,6 +721,14 @@ describe('Chat', () => { ); await elementUpdated(chat); await clock.tickAsync(500); + + expect(eventSpy).calledWith('igcMessageCreated'); + const eventArgs = eventSpy.getCall(0).args[1]?.detail; + const args = + eventArgs && typeof eventArgs === 'object' + ? { ...eventArgs, text: 'Hello!', sender: 'user' } + : { text: 'Hello!', sender: 'user' }; + expect(eventArgs).to.deep.equal(args); expect(chat.messages.length).to.equal(1); expect(chat.messages[0].text).to.equal('Hello!'); expect(chat.messages[0].sender).to.equal('user'); @@ -653,7 +738,6 @@ describe('Chat', () => { }); describe('Events', () => { - it('emits igcMessageCreated', async () => {}); it('emits igcAttachmentClick', async () => {}); it('emits igcAttachmentChange', async () => {}); it('emits igcTypingChange', async () => {}); diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index 0e433cbf7..a5e380903 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -1,5 +1,5 @@ import { ContextProvider } from '@lit/context'; -import { LitElement, html } from 'lit'; +import { html, LitElement } from 'lit'; import { property } from 'lit/decorators.js'; import IgcButtonComponent from '../button/button.js'; import { chatContext } from '../common/context.js'; @@ -98,16 +98,7 @@ export default class IgcChatComponent extends EventEmitterMixin< if (!text.trim() && attachments.length === 0) return; - const newMessage: IgcMessage = { - id: Date.now().toString(), - text, - sender: 'user', - timestamp: new Date(), - attachments, - }; - - this.messages = [...this.messages, newMessage]; - this.emitEvent('igcMessageCreated', { detail: newMessage }); + this.addMessage({ text, attachments }); } private handleAttachmentClick(e: CustomEvent) { @@ -115,6 +106,24 @@ export default class IgcChatComponent extends EventEmitterMixin< this.emitEvent('igcAttachmentClick', { detail: attachmentArgs }); } + private addMessage(message: { + id?: string; + text: string; + sender?: string; + timestamp?: Date; + attachments?: IgcMessageAttachment[]; + }) { + const newMessage: IgcMessage = { + id: message.id ?? Date.now().toString(), + text: message.text, + sender: message.sender ?? 'user', + timestamp: message.timestamp ?? new Date(), + attachments: message.attachments || [], + }; + this.messages = [...this.messages, newMessage]; + this.emitEvent('igcMessageCreated', { detail: newMessage }); + } + protected override firstUpdated() { this._context.setValue(this, true); } @@ -132,6 +141,21 @@ export default class IgcChatComponent extends EventEmitterMixin<
+
+ + ${this.options?.suggestions?.map( + (suggestion) => html` + + this.addMessage({ text: suggestion })} + > + ${suggestion} + + + ` + )} + +
diff --git a/src/components/chat/themes/chat.base.scss b/src/components/chat/themes/chat.base.scss index 8dfcb2b72..6828b4d7a 100644 --- a/src/components/chat/themes/chat.base.scss +++ b/src/components/chat/themes/chat.base.scss @@ -75,4 +75,16 @@ .action-button:hover { background-color: #E5E5EA; + } + + .suggestions-container { + display: flex; + justify-content: end; + + igc-chip::part(base) { + background-color: transparent; + border: 1px solid var(--ig-primary-500); + color: var(--ig-primary-500); + } + } \ No newline at end of file diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts index e4a180b96..bbcd86ec8 100644 --- a/src/components/chat/types.ts +++ b/src/components/chat/types.ts @@ -36,6 +36,7 @@ export type IgcChatOptions = { */ acceptedFiles?: string; headerText?: string; + suggestions?: string[]; templates?: IgcChatTemplates; }; diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index d6234640b..72d2ce1b2 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -101,12 +101,13 @@ const messageActionsTemplate = (msg: any) => { > ` - : '' - : ''; + : html`` + : html``; }; const ai_chat_options = { headerText: 'Chat', + suggestions: ['Hello', 'Hi', 'Generate an image of a pig!'], templates: { messageActionsTemplate: messageActionsTemplate, }, @@ -413,6 +414,8 @@ async function handleAIMessageSend(e: CustomEvent) { return; } + chat.options = { ...ai_chat_options, suggestions: [] }; + let response: any; let responseText = ''; const attachments: IgcMessageAttachment[] = []; @@ -495,6 +498,7 @@ async function handleAIMessageSend(e: CustomEvent) { }; chat.messages = [...chat.messages]; } + chat.options = { ...ai_chat_options, suggestions: ['Thank you!'] }; } } From c35a0b41fbb010526d56428aece634abc2effca4 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Thu, 26 Jun 2025 15:02:51 +0300 Subject: [PATCH 056/252] feat(chat): expose isComposing prop & add tests --- src/components/chat/chat-message-list.ts | 25 ++-- src/components/chat/chat.spec.ts | 53 ++++++++ src/components/chat/types.ts | 2 + stories/chat.stories.ts | 157 ++++++++++++----------- 4 files changed, 149 insertions(+), 88 deletions(-) diff --git a/src/components/chat/chat-message-list.ts b/src/components/chat/chat-message-list.ts index d7f95a608..bd753ab16 100644 --- a/src/components/chat/chat-message-list.ts +++ b/src/components/chat/chat-message-list.ts @@ -1,10 +1,10 @@ import { consume } from '@lit/context'; -import { LitElement, html } from 'lit'; +import { html, LitElement } from 'lit'; import { repeat } from 'lit/directives/repeat.js'; import { chatContext } from '../common/context.js'; import { registerComponent } from '../common/definitions/register.js'; -import IgcChatMessageComponent from './chat-message.js'; import type IgcChatComponent from './chat.js'; +import IgcChatMessageComponent from './chat-message.js'; import { styles } from './themes/message-list.base.css.js'; import type { IgcMessage } from './types.js'; @@ -87,6 +87,16 @@ export default class IgcChatMessageListComponent extends LitElement { } } + protected *renderLoadingTemplate() { + yield html` ${this._chat?.options?.templates?.composingIndicatorTemplate + ? this._chat.options.templates.composingIndicatorTemplate + : html`
+
+
+
+
`}`; + } + protected override render() { const groupedMessages = this.groupMessagesByDate( this._chat?.messages ?? [] @@ -109,16 +119,7 @@ export default class IgcChatMessageListComponent extends LitElement { ` )} ${ - '' - // this.isAiResponding - // ? html` - //
- //
- //
- //
- //
- // ` - // : '' + this._chat?.options?.isComposing ? this.renderLoadingTemplate() : '' } diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index 0099bde8c..122230ba4 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -19,6 +19,8 @@ describe('Chat', () => { : html``; }; + const composingIndicatorTemplate = html`loading...`; + const attachmentTemplate = (attachments: any[]) => { return html`${attachments.map((attachment) => { return html`${attachment.name}`; @@ -486,6 +488,34 @@ describe('Chat', () => { ` ); }); + + it('should render composing indicator if `isComposing` is true', async () => { + chat.messages = [messages[0]]; + chat.options = { + isComposing: true, + }; + await elementUpdated(chat); + + const messageContainer = chat.shadowRoot + ?.querySelector('igc-chat-message-list') + ?.shadowRoot?.querySelector('.message-list'); + + expect(chat.messages.length).to.equal(1); + expect(messageContainer).dom.to.equal( + `
+ + +
+
+
+
+
+
+
+
+
` + ); + }); }); describe('Slots', () => { @@ -629,6 +659,29 @@ describe('Chat', () => { } }); }); + + it('should render composingIndicatorTemplate', async () => { + chat.messages = [messages[0]]; + chat.options = { + isComposing: true, + templates: { + composingIndicatorTemplate: composingIndicatorTemplate, + }, + }; + await elementUpdated(chat); + const messageContainer = chat.shadowRoot + ?.querySelector('igc-chat-message-list') + ?.shadowRoot?.querySelector('.message-list'); + + expect(chat.messages.length).to.equal(1); + expect(messageContainer).dom.to.equal( + `
+ + + loading... +
` + ); + }); }); describe('Interactions', () => { diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts index bbcd86ec8..6a3a7f3f6 100644 --- a/src/components/chat/types.ts +++ b/src/components/chat/types.ts @@ -30,6 +30,7 @@ export type IgcChatOptions = { hideUserName?: boolean; disableAutoScroll?: boolean; disableAttachments?: boolean; + isComposing?: boolean; /** * The accepted files that could be attached. * Defines the file types as a list of comma-separated values that the file input should accept. @@ -46,6 +47,7 @@ export type IgcChatTemplates = { attachmentActionsTemplate?: AttachmentTemplate; attachmentContentTemplate?: AttachmentTemplate; messageActionsTemplate?: MessageActionsTemplate; + composingIndicatorTemplate?: TemplateResult; }; export const attachmentIcon = diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 72d2ce1b2..a0c76f5be 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -105,11 +105,14 @@ const messageActionsTemplate = (msg: any) => { : html``; }; +const _composingIndicatorTemplate = html`LOADING...`; + const ai_chat_options = { headerText: 'Chat', suggestions: ['Hello', 'Hi', 'Generate an image of a pig!'], templates: { messageActionsTemplate: messageActionsTemplate, + //composingIndicatorTemplate: _composingIndicatorTemplate, }, }; @@ -414,92 +417,94 @@ async function handleAIMessageSend(e: CustomEvent) { return; } - chat.options = { ...ai_chat_options, suggestions: [] }; - - let response: any; - let responseText = ''; - const attachments: IgcMessageAttachment[] = []; - const botResponse: IgcMessage = { - id: Date.now().toString(), - text: responseText, - sender: 'bot', - timestamp: new Date(), - }; + chat.options = { ...ai_chat_options, suggestions: [], isComposing: true }; + setTimeout(async () => { + chat.options = { ...ai_chat_options, suggestions: [], isComposing: false }; + let response: any; + let responseText = ''; + const attachments: IgcMessageAttachment[] = []; + const botResponse: IgcMessage = { + id: Date.now().toString(), + text: responseText, + sender: 'bot', + timestamp: new Date(), + }; - userMessages.push({ role: 'user', parts: [{ text: newMessage.text }] }); + userMessages.push({ role: 'user', parts: [{ text: newMessage.text }] }); - if (newMessage.attachments && newMessage.attachments.length > 0) { - for (const attachment of newMessage.attachments) { - if (attachment.file) { - const filePart = fileToGenerativePart( - await attachment.file.arrayBuffer(), - attachment.file.type - ); - userMessages.push({ role: 'user', parts: [filePart] }); + if (newMessage.attachments && newMessage.attachments.length > 0) { + for (const attachment of newMessage.attachments) { + if (attachment.file) { + const filePart = fileToGenerativePart( + await attachment.file.arrayBuffer(), + attachment.file.type + ); + userMessages.push({ role: 'user', parts: [filePart] }); + } } } - } - if (newMessage.text.includes('image')) { - response = await ai.models.generateContent({ - model: 'gemini-2.0-flash-preview-image-generation', - contents: userMessages, - config: { - responseModalities: [Modality.TEXT, Modality.IMAGE], - }, - }); - - for (const part of response?.candidates?.[0]?.content?.parts || []) { - // Based on the part type, either show the text or save the image - if (part.text) { - responseText = part.text; - } else if (part.inlineData) { - const _imageData = part.inlineData.data; - const byteCharacters = atob(_imageData); - const byteNumbers = new Array(byteCharacters.length); - for (let i = 0; i < byteCharacters.length; i++) { - byteNumbers[i] = byteCharacters.charCodeAt(i); + if (newMessage.text.includes('image')) { + response = await ai.models.generateContent({ + model: 'gemini-2.0-flash-preview-image-generation', + contents: userMessages, + config: { + responseModalities: [Modality.TEXT, Modality.IMAGE], + }, + }); + + for (const part of response?.candidates?.[0]?.content?.parts || []) { + // Based on the part type, either show the text or save the image + if (part.text) { + responseText = part.text; + } else if (part.inlineData) { + const _imageData = part.inlineData.data; + const byteCharacters = atob(_imageData); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + const type = part.inlineData.type || 'image/png'; + const blob = new Blob([byteArray], { type: type }); + const file = new File([blob], 'generated_image.png', { + type: type, + }); + const attachment: IgcMessageAttachment = { + id: Date.now().toString(), + name: 'generated_image.png', + type: 'image', + url: URL.createObjectURL(file), + file: file, + }; + attachments.push(attachment); } - const byteArray = new Uint8Array(byteNumbers); - const type = part.inlineData.type || 'image/png'; - const blob = new Blob([byteArray], { type: type }); - const file = new File([blob], 'generated_image.png', { - type: type, - }); - const attachment: IgcMessageAttachment = { - id: Date.now().toString(), - name: 'generated_image.png', - type: 'image', - url: URL.createObjectURL(file), - file: file, - }; - attachments.push(attachment); } - } - botResponse.text = responseText; - botResponse.attachments = attachments; - chat.messages = [...chat.messages, botResponse]; - } else { - chat.messages = [...chat.messages, botResponse]; - response = await ai.models.generateContentStream({ - model: 'gemini-2.0-flash', - contents: userMessages, - config: { - responseModalities: [Modality.TEXT], - }, - }); + botResponse.text = responseText; + botResponse.attachments = attachments; + chat.messages = [...chat.messages, botResponse]; + } else { + chat.messages = [...chat.messages, botResponse]; + response = await ai.models.generateContentStream({ + model: 'gemini-2.0-flash', + contents: userMessages, + config: { + responseModalities: [Modality.TEXT], + }, + }); - const lastMessageIndex = chat.messages.length - 1; - for await (const chunk of response) { - chat.messages[lastMessageIndex] = { - ...chat.messages[lastMessageIndex], - text: `${chat.messages[lastMessageIndex].text}${chunk.text}`, - }; - chat.messages = [...chat.messages]; + const lastMessageIndex = chat.messages.length - 1; + for await (const chunk of response) { + chat.messages[lastMessageIndex] = { + ...chat.messages[lastMessageIndex], + text: `${chat.messages[lastMessageIndex].text}${chunk.text}`, + }; + chat.messages = [...chat.messages]; + } + chat.options = { ...ai_chat_options, suggestions: ['Thank you!'] }; } - chat.options = { ...ai_chat_options, suggestions: ['Thank you!'] }; - } + }, 2000); } export const Basic: Story = { From d12b8cad2ba875cc7c51d27f51b69870d5502f5b Mon Sep 17 00:00:00 2001 From: teodosiah Date: Fri, 27 Jun 2025 14:04:56 +0300 Subject: [PATCH 057/252] feat(chat): expose currentUserId prop & add tests --- src/components/chat/chat-message.ts | 4 +- src/components/chat/chat.spec.ts | 60 +++++++++++++++++++++++++++-- src/components/chat/chat.ts | 7 +++- stories/chat.stories.ts | 7 +++- 4 files changed, 69 insertions(+), 9 deletions(-) diff --git a/src/components/chat/chat-message.ts b/src/components/chat/chat-message.ts index e5795c2fe..080e8cace 100644 --- a/src/components/chat/chat-message.ts +++ b/src/components/chat/chat-message.ts @@ -1,5 +1,5 @@ import { consume } from '@lit/context'; -import { LitElement, html } from 'lit'; +import { html, LitElement } from 'lit'; import { property } from 'lit/decorators.js'; import IgcAvatarComponent from '../avatar/avatar.js'; import { chatContext } from '../common/context.js'; @@ -37,7 +37,7 @@ export default class IgcChatMessageComponent extends LitElement { public message: IgcMessage | undefined; protected override render() { - const containerClass = `message-container ${this.message?.sender === 'user' ? 'sent' : ''}`; + const containerClass = `message-container ${this.message?.sender === this._chat?.currentUserId ? 'sent' : ''}`; return html`
diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index 122230ba4..60aedcb95 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -104,14 +104,15 @@ describe('Chat', () => { describe('Initialization', () => { it('is correctly initialized with its default component state', () => { + expect(chat.currentUserId).to.equal('user'); expect(chat.messages.length).to.equal(0); expect(chat.options).to.be.undefined; }); it('is rendered correctly', () => { expect(chat).dom.to.equal( - ` - ` + ` + ` ); expect(chat).shadowDom.to.equal( @@ -212,7 +213,7 @@ describe('Chat', () => { ); expect( - messageContainer?.querySelector('igc-chat-message') + messageContainer?.querySelectorAll('igc-chat-message')[0] ).shadowDom.to.equal( `
@@ -222,6 +223,59 @@ describe('Chat', () => {
` ); + + expect( + messageContainer?.querySelectorAll('igc-chat-message')[3] + ).shadowDom.to.equal( + `
+
+
+

Thank you too!

+
+
+
` + ); + }); + + it('should render messages from the current user correctly', async () => { + const initialMessages = [ + messages[0], + messages[3], + { + id: '2', + text: 'Hello!', + sender: 'me', + timestamp: new Date(Date.now() - 3200000), + }, + ]; + chat = await fixture( + html` + ` + ); + + const messageContainer = chat.shadowRoot + ?.querySelector('igc-chat-message-list') + ?.shadowRoot?.querySelector('.message-list'); + + expect(chat.messages.length).to.equal(3); + + messageContainer + ?.querySelectorAll('igc-chat-message') + .forEach((messageElement, index) => { + if (index !== 2) { + expect( + messageElement.shadowRoot + ?.querySelector('.message-container') + ?.classList.contains('sent') + ).to.be.false; + } else { + expect( + messageElement.shadowRoot + ?.querySelector('.message-container') + ?.classList.contains('sent') + ).to.be.true; + } + }); }); it('should apply `headerText` correctly', async () => { diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index a5e380903..3e52f4a7d 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -55,6 +55,8 @@ export default class IgcChatComponent extends EventEmitterMixin< context: chatContext, initialValue: this, }); + @property({ type: String, reflect: true, attribute: 'current-user-id' }) + public currentUserId = 'user'; @property({ reflect: true, attribute: false }) public messages: IgcMessage[] = []; @@ -62,8 +64,9 @@ export default class IgcChatComponent extends EventEmitterMixin< @property({ attribute: false }) public options?: IgcChatOptions; - @watch('options') + @watch('currentUserId') @watch('messages') + @watch('options') protected contextChanged() { this._context.setValue(this, true); } @@ -116,7 +119,7 @@ export default class IgcChatComponent extends EventEmitterMixin< const newMessage: IgcMessage = { id: message.id ?? Date.now().toString(), text: message.text, - sender: message.sender ?? 'user', + sender: message.sender ?? this.currentUserId, timestamp: message.timestamp ?? new Date(), attachments: message.attachments || [], }; diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index a0c76f5be..9070d3b34 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -430,7 +430,10 @@ async function handleAIMessageSend(e: CustomEvent) { timestamp: new Date(), }; - userMessages.push({ role: 'user', parts: [{ text: newMessage.text }] }); + userMessages.push({ + role: chat.currentUserId, + parts: [{ text: newMessage.text }], + }); if (newMessage.attachments && newMessage.attachments.length > 0) { for (const attachment of newMessage.attachments) { @@ -439,7 +442,7 @@ async function handleAIMessageSend(e: CustomEvent) { await attachment.file.arrayBuffer(), attachment.file.type ); - userMessages.push({ role: 'user', parts: [filePart] }); + userMessages.push({ role: chat.currentUserId, parts: [filePart] }); } } } From 2cfaf7158828babb31781d70c556791b3dbae8d7 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Tue, 1 Jul 2025 12:01:41 +0300 Subject: [PATCH 058/252] feat(chat): expose attachment/typing events & add tests --- src/components/chat/chat-input.ts | 43 +++- src/components/chat/chat.spec.ts | 208 ++++++++++++++++++- src/components/chat/chat.ts | 33 ++- src/components/file-input/file-input.spec.ts | 2 +- 4 files changed, 263 insertions(+), 23 deletions(-) diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index eb60ad3b1..a66097ae2 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -1,5 +1,5 @@ import { consume } from '@lit/context'; -import { LitElement, html } from 'lit'; +import { html, LitElement } from 'lit'; import { query, state } from 'lit/decorators.js'; import IgcIconButtonComponent from '../button/icon-button.js'; import IgcChipComponent from '../chip/chip.js'; @@ -13,8 +13,8 @@ import IgcTextareaComponent from '../textarea/textarea.js'; import type IgcChatComponent from './chat.js'; import { styles } from './themes/input.base.css.js'; import { - type IgcMessageAttachment, attachmentIcon, + type IgcMessageAttachment, sendButtonIcon, } from './types.js'; @@ -83,15 +83,41 @@ export default class IgcChatInputComponent extends LitElement { const target = e.target as HTMLTextAreaElement; this.inputValue = target.value; this.adjustTextareaHeight(); + const inputEvent = new CustomEvent('input-change', { + detail: { value: this.inputValue }, + }); + this.dispatchEvent(inputEvent); } private handleKeyDown(e: KeyboardEvent) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendMessage(); + } else { + const typingEvent = new CustomEvent('typing-change', { + detail: { isTyping: true }, + }); + this.dispatchEvent(typingEvent); + // wait 3 seconds and dispatch a stop-typing event + setTimeout(() => { + const stopTypingEvent = new CustomEvent('typing-change', { + detail: { isTyping: false }, + }); + this.dispatchEvent(stopTypingEvent); + }, 3000); } } + private handleFocus() { + const focusEvent = new CustomEvent('focus-input'); + this.dispatchEvent(focusEvent); + } + + private handleBlur() { + const blurEvent = new CustomEvent('blur-input'); + this.dispatchEvent(blurEvent); + } + private setupDragAndDrop() { const container = this.shadowRoot?.querySelector( '.input-container' @@ -207,6 +233,11 @@ export default class IgcChatInputComponent extends LitElement { }); }); this.attachments = [...this.attachments, ...newAttachments]; + const attachmentEvent = new CustomEvent('attachment-change', { + detail: this.attachments, + }); + + this.dispatchEvent(attachmentEvent); } private updateAcceptedTypesCache() { @@ -258,6 +289,12 @@ export default class IgcChatInputComponent extends LitElement { private removeAttachment(index: number) { this.attachments = this.attachments.filter((_, i) => i !== index); + + const attachmentEvent = new CustomEvent('attachment-change', { + detail: this.attachments, + }); + + this.dispatchEvent(attachmentEvent); } protected override render() { @@ -287,6 +324,8 @@ export default class IgcChatInputComponent extends LitElement { .value=${this.inputValue} @input=${this.handleInput} @keydown=${this.handleKeyDown} + @focus=${this.handleFocus} + @blur=${this.handleBlur} >
diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index 60aedcb95..80f166fdf 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -2,7 +2,13 @@ import { aTimeout, elementUpdated, expect, fixture } from '@open-wc/testing'; import { html } from 'lit'; import { type SinonFakeTimers, spy, useFakeTimers } from 'sinon'; import { defineComponents } from '../common/definitions/defineComponents.js'; -import { simulateClick, simulateFocus } from '../common/utils.spec.js'; +import { + simulateBlur, + simulateClick, + simulateFocus, + simulateKeyboard, +} from '../common/utils.spec.js'; +import { simulateFileUpload } from '../file-input/file-input.spec.js'; import IgcChatComponent from './chat.js'; describe('Chat', () => { @@ -90,6 +96,11 @@ describe('Chat', () => { }, ]; + const files = [ + new File(['test content'], 'test.txt', { type: 'text/plain' }), + new File(['image data'], 'image.png', { type: 'image/png' }), + ]; + let chat: IgcChatComponent; let clock: SinonFakeTimers; @@ -390,6 +401,89 @@ describe('Chat', () => { ); }); + it('should update the file-input accepted prop based on the `acceptedFiles`', async () => { + chat.options = { + acceptedFiles: 'image/*', + }; + await elementUpdated(chat); + const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); + const element = inputArea?.shadowRoot?.querySelector('igc-file-input'); + if (element) { + expect(element.accept).to.equal('image/*'); + + chat.options = { + acceptedFiles: '', + }; + await elementUpdated(chat); + + expect(element.accept).to.be.empty; + } + }); + + it('should render attachments chips correctly', async () => { + const eventSpy = spy(chat, 'emitEvent'); + const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); + const fileInput = inputArea?.shadowRoot + ?.querySelector('igc-file-input') + ?.shadowRoot?.querySelector('input') as HTMLInputElement; + simulateFileUpload(fileInput, files); + await elementUpdated(chat); + + expect(eventSpy).calledWith('igcAttachmentChange'); + const eventArgs = eventSpy.getCall(0).args[1]?.detail; + const args = Array.isArray(eventArgs) + ? eventArgs.map((file: File, index) => ({ ...file, ...files[index] })) + : []; + expect(eventArgs).to.deep.equal(args); + + expect(inputArea).shadowDom.to.equal( + `
+ + + + +
+ + +
+
+ + +
+
+
+
+ + + test.txt + + +
+
+ + + image.png + + +
+
` + ); + }); + it('should render attachments correctly', async () => { chat.messages = [messages[1], messages[2]]; await elementUpdated(chat); @@ -757,7 +851,7 @@ describe('Chat', () => { await clock.tickAsync(500); expect(eventSpy).calledWith('igcMessageCreated'); - const eventArgs = eventSpy.getCall(0).args[1]?.detail; + const eventArgs = eventSpy.getCall(1).args[1]?.detail; const args = eventArgs && typeof eventArgs === 'object' ? { ...eventArgs, text: 'Hello!', sender: 'user' } @@ -798,7 +892,29 @@ describe('Chat', () => { } }); - it('should remove attachement on chip remove button click', () => {}); + it('should remove attachement on chip remove button click', async () => { + const eventSpy = spy(chat, 'emitEvent'); + const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); + const fileInput = inputArea?.shadowRoot + ?.querySelector('igc-file-input') + ?.shadowRoot?.querySelector('input') as HTMLInputElement; + simulateFileUpload(fileInput, files); + await elementUpdated(chat); + await aTimeout(500); + + const removeFileButton = inputArea?.shadowRoot + ?.querySelectorAll('igc-chip')[0] + ?.shadowRoot?.querySelector('igc-icon') as HTMLElement; + simulateClick(removeFileButton); + await elementUpdated(chat); + + expect(eventSpy).calledTwice; + expect(eventSpy.alwaysCalledWith('igcAttachmentChange')).to.be.true; + const eventArgs = eventSpy.getCall(1).args[1]?.detail; + const argsArr = Array.isArray(eventArgs) ? [...eventArgs] : []; + expect(argsArr.length).to.equal(1); + expect(argsArr[0].name).to.equal(files[1].name); + }); }); describe('Drag &Drop', () => { @@ -830,7 +946,7 @@ describe('Chat', () => { await clock.tickAsync(500); expect(eventSpy).calledWith('igcMessageCreated'); - const eventArgs = eventSpy.getCall(0).args[1]?.detail; + const eventArgs = eventSpy.getCall(2).args[1]?.detail; const args = eventArgs && typeof eventArgs === 'object' ? { ...eventArgs, text: 'Hello!', sender: 'user' } @@ -845,11 +961,83 @@ describe('Chat', () => { }); describe('Events', () => { - it('emits igcAttachmentClick', async () => {}); - it('emits igcAttachmentChange', async () => {}); - it('emits igcTypingChange', async () => {}); - it('emits igcInputFocus', async () => {}); - it('emits igcInputBlur', async () => {}); - it('emits igcInputChange', async () => {}); + it('emits igcAttachmentClick', async () => { + const eventSpy = spy(chat, 'emitEvent'); + chat.messages = [messages[1]]; + await elementUpdated(chat); + await aTimeout(500); + + const messageElement = chat.shadowRoot + ?.querySelector('igc-chat-message-list') + ?.shadowRoot?.querySelector('.message-list') + ?.querySelector('igc-chat-message'); + + const attachmentHeader = messageElement?.shadowRoot + ?.querySelector('igc-message-attachments') + ?.shadowRoot?.querySelector('igc-expansion-panel') + ?.shadowRoot?.querySelector(`div[part='header']`) as HTMLElement; + + simulateClick(attachmentHeader); + expect(eventSpy).calledWith('igcAttachmentClick', { + detail: { ...messages[1].attachments[0] }, + }); + }); + + it('emits igcTypingChange', async () => { + const eventSpy = spy(chat, 'emitEvent'); + const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); + const textArea = inputArea?.shadowRoot?.querySelector('igc-textarea'); + + if (textArea) { + simulateFocus(textArea); + simulateKeyboard(textArea, 'a'); + expect(eventSpy).calledWith('igcTypingChange', { + detail: { isTyping: true }, + }); + + aTimeout(1000).then(() => { + expect(eventSpy).calledWith('igcTypingChange', { + detail: { isTyping: false }, + }); + }); + } + }); + + it('emits igcInputFocus', async () => { + const eventSpy = spy(chat, 'emitEvent'); + const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); + const textArea = inputArea?.shadowRoot?.querySelector('igc-textarea'); + + if (textArea) { + simulateFocus(textArea); + expect(eventSpy).calledWith('igcInputFocus'); + } + }); + + it('emits igcInputBlur', async () => { + const eventSpy = spy(chat, 'emitEvent'); + const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); + const textArea = inputArea?.shadowRoot?.querySelector('igc-textarea'); + + if (textArea) { + simulateBlur(textArea); + expect(eventSpy).calledWith('igcInputBlur'); + } + }); + + it('emits igcInputChange', async () => { + const eventSpy = spy(chat, 'emitEvent'); + const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); + const textArea = inputArea?.shadowRoot?.querySelector('igc-textarea'); + + if (textArea) { + textArea.setAttribute('value', 'Hello!'); + textArea.dispatchEvent(new Event('input')); + await elementUpdated(chat); + expect(eventSpy).calledWith('igcInputChange', { + detail: { value: 'Hello!' }, + }); + } + }); }); }); diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index 3e52f4a7d..6a0bf1ce9 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -73,10 +73,6 @@ export default class IgcChatComponent extends EventEmitterMixin< public override connectedCallback() { super.connectedCallback(); - this.addEventListener( - 'message-created', - this.handleSendMessage as EventListener - ); this.addEventListener( 'attachment-click', this.handleAttachmentClick as EventListener @@ -85,10 +81,6 @@ export default class IgcChatComponent extends EventEmitterMixin< public override disconnectedCallback() { super.disconnectedCallback(); - this.removeEventListener( - 'message-created', - this.handleSendMessage as EventListener - ); this.removeEventListener( 'attachment-click', this.handleAttachmentClick as EventListener @@ -123,8 +115,14 @@ export default class IgcChatComponent extends EventEmitterMixin< timestamp: message.timestamp ?? new Date(), attachments: message.attachments || [], }; - this.messages = [...this.messages, newMessage]; - this.emitEvent('igcMessageCreated', { detail: newMessage }); + const allowed = this.emitEvent('igcMessageCreated', { + detail: newMessage, + cancelable: true, + }); + + if (allowed) { + this.messages = [...this.messages, newMessage]; + } } protected override firstUpdated() { @@ -161,6 +159,21 @@ export default class IgcChatComponent extends EventEmitterMixin< { + this.emitEvent('igcTypingChange', { detail: e.detail }); + }} + @input-change=${(e: CustomEvent) => { + this.emitEvent('igcInputChange', { detail: e.detail }); + }} + @attachment-change=${(e: CustomEvent) => { + this.emitEvent('igcAttachmentChange', { detail: e.detail }); + }} + @focus-input=${() => { + this.emitEvent('igcInputFocus'); + }} + @blur-input=${() => { + this.emitEvent('igcInputBlur'); + }} > `; diff --git a/src/components/file-input/file-input.spec.ts b/src/components/file-input/file-input.spec.ts index 1e756bf57..5528f781a 100644 --- a/src/components/file-input/file-input.spec.ts +++ b/src/components/file-input/file-input.spec.ts @@ -264,7 +264,7 @@ describe('Validation message slots', () => { }); }); -function simulateFileUpload(input: HTMLInputElement, files: File[]) { +export function simulateFileUpload(input: HTMLInputElement, files: File[]) { const dataTransfer = new DataTransfer(); for (const file of files) { From b4fdecaf472d0e19b784ca4bbb446787ba9b0ff2 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Tue, 1 Jul 2025 15:04:16 +0300 Subject: [PATCH 059/252] feat(chat): expose drag&drop events, cancelable attachment change event --- src/components/chat/chat-input.ts | 24 +++++++++-------- src/components/chat/chat.spec.ts | 44 ++++++++++++++++++++++++++++++- src/components/chat/chat.ts | 30 ++++++++++++++++----- 3 files changed, 80 insertions(+), 18 deletions(-) diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index a66097ae2..dcda2dbec 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -1,6 +1,6 @@ import { consume } from '@lit/context'; import { html, LitElement } from 'lit'; -import { query, state } from 'lit/decorators.js'; +import { property, query, state } from 'lit/decorators.js'; import IgcIconButtonComponent from '../button/icon-button.js'; import IgcChipComponent from '../chip/chip.js'; import { chatContext } from '../common/context.js'; @@ -55,12 +55,12 @@ export default class IgcChatInputComponent extends LitElement { @state() private inputValue = ''; - @state() - private attachments: IgcMessageAttachment[] = []; - @state() private dragClass = ''; + @property({ attribute: false }) + public attachments: IgcMessageAttachment[] = []; + // Cache for accepted file types private _acceptedTypesCache: { extensions: Set; @@ -142,6 +142,9 @@ export default class IgcChatInputComponent extends LitElement { ); this.dragClass = hasValidFiles ? 'dragging' : ''; + + const dragEvent = new CustomEvent('drag-attachment'); + this.dispatchEvent(dragEvent); } private handleDragOver(e: DragEvent) { @@ -178,6 +181,9 @@ export default class IgcChatInputComponent extends LitElement { const validFiles = files.filter((file) => this.isFileTypeAccepted(file)); + const dropEvent = new CustomEvent('drop-attachment'); + this.dispatchEvent(dropEvent); + this.attachFiles(validFiles); } @@ -199,7 +205,6 @@ export default class IgcChatInputComponent extends LitElement { this.dispatchEvent(messageEvent); this.inputValue = ''; - this.attachments = []; if (this.textInputElement) { this.textInputElement.style.height = 'auto'; @@ -232,11 +237,10 @@ export default class IgcChatInputComponent extends LitElement { thumbnail: isImage ? URL.createObjectURL(file) : undefined, }); }); - this.attachments = [...this.attachments, ...newAttachments]; + const attachmentEvent = new CustomEvent('attachment-change', { - detail: this.attachments, + detail: [...this.attachments, ...newAttachments], }); - this.dispatchEvent(attachmentEvent); } @@ -288,10 +292,8 @@ export default class IgcChatInputComponent extends LitElement { } private removeAttachment(index: number) { - this.attachments = this.attachments.filter((_, i) => i !== index); - const attachmentEvent = new CustomEvent('attachment-change', { - detail: this.attachments, + detail: this.attachments.filter((_, i) => i !== index), }); this.dispatchEvent(attachmentEvent); diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index 80f166fdf..dbd75bd80 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -995,7 +995,7 @@ describe('Chat', () => { detail: { isTyping: true }, }); - aTimeout(1000).then(() => { + aTimeout(3000).then(() => { expect(eventSpy).calledWith('igcTypingChange', { detail: { isTyping: false }, }); @@ -1039,5 +1039,47 @@ describe('Chat', () => { }); } }); + + it('can cancel `igcMessageCreated` event', async () => { + const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); + const sendButton = inputArea?.shadowRoot?.querySelector( + 'igc-icon-button[name="send-message"]' + ); + const textArea = inputArea?.shadowRoot?.querySelector('igc-textarea'); + + chat.addEventListener('igcMessageCreated', (event) => { + event.preventDefault(); + }); + + if (sendButton && textArea) { + textArea.setAttribute('value', 'Hello!'); + textArea.dispatchEvent(new Event('input')); + await elementUpdated(chat); + simulateClick(sendButton); + await elementUpdated(chat); + await clock.tickAsync(500); + + expect(chat.messages.length).to.equal(0); + } + }); + + it('can cancel `igcAttachmentChange` event', async () => { + const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); + const fileInput = inputArea?.shadowRoot + ?.querySelector('igc-file-input') + ?.shadowRoot?.querySelector('input') as HTMLInputElement; + + chat.addEventListener('igcAttachmentChange', (event) => { + event.preventDefault(); + }); + + simulateFileUpload(fileInput, files); + await elementUpdated(chat); + aTimeout(500); + + expect( + inputArea?.shadowRoot?.querySelectorAll('igc-chip').length + ).to.equal(0); + }); }); }); diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index 6a0bf1ce9..e6b1815b9 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -1,6 +1,6 @@ import { ContextProvider } from '@lit/context'; import { html, LitElement } from 'lit'; -import { property } from 'lit/decorators.js'; +import { property, state } from 'lit/decorators.js'; import IgcButtonComponent from '../button/button.js'; import { chatContext } from '../common/context.js'; import { watch } from '../common/decorators/watch.js'; @@ -20,9 +20,11 @@ export interface IgcChatComponentEventMap { igcMessageCreated: CustomEvent; igcAttachmentClick: CustomEvent; igcAttachmentChange: CustomEvent; + igcAttachmentDrag: CustomEvent; + igcAttachmentDrop: CustomEvent; igcTypingChange: CustomEvent; - igcInputFocus: CustomEvent; - igcInputBlur: CustomEvent; + igcInputFocus: CustomEvent; + igcInputBlur: CustomEvent; igcInputChange: CustomEvent; igcMessageCopied: CustomEvent; } @@ -55,6 +57,10 @@ export default class IgcChatComponent extends EventEmitterMixin< context: chatContext, initialValue: this, }); + + @state() + private inputAttachments: IgcMessageAttachment[] = []; + @property({ type: String, reflect: true, attribute: 'current-user-id' }) public currentUserId = 'user'; @@ -101,6 +107,16 @@ export default class IgcChatComponent extends EventEmitterMixin< this.emitEvent('igcAttachmentClick', { detail: attachmentArgs }); } + private handleAttachmentChange(e: CustomEvent) { + const allowed = this.emitEvent('igcAttachmentChange', { + detail: e.detail, + cancelable: true, + }); + if (allowed) { + this.inputAttachments = [...e.detail]; + } + } + private addMessage(message: { id?: string; text: string; @@ -122,6 +138,7 @@ export default class IgcChatComponent extends EventEmitterMixin< if (allowed) { this.messages = [...this.messages, newMessage]; + this.inputAttachments = []; } } @@ -158,6 +175,7 @@ export default class IgcChatComponent extends EventEmitterMixin< { this.emitEvent('igcTypingChange', { detail: e.detail }); @@ -165,9 +183,9 @@ export default class IgcChatComponent extends EventEmitterMixin< @input-change=${(e: CustomEvent) => { this.emitEvent('igcInputChange', { detail: e.detail }); }} - @attachment-change=${(e: CustomEvent) => { - this.emitEvent('igcAttachmentChange', { detail: e.detail }); - }} + @attachment-change=${this.handleAttachmentChange} + @drop-attachment=${() => this.emitEvent('igcAttachmentDrop')} + @drag-attachment=${() => this.emitEvent('igcAttachmentDrag')} @focus-input=${() => { this.emitEvent('igcInputFocus'); }} From 22b64ea0d4925950690e2f9865aac2309412e987 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Tue, 1 Jul 2025 16:59:42 +0300 Subject: [PATCH 060/252] feat(chat): add drop accepted files test --- src/components/chat/chat.spec.ts | 39 ++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index dbd75bd80..c577f048d 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -917,8 +917,43 @@ describe('Chat', () => { }); }); - describe('Drag &Drop', () => { - it('should be able to drop files base on the types listed in `acceptedFiles`', () => {}); + describe('Drag & Drop', () => { + beforeEach(async () => { + const options = { + acceptedFiles: '.txt', + }; + chat = await fixture( + html` ` + ); + }); + + it('should be able to drop files based on the types listed in `acceptedFiles`', async () => { + const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); + const dropZone = + inputArea?.shadowRoot?.querySelector('.input-container'); + + if (dropZone) { + const mockDataTransfer = { + files: files, + } as unknown as DataTransfer; + + const dropEvent = new DragEvent('drop', { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(dropEvent, 'dataTransfer', { + value: mockDataTransfer, + }); + + dropZone.dispatchEvent(dropEvent); + await elementUpdated(chat); + + const attachments = + inputArea?.shadowRoot?.querySelectorAll('igc-chip'); + expect(attachments?.length).to.equal(1); + expect(attachments?.[0]?.textContent?.trim()).to.equal('test.txt'); + } + }); }); describe('Keyboard', () => { From 4dbf31a19d9704169d496ffeaa51778803c0156c Mon Sep 17 00:00:00 2001 From: Galina Edinakova Date: Wed, 2 Jul 2025 15:59:32 +0300 Subject: [PATCH 061/252] fix(DragDrop): Tweaked a little the drag&drop test. --- src/components/chat/chat.spec.ts | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index c577f048d..537d54b94 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -927,15 +927,32 @@ describe('Chat', () => { ); }); - it('should be able to drop files based on the types listed in `acceptedFiles`', async () => { - const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); + it('should be able to drag & drop files based on the types listed in `acceptedFiles`', async () => { + const eventSpy = spy(chat, 'emitEvent'); + const inputArea = chat.shadowRoot?.querySelector('igc-chat-input')!; const dropZone = inputArea?.shadowRoot?.querySelector('.input-container'); if (dropZone) { - const mockDataTransfer = { - files: files, - } as unknown as DataTransfer; + const mockDataTransfer = new DataTransfer(); + files.forEach((file) => { + mockDataTransfer.items.add(file); + }); + + const dragEnterEvent = new DragEvent('dragenter', { + bubbles: true, + cancelable: true, + }); + + Object.defineProperty(dragEnterEvent, 'dataTransfer', { + value: mockDataTransfer, + }); + + dropZone?.dispatchEvent(dragEnterEvent); + await elementUpdated(chat); + + expect(eventSpy.callCount).to.equal(1); + expect(eventSpy).calledWith('igcAttachmentDrag'); const dropEvent = new DragEvent('drop', { bubbles: true, @@ -948,6 +965,7 @@ describe('Chat', () => { dropZone.dispatchEvent(dropEvent); await elementUpdated(chat); + expect(eventSpy).calledWith('igcAttachmentDrop'); const attachments = inputArea?.shadowRoot?.querySelectorAll('igc-chip'); expect(attachments?.length).to.equal(1); From a35ce692dd08bd224f17b08b94612183624c63af Mon Sep 17 00:00:00 2001 From: teodosiah Date: Wed, 2 Jul 2025 18:07:56 +0300 Subject: [PATCH 062/252] refactor(chat): move some logic in separate render methods --- src/components/chat/chat-input.ts | 96 +++++---- src/components/chat/chat-message-list.ts | 1 - src/components/chat/chat-message.ts | 1 - src/components/chat/chat.spec.ts | 4 +- src/components/chat/chat.ts | 108 +++++------ src/components/chat/message-attachments.ts | 215 +++++++++++---------- 6 files changed, 226 insertions(+), 199 deletions(-) diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index dcda2dbec..3896249a3 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -24,7 +24,6 @@ import { * */ export default class IgcChatInputComponent extends LitElement { - /** @private */ public static readonly tagName = 'igc-chat-input'; public static override styles = styles; @@ -299,24 +298,62 @@ export default class IgcChatInputComponent extends LitElement { this.dispatchEvent(attachmentEvent); } + private renderFileUploadArea() { + return html` ${this._chat?.options?.disableAttachments + ? '' + : html` + + + + `}`; + } + + private renderTextarea() { + return html``; + } + + private renderActionsArea() { + return html`
+ +
`; + } + + private renderAttachmentsArea() { + return html`
+ ${this.attachments?.map( + (attachment, index) => html` +
+ this.removeAttachment(index)} + > + ${attachment.name} + +
+ ` + )} +
`; + } + protected override render() { return html`
- ${this._chat?.options?.disableAttachments - ? '' - : html` - - - - `} + ${this.renderFileUploadArea()}
-
- -
-
-
- ${this.attachments?.map( - (attachment, index) => html` -
- this.removeAttachment(index)} - > - ${attachment.name} - -
- ` - )} + ${this.renderActionsArea()}
+ ${this.renderAttachmentsArea()} `; } } diff --git a/src/components/chat/chat-message-list.ts b/src/components/chat/chat-message-list.ts index bd753ab16..9ac747e66 100644 --- a/src/components/chat/chat-message-list.ts +++ b/src/components/chat/chat-message-list.ts @@ -14,7 +14,6 @@ import type { IgcMessage } from './types.js'; * */ export default class IgcChatMessageListComponent extends LitElement { - /** @private */ public static readonly tagName = 'igc-chat-message-list'; public static override styles = styles; diff --git a/src/components/chat/chat-message.ts b/src/components/chat/chat-message.ts index 080e8cace..5b74c8b89 100644 --- a/src/components/chat/chat-message.ts +++ b/src/components/chat/chat-message.ts @@ -16,7 +16,6 @@ import type { IgcMessage } from './types.js'; * */ export default class IgcChatMessageComponent extends LitElement { - /** @private */ public static readonly tagName = 'igc-chat-message'; public static override styles = styles; diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index c577f048d..7bc221047 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -115,7 +115,6 @@ describe('Chat', () => { describe('Initialization', () => { it('is correctly initialized with its default component state', () => { - expect(chat.currentUserId).to.equal('user'); expect(chat.messages.length).to.equal(0); expect(chat.options).to.be.undefined; }); @@ -928,6 +927,7 @@ describe('Chat', () => { }); it('should be able to drop files based on the types listed in `acceptedFiles`', async () => { + const eventSpy = spy(chat, 'emitEvent'); const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); const dropZone = inputArea?.shadowRoot?.querySelector('.input-container'); @@ -952,6 +952,8 @@ describe('Chat', () => { inputArea?.shadowRoot?.querySelectorAll('igc-chip'); expect(attachments?.length).to.equal(1); expect(attachments?.[0]?.textContent?.trim()).to.equal('test.txt'); + expect(eventSpy).calledWith('igcAttachmentDrop'); + expect(eventSpy).calledWith('igcAttachmentChange'); } }); }); diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index e6b1815b9..5be342853 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -38,7 +38,6 @@ export default class IgcChatComponent extends EventEmitterMixin< IgcChatComponentEventMap, Constructor >(LitElement) { - /** @private */ public static readonly tagName = 'igc-chat'; public static styles = styles; @@ -77,22 +76,14 @@ export default class IgcChatComponent extends EventEmitterMixin< this._context.setValue(this, true); } - public override connectedCallback() { - super.connectedCallback(); + constructor() { + super(); this.addEventListener( 'attachment-click', this.handleAttachmentClick as EventListener ); } - public override disconnectedCallback() { - super.disconnectedCallback(); - this.removeEventListener( - 'attachment-click', - this.handleAttachmentClick as EventListener - ); - } - private handleSendMessage(e: CustomEvent) { const text = e.detail.text; const attachments = e.detail.attachments || []; @@ -142,6 +133,56 @@ export default class IgcChatComponent extends EventEmitterMixin< } } + private renderHeader() { + return html`
+
+ + ${this.options?.headerText} +
+ + + +
`; + } + + private renderSuggestions() { + return html`
+ + ${this.options?.suggestions?.map( + (suggestion) => html` + + this.addMessage({ text: suggestion })}> + ${suggestion} + + + ` + )} + +
`; + } + + private renderInputArea() { + return html` { + this.emitEvent('igcTypingChange', { detail: e.detail }); + }} + @input-change=${(e: CustomEvent) => { + this.emitEvent('igcInputChange', { detail: e.detail }); + }} + @attachment-change=${this.handleAttachmentChange} + @drop-attachment=${() => this.emitEvent('igcAttachmentDrop')} + @drag-attachment=${() => this.emitEvent('igcAttachmentDrag')} + @focus-input=${() => { + this.emitEvent('igcInputFocus'); + }} + @blur-input=${() => { + this.emitEvent('igcInputBlur'); + }} + >`; + } + protected override firstUpdated() { this._context.setValue(this, true); } @@ -149,50 +190,9 @@ export default class IgcChatComponent extends EventEmitterMixin< protected override render() { return html`
-
-
- - ${this.options?.headerText} -
- - - -
+ ${this.renderHeader()} -
- - ${this.options?.suggestions?.map( - (suggestion) => html` - - this.addMessage({ text: suggestion })} - > - ${suggestion} - - - ` - )} - -
- { - this.emitEvent('igcTypingChange', { detail: e.detail }); - }} - @input-change=${(e: CustomEvent) => { - this.emitEvent('igcInputChange', { detail: e.detail }); - }} - @attachment-change=${this.handleAttachmentChange} - @drop-attachment=${() => this.emitEvent('igcAttachmentDrop')} - @drag-attachment=${() => this.emitEvent('igcAttachmentDrag')} - @focus-input=${() => { - this.emitEvent('igcInputFocus'); - }} - @blur-input=${() => { - this.emitEvent('igcInputBlur'); - }} - > + ${this.renderSuggestions()} ${this.renderInputArea()}
`; } diff --git a/src/components/chat/message-attachments.ts b/src/components/chat/message-attachments.ts index e974a811b..2ca28df3a 100644 --- a/src/components/chat/message-attachments.ts +++ b/src/components/chat/message-attachments.ts @@ -24,7 +24,6 @@ import { * */ export default class IgcMessageAttachmentsComponent extends LitElement { - /** @private */ public static readonly tagName = 'igc-message-attachments'; public static override styles = styles; @@ -89,113 +88,127 @@ export default class IgcMessageAttachmentsComponent extends LitElement { return ''; } + private renderAttachmentHeaderText(attachment: IgcMessageAttachment) { + return html`
+ ${this._chat?.options?.templates?.attachmentHeaderTemplate + ? this._chat.options.templates.attachmentHeaderTemplate( + this.attachments + ) + : html` + + ${attachment.type === 'image' || + attachment.file?.type.startsWith('image/') + ? html`` + : html``} + + + ${attachment.name} + + `} +
`; + } + + private renderAttachmentHeaderActions(attachment: IgcMessageAttachment) { + return html`
+ ${this._chat?.options?.templates?.attachmentActionsTemplate + ? this._chat.options.templates.attachmentActionsTemplate( + this.attachments + ) + : html` + + ${attachment.type === 'image' || + attachment.file?.type.startsWith('image/') + ? html` this.openImagePreview(attachment)} + >` + : ''} + + + `} +
`; + } + + private renderAttachmentContent(attachment: IgcMessageAttachment) { + return html` ${this._chat?.options?.templates?.attachmentContentTemplate + ? this._chat.options.templates.attachmentContentTemplate(this.attachments) + : html` + + ${attachment.type === 'image' || + attachment.file?.type.startsWith('image/') + ? html` ${attachment.name}` + : ''} + + `}`; + } + + private renderDefaultAttachmentsTemplate() { + return html` ${this.attachments.map( + (attachment) => + html` this.handleToggle(ev, attachment)} + @igcOpening=${(ev: CustomEvent) => this.handleToggle(ev, attachment)} + > +
+ ${this.renderAttachmentHeaderText(attachment)} + ${this.renderAttachmentHeaderActions(attachment)} +
+ + ${this.renderAttachmentContent(attachment)} +
` + )}`; + } + + private renderImagePreview() { + return html` ${this.previewImage + ? html` +
+ + +
+ ` + : ''}`; + } + protected override render() { return html`
${this._chat?.options?.templates?.attachmentTemplate ? this._chat.options.templates.attachmentTemplate(this.attachments) - : html` ${this.attachments.map( - (attachment) => - html` - this.handleToggle(ev, attachment)} - @igcOpening=${(ev: CustomEvent) => - this.handleToggle(ev, attachment)} - > -
-
- ${this._chat?.options?.templates?.attachmentHeaderTemplate - ? this._chat.options.templates.attachmentHeaderTemplate( - this.attachments - ) - : html` - - ${attachment.type === 'image' || - attachment.file?.type.startsWith('image/') - ? html`` - : html``} - - - ${attachment.name} - - `} -
-
- ${this._chat?.options?.templates - ?.attachmentActionsTemplate - ? this._chat.options.templates.attachmentActionsTemplate( - this.attachments - ) - : html` - - ${attachment.type === 'image' || - attachment.file?.type.startsWith('image/') - ? html` - this.openImagePreview(attachment)} - >` - : ''} - - - `} -
-
- - ${this._chat?.options?.templates?.attachmentContentTemplate - ? this._chat.options.templates.attachmentContentTemplate( - this.attachments - ) - : html` - - ${attachment.type === 'image' || - attachment.file?.type.startsWith('image/') - ? html` ${attachment.name}` - : ''} - - `} -
` - )}`} + : this.renderDefaultAttachmentsTemplate()}
- ${this.previewImage - ? html` -
- - -
- ` - : ''} + ${this.renderImagePreview()} `; } } From 05a006378b001bf9e4504813c8b9eabd7a996ae7 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Wed, 2 Jul 2025 18:15:57 +0300 Subject: [PATCH 063/252] fix(chat): remove unused method --- src/components/chat/chat-input.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index 3896249a3..65aecb9a7 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -316,10 +316,6 @@ export default class IgcChatInputComponent extends LitElement { `}`; } - private renderTextarea() { - return html``; - } - private renderActionsArea() { return html`
Date: Thu, 3 Jul 2025 08:16:11 +0300 Subject: [PATCH 064/252] refactor(chat): do not expose non-public components, remove timeout --- src/components/chat/chat-input.ts | 8 ++++---- src/index.ts | 4 ---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index 65aecb9a7..ac5ebdbd0 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -43,8 +43,8 @@ export default class IgcChatInputComponent extends LitElement { ); } - @query('textarea') - private textInputElement!: HTMLTextAreaElement; + @query(IgcTextareaComponent.tagName) + private textInputElement!: IgcTextareaComponent; @watch('acceptedFiles', { waitUntilFirstUpdate: true }) protected acceptedFilesChange(): void { @@ -209,9 +209,9 @@ export default class IgcChatInputComponent extends LitElement { this.textInputElement.style.height = 'auto'; } - setTimeout(() => { + this.updateComplete.then(() => { this.textInputElement?.focus(); - }, 0); + }); } private handleFileUpload(e: Event) { diff --git a/src/index.ts b/src/index.ts index c64d4688d..07b7fc58b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,10 +15,6 @@ export { default as IgcCarouselComponent } from './components/carousel/carousel. export { default as IgcCarouselIndicatorComponent } from './components/carousel/carousel-indicator.js'; export { default as IgcCarouselSlideComponent } from './components/carousel/carousel-slide.js'; export { default as IgcChatComponent } from './components/chat/chat.js'; -export { default as IgcChatInputComponent } from './components/chat/chat-input.js'; -export { default as IgcChatMessageComponent } from './components/chat/chat-message.js'; -export { default as IgcChatMessageListComponent } from './components/chat/chat-message-list.js'; -export { default as IgcChatMessageAttachmentsComponent } from './components/chat/message-attachments.js'; export { default as IgcCheckboxComponent } from './components/checkbox/checkbox.js'; export { default as IgcCircularProgressComponent } from './components/progress/circular-progress.js'; export { default as IgcCircularGradientComponent } from './components/progress/circular-gradient.js'; From dd3fed182e2c511d091bf5338671cfe4cf327231 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Fri, 4 Jul 2025 12:47:15 +0300 Subject: [PATCH 065/252] refactor(chat): move common logic into chat state class --- src/components/chat/chat-input.ts | 167 ++++------------ src/components/chat/chat-message-list.ts | 20 +- src/components/chat/chat-message.ts | 24 +-- src/components/chat/chat-state.ts | 219 +++++++++++++++++++++ src/components/chat/chat.spec.ts | 9 +- src/components/chat/chat.ts | 130 ++++-------- src/components/chat/message-attachments.ts | 61 +++--- src/components/chat/types.ts | 1 + src/components/common/context.ts | 4 +- 9 files changed, 358 insertions(+), 277 deletions(-) create mode 100644 src/components/chat/chat-state.ts diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index 5ffce463b..c1fd96297 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -1,6 +1,6 @@ import { consume } from '@lit/context'; -import { LitElement, html } from 'lit'; -import { property, query, state } from 'lit/decorators.js'; +import { html, LitElement, nothing } from 'lit'; +import { query, state } from 'lit/decorators.js'; import IgcIconButtonComponent from '../button/icon-button.js'; import IgcChipComponent from '../chip/chip.js'; import { chatContext } from '../common/context.js'; @@ -10,13 +10,9 @@ import IgcFileInputComponent from '../file-input/file-input.js'; import IgcIconComponent from '../icon/icon.js'; import { registerIconFromText } from '../icon/icon.registry.js'; import IgcTextareaComponent from '../textarea/textarea.js'; -import type IgcChatComponent from './chat.js'; +import type { ChatState } from './chat-state.js'; import { styles } from './themes/input.base.css.js'; -import { - type IgcMessageAttachment, - attachmentIcon, - sendButtonIcon, -} from './types.js'; +import { attachmentIcon, sendButtonIcon } from './types.js'; /** * @@ -29,7 +25,7 @@ export default class IgcChatInputComponent extends LitElement { public static override styles = styles; @consume({ context: chatContext, subscribe: true }) - private _chat?: IgcChatComponent; + private _chatState?: ChatState; /* blazorSuppress */ public static register() { @@ -48,7 +44,7 @@ export default class IgcChatInputComponent extends LitElement { @watch('acceptedFiles', { waitUntilFirstUpdate: true }) protected acceptedFilesChange(): void { - this.updateAcceptedTypesCache(); + this._chatState?.updateAcceptedTypesCache(); } @state() @@ -57,16 +53,6 @@ export default class IgcChatInputComponent extends LitElement { @state() private dragClass = ''; - @property({ attribute: false }) - public attachments: IgcMessageAttachment[] = []; - - // Cache for accepted file types - private _acceptedTypesCache: { - extensions: Set; - mimeTypes: Set; - wildcardTypes: Set; - } | null = null; - constructor() { super(); registerIconFromText('attachment', attachmentIcon, 'material'); @@ -75,17 +61,14 @@ export default class IgcChatInputComponent extends LitElement { protected override firstUpdated() { this.setupDragAndDrop(); - this.updateAcceptedTypesCache(); + this._chatState?.updateAcceptedTypesCache(); } private handleInput(e: Event) { const target = e.target as HTMLTextAreaElement; this.inputValue = target.value; + this._chatState?.handleInputChange(this.inputValue); this.adjustTextareaHeight(); - const inputEvent = new CustomEvent('input-change', { - detail: { value: this.inputValue }, - }); - this.dispatchEvent(inputEvent); } private handleKeyDown(e: KeyboardEvent) { @@ -93,28 +76,25 @@ export default class IgcChatInputComponent extends LitElement { e.preventDefault(); this.sendMessage(); } else { - const typingEvent = new CustomEvent('typing-change', { + this._chatState?.emitEvent('igcTypingChange', { detail: { isTyping: true }, }); - this.dispatchEvent(typingEvent); + // wait 3 seconds and dispatch a stop-typing event setTimeout(() => { - const stopTypingEvent = new CustomEvent('typing-change', { + this._chatState?.emitEvent('igcTypingChange', { detail: { isTyping: false }, }); - this.dispatchEvent(stopTypingEvent); }, 3000); } } private handleFocus() { - const focusEvent = new CustomEvent('focus-input'); - this.dispatchEvent(focusEvent); + this._chatState?.emitEvent('igcInputFocus'); } private handleBlur() { - const blurEvent = new CustomEvent('blur-input'); - this.dispatchEvent(blurEvent); + this._chatState?.emitEvent('igcInputBlur'); } private setupDragAndDrop() { @@ -137,13 +117,12 @@ export default class IgcChatInputComponent extends LitElement { (item) => item.kind === 'file' ); const hasValidFiles = files.some((item) => - this.isFileTypeAccepted(item.getAsFile() as File, item.type) + this._chatState?.isFileTypeAccepted(item.getAsFile() as File, item.type) ); this.dragClass = hasValidFiles ? 'dragging' : ''; - const dragEvent = new CustomEvent('drag-attachment'); - this.dispatchEvent(dragEvent); + this._chatState?.emitEvent('igcAttachmentDrag'); } private handleDragOver(e: DragEvent) { @@ -178,12 +157,14 @@ export default class IgcChatInputComponent extends LitElement { const files = Array.from(e.dataTransfer?.files || []); if (files.length === 0) return; - const validFiles = files.filter((file) => this.isFileTypeAccepted(file)); + const validFiles = files.filter((file) => + this._chatState?.isFileTypeAccepted(file) + ); - const dropEvent = new CustomEvent('drop-attachment'); - this.dispatchEvent(dropEvent); + this._chatState?.emitEvent('igcAttachmentDrop'); - this.attachFiles(validFiles); + this._chatState?.attachFiles(validFiles); + this.requestUpdate(); } private adjustTextareaHeight() { @@ -196,13 +177,16 @@ export default class IgcChatInputComponent extends LitElement { } private sendMessage() { - if (!this.inputValue.trim() && this.attachments.length === 0) return; + if ( + !this.inputValue.trim() && + this._chatState?.inputAttachments.length === 0 + ) + return; - const messageEvent = new CustomEvent('message-created', { - detail: { text: this.inputValue, attachments: this.attachments }, + this._chatState?.addMessage({ + text: this.inputValue, + attachments: this._chatState?.inputAttachments, }); - - this.dispatchEvent(messageEvent); this.inputValue = ''; if (this.textInputElement) { @@ -219,92 +203,22 @@ export default class IgcChatInputComponent extends LitElement { if (!input.files || input.files.length === 0) return; const files = Array.from(input.files); - this.attachFiles(files); - } - - private attachFiles(files: File[]) { - const newAttachments: IgcMessageAttachment[] = []; - let count = this.attachments.length; - files.forEach((file) => { - const isImage = file.type.startsWith('image/'); - newAttachments.push({ - id: Date.now().toString() + count++, - // type: isImage ? 'image' : 'file', - url: URL.createObjectURL(file), - name: file.name, - file: file, - thumbnail: isImage ? URL.createObjectURL(file) : undefined, - }); - }); - - const attachmentEvent = new CustomEvent('attachment-change', { - detail: [...this.attachments, ...newAttachments], - }); - this.dispatchEvent(attachmentEvent); - } - - private updateAcceptedTypesCache() { - if (!this._chat?.options?.acceptedFiles) { - this._acceptedTypesCache = null; - return; - } - - const types = this._chat?.options?.acceptedFiles - .split(',') - .map((type) => type.trim().toLowerCase()); - this._acceptedTypesCache = { - extensions: new Set(types.filter((t) => t.startsWith('.'))), - mimeTypes: new Set( - types.filter((t) => !t.startsWith('.') && !t.endsWith('/*')) - ), - wildcardTypes: new Set( - types.filter((t) => t.endsWith('/*')).map((t) => t.slice(0, -2)) - ), - }; - } - - private isFileTypeAccepted(file: File, type = ''): boolean { - if (!this._acceptedTypesCache) return true; - - if (file === null && type === '') return false; - - const fileType = - file != null ? file.type.toLowerCase() : type.toLowerCase(); - const fileExtension = - file != null - ? `.${file.name.split('.').pop()?.toLowerCase()}` - : `.${type.split('/').pop()?.toLowerCase()}`; - - // Check file extension - if (this._acceptedTypesCache.extensions.has(fileExtension)) { - return true; - } - - // Check exact MIME type - if (this._acceptedTypesCache.mimeTypes.has(fileType)) { - return true; - } - - // Check wildcard MIME types - const [fileBaseType] = fileType.split('/'); - return this._acceptedTypesCache.wildcardTypes.has(fileBaseType); + this._chatState?.attachFiles(files); + this.requestUpdate(); } private removeAttachment(index: number) { - const attachmentEvent = new CustomEvent('attachment-change', { - detail: this.attachments.filter((_, i) => i !== index), - }); - - this.dispatchEvent(attachmentEvent); + this._chatState?.removeAttachment(index); + this.requestUpdate(); } private renderFileUploadArea() { - return html` ${this._chat?.options?.disableAttachments - ? '' + return html`${this._chatState?.options?.disableAttachments + ? nothing : html` + return html`
`; } private renderAttachmentsArea() { - return html`
- ${this.attachments?.map( + return html`
+ ${this._chatState?.inputAttachments?.map( (attachment, index) => html`
@@ -98,7 +98,7 @@ export default class IgcChatMessageListComponent extends LitElement { protected override render() { const groupedMessages = this.groupMessagesByDate( - this._chat?.messages ?? [] + this._chatState?.messages ?? [] ); return html` @@ -118,7 +118,9 @@ export default class IgcChatMessageListComponent extends LitElement { ` )} ${ - this._chat?.options?.isComposing ? this.renderLoadingTemplate() : '' + this._chatState?.options?.isComposing + ? this.renderLoadingTemplate() + : nothing }
diff --git a/src/components/chat/chat-message.ts b/src/components/chat/chat-message.ts index 4df74e942..6f8db52f3 100644 --- a/src/components/chat/chat-message.ts +++ b/src/components/chat/chat-message.ts @@ -1,10 +1,10 @@ import { consume } from '@lit/context'; -import { LitElement, html } from 'lit'; +import { html, LitElement, nothing } from 'lit'; import { property } from 'lit/decorators.js'; import IgcAvatarComponent from '../avatar/avatar.js'; import { chatContext } from '../common/context.js'; import { registerComponent } from '../common/definitions/register.js'; -import type IgcChatComponent from './chat.js'; +import type { ChatState } from './chat-state.js'; import { renderMarkdown } from './markdown-util.js'; import IgcMessageAttachmentsComponent from './message-attachments.js'; import { styles } from './themes/message.base.css.js'; @@ -21,7 +21,7 @@ export default class IgcChatMessageComponent extends LitElement { public static override styles = styles; @consume({ context: chatContext, subscribe: true }) - private _chat?: IgcChatComponent; + private _chatState?: ChatState; /* blazorSuppress */ public static register() { @@ -32,28 +32,30 @@ export default class IgcChatMessageComponent extends LitElement { ); } - @property({ reflect: true, attribute: false }) + @property({ attribute: false }) public message: IgcMessage | undefined; protected override render() { - const containerClass = `message-container ${this.message?.sender === this._chat?.currentUserId ? 'sent' : ''}`; + const containerClass = `message-container ${this.message?.sender === this._chatState?.currentUserId ? 'sent' : ''}`; return html`
${this.message?.text.trim() - ? html`
${renderMarkdown(this.message?.text)}
` - : ''} + ? html`
${renderMarkdown(this.message?.text)}
` + : nothing} ${this.message?.attachments && this.message?.attachments.length > 0 ? html` ` - : ''} - ${this._chat?.options?.templates?.messageActionsTemplate && + : nothing} + ${this._chatState?.options?.templates?.messageActionsTemplate && this.message - ? this._chat.options.templates.messageActionsTemplate(this.message) - : ''} + ? this._chatState.options.templates.messageActionsTemplate( + this.message + ) + : nothing}
`; diff --git a/src/components/chat/chat-state.ts b/src/components/chat/chat-state.ts new file mode 100644 index 000000000..3aee60600 --- /dev/null +++ b/src/components/chat/chat-state.ts @@ -0,0 +1,219 @@ +import type IgcChatComponent from './chat.js'; +import type { IgcChatComponentEventMap } from './chat.js'; +import type { + IgcChatOptions, + IgcMessage, + IgcMessageAttachment, +} from './types.js'; + +export class ChatState { + //#region Internal properties and state + private readonly _host: IgcChatComponent; + private _messages: IgcMessage[] = []; + private _options?: IgcChatOptions; + private _inputAttachments: IgcMessageAttachment[] = []; + private _inputValue = ''; + // Cache for accepted file types + private _acceptedTypesCache: { + extensions: Set; + mimeTypes: Set; + wildcardTypes: Set; + } | null = null; + + //#endregion + + //#region Public properties + + /** Chat message list. */ + public get messages(): IgcMessage[] { + return this._messages; + } + + /** Sets the chat message list. */ + public set messages(value: IgcMessage[]) { + this._messages = value; + this._host.requestUpdate(); + } + + /** Chat config options. */ + public get options(): IgcChatOptions | undefined { + return this._options; + } + + /** Sets the chat options. */ + public set options(value: IgcChatOptions) { + this._options = value; + this._host.requestUpdate(); + } + + /** Gets the current user id. */ + public get currentUserId(): string { + return this._options?.currentUserId ?? 'user'; + } + + /** Input attachments. */ + public get inputAttachments(): IgcMessageAttachment[] { + return this._inputAttachments; + } + + /** Sets input attachments. */ + public set inputAttachments(value: IgcMessageAttachment[]) { + this._inputAttachments = value; + this._host.requestUpdate(); // Notify the host component to re-render + } + + /** Input value. */ + public get inputValue(): string { + return this._inputValue; + } + + public set inputValue(value: string) { + this._inputValue = value; + this._host.requestUpdate(); + } + //#endregion + + constructor(chat: IgcChatComponent) { + this._host = chat; + } + + //#region Event handlers + + /** Emmits chat events. */ + public emitEvent(name: keyof IgcChatComponentEventMap, args?: any) { + return this._host.emitEvent(name, args); + } + + /** Handles input changes. */ + public handleInputChange(value: string): void { + this.inputValue = value; + this.emitEvent('igcInputChange', { detail: { value: this.inputValue } }); + } + + //#endregion + + //#region Public API + + /** Adds a new message to the chat. */ + public addMessage(message: { + id?: string; + text: string; + sender?: string; + timestamp?: Date; + attachments?: IgcMessageAttachment[]; + }): void { + const newMessage: IgcMessage = { + id: message.id ?? Date.now().toString(), + text: message.text, + sender: message.sender ?? this.currentUserId, + timestamp: message.timestamp ?? new Date(), + attachments: message.attachments || [], + }; + + const allowed = this.emitEvent('igcMessageCreated', { + detail: newMessage, + cancelable: true, + }); + + if (allowed) { + this.messages = [...this.messages, newMessage]; + this.inputAttachments = []; + } + } + + /** Adds a new attachmnet to the attachments list. */ + public attachFiles(files: File[]) { + const newAttachments: IgcMessageAttachment[] = []; + let count = this.inputAttachments.length; + files.forEach((file) => { + const isImage = file.type.startsWith('image/'); + newAttachments.push({ + id: Date.now().toString() + count++, + // type: isImage ? 'image' : 'file', + url: URL.createObjectURL(file), + name: file.name, + file: file, + thumbnail: isImage ? URL.createObjectURL(file) : undefined, + }); + }); + + const allowed = this.emitEvent('igcAttachmentChange', { + detail: [...this.inputAttachments, ...newAttachments], + cancelable: true, + }); + if (allowed) { + this.inputAttachments = [...this.inputAttachments, ...newAttachments]; + } + } + + /** Removes an attachment by index. */ + public removeAttachment(index: number): void { + const allowed = this.emitEvent('igcAttachmentChange', { + detail: this.inputAttachments.filter((_, i) => i !== index), + cancelable: true, + }); + if (allowed) { + this.inputAttachments = this.inputAttachments.filter( + (_, i) => i !== index + ); + } + } + + /** Updates chat options dynamically. */ + public updateOptions(options: Partial): void { + this.options = { ...this.options, ...options }; + } + + public updateAcceptedTypesCache() { + if (!this.options?.acceptedFiles) { + this._acceptedTypesCache = null; + return; + } + + const types = this.options?.acceptedFiles + .split(',') + .map((type) => type.trim().toLowerCase()); + this._acceptedTypesCache = { + extensions: new Set(types.filter((t) => t.startsWith('.'))), + mimeTypes: new Set( + types.filter((t) => !t.startsWith('.') && !t.endsWith('/*')) + ), + wildcardTypes: new Set( + types.filter((t) => t.endsWith('/*')).map((t) => t.slice(0, -2)) + ), + }; + } + + /** Checks if a file could be attached based on the acceptedFiles. */ + public isFileTypeAccepted(file: File, type = ''): boolean { + if (!this._acceptedTypesCache) return true; + + if (file === null && type === '') return false; + + const fileType = + file != null ? file.type.toLowerCase() : type.toLowerCase(); + const fileExtension = + file != null + ? `.${file.name.split('.').pop()?.toLowerCase()}` + : `.${type.split('/').pop()?.toLowerCase()}`; + + // Check file extension + if (this._acceptedTypesCache.extensions.has(fileExtension)) { + return true; + } + + // Check exact MIME type + if (this._acceptedTypesCache.mimeTypes.has(fileType)) { + return true; + } + + // Check wildcard MIME types + const [fileBaseType] = fileType.split('/'); + return this._acceptedTypesCache.wildcardTypes.has(fileBaseType); + } + //#endregion +} + +export function createChatState(host: IgcChatComponent): ChatState { + return new ChatState(host); +} diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index 46060c0ee..9affa5ffa 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -121,7 +121,7 @@ describe('Chat', () => { it('is rendered correctly', () => { expect(chat).dom.to.equal( - ` + ` ` ); @@ -258,8 +258,13 @@ describe('Chat', () => { timestamp: new Date(Date.now() - 3200000), }, ]; + + const options = { + currentUserId: 'me', + }; + chat = await fixture( - html` + html` ` ); diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index 8d1060054..6228bf782 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -1,6 +1,6 @@ import { ContextProvider } from '@lit/context'; -import { LitElement, html } from 'lit'; -import { property, state } from 'lit/decorators.js'; +import { html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; import IgcButtonComponent from '../button/button.js'; import { chatContext } from '../common/context.js'; import { watch } from '../common/decorators/watch.js'; @@ -9,6 +9,7 @@ import type { Constructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; import IgcChatInputComponent from './chat-input.js'; import IgcChatMessageListComponent from './chat-message-list.js'; +import { createChatState } from './chat-state.js'; import { styles } from './themes/chat.base.css.js'; import type { IgcChatOptions, @@ -52,92 +53,52 @@ export default class IgcChatComponent extends EventEmitterMixin< ); } + private readonly _chatState = createChatState(this); + private _context = new ContextProvider(this, { context: chatContext, - initialValue: this, + initialValue: this._chatState, }); - @state() - private inputAttachments: IgcMessageAttachment[] = []; - - @property({ type: String, reflect: true, attribute: 'current-user-id' }) - public currentUserId = 'user'; - + /** + * The list of chat messages currently displayed. + */ @property({ reflect: true, attribute: false }) - public messages: IgcMessage[] = []; - - @property({ attribute: false }) - public options?: IgcChatOptions; - - @watch('currentUserId') - @watch('messages') - @watch('options') - protected contextChanged() { - this._context.setValue(this, true); + public set messages(value: IgcMessage[]) { + this._chatState.messages = value; } - constructor() { - super(); - this.addEventListener( - 'attachment-click', - this.handleAttachmentClick as EventListener - ); + public get messages(): IgcMessage[] { + return this._chatState.messages; } - private handleSendMessage(e: CustomEvent) { - const text = e.detail.text; - const attachments = e.detail.attachments || []; + /** + * Controls the chat configuration and how it will be displayed. + */ - if (!text.trim() && attachments.length === 0) return; - - this.addMessage({ text, attachments }); - } - - private handleAttachmentClick(e: CustomEvent) { - const attachmentArgs = e.detail.attachment; - this.emitEvent('igcAttachmentClick', { detail: attachmentArgs }); + @property({ attribute: false }) + public set options(value: IgcChatOptions) { + this._chatState.options = value; } - private handleAttachmentChange(e: CustomEvent) { - const allowed = this.emitEvent('igcAttachmentChange', { - detail: e.detail, - cancelable: true, - }); - if (allowed) { - this.inputAttachments = [...e.detail]; - } + public get options(): IgcChatOptions | undefined { + return this._chatState.options; } - private addMessage(message: { - id?: string; - text: string; - sender?: string; - timestamp?: Date; - attachments?: IgcMessageAttachment[]; - }) { - const newMessage: IgcMessage = { - id: message.id ?? Date.now().toString(), - text: message.text, - sender: message.sender ?? this.currentUserId, - timestamp: message.timestamp ?? new Date(), - attachments: message.attachments || [], - }; - const allowed = this.emitEvent('igcMessageCreated', { - detail: newMessage, - cancelable: true, - }); - - if (allowed) { - this.messages = [...this.messages, newMessage]; - this.inputAttachments = []; - } + @watch('currentUserId') + @watch('messages') + @watch('options') + protected contextChanged() { + this._context.setValue(this._chatState, true); } private renderHeader() { return html`
- ${this.options?.headerText} + ${this._chatState.options?.headerText}
@@ -148,10 +109,12 @@ export default class IgcChatComponent extends EventEmitterMixin< private renderSuggestions() { return html`
- ${this.options?.suggestions?.map( + ${this._chatState.options?.suggestions?.map( (suggestion) => html` - this.addMessage({ text: suggestion })}> + this._chatState.addMessage({ text: suggestion })} + > ${suggestion} @@ -161,30 +124,8 @@ export default class IgcChatComponent extends EventEmitterMixin<
`; } - private renderInputArea() { - return html` { - this.emitEvent('igcTypingChange', { detail: e.detail }); - }} - @input-change=${(e: CustomEvent) => { - this.emitEvent('igcInputChange', { detail: e.detail }); - }} - @attachment-change=${this.handleAttachmentChange} - @drop-attachment=${() => this.emitEvent('igcAttachmentDrop')} - @drag-attachment=${() => this.emitEvent('igcAttachmentDrag')} - @focus-input=${() => { - this.emitEvent('igcInputFocus'); - }} - @blur-input=${() => { - this.emitEvent('igcInputBlur'); - }} - >`; - } - protected override firstUpdated() { - this._context.setValue(this, true); + this._context.setValue(this._chatState, true); } protected override render() { @@ -192,7 +133,8 @@ export default class IgcChatComponent extends EventEmitterMixin<
${this.renderHeader()} - ${this.renderSuggestions()} ${this.renderInputArea()} + ${this.renderSuggestions()} +
`; } diff --git a/src/components/chat/message-attachments.ts b/src/components/chat/message-attachments.ts index 2ebc6e477..4f7254e40 100644 --- a/src/components/chat/message-attachments.ts +++ b/src/components/chat/message-attachments.ts @@ -1,5 +1,5 @@ import { consume } from '@lit/context'; -import { LitElement, html } from 'lit'; +import { html, LitElement, nothing } from 'lit'; import { property } from 'lit/decorators.js'; import IgcIconButtonComponent from '../button/icon-button.js'; import { chatContext } from '../common/context.js'; @@ -7,12 +7,12 @@ import { registerComponent } from '../common/definitions/register.js'; import IgcExpansionPanelComponent from '../expansion-panel/expansion-panel.js'; import IgcIconComponent from '../icon/icon.js'; import { registerIconFromText } from '../icon/icon.registry.js'; -import type IgcChatComponent from './chat.js'; +import type { ChatState } from './chat-state.js'; import { styles } from './themes/message-attachments.base.css.js'; import { - type IgcMessageAttachment, closeIcon, fileIcon, + type IgcMessageAttachment, imageIcon, moreIcon, previewIcon, @@ -29,7 +29,7 @@ export default class IgcMessageAttachmentsComponent extends LitElement { public static override styles = styles; @consume({ context: chatContext, subscribe: true }) - private _chat?: IgcChatComponent; + private _chatState?: ChatState; /* blazorSuppress */ public static register() { @@ -40,10 +40,10 @@ export default class IgcMessageAttachmentsComponent extends LitElement { IgcExpansionPanelComponent ); } - @property({ type: Array }) + @property({ attribute: false }) attachments: IgcMessageAttachment[] = []; - @property({ type: String }) + @property({ attribute: false }) previewImage = ''; constructor() { @@ -64,20 +64,10 @@ export default class IgcMessageAttachmentsComponent extends LitElement { } private handleToggle(e: CustomEvent, attachment: IgcMessageAttachment) { - this.handleAttachmentClick(attachment); + this._chatState?.emitEvent('igcAttachmentClick', { detail: attachment }); e.preventDefault(); } - private handleAttachmentClick(attachment: IgcMessageAttachment) { - this.dispatchEvent( - new CustomEvent('attachment-click', { - detail: { attachment }, - bubbles: true, - composed: true, - }) - ); - } - private getURL(attachment: IgcMessageAttachment): string { if (attachment.url) { return attachment.url; @@ -89,9 +79,9 @@ export default class IgcMessageAttachmentsComponent extends LitElement { } private renderAttachmentHeaderText(attachment: IgcMessageAttachment) { - return html`
- ${this._chat?.options?.templates?.attachmentHeaderTemplate - ? this._chat.options.templates.attachmentHeaderTemplate( + return html`
+ ${this._chatState?.options?.templates?.attachmentHeaderTemplate + ? this._chatState.options.templates.attachmentHeaderTemplate( this.attachments ) : html` @@ -117,9 +107,9 @@ export default class IgcMessageAttachmentsComponent extends LitElement { } private renderAttachmentHeaderActions(attachment: IgcMessageAttachment) { - return html`
- ${this._chat?.options?.templates?.attachmentActionsTemplate - ? this._chat.options.templates.attachmentActionsTemplate( + return html`
+ ${this._chatState?.options?.templates?.attachmentActionsTemplate + ? this._chatState.options.templates.attachmentActionsTemplate( this.attachments ) : html` @@ -133,7 +123,7 @@ export default class IgcMessageAttachmentsComponent extends LitElement { class="small" @click=${() => this.openImagePreview(attachment)} >` - : ''} + : nothing} ${attachment.type === 'image' || @@ -157,19 +150,19 @@ export default class IgcMessageAttachmentsComponent extends LitElement { src=${this.getURL(attachment)} alt=${attachment.name} />` - : ''} + : nothing} `}`; } private renderDefaultAttachmentsTemplate() { - return html` ${this.attachments.map( + return html`${this.attachments.map( (attachment) => - html` this.handleToggle(ev, attachment)} @igcOpening=${(ev: CustomEvent) => this.handleToggle(ev, attachment)} > @@ -197,14 +190,16 @@ export default class IgcMessageAttachmentsComponent extends LitElement { >
` - : ''}`; + : nothing}`; } protected override render() { return html`
- ${this._chat?.options?.templates?.attachmentTemplate - ? this._chat.options.templates.attachmentTemplate(this.attachments) + ${this._chatState?.options?.templates?.attachmentTemplate + ? this._chatState.options.templates.attachmentTemplate( + this.attachments + ) : this.renderDefaultAttachmentsTemplate()}
diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts index 6a3a7f3f6..f6c0bb2bb 100644 --- a/src/components/chat/types.ts +++ b/src/components/chat/types.ts @@ -25,6 +25,7 @@ export type AttachmentTemplate = ( export type MessageActionsTemplate = (message: IgcMessage) => TemplateResult; export type IgcChatOptions = { + currentUserId?: string; hideAvatar?: boolean; hideTimestamp?: boolean; hideUserName?: boolean; diff --git a/src/components/common/context.ts b/src/components/common/context.ts index e6e640357..163ea57cd 100644 --- a/src/components/common/context.ts +++ b/src/components/common/context.ts @@ -1,7 +1,7 @@ import { createContext } from '@lit/context'; import type { Ref } from 'lit/directives/ref.js'; import type IgcCarouselComponent from '../carousel/carousel.js'; -import type IgcChatComponent from '../chat/chat.js'; +import type { ChatState } from '../chat/chat-state.js'; import type IgcTileManagerComponent from '../tile-manager/tile-manager.js'; export type TileManagerContext = { @@ -19,6 +19,6 @@ const tileManagerContext = createContext( Symbol('tile-manager-context') ); -const chatContext = createContext(Symbol('chat-context')); +const chatContext = createContext(Symbol('chat-context')); export { carouselContext, tileManagerContext, chatContext }; From 78b4e4da056705203e6147236c05f8e4b3d6b1e7 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Tue, 8 Jul 2025 10:09:58 +0300 Subject: [PATCH 066/252] feat(chat): expose message template --- src/components/chat/chat-message.ts | 21 ++++++++++-------- src/components/chat/chat.spec.ts | 34 +++++++++++++++++++++++++++++ src/components/chat/types.ts | 5 +++-- stories/chat.stories.ts | 4 ++-- 4 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/components/chat/chat-message.ts b/src/components/chat/chat-message.ts index 6f8db52f3..2e607735e 100644 --- a/src/components/chat/chat-message.ts +++ b/src/components/chat/chat-message.ts @@ -41,15 +41,18 @@ export default class IgcChatMessageComponent extends LitElement { return html`
- ${this.message?.text.trim() - ? html`
${renderMarkdown(this.message?.text)}
` - : nothing} - ${this.message?.attachments && this.message?.attachments.length > 0 - ? html` - ` - : nothing} + ${this._chatState?.options?.templates?.messageTemplate && this.message + ? this._chatState.options.templates.messageTemplate(this.message) + : html` ${this.message?.text.trim() + ? html`
${renderMarkdown(this.message?.text)}
` + : nothing} + ${this.message?.attachments && + this.message?.attachments.length > 0 + ? html` + ` + : nothing}`} ${this._chatState?.options?.templates?.messageActionsTemplate && this.message ? this._chatState.options.templates.messageActionsTemplate( diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index 9affa5ffa..df064bdf4 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -17,6 +17,14 @@ describe('Chat', () => { }); const createChatComponent = () => html``; + + const messageTemplate = (msg: any) => { + return html`
+
${msg.sender === 'user' ? 'You' : 'Bot'}:
+

${msg.text}

+
`; + }; + const messageActionsTemplate = (msg: any) => { return msg.sender !== 'user' && msg.text.trim() ? html`
@@ -770,6 +778,32 @@ describe('Chat', () => { }); }); + it('should render messageTemplate', async () => { + chat.options = { + templates: { + messageTemplate: messageTemplate, + }, + }; + await elementUpdated(chat); + await aTimeout(500); + const messageElements = chat.shadowRoot + ?.querySelector('igc-chat-message-list') + ?.shadowRoot?.querySelector('.message-list') + ?.querySelectorAll('igc-chat-message'); + messageElements?.forEach((messageElement, index) => { + const messsageContainer = + messageElement.shadowRoot?.querySelector('.bubble'); + expect(messsageContainer).dom.to.equal( + `
+
+
${chat.messages[index].sender === 'user' ? 'You' : 'Bot'}:
+

${(messsageContainer?.querySelector('p') as HTMLElement)?.innerText}

+
+
` + ); + }); + }); + it('should render messageActionsTemplate', async () => { chat.options = { templates: { diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts index f6c0bb2bb..aa1f694ba 100644 --- a/src/components/chat/types.ts +++ b/src/components/chat/types.ts @@ -22,7 +22,7 @@ export interface IgcMessageAttachment { export type AttachmentTemplate = ( attachments: IgcMessageAttachment[] ) => TemplateResult; -export type MessageActionsTemplate = (message: IgcMessage) => TemplateResult; +export type MessageTemplate = (message: IgcMessage) => TemplateResult; export type IgcChatOptions = { currentUserId?: string; @@ -47,7 +47,8 @@ export type IgcChatTemplates = { attachmentHeaderTemplate?: AttachmentTemplate; attachmentActionsTemplate?: AttachmentTemplate; attachmentContentTemplate?: AttachmentTemplate; - messageActionsTemplate?: MessageActionsTemplate; + messageTemplate?: MessageTemplate; + messageActionsTemplate?: MessageTemplate; composingIndicatorTemplate?: TemplateResult; }; diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 9070d3b34..a46ed1ce3 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -431,7 +431,7 @@ async function handleAIMessageSend(e: CustomEvent) { }; userMessages.push({ - role: chat.currentUserId, + role: 'user', parts: [{ text: newMessage.text }], }); @@ -442,7 +442,7 @@ async function handleAIMessageSend(e: CustomEvent) { await attachment.file.arrayBuffer(), attachment.file.type ); - userMessages.push({ role: chat.currentUserId, parts: [filePart] }); + userMessages.push({ role: 'user', parts: [filePart] }); } } } From e4c9314dc4711d5cfbdbdd31a9498943021c2129 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Tue, 8 Jul 2025 10:16:15 +0300 Subject: [PATCH 067/252] fix(chat): export correct type in index.ts --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 07b7fc58b..14cd887ad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -160,5 +160,5 @@ export type { IgcMessage, IgcMessageAttachment, AttachmentTemplate, - MessageActionsTemplate, + MessageTemplate, } from './components/chat/types.js'; From 52590982ebd69cf1c44449648c886bde67681fc4 Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Tue, 8 Jul 2025 10:53:53 +0300 Subject: [PATCH 068/252] feat(chat): add DOMPurify sanitizer --- package-lock.json | 14 +++++++++++--- package.json | 5 +++-- src/components/chat/chat-message.ts | 9 ++++++++- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 63e3fdfc2..ce96bf579 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,7 @@ "peerDependencies": { "@google/genai": "^1.3.0", "@supabase/supabase-js": "^2.49.4", + "dompurify": "^3.2.6", "marked": "^12.0.0" } }, @@ -3130,7 +3131,6 @@ "version": "24.0.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.4.tgz", "integrity": "sha512-ulyqAkrhnuNq9pB76DRBTkcS6YsmDALy6Ua63V8OhrOBgbcYt6IOdzpw5P1+dyRIyMerzLkeYWBeOXPpA9GMAA==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.8.0" @@ -6030,6 +6030,16 @@ "license": "MIT", "peer": true }, + "node_modules/dompurify": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", + "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -14059,7 +14069,6 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", - "dev": true, "license": "MIT" }, "node_modules/unicorn-magic": { @@ -14759,7 +14768,6 @@ "version": "3.25.67", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz", "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 7e91f10a8..2d0a653a2 100644 --- a/package.json +++ b/package.json @@ -104,9 +104,10 @@ "vite": "^7.0.0" }, "peerDependencies": { + "@google/genai": "^1.3.0", "@supabase/supabase-js": "^2.49.4", - "marked": "^12.0.0", - "@google/genai": "^1.3.0" + "dompurify": "^3.2.6", + "marked": "^12.0.0" }, "browserslist": [ "defaults" diff --git a/src/components/chat/chat-message.ts b/src/components/chat/chat-message.ts index 6f8db52f3..b1fd50ac8 100644 --- a/src/components/chat/chat-message.ts +++ b/src/components/chat/chat-message.ts @@ -1,4 +1,5 @@ import { consume } from '@lit/context'; +import DOMPurify from 'dompurify'; import { html, LitElement, nothing } from 'lit'; import { property } from 'lit/decorators.js'; import IgcAvatarComponent from '../avatar/avatar.js'; @@ -35,6 +36,10 @@ export default class IgcChatMessageComponent extends LitElement { @property({ attribute: false }) public message: IgcMessage | undefined; + private sanitizeMessageText(text: string): string { + return DOMPurify.sanitize(text); + } + protected override render() { const containerClass = `message-container ${this.message?.sender === this._chatState?.currentUserId ? 'sent' : ''}`; @@ -42,7 +47,9 @@ export default class IgcChatMessageComponent extends LitElement {
${this.message?.text.trim() - ? html`
${renderMarkdown(this.message?.text)}
` + ? html`
+ ${renderMarkdown(this.sanitizeMessageText(this.message?.text))} +
` : nothing} ${this.message?.attachments && this.message?.attachments.length > 0 ? html` Date: Wed, 9 Jul 2025 10:29:53 +0300 Subject: [PATCH 069/252] feat(chat): add support for custom markdown renderer --- src/components/chat/chat-message.ts | 4 +++- src/components/chat/types.ts | 2 ++ stories/chat.stories.ts | 3 +++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/chat/chat-message.ts b/src/components/chat/chat-message.ts index 824bc2b52..2b15b6f25 100644 --- a/src/components/chat/chat-message.ts +++ b/src/components/chat/chat-message.ts @@ -45,6 +45,8 @@ export default class IgcChatMessageComponent extends LitElement { const sanitizedMessageText = this.sanitizeMessageText( this.message?.text.trim() || '' ); + const renderer = + this._chatState?.options?.markdownRenderer || renderMarkdown; return html`
@@ -52,7 +54,7 @@ export default class IgcChatMessageComponent extends LitElement { ${this._chatState?.options?.templates?.messageTemplate && this.message ? this._chatState.options.templates.messageTemplate(this.message) : html` ${sanitizedMessageText - ? html`
${renderMarkdown(sanitizedMessageText)}
` + ? html`
${renderer(sanitizedMessageText)}
` : nothing} ${this.message?.attachments && this.message?.attachments.length > 0 diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts index aa1f694ba..8152ef105 100644 --- a/src/components/chat/types.ts +++ b/src/components/chat/types.ts @@ -23,6 +23,7 @@ export type AttachmentTemplate = ( attachments: IgcMessageAttachment[] ) => TemplateResult; export type MessageTemplate = (message: IgcMessage) => TemplateResult; +export type MarkdownRenderer = (text: string) => TemplateResult; export type IgcChatOptions = { currentUserId?: string; @@ -40,6 +41,7 @@ export type IgcChatOptions = { headerText?: string; suggestions?: string[]; templates?: IgcChatTemplates; + markdownRenderer?: MarkdownRenderer; }; export type IgcChatTemplates = { diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index a46ed1ce3..765025f09 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -106,6 +106,8 @@ const messageActionsTemplate = (msg: any) => { }; const _composingIndicatorTemplate = html`LOADING...`; +const _customRenderer = (text: string) => + html`${text.toUpperCase()}`; const ai_chat_options = { headerText: 'Chat', @@ -114,6 +116,7 @@ const ai_chat_options = { messageActionsTemplate: messageActionsTemplate, //composingIndicatorTemplate: _composingIndicatorTemplate, }, + // markdownRenderer: _customRenderer }; const chat_options = { From 7df3050542582fea17f4b70ccd84e9a473935b10 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Fri, 11 Jul 2025 12:00:18 +0300 Subject: [PATCH 070/252] feat(chat): expose draftMessage prop & input area templates --- src/components/chat/chat-input.ts | 86 +++++++++++++++++------------ src/components/chat/chat-state.ts | 2 +- src/components/chat/chat.spec.ts | 90 +++++++++++++++++++++++++++++++ src/components/chat/chat.ts | 27 +++++++++- src/components/chat/types.ts | 4 ++ stories/chat.stories.ts | 42 +++++++++++++++ 6 files changed, 216 insertions(+), 35 deletions(-) diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index c1fd96297..7e61aa337 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -64,6 +64,10 @@ export default class IgcChatInputComponent extends LitElement { this._chatState?.updateAcceptedTypesCache(); } + protected override updated() { + this.inputValue = this._chatState?.inputValue || ''; + } + private handleInput(e: Event) { const target = e.target as HTMLTextAreaElement; this.inputValue = target.value; @@ -212,8 +216,8 @@ export default class IgcChatInputComponent extends LitElement { this.requestUpdate(); } - private renderFileUploadArea() { - return html`${this._chatState?.options?.disableAttachments + protected *renderDefaultFileUploadTemplate() { + yield html`${this._chatState?.options?.disableAttachments ? nothing : html` - + ${this._chatState?.options?.templates?.textAreaActionsTemplate + ? this._chatState?.options?.templates?.textAreaActionsTemplate + : html` `}
`; } private renderAttachmentsArea() { return html`
- ${this._chatState?.inputAttachments?.map( - (attachment, index) => html` -
- this.removeAttachment(index)} - > - ${attachment.name} - -
- ` - )} + ${this._chatState?.options?.templates?.textAreaAttachmentsTemplate + ? this._chatState.options.templates.textAreaAttachmentsTemplate( + this._chatState?.inputAttachments + ) + : html`${this._chatState?.inputAttachments?.map( + (attachment, index) => html` +
+ this.removeAttachment(index)} + > + ${attachment.name} + +
+ ` + )} `}
`; } @@ -267,16 +283,20 @@ export default class IgcChatInputComponent extends LitElement { ${this.renderFileUploadArea()}
- + ${this._chatState?.options?.templates?.textInputTemplate + ? this._chatState.options.templates.textInputTemplate( + this._chatState?.inputValue + ) + : html` `}
${this.renderActionsArea()} diff --git a/src/components/chat/chat-state.ts b/src/components/chat/chat-state.ts index 3aee60600..dde4ffc75 100644 --- a/src/components/chat/chat-state.ts +++ b/src/components/chat/chat-state.ts @@ -117,6 +117,7 @@ export class ChatState { if (allowed) { this.messages = [...this.messages, newMessage]; + this.inputValue = ''; this.inputAttachments = []; } } @@ -129,7 +130,6 @@ export class ChatState { const isImage = file.type.startsWith('image/'); newAttachments.push({ id: Date.now().toString() + count++, - // type: isImage ? 'image' : 'file', url: URL.createObjectURL(file), name: file.name, file: file, diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index 1dd1b57b1..96f22d84a 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -61,6 +61,31 @@ describe('Chat', () => { })}`; }; + const textInputTemplate = (text: string) => + html``; + + const fileUploadTemplate = html`Upload`; + + const textAreaActionsTemplate = html`Send`; + + const textAreaAttachmentsTemplate = (attachments: any[]) => { + return html`
+ ${attachments.map( + (attachment) => + html`${attachment.name}` + )} +
`; + }; + const messages: any[] = [ { id: '1', @@ -104,6 +129,18 @@ describe('Chat', () => { }, ]; + const draftMessage = { + text: 'Draft message', + attachments: [ + { + id: 'img1', + name: 'img1.png', + url: 'https://www.infragistics.com/angular-demos/assets/images/men/1.jpg', + type: 'image', + }, + ], + }; + const files = [ new File(['test content'], 'test.txt', { type: 'text/plain' }), new File(['image data'], 'image.png', { type: 'image/png' }), @@ -333,6 +370,22 @@ describe('Chat', () => { }); }); + it('should render the message in `draftMessage` correctly', async () => { + chat = await fixture( + html`` + ); + + const textArea = chat.shadowRoot + ?.querySelector('igc-chat-input') + ?.shadowRoot?.querySelector('igc-textarea'); + const attachmentsArea = chat.shadowRoot + ?.querySelector('igc-chat-input') + ?.shadowRoot?.querySelectorAll('igc-chip'); + + expect(textArea?.value).to.equal(draftMessage.text); + expect(attachmentsArea?.length).to.equal(draftMessage.attachments.length); + }); + it('should apply `headerText` correctly', async () => { chat.options = { headerText: 'Chat', @@ -901,6 +954,43 @@ describe('Chat', () => {
` ); }); + + it('should render text area templates', async () => { + chat.draftMessage = draftMessage; + chat.options = { + templates: { + textInputTemplate: textInputTemplate, + fileUploadTemplate: fileUploadTemplate, + textAreaActionsTemplate: textAreaActionsTemplate, + textAreaAttachmentsTemplate: textAreaAttachmentsTemplate, + }, + }; + await elementUpdated(chat); + const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); + + expect(inputArea).shadowDom.to.equal( + `
+ Upload +
+ +
+
+ Send +
+
+ ` + ); + + expect(inputArea?.shadowRoot?.querySelector('igc-input')?.value).to.equal( + draftMessage.text + ); + }); }); describe('Interactions', () => { diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index 6228bf782..d6d9e412a 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -72,6 +72,31 @@ export default class IgcChatComponent extends EventEmitterMixin< return this._chatState.messages; } + /** + * The chat message that is still unsend. + */ + @property({ reflect: true, attribute: false }) + public set draftMessage(value: { + text: string; + attachments?: IgcMessageAttachment[]; + }) { + if (this._chatState) { + this._chatState.inputValue = value.text; + this._chatState.inputAttachments = value.attachments || []; + this.requestUpdate(); + } + } + + public get draftMessage(): { + text: string; + attachments?: IgcMessageAttachment[]; + } { + return { + text: this._chatState?.inputValue, + attachments: this._chatState?.inputAttachments, + }; + } + /** * Controls the chat configuration and how it will be displayed. */ @@ -85,8 +110,8 @@ export default class IgcChatComponent extends EventEmitterMixin< return this._chatState.options; } - @watch('currentUserId') @watch('messages') + @watch('draftMessage') @watch('options') protected contextChanged() { this._context.setValue(this._chatState, true); diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts index 8152ef105..2775ba6e4 100644 --- a/src/components/chat/types.ts +++ b/src/components/chat/types.ts @@ -52,6 +52,10 @@ export type IgcChatTemplates = { messageTemplate?: MessageTemplate; messageActionsTemplate?: MessageTemplate; composingIndicatorTemplate?: TemplateResult; + textInputTemplate?: (text: string) => TemplateResult; + fileUploadTemplate?: TemplateResult; + textAreaActionsTemplate?: TemplateResult; + textAreaAttachmentsTemplate?: AttachmentTemplate; }; export const attachmentIcon = diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 765025f09..39894d784 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -71,6 +71,8 @@ let messages: any[] = [ }, ]; +const draftMessage = { text: 'Hi' }; + const userMessages: any[] = []; let isResponseSent: boolean; @@ -106,6 +108,26 @@ const messageActionsTemplate = (msg: any) => { }; const _composingIndicatorTemplate = html`LOADING...`; +const _textInputTemplate = (text: string) => + html``; +const _textAreaActionsTemplate = html`Send`; +const _textAreaAttachmentsTemplate = (attachments: IgcMessageAttachment[]) => { + return html`
+ ${attachments.map( + (attachment) => + html`${attachment.name}` + )} +
`; +}; const _customRenderer = (text: string) => html`${text.toUpperCase()}`; @@ -115,6 +137,9 @@ const ai_chat_options = { templates: { messageActionsTemplate: messageActionsTemplate, //composingIndicatorTemplate: _composingIndicatorTemplate, + // textInputTemplate: _textInputTemplate, + // textAreaActionsTemplate: _textAreaActionsTemplate, + // textAreaAttachmentsTemplate: _textAreaAttachmentsTemplate, }, // markdownRenderer: _customRenderer }; @@ -124,6 +149,22 @@ const chat_options = { disableAttachments: true, }; +function handleCustomSendClick() { + const chat = document.querySelector('igc-chat'); + if (!chat) { + return; + } + const newMessage: IgcMessage = { + id: Date.now().toString(), + text: chat.draftMessage.text, + sender: 'user', + attachments: chat.draftMessage.attachments || [], + timestamp: new Date(), + }; + chat.messages = [...chat.messages, newMessage]; + chat.draftMessage = { text: '', attachments: [] }; +} + function handleMessageSend(e: CustomEvent) { const newMessage = e.detail; messages.push(newMessage); @@ -542,6 +583,7 @@ export const Supabase: Story = { export const AI: Story = { render: () => html` From a381855af8b68573b18f190a4511170d2750a548 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Wed, 16 Jul 2025 08:48:05 +0300 Subject: [PATCH 071/252] feat(chat): add initial KB nav between messages --- src/components/chat/chat-input.ts | 5 +- src/components/chat/chat-message-list.ts | 72 +++++++++++++++++-- src/components/chat/chat-state.ts | 28 ++++++++ src/components/chat/chat.spec.ts | 17 +++-- src/components/chat/chat.ts | 3 +- src/components/chat/message-attachments.ts | 3 + .../chat/themes/message-list.base.scss | 4 ++ stories/chat.stories.ts | 4 +- 8 files changed, 121 insertions(+), 15 deletions(-) diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index 7e61aa337..7733899d5 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -61,7 +61,10 @@ export default class IgcChatInputComponent extends LitElement { protected override firstUpdated() { this.setupDragAndDrop(); - this._chatState?.updateAcceptedTypesCache(); + if (this._chatState) { + this._chatState.updateAcceptedTypesCache(); + this._chatState.textArea = this.textInputElement; + } } protected override updated() { diff --git a/src/components/chat/chat-message-list.ts b/src/components/chat/chat-message-list.ts index ad1383cd4..12d2ed02b 100644 --- a/src/components/chat/chat-message-list.ts +++ b/src/components/chat/chat-message-list.ts @@ -1,5 +1,6 @@ import { consume } from '@lit/context'; import { html, LitElement, nothing } from 'lit'; +import { state } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; import { chatContext } from '../common/context.js'; import { registerComponent } from '../common/definitions/register.js'; @@ -21,6 +22,9 @@ export default class IgcChatMessageListComponent extends LitElement { @consume({ context: chatContext, subscribe: true }) private _chatState?: ChatState; + @state() + private _activeMessageId = ''; + /* blazorSuppress */ public static register() { registerComponent(IgcChatMessageListComponent, IgcChatMessageComponent); @@ -74,6 +78,50 @@ export default class IgcChatMessageListComponent extends LitElement { }); } + private scrollToMessage(messageId: string) { + const messageElement = this.shadowRoot?.querySelector( + `#message-${messageId}` + ); + messageElement?.scrollIntoView(); + } + + private handleFocusIn() { + if (!this._chatState?.messages || this._chatState.messages.length === 0) { + return; + } + const lastMessage = this._chatState.sortedMessagesIds?.pop() ?? ''; + this._activeMessageId = lastMessage !== '' ? `message-${lastMessage}` : ''; + } + + private handleFocusOut() { + this._activeMessageId = ''; + } + + private handleKeyDown(e: KeyboardEvent) { + if (!this._chatState?.messages || this._chatState.messages.length === 0) { + return; + } + + const currentIndex = this._chatState?.sortedMessagesIds.findIndex( + (id) => `message-${id}` === this._activeMessageId + ); + + if (e.key === 'ArrowUp' && currentIndex > 0) { + const previousMessageId = + this._chatState.sortedMessagesIds[currentIndex - 1]; + this._activeMessageId = `message-${previousMessageId}`; + this.scrollToMessage(previousMessageId); + } + if ( + e.key === 'ArrowDown' && + currentIndex < this._chatState?.messages.length - 1 + ) { + const nextMessageId = this._chatState.sortedMessagesIds[currentIndex + 1]; + this._activeMessageId = `message-${nextMessageId}`; + this.scrollToMessage(nextMessageId); + } + } + protected override updated() { if (!this._chatState?.options?.disableAutoScroll) { this.scrollToBottom(); @@ -102,7 +150,13 @@ export default class IgcChatMessageListComponent extends LitElement { ); return html` -
+
${repeat( groupedMessages, @@ -111,9 +165,19 @@ export default class IgcChatMessageListComponent extends LitElement { ${repeat( group.messages, (message) => message.id, - (message) => html` - - ` + (message) => { + const messageId = `message-${message.id}`; + return html` + + + `; + } )} ` )} diff --git a/src/components/chat/chat-state.ts b/src/components/chat/chat-state.ts index dde4ffc75..51b1eb727 100644 --- a/src/components/chat/chat-state.ts +++ b/src/components/chat/chat-state.ts @@ -1,3 +1,4 @@ +import type IgcTextareaComponent from '../textarea/textarea.js'; import type IgcChatComponent from './chat.js'; import type { IgcChatComponentEventMap } from './chat.js'; import type { @@ -9,7 +10,9 @@ import type { export class ChatState { //#region Internal properties and state private readonly _host: IgcChatComponent; + private _textArea: IgcTextareaComponent | null = null; private _messages: IgcMessage[] = []; + private _sortedMessages: IgcMessage[] = []; private _options?: IgcChatOptions; private _inputAttachments: IgcMessageAttachment[] = []; private _inputValue = ''; @@ -24,6 +27,10 @@ export class ChatState { //#region Public properties + public get sortedMessagesIds(): string[] { + return this._sortedMessages.map((m) => m.id); + } + /** Chat message list. */ public get messages(): IgcMessage[] { return this._messages; @@ -32,6 +39,9 @@ export class ChatState { /** Sets the chat message list. */ public set messages(value: IgcMessage[]) { this._messages = value; + this._sortedMessages = value.slice().sort((a, b) => { + return a.timestamp.getTime() - b.timestamp.getTime(); + }); this._host.requestUpdate(); } @@ -51,6 +61,16 @@ export class ChatState { return this._options?.currentUserId ?? 'user'; } + /** Gets the textarea component. */ + public get textArea(): IgcTextareaComponent | null { + return this._textArea; + } + + /** Sets the textarea component. */ + public set textArea(value: IgcTextareaComponent) { + this._textArea = value; + } + /** Input attachments. */ public get inputAttachments(): IgcMessageAttachment[] { return this._inputAttachments; @@ -159,6 +179,14 @@ export class ChatState { } } + /** Send message and focus back the text area when a suggestion is selected */ + public handleSuggestionClick(suggestion: string): void { + this.addMessage({ text: suggestion }); + if (this.textArea) { + this.textArea.focus(); + } + } + /** Updates chat options dynamically. */ public updateOptions(options: Partial): void { this.options = { ...this.options, ...options }; diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index 96f22d84a..fde862b6b 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -201,7 +201,7 @@ describe('Chat', () => { ); expect(messageList).shadowDom.to.equal( - `
+ `
` @@ -256,13 +256,13 @@ describe('Chat', () => { expect(chat.messages.length).to.equal(4); expect(messageContainer).dom.to.equal( `
- + - + - + - +
` ); @@ -633,6 +633,7 @@ describe('Chat', () => { class="small" collection="material" name="preview" + tabindex="-1" type="button" variant="flat" > @@ -641,6 +642,7 @@ describe('Chat', () => { class="small" collection="material" name="more" + tabindex="-1" type="button" variant="flat" > @@ -686,6 +688,7 @@ describe('Chat', () => { class="small" collection="material" name="more" + tabindex="-1" type="button" variant="flat" > @@ -748,7 +751,7 @@ describe('Chat', () => { expect(chat.messages.length).to.equal(1); expect(messageContainer).dom.to.equal( `
- +
@@ -948,7 +951,7 @@ describe('Chat', () => { expect(chat.messages.length).to.equal(1); expect(messageContainer).dom.to.equal( `
- + loading...
` diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index d6d9e412a..dc5a1ebf5 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -138,7 +138,8 @@ export default class IgcChatComponent extends EventEmitterMixin< (suggestion) => html` this._chatState.addMessage({ text: suggestion })} + @click=${() => + this._chatState?.handleSuggestionClick(suggestion)} > ${suggestion} diff --git a/src/components/chat/message-attachments.ts b/src/components/chat/message-attachments.ts index 4f7254e40..0497e0295 100644 --- a/src/components/chat/message-attachments.ts +++ b/src/components/chat/message-attachments.ts @@ -121,6 +121,7 @@ export default class IgcMessageAttachmentsComponent extends LitElement { collection="material" variant="flat" class="small" + tabIndex="-1" @click=${() => this.openImagePreview(attachment)} >` : nothing} @@ -129,6 +130,7 @@ export default class IgcMessageAttachmentsComponent extends LitElement { collection="material" variant="flat" class="small" + tabIndex="-1" > `} @@ -186,6 +188,7 @@ export default class IgcMessageAttachmentsComponent extends LitElement { collection="material" variant="contained" class="small" + tabIndex="-1" @click=${this.closeImagePreview} >
diff --git a/src/components/chat/themes/message-list.base.scss b/src/components/chat/themes/message-list.base.scss index c14dda941..756711238 100644 --- a/src/components/chat/themes/message-list.base.scss +++ b/src/components/chat/themes/message-list.base.scss @@ -31,6 +31,10 @@ margin: 0 8px; } +igc-chat-message.active { + border: 2px solid #7f8386; + } + .typing-indicator { display: flex; align-items: center; diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 39894d784..60c27029c 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -77,7 +77,7 @@ const userMessages: any[] = []; let isResponseSent: boolean; -const messageActionsTemplate = (msg: any) => { +const _messageActionsTemplate = (msg: any) => { return msg.sender !== 'user' && msg.text.trim() ? isResponseSent !== false ? html` @@ -135,7 +135,7 @@ const ai_chat_options = { headerText: 'Chat', suggestions: ['Hello', 'Hi', 'Generate an image of a pig!'], templates: { - messageActionsTemplate: messageActionsTemplate, + // messageActionsTemplate: messageActionsTemplate, //composingIndicatorTemplate: _composingIndicatorTemplate, // textInputTemplate: _textInputTemplate, // textAreaActionsTemplate: _textAreaActionsTemplate, From 6e5f0f173a7ebf5114a81192daf91b1dbe5fd2b6 Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Thu, 17 Jul 2025 15:42:03 +0300 Subject: [PATCH 072/252] feat(chat): expand input textarea on long input --- src/components/chat/chat-input.ts | 1 + src/components/chat/themes/input.base.scss | 13 +++++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index c1fd96297..5e1030d0a 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -270,6 +270,7 @@ export default class IgcChatInputComponent extends LitElement { Date: Thu, 17 Jul 2025 17:29:58 +0300 Subject: [PATCH 073/252] chore(*): update input wrapper prop in tests --- src/components/chat/chat.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index fde862b6b..fb6e8ce27 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -223,6 +223,7 @@ describe('Chat', () => { @@ -477,6 +478,7 @@ describe('Chat', () => { @@ -547,6 +549,7 @@ describe('Chat', () => { From 39d443d1349a5bac73249ece53ae425f01a0298f Mon Sep 17 00:00:00 2001 From: teodosiah Date: Fri, 18 Jul 2025 11:30:00 +0300 Subject: [PATCH 074/252] test(chat): add input focus & KB arrow up/down tests --- src/components/chat/chat.spec.ts | 87 +++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index fb6e8ce27..49720c583 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -1,8 +1,16 @@ -import { aTimeout, elementUpdated, expect, fixture } from '@open-wc/testing'; +import { + aTimeout, + elementUpdated, + expect, + fixture, + nextFrame, +} from '@open-wc/testing'; import { html } from 'lit'; import { type SinonFakeTimers, spy, useFakeTimers } from 'sinon'; +import { arrowDown, arrowUp } from '../common/controllers/key-bindings.js'; import { defineComponents } from '../common/definitions/defineComponents.js'; import { + isFocused, simulateBlur, simulateClick, simulateFocus, @@ -1027,6 +1035,8 @@ describe('Chat', () => { expect(chat.messages.length).to.equal(1); expect(chat.messages[0].text).to.equal('Hello!'); expect(chat.messages[0].sender).to.equal('user'); + // The focus should be on the input area after send button is clicked + expect(isFocused(textArea)).to.be.true; } }); @@ -1056,6 +1066,12 @@ describe('Chat', () => { expect(chat.messages.length).to.equal(1); expect(chat.messages[0].text).to.equal('Suggestion 1'); expect(chat.messages[0].sender).to.equal('user'); + // The focus should be on the input area after suggestion click + const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); + const textArea = inputArea?.shadowRoot?.querySelector( + 'igc-textarea' + ) as HTMLElement; + expect(isFocused(textArea)).to.be.true; } }); @@ -1177,8 +1193,77 @@ describe('Chat', () => { expect(chat.messages.length).to.equal(1); expect(chat.messages[0].text).to.equal('Hello!'); expect(chat.messages[0].sender).to.equal('user'); + + // The focus should be on the input area after message is sent + expect(isFocused(textArea)).to.be.true; } }); + + it('should activates the recent message when the message list is focused', async () => { + chat.messages = messages; + await elementUpdated(chat); + await aTimeout(500); + + const messageContainer = chat.shadowRoot + ?.querySelector('igc-chat-message-list') + ?.shadowRoot?.querySelector('.message-container') as HTMLElement; + messageContainer.focus(); + await elementUpdated(chat); + + expect(messageContainer.getAttribute('aria-activedescendant')).to.equal( + 'message-4' + ); + + const messageElements = + messageContainer?.querySelectorAll('igc-chat-message'); + messageElements?.forEach((message, index) => { + if (index === messages.length - 1) { + expect(message.classList.contains('active')).to.be.true; + } else { + expect(message.classList.contains('active')).to.be.false; + } + }); + }); + }); + + it('should activates the next/previous message on `ArrowDown`/`ArrowUp`', async () => { + chat.messages = messages; + await elementUpdated(chat); + await aTimeout(500); + + const messageContainer = chat.shadowRoot + ?.querySelector('igc-chat-message-list') + ?.shadowRoot?.querySelector('.message-container') as HTMLElement; + messageContainer.focus(); + await elementUpdated(chat); + await nextFrame(); + await nextFrame(); + + // Activates the previous message on `ArrowUp` + messageContainer.dispatchEvent( + new KeyboardEvent('keydown', { + key: arrowUp, + bubbles: true, + cancelable: true, + }) + ); + await nextFrame(); + expect(messageContainer.getAttribute('aria-activedescendant')).to.equal( + 'message-3' + ); + + // Activates the next message on `ArrowDown` + messageContainer.dispatchEvent( + new KeyboardEvent('keydown', { + key: arrowDown, + bubbles: true, + cancelable: true, + }) + ); + await nextFrame(); + expect(messageContainer.getAttribute('aria-activedescendant')).to.equal( + 'message-4' + ); }); }); From 99320a4749663fb5192e1299e30d05fa9a9f6c7c Mon Sep 17 00:00:00 2001 From: teodosiah Date: Tue, 22 Jul 2025 08:36:26 +0300 Subject: [PATCH 075/252] feat(chat): add aria attributes to the chat elements --- src/components/chat/chat-input.ts | 5 +- src/components/chat/chat-message-list.ts | 3 ++ src/components/chat/chat.spec.ts | 61 +++++++++++++----------- src/components/chat/chat.ts | 9 ++-- 4 files changed, 44 insertions(+), 34 deletions(-) diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index 98ad2d2b8..0a24f702a 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -248,6 +248,7 @@ export default class IgcChatInputComponent extends LitElement { ${this._chatState?.options?.templates?.textAreaActionsTemplate ? this._chatState?.options?.templates?.textAreaActionsTemplate : html` + return html`
${this._chatState?.options?.templates?.textAreaAttachmentsTemplate ? this._chatState.options.templates.textAreaAttachmentsTemplate( this._chatState?.inputAttachments ) : html`${this._chatState?.inputAttachments?.map( (attachment, index) => html` -
+
this.removeAttachment(index)} diff --git a/src/components/chat/chat-message-list.ts b/src/components/chat/chat-message-list.ts index 12d2ed02b..4fd5b3106 100644 --- a/src/components/chat/chat-message-list.ts +++ b/src/components/chat/chat-message-list.ts @@ -153,6 +153,8 @@ export default class IgcChatMessageListComponent extends LitElement {
{
-
+
@@ -209,7 +209,7 @@ describe('Chat', () => { ); expect(messageList).shadowDom.to.equal( - `
+ `
` @@ -238,17 +238,18 @@ describe('Chat', () => {
-
+
` ); }); @@ -265,13 +266,13 @@ describe('Chat', () => { expect(chat.messages.length).to.equal(4); expect(messageContainer).dom.to.equal( `
- + - + - + - +
` ); @@ -493,17 +494,18 @@ describe('Chat', () => {
-
+
` ); }); @@ -564,6 +566,7 @@ describe('Chat', () => {
{
-
-
+
+
test.txt
-
+
image.png @@ -727,16 +730,16 @@ describe('Chat', () => { ); expect(suggestionsContainer).dom.to.equal( - `
+ `
- + Suggestion 1 - + Suggestion 2 @@ -762,7 +765,7 @@ describe('Chat', () => { expect(chat.messages.length).to.equal(1); expect(messageContainer).dom.to.equal( `
- +
@@ -962,7 +965,7 @@ describe('Chat', () => { expect(chat.messages.length).to.equal(1); expect(messageContainer).dom.to.equal( `
- + loading...
` @@ -992,7 +995,7 @@ describe('Chat', () => { Send
-
+
${draftMessage.attachments[0].name} diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index dc5a1ebf5..c99a319df 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -27,7 +27,6 @@ export interface IgcChatComponentEventMap { igcInputFocus: CustomEvent; igcInputBlur: CustomEvent; igcInputChange: CustomEvent; - igcMessageCopied: CustomEvent; } /** @@ -132,11 +131,15 @@ export default class IgcChatComponent extends EventEmitterMixin< } private renderSuggestions() { - return html`
+ return html`
${this._chatState.options?.suggestions?.map( (suggestion) => html` - + this._chatState?.handleSuggestionClick(suggestion)} From fe31e841a07acb6b0733e68e0dae4a9e2e66987d Mon Sep 17 00:00:00 2001 From: teodosiah Date: Tue, 22 Jul 2025 12:37:50 +0300 Subject: [PATCH 076/252] feat(chat): add slot for empty message list area --- src/components/chat/chat.spec.ts | 1 + src/components/chat/chat.ts | 6 +++++- src/components/chat/themes/chat.base.scss | 4 ++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index c16bb202d..7ac9651be 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -798,6 +798,7 @@ describe('Chat', () => { it('should slot header prefix', () => {}); it('should slot header title', () => {}); it('should slot header action buttons area', () => {}); + it('should slot message list area when there are no messages', () => {}); it('should slot suggestions area', () => {}); }); diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index c99a319df..a51657ff2 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -161,7 +161,11 @@ export default class IgcChatComponent extends EventEmitterMixin< return html`
${this.renderHeader()} - + ${this.messages.length === 0 + ? html`
+ +
` + : html` `} ${this.renderSuggestions()}
diff --git a/src/components/chat/themes/chat.base.scss b/src/components/chat/themes/chat.base.scss index 6828b4d7a..3da85e3c9 100644 --- a/src/components/chat/themes/chat.base.scss +++ b/src/components/chat/themes/chat.base.scss @@ -25,6 +25,10 @@ background-color: #edeff0; } + .empty-state { + height: 100%; + } + .info { display: flex; align-items: center; From 3c6c454288720a663240514a41a8f62ec62d1bb0 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Tue, 22 Jul 2025 13:49:22 +0300 Subject: [PATCH 077/252] test(chat): fix tests after exposing empty message list slot --- src/components/chat/chat.spec.ts | 38 ++++++++++++++------------------ 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index 7ac9651be..53b93d93d 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -193,8 +193,10 @@ describe('Chat', () => {
- - +
+ + +
@@ -204,17 +206,6 @@ describe('Chat', () => {
` ); - const messageList = chat.shadowRoot?.querySelector( - 'igc-chat-message-list' - ); - - expect(messageList).shadowDom.to.equal( - `
-
-
-
` - ); - const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); expect(inputArea).shadowDom.to.equal( @@ -259,14 +250,17 @@ describe('Chat', () => { html` ` ); - const messageContainer = chat.shadowRoot - ?.querySelector('igc-chat-message-list') - ?.shadowRoot?.querySelector('.message-list'); + const messageList = chat.shadowRoot?.querySelector( + 'igc-chat-message-list' + ); + const messageContainer = + messageList?.shadowRoot?.querySelector('.message-list'); - expect(chat.messages.length).to.equal(4); - expect(messageContainer).dom.to.equal( - `
- + expect(messageList).shadowDom.to.equal( + `
+
+
+ @@ -274,9 +268,11 @@ describe('Chat', () => { -
` +
` ); + expect(chat.messages.length).to.equal(4); + expect( messageContainer?.querySelectorAll('igc-chat-message')[0] ).shadowDom.to.equal( From 1d6d140ca21c0b159b137071bf3d2c113b148de6 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Wed, 23 Jul 2025 12:29:15 +0300 Subject: [PATCH 078/252] chore(chat): remove default attachment action buttons & fix duplicated messages issue --- src/components/chat/chat-state.ts | 4 +- src/components/chat/message-attachments.ts | 55 ++-------------------- 2 files changed, 6 insertions(+), 53 deletions(-) diff --git a/src/components/chat/chat-state.ts b/src/components/chat/chat-state.ts index 51b1eb727..ca09be272 100644 --- a/src/components/chat/chat-state.ts +++ b/src/components/chat/chat-state.ts @@ -136,7 +136,9 @@ export class ChatState { }); if (allowed) { - this.messages = [...this.messages, newMessage]; + if (!this.messages.some((msg) => msg.id === newMessage.id)) { + this.messages = [...this.messages, newMessage]; + } this.inputValue = ''; this.inputAttachments = []; } diff --git a/src/components/chat/message-attachments.ts b/src/components/chat/message-attachments.ts index 0497e0295..e3d162437 100644 --- a/src/components/chat/message-attachments.ts +++ b/src/components/chat/message-attachments.ts @@ -55,14 +55,6 @@ export default class IgcMessageAttachmentsComponent extends LitElement { registerIconFromText('more', moreIcon, 'material'); } - private openImagePreview(attachment: IgcMessageAttachment) { - this.previewImage = this.getURL(attachment); - } - - private closeImagePreview() { - this.previewImage = ''; - } - private handleToggle(e: CustomEvent, attachment: IgcMessageAttachment) { this._chatState?.emitEvent('igcAttachmentClick', { detail: attachment }); e.preventDefault(); @@ -106,34 +98,13 @@ export default class IgcMessageAttachmentsComponent extends LitElement {
`; } - private renderAttachmentHeaderActions(attachment: IgcMessageAttachment) { + private renderAttachmentHeaderActions() { return html`
${this._chatState?.options?.templates?.attachmentActionsTemplate ? this._chatState.options.templates.attachmentActionsTemplate( this.attachments ) - : html` - - ${attachment.type === 'image' || - attachment.file?.type.startsWith('image/') - ? html` this.openImagePreview(attachment)} - >` - : nothing} - - - `} + : nothing}
`; } @@ -170,7 +141,7 @@ export default class IgcMessageAttachmentsComponent extends LitElement { >
${this.renderAttachmentHeaderText(attachment)} - ${this.renderAttachmentHeaderActions(attachment)} + ${this.renderAttachmentHeaderActions()}
${this.renderAttachmentContent(attachment)} @@ -178,24 +149,6 @@ export default class IgcMessageAttachmentsComponent extends LitElement { )}`; } - private renderImagePreview() { - return html` ${this.previewImage - ? html` -
- - -
- ` - : nothing}`; - } - protected override render() { return html`
@@ -205,8 +158,6 @@ export default class IgcMessageAttachmentsComponent extends LitElement { ) : this.renderDefaultAttachmentsTemplate()}
- - ${this.renderImagePreview()} `; } } From e57787e09243469ee15a8bb1173456d7f6f0beea Mon Sep 17 00:00:00 2001 From: teodosiah Date: Wed, 23 Jul 2025 12:32:46 +0300 Subject: [PATCH 079/252] test(chat): fix attachment header actions test --- src/components/chat/chat.spec.ts | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index 53b93d93d..c346b8f8d 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -638,26 +638,6 @@ describe('Chat', () => {
- - - - - -
@@ -693,17 +673,6 @@ describe('Chat', () => {
- - - -
From 6839f7d6cbe3f9dd01e06eebfd73fc1a8bf96478 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Wed, 23 Jul 2025 13:52:59 +0300 Subject: [PATCH 080/252] feat(chat): add home/end KB nav & additional tests --- src/components/chat/chat-message-list.ts | 47 ++++--- src/components/chat/chat.spec.ts | 154 +++++++++++++++++------ 2 files changed, 149 insertions(+), 52 deletions(-) diff --git a/src/components/chat/chat-message-list.ts b/src/components/chat/chat-message-list.ts index 4fd5b3106..059147d83 100644 --- a/src/components/chat/chat-message-list.ts +++ b/src/components/chat/chat-message-list.ts @@ -3,6 +3,12 @@ import { html, LitElement, nothing } from 'lit'; import { state } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; import { chatContext } from '../common/context.js'; +import { + arrowDown, + arrowUp, + endKey, + homeKey, +} from '../common/controllers/key-bindings.js'; import { registerComponent } from '../common/definitions/register.js'; import IgcChatMessageComponent from './chat-message.js'; import type { ChatState } from './chat-state.js'; @@ -105,21 +111,34 @@ export default class IgcChatMessageListComponent extends LitElement { const currentIndex = this._chatState?.sortedMessagesIds.findIndex( (id) => `message-${id}` === this._activeMessageId ); - - if (e.key === 'ArrowUp' && currentIndex > 0) { - const previousMessageId = - this._chatState.sortedMessagesIds[currentIndex - 1]; - this._activeMessageId = `message-${previousMessageId}`; - this.scrollToMessage(previousMessageId); - } - if ( - e.key === 'ArrowDown' && - currentIndex < this._chatState?.messages.length - 1 - ) { - const nextMessageId = this._chatState.sortedMessagesIds[currentIndex + 1]; - this._activeMessageId = `message-${nextMessageId}`; - this.scrollToMessage(nextMessageId); + let activeMessageId = ''; + + switch (e.key) { + case homeKey: + activeMessageId = this._chatState.sortedMessagesIds[0]; + break; + case endKey: + activeMessageId = + this._chatState.sortedMessagesIds[ + this._chatState.sortedMessagesIds.length - 1 + ]; + break; + case arrowUp: + if (currentIndex > 0) { + activeMessageId = this._chatState.sortedMessagesIds[currentIndex - 1]; + } + break; + case arrowDown: + if (currentIndex < this._chatState?.messages.length - 1) { + activeMessageId = this._chatState.sortedMessagesIds[currentIndex + 1]; + } + break; + default: + return; // Exit if the key is not one of the specified keys } + + this._activeMessageId = `message-${activeMessageId}`; + this.scrollToMessage(activeMessageId); } protected override updated() { diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index c346b8f8d..62365917a 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -7,7 +7,12 @@ import { } from '@open-wc/testing'; import { html } from 'lit'; import { type SinonFakeTimers, spy, useFakeTimers } from 'sinon'; -import { arrowDown, arrowUp } from '../common/controllers/key-bindings.js'; +import { + arrowDown, + arrowUp, + endKey, + homeKey, +} from '../common/controllers/key-bindings.js'; import { defineComponents } from '../common/definitions/defineComponents.js'; import { isFocused, @@ -170,6 +175,7 @@ describe('Chat', () => { it('is correctly initialized with its default component state', () => { expect(chat.messages.length).to.equal(0); expect(chat.options).to.be.undefined; + expect(chat.draftMessage).to.deep.equal({ text: '', attachments: [] }); }); it('is rendered correctly', () => { @@ -469,6 +475,38 @@ describe('Chat', () => { // expect(messagesContainer?.scrollTop).to.equal(0); // }); + it('should enable/disable the send button properly', async () => { + const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); + const sendButton = + inputArea?.shadowRoot?.querySelector('igc-icon-button'); + + expect(sendButton?.disabled).to.be.true; + const textArea = inputArea?.shadowRoot?.querySelector('igc-textarea'); + + // When there is a text in the text area, the send button should be enabled + textArea?.setAttribute('value', 'Hello!'); + textArea?.dispatchEvent(new Event('input')); + await elementUpdated(chat); + + expect(sendButton?.disabled).to.be.false; + + // When there is no text in the text area, the send button should be disabled + textArea?.setAttribute('value', ''); + textArea?.dispatchEvent(new Event('input')); + await elementUpdated(chat); + + expect(sendButton?.disabled).to.be.true; + + // When there are attachments, the send button should be enabled regardless of the text area content + const fileInput = inputArea?.shadowRoot + ?.querySelector('igc-file-input') + ?.shadowRoot?.querySelector('input') as HTMLInputElement; + simulateFileUpload(fileInput, files); + await elementUpdated(chat); + + expect(sendButton?.disabled).to.be.false; + }); + it('should not render attachment button if `disableAttachments` is true', async () => { chat.options = { disableAttachments: true, @@ -1193,46 +1231,86 @@ describe('Chat', () => { } }); }); - }); - it('should activates the next/previous message on `ArrowDown`/`ArrowUp`', async () => { - chat.messages = messages; - await elementUpdated(chat); - await aTimeout(500); + it('should activates the next/previous message on `ArrowDown`/`ArrowUp`', async () => { + chat.messages = messages; + await elementUpdated(chat); + await aTimeout(500); - const messageContainer = chat.shadowRoot - ?.querySelector('igc-chat-message-list') - ?.shadowRoot?.querySelector('.message-container') as HTMLElement; - messageContainer.focus(); - await elementUpdated(chat); - await nextFrame(); - await nextFrame(); - - // Activates the previous message on `ArrowUp` - messageContainer.dispatchEvent( - new KeyboardEvent('keydown', { - key: arrowUp, - bubbles: true, - cancelable: true, - }) - ); - await nextFrame(); - expect(messageContainer.getAttribute('aria-activedescendant')).to.equal( - 'message-3' - ); + const messageContainer = chat.shadowRoot + ?.querySelector('igc-chat-message-list') + ?.shadowRoot?.querySelector('.message-container') as HTMLElement; + messageContainer.focus(); + await elementUpdated(chat); + await nextFrame(); + await nextFrame(); - // Activates the next message on `ArrowDown` - messageContainer.dispatchEvent( - new KeyboardEvent('keydown', { - key: arrowDown, - bubbles: true, - cancelable: true, - }) - ); - await nextFrame(); - expect(messageContainer.getAttribute('aria-activedescendant')).to.equal( - 'message-4' - ); + // Activates the previous message on `ArrowUp` + messageContainer.dispatchEvent( + new KeyboardEvent('keydown', { + key: arrowUp, + bubbles: true, + cancelable: true, + }) + ); + await nextFrame(); + expect(messageContainer.getAttribute('aria-activedescendant')).to.equal( + 'message-3' + ); + + // Activates the next message on `ArrowDown` + messageContainer.dispatchEvent( + new KeyboardEvent('keydown', { + key: arrowDown, + bubbles: true, + cancelable: true, + }) + ); + await nextFrame(); + expect(messageContainer.getAttribute('aria-activedescendant')).to.equal( + 'message-4' + ); + }); + + it('should activates the first/last message on `Home`/`End`', async () => { + chat.messages = messages; + await elementUpdated(chat); + await aTimeout(500); + + const messageContainer = chat.shadowRoot + ?.querySelector('igc-chat-message-list') + ?.shadowRoot?.querySelector('.message-container') as HTMLElement; + messageContainer.focus(); + await elementUpdated(chat); + await nextFrame(); + await nextFrame(); + + // Activates the first message on `Home` + messageContainer.dispatchEvent( + new KeyboardEvent('keydown', { + key: homeKey, + bubbles: true, + cancelable: true, + }) + ); + await nextFrame(); + expect(messageContainer.getAttribute('aria-activedescendant')).to.equal( + 'message-1' + ); + + // Activates the last message on `End` + messageContainer.dispatchEvent( + new KeyboardEvent('keydown', { + key: endKey, + bubbles: true, + cancelable: true, + }) + ); + await nextFrame(); + expect(messageContainer.getAttribute('aria-activedescendant')).to.equal( + 'message-4' + ); + }); }); }); From 212711b5cedd8a70ac8245c12e27df7114c12df2 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Wed, 23 Jul 2025 15:33:54 +0300 Subject: [PATCH 081/252] refactor(chat): change action button and attachments places in the input area --- src/components/chat/chat-input.ts | 31 +++--- src/components/chat/chat.spec.ts | 107 ++++++++++----------- src/components/chat/chat.ts | 4 +- src/components/chat/themes/input.base.scss | 4 +- src/components/chat/types.ts | 1 - 5 files changed, 69 insertions(+), 78 deletions(-) diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index 0a24f702a..fc3bd4686 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -237,26 +237,25 @@ export default class IgcChatInputComponent extends LitElement { `}`; } - private renderFileUploadArea() { - return html` ${this._chatState?.options?.templates?.fileUploadTemplate - ? this._chatState?.options?.templates?.fileUploadTemplate - : this.renderDefaultFileUploadTemplate()}`; + protected *renderDefaultSendButtonTemplate() { + yield html` `; } private renderActionsArea() { return html`
${this._chatState?.options?.templates?.textAreaActionsTemplate ? this._chatState?.options?.templates?.textAreaActionsTemplate - : html` `} + : html` ${this.renderDefaultFileUploadTemplate()} + ${this.renderDefaultSendButtonTemplate()}`}
`; } @@ -284,7 +283,7 @@ export default class IgcChatInputComponent extends LitElement { protected override render() { return html`
- ${this.renderFileUploadArea()} + ${this.renderAttachmentsArea()}
${this._chatState?.options?.templates?.textInputTemplate @@ -303,10 +302,8 @@ export default class IgcChatInputComponent extends LitElement { @blur=${this.handleBlur} >`}
- ${this.renderActionsArea()}
- ${this.renderAttachmentsArea()} `; } } diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index 62365917a..1cb1962e0 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -80,9 +80,10 @@ describe('Chat', () => { .value=${text} >`; - const fileUploadTemplate = html`Upload`; - - const textAreaActionsTemplate = html`Send`; + const textAreaActionsTemplate = html`
+ Upload + Send +
`; const textAreaAttachmentsTemplate = (attachments: any[]) => { return html`
@@ -194,9 +195,6 @@ describe('Chat', () => {
- - ⋯ -
@@ -216,14 +214,8 @@ describe('Chat', () => { expect(inputArea).shadowDom.to.equal( `
- - - - +
+
{
+ + + + { >
-
-
` ); }); @@ -416,9 +414,6 @@ describe('Chat', () => {
- - ⋯ -
` ); @@ -517,6 +512,8 @@ describe('Chat', () => { expect(inputArea).shadowDom.to.equal( `
+
+
{ >
-
-
` ); }); @@ -581,14 +576,22 @@ describe('Chat', () => { expect(inputArea).shadowDom.to.equal( `
- - - - +
+
+ + + test.txt + + +
+
+ + + image.png + + +
+
{
+ + + + { >
-
-
-
- - - test.txt - - -
-
- - - image.png - - -
-
` +
` ); }); @@ -981,7 +976,6 @@ describe('Chat', () => { chat.options = { templates: { textInputTemplate: textInputTemplate, - fileUploadTemplate: fileUploadTemplate, textAreaActionsTemplate: textAreaActionsTemplate, textAreaAttachmentsTemplate: textAreaAttachmentsTemplate, }, @@ -991,21 +985,24 @@ describe('Chat', () => { expect(inputArea).shadowDom.to.equal( ` - ` + ` ); expect(inputArea?.shadowRoot?.querySelector('igc-input')?.value).to.equal( diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index a51657ff2..c354dd543 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -124,9 +124,7 @@ export default class IgcChatComponent extends EventEmitterMixin< >${this._chatState.options?.headerText}
- - - +
`; } diff --git a/src/components/chat/themes/input.base.scss b/src/components/chat/themes/input.base.scss index 069a0a7cd..4654f0ea0 100644 --- a/src/components/chat/themes/input.base.scss +++ b/src/components/chat/themes/input.base.scss @@ -17,7 +17,7 @@ igc-file-input::part(file-names){ .input-container { display: flex; - align-items: center; + flex-direction: column; gap: 12px; } @@ -45,7 +45,7 @@ igc-file-input::part(file-names){ .buttons-container { display: flex; - align-items: center; + justify-content: space-between; } .input-button { diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts index 2775ba6e4..c1c2eaae0 100644 --- a/src/components/chat/types.ts +++ b/src/components/chat/types.ts @@ -53,7 +53,6 @@ export type IgcChatTemplates = { messageActionsTemplate?: MessageTemplate; composingIndicatorTemplate?: TemplateResult; textInputTemplate?: (text: string) => TemplateResult; - fileUploadTemplate?: TemplateResult; textAreaActionsTemplate?: TemplateResult; textAreaAttachmentsTemplate?: AttachmentTemplate; }; From 8a03d6e01b7ea75ee926fe39ced50254c9acba0d Mon Sep 17 00:00:00 2001 From: sivanova Date: Thu, 24 Jul 2025 11:26:59 +0300 Subject: [PATCH 082/252] feat(chat): initial styling --- package-lock.json | 8 +- package.json | 2 +- src/components/chat/chat-input.ts | 16 +- src/components/chat/chat-message-list.ts | 16 +- src/components/chat/chat-message.ts | 7 +- src/components/chat/chat.spec.ts | 267 +++++++++--------- src/components/chat/chat.ts | 22 +- src/components/chat/message-attachments.ts | 19 +- src/components/chat/themes/chat.base.scss | 136 ++++----- src/components/chat/themes/dark/_themes.scss | 7 + .../chat/themes/dark/chat.bootstrap.scss | 9 + .../chat/themes/dark/chat.fluent.scss | 9 + .../chat/themes/dark/chat.indigo.scss | 9 + .../chat/themes/dark/chat.material.scss | 9 + src/components/chat/themes/input.base.scss | 132 ++------- src/components/chat/themes/light/_themes.scss | 8 + .../chat/themes/light/chat.bootstrap.scss | 8 + .../chat/themes/light/chat.fluent.scss | 8 + .../chat/themes/light/chat.indigo.scss | 8 + .../chat/themes/light/chat.material.scss | 8 + .../chat/themes/light/chat.shared.scss | 8 + .../chat/themes/message-list.base.scss | 88 +----- src/components/chat/themes/message.base.scss | 124 ++------ .../themes/shared/chat-message.common.scss | 12 + .../chat/themes/shared/chat.common.scss | 13 + .../shared/message-attachments.common.scss | 30 ++ src/components/chat/themes/themes.ts | 52 ++++ stories/chat.stories.ts | 2 +- 28 files changed, 472 insertions(+), 565 deletions(-) create mode 100644 src/components/chat/themes/dark/_themes.scss create mode 100644 src/components/chat/themes/dark/chat.bootstrap.scss create mode 100644 src/components/chat/themes/dark/chat.fluent.scss create mode 100644 src/components/chat/themes/dark/chat.indigo.scss create mode 100644 src/components/chat/themes/dark/chat.material.scss create mode 100644 src/components/chat/themes/light/_themes.scss create mode 100644 src/components/chat/themes/light/chat.bootstrap.scss create mode 100644 src/components/chat/themes/light/chat.fluent.scss create mode 100644 src/components/chat/themes/light/chat.indigo.scss create mode 100644 src/components/chat/themes/light/chat.material.scss create mode 100644 src/components/chat/themes/light/chat.shared.scss create mode 100644 src/components/chat/themes/shared/chat-message.common.scss create mode 100644 src/components/chat/themes/shared/chat.common.scss create mode 100644 src/components/chat/themes/shared/message-attachments.common.scss create mode 100644 src/components/chat/themes/themes.ts diff --git a/package-lock.json b/package-lock.json index ef3305f49..bbaa07cdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,7 @@ "globby": "^14.1.0", "husky": "^9.1.7", "ig-typedoc-theme": "^6.2.2", - "igniteui-theming": "^19.1.2", + "igniteui-theming": "^19.3.0-beta.1", "keep-a-changelog": "^2.6.2", "lint-staged": "^16.1.2", "lit-analyzer": "^2.0.3", @@ -7970,9 +7970,9 @@ } }, "node_modules/igniteui-theming": { - "version": "19.1.2", - "resolved": "https://registry.npmjs.org/igniteui-theming/-/igniteui-theming-19.1.2.tgz", - "integrity": "sha512-UAIAIm75NKmUPmIyldYM6hlVooY0J+cnOYOCCFveTJ5AhAHkKqNMNqn0x0Qk+uR33ncY1Ayk9FbwpeSZcU1oaA==", + "version": "19.3.0-beta.1", + "resolved": "https://registry.npmjs.org/igniteui-theming/-/igniteui-theming-19.3.0-beta.1.tgz", + "integrity": "sha512-u/Vj+rAVo046CH8Ty2eq/MLfBLqduT4AX6sf3vj1W1xESwQAf0CT6V9KwEeYg7C3iDc5egjkiSzif6X0AKzBWw==", "dev": true, "license": "MIT", "peerDependencies": { diff --git a/package.json b/package.json index 92a639203..00e0a3a96 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "globby": "^14.1.0", "husky": "^9.1.7", "ig-typedoc-theme": "^6.2.2", - "igniteui-theming": "^19.1.2", + "igniteui-theming": "^19.3.0-beta.1", "keep-a-changelog": "^2.6.2", "lint-staged": "^16.1.2", "lit-analyzer": "^2.0.3", diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index fc3bd4686..08828bf8a 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -243,7 +243,7 @@ export default class IgcChatInputComponent extends LitElement { name="send-message" collection="material" variant="contained" - class="small" + part="send-button" ?disabled=${!this.inputValue.trim() && this._chatState?.inputAttachments.length === 0} @click=${this.sendMessage} @@ -251,7 +251,7 @@ export default class IgcChatInputComponent extends LitElement { } private renderActionsArea() { - return html`
+ return html`
${this._chatState?.options?.templates?.textAreaActionsTemplate ? this._chatState?.options?.templates?.textAreaActionsTemplate : html` ${this.renderDefaultFileUploadTemplate()} @@ -260,19 +260,19 @@ export default class IgcChatInputComponent extends LitElement { } private renderAttachmentsArea() { - return html`
+ return html`
${this._chatState?.options?.templates?.textAreaAttachmentsTemplate ? this._chatState.options.templates.textAreaAttachmentsTemplate( this._chatState?.inputAttachments ) : html`${this._chatState?.inputAttachments?.map( (attachment, index) => html` -
+
this.removeAttachment(index)} > - ${attachment.name} + ${attachment.name}
` @@ -282,16 +282,16 @@ export default class IgcChatInputComponent extends LitElement { protected override render() { return html` -
+
${this.renderAttachmentsArea()} -
+
${this._chatState?.options?.templates?.textInputTemplate ? this._chatState.options.templates.textInputTemplate( this._chatState?.inputValue ) : html` -
-
-
+ : html`
+
+
+
`}`; } @@ -170,7 +170,7 @@ export default class IgcChatMessageListComponent extends LitElement { return html`
-
+
${repeat( groupedMessages, (group) => group.date, @@ -192,9 +192,9 @@ export default class IgcChatMessageListComponent extends LitElement { diff --git a/src/components/chat/chat-message.ts b/src/components/chat/chat-message.ts index 2b15b6f25..b6543e188 100644 --- a/src/components/chat/chat-message.ts +++ b/src/components/chat/chat-message.ts @@ -9,6 +9,7 @@ import type { ChatState } from './chat-state.js'; import { renderMarkdown } from './markdown-util.js'; import IgcMessageAttachmentsComponent from './message-attachments.js'; import { styles } from './themes/message.base.css.js'; +import { styles as shared } from './themes/shared/chat-message.common.css.js'; import type { IgcMessage } from './types.js'; /** @@ -19,7 +20,7 @@ import type { IgcMessage } from './types.js'; export default class IgcChatMessageComponent extends LitElement { public static readonly tagName = 'igc-chat-message'; - public static override styles = styles; + public static override styles = [styles, shared]; @consume({ context: chatContext, subscribe: true }) private _chatState?: ChatState; @@ -49,8 +50,8 @@ export default class IgcChatMessageComponent extends LitElement { this._chatState?.options?.markdownRenderer || renderMarkdown; return html` -
-
+
+
${this._chatState?.options?.templates?.messageTemplate && this.message ? this._chatState.options.templates.messageTemplate(this.message) : html` ${sanitizedMessageText diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index 1cb1962e0..8278315dc 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -186,22 +186,22 @@ describe('Chat', () => { ); expect(chat).shadowDom.to.equal( - `
-
-
+ `
+
+
- +
-
+
-
+
@@ -213,19 +213,19 @@ describe('Chat', () => { const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); expect(inputArea).shadowDom.to.equal( - `
+ `
-
+
-
+
{ { const messageList = chat.shadowRoot?.querySelector( 'igc-chat-message-list' ); - const messageContainer = - messageList?.shadowRoot?.querySelector('.message-list'); + const messageContainer = messageList?.shadowRoot?.querySelector( + `div[part='message-list']` + ); expect(messageList).shadowDom.to.equal( - `
+ `
-
- +
+ - + - + - +
` ); @@ -280,8 +281,8 @@ describe('Chat', () => { expect( messageContainer?.querySelectorAll('igc-chat-message')[0] ).shadowDom.to.equal( - `
-
+ `
+

Hello! How can I help you today?

@@ -292,8 +293,8 @@ describe('Chat', () => { expect( messageContainer?.querySelectorAll('igc-chat-message')[3] ).shadowDom.to.equal( - `
-
+ `
+

Thank you too!

@@ -317,15 +318,15 @@ describe('Chat', () => { const messageContainer = chat.shadowRoot ?.querySelector('igc-chat-message-list') - ?.shadowRoot?.querySelector('.message-list'); + ?.shadowRoot?.querySelector(`div[part='message-list']`); expect(chat.messages.length).to.equal(1); expect( messageContainer?.querySelectorAll('igc-chat-message')[0] ).shadowDom.to.equal( - `
-
+ `
+

Hello!

@@ -367,13 +368,13 @@ describe('Chat', () => { if (index !== 2) { expect( messageElement.shadowRoot - ?.querySelector('.message-container') + ?.querySelector(`div[part='message-container']`) ?.classList.contains('sent') ).to.be.false; } else { expect( messageElement.shadowRoot - ?.querySelector('.message-container') + ?.querySelector(`div[part='message-container']`) ?.classList.contains('sent') ).to.be.true; } @@ -402,73 +403,54 @@ describe('Chat', () => { }; await elementUpdated(chat); - const headerArea = chat.shadowRoot?.querySelector('.header'); + const headerArea = chat.shadowRoot?.querySelector(`div[part='header']`); expect(headerArea).dom.to.equal( - `
-
+ `
+
Chat
- +
` ); }); - // it('should scroll to bottom by default', async () => { - // chat.messages = [messages[0], messages[1], messages[2]]; - // await elementUpdated(chat); - // await clock.tickAsync(500); - - // const messagesContainer = chat.shadowRoot?.querySelector( - // 'igc-chat-message-list' - // ); - // let scrollPosition = messagesContainer - // ? messagesContainer.scrollHeight - messagesContainer.scrollTop - // : 0; - // expect(scrollPosition).to.equal(messagesContainer?.clientHeight); - - // chat.messages = [...chat.messages, messages[3]]; - // await chat.updateComplete; - // await clock.tickAsync(500); - - // scrollPosition = messagesContainer - // ? messagesContainer.scrollHeight - messagesContainer.scrollTop - // : 0; - - // expect(chat.messages.length).to.equal(4); - // expect(messagesContainer?.scrollTop).not.to.equal(0); - // expect(scrollPosition).to.equal(messagesContainer?.clientHeight); - // }); - - // it('should not scroll to bottom if `disableAutoScroll` is true', async () => { - // chat.messages = [messages[0], messages[1], messages[2]]; - // chat.options = { - // disableAutoScroll: true, - // }; - // await elementUpdated(chat); - // await clock.tickAsync(500); - - // const messagesContainer = chat.shadowRoot?.querySelector( - // 'igc-chat-message-list' - // ); - // const scrollPosition = messagesContainer - // ? messagesContainer.scrollHeight - messagesContainer.scrollTop - // : 0; - // expect(scrollPosition).to.equal(messagesContainer?.clientHeight); - - // messagesContainer?.scrollTo(0, 0); - // chat.messages = [...chat.messages, messages[3]]; - // await chat.updateComplete; - // await clock.tickAsync(500); - - // expect(chat.messages.length).to.equal(4); - // expect(messagesContainer?.scrollTop).to.equal(0); - // }); + it('should render suggestions', async () => { + chat.options = { + suggestions: ['Suggestion 1', 'Suggestion 2'], + }; + await elementUpdated(chat); + + const suggestionsContainer = chat.shadowRoot?.querySelector( + 'div[part="suggestions-container"]' + ); + + expect(suggestionsContainer).dom.to.equal( + `
+ + + + + Suggestion 1 + + + + + + + Suggestion 2 + + + + +
` + ); + }); it('should enable/disable the send button properly', async () => { const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); @@ -511,22 +493,22 @@ describe('Chat', () => { const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); expect(inputArea).shadowDom.to.equal( - `
+ `
-
+
-
+
{ expect(eventArgs).to.deep.equal(args); expect(inputArea).shadowDom.to.equal( - `
+ `
-
+
- + test.txt
-
+
- + image.png
-
+
-
+
{ { const messsageContainer = messageElement.shadowRoot?.querySelector('.bubble'); expect(messsageContainer).dom.to.equal( - `
+ `

${(messsageContainer as HTMLElement)?.innerText}

@@ -652,31 +634,31 @@ describe('Chat', () => { // Check if image attachments are rendered correctly if (index === 0) { expect(attachments).shadowDom.to.equal( - `
+ `
-
-
+
+
- + img1.png
-
+
img1.png @@ -687,25 +669,25 @@ describe('Chat', () => { // Check if non-image attachments are rendered correctly if (index === 1) { expect(attachments).shadowDom.to.equal( - `
+ `
-
-
+
+
- + img2.png
-
+
@@ -724,11 +706,11 @@ describe('Chat', () => { await elementUpdated(chat); const suggestionsContainer = chat.shadowRoot?.querySelector( - '.suggestions-container' + `div[part='suggestions-container']` ); expect(suggestionsContainer).dom.to.equal( - `
+ `
@@ -758,19 +740,19 @@ describe('Chat', () => { const messageContainer = chat.shadowRoot ?.querySelector('igc-chat-message-list') - ?.shadowRoot?.querySelector('.message-list'); + ?.shadowRoot?.querySelector(`div[part='message-list']`); expect(chat.messages.length).to.equal(1); expect(messageContainer).dom.to.equal( - `
- + `
+ -
-
+
+
-
+
-
+
` @@ -825,7 +807,7 @@ describe('Chat', () => { 'igc-message-attachments' ); expect(attachments).shadowDom.to.equal( - `
+ `
${chat.messages[index].attachments?.[0].name || ''} @@ -861,14 +843,14 @@ describe('Chat', () => { const details = attachments?.shadowRoot?.querySelector('.details'); expect(details).dom.to.equal( - `
+ `
Custom ${chat.messages[index].attachments?.[0].name}
` ); const actions = attachments?.shadowRoot?.querySelector('.actions'); expect(actions).dom.to.equal( - `
+ `
?
` ); @@ -896,7 +878,7 @@ describe('Chat', () => { const messsageContainer = messageElement.shadowRoot?.querySelector('.bubble'); expect(messsageContainer).dom.to.equal( - `
+ `
${chat.messages[index].sender === 'user' ? 'You' : 'Bot'}:

${(messsageContainer?.querySelector('p') as HTMLElement)?.innerText}

@@ -923,7 +905,7 @@ describe('Chat', () => { messageElement.shadowRoot?.querySelector('.bubble'); if (index === 0) { expect(messsageContainer).dom.to.equal( - `
+ `

${(messsageContainer?.querySelector('p') as HTMLElement)?.innerText}

@@ -933,7 +915,7 @@ describe('Chat', () => { ); } else { expect(messsageContainer).dom.to.equal( - `
+ `

${(messsageContainer?.querySelector('p') as HTMLElement)?.innerText}

@@ -959,12 +941,12 @@ describe('Chat', () => { await elementUpdated(chat); const messageContainer = chat.shadowRoot ?.querySelector('igc-chat-message-list') - ?.shadowRoot?.querySelector('.message-list'); + ?.shadowRoot?.querySelector(`div[part='message-list']`); expect(chat.messages.length).to.equal(1); expect(messageContainer).dom.to.equal( - `
- + `
+ loading...
` @@ -984,7 +966,7 @@ describe('Chat', () => { const inputArea = chat.shadowRoot?.querySelector('igc-chat-input'); expect(inputArea).shadowDom.to.equal( - `
+ `
-
+
-
+
Upload Send @@ -1052,7 +1034,7 @@ describe('Chat', () => { await elementUpdated(chat); const suggestionChips = chat.shadowRoot - ?.querySelector('.suggestions-container') + ?.querySelector(`div[part='suggestions-container']`) ?.querySelectorAll('igc-chip'); expect(suggestionChips?.length).to.equal(2); @@ -1210,7 +1192,9 @@ describe('Chat', () => { const messageContainer = chat.shadowRoot ?.querySelector('igc-chat-message-list') - ?.shadowRoot?.querySelector('.message-container') as HTMLElement; + ?.shadowRoot?.querySelector( + `div[part='message-container']` + ) as HTMLElement; messageContainer.focus(); await elementUpdated(chat); @@ -1222,9 +1206,12 @@ describe('Chat', () => { messageContainer?.querySelectorAll('igc-chat-message'); messageElements?.forEach((message, index) => { if (index === messages.length - 1) { - expect(message.classList.contains('active')).to.be.true; + expect(message.part.length).to.equal(2); + expect(message.part[0]).to.equal('message-item'); + expect(message.part[1]).to.equal('active'); } else { - expect(message.classList.contains('active')).to.be.false; + expect(message.part.length).to.equal(1); + expect(message.part[0]).to.equal('message-item'); } }); }); @@ -1236,7 +1223,9 @@ describe('Chat', () => { const messageContainer = chat.shadowRoot ?.querySelector('igc-chat-message-list') - ?.shadowRoot?.querySelector('.message-container') as HTMLElement; + ?.shadowRoot?.querySelector( + `div[part='message-container']` + ) as HTMLElement; messageContainer.focus(); await elementUpdated(chat); await nextFrame(); @@ -1276,7 +1265,9 @@ describe('Chat', () => { const messageContainer = chat.shadowRoot ?.querySelector('igc-chat-message-list') - ?.shadowRoot?.querySelector('.message-container') as HTMLElement; + ?.shadowRoot?.querySelector( + `div[part='message-container']` + ) as HTMLElement; messageContainer.focus(); await elementUpdated(chat); await nextFrame(); @@ -1320,7 +1311,7 @@ describe('Chat', () => { const messageElement = chat.shadowRoot ?.querySelector('igc-chat-message-list') - ?.shadowRoot?.querySelector('.message-list') + ?.shadowRoot?.querySelector(`div[part='message-list'`) ?.querySelector('igc-chat-message'); const attachmentHeader = messageElement?.shadowRoot diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index c354dd543..dd6f81221 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -1,6 +1,7 @@ import { ContextProvider } from '@lit/context'; import { html, LitElement } from 'lit'; import { property } from 'lit/decorators.js'; +import { addThemingController } from '../../theming/theming-controller.js'; import IgcButtonComponent from '../button/button.js'; import { chatContext } from '../common/context.js'; import { watch } from '../common/decorators/watch.js'; @@ -11,6 +12,8 @@ import IgcChatInputComponent from './chat-input.js'; import IgcChatMessageListComponent from './chat-message-list.js'; import { createChatState } from './chat-state.js'; import { styles } from './themes/chat.base.css.js'; +import { styles as shared } from './themes/shared/chat.common.css.js'; +import { all } from './themes/themes.js'; import type { IgcChatOptions, IgcMessage, @@ -40,7 +43,7 @@ export default class IgcChatComponent extends EventEmitterMixin< >(LitElement) { public static readonly tagName = 'igc-chat'; - public static styles = styles; + public static styles = [styles, shared]; /* blazorSuppress */ public static register() { @@ -59,6 +62,11 @@ export default class IgcChatComponent extends EventEmitterMixin< initialValue: this._chatState, }); + constructor() { + super(); + addThemingController(this, all); + } + /** * The list of chat messages currently displayed. */ @@ -117,20 +125,20 @@ export default class IgcChatComponent extends EventEmitterMixin< } private renderHeader() { - return html`
-
+ return html`
+
${this._chatState.options?.headerText}
- +
`; } private renderSuggestions() { return html`
@@ -157,10 +165,10 @@ export default class IgcChatComponent extends EventEmitterMixin< protected override render() { return html` -
+
${this.renderHeader()} ${this.messages.length === 0 - ? html`
+ ? html`
` : html` `} diff --git a/src/components/chat/message-attachments.ts b/src/components/chat/message-attachments.ts index e3d162437..8340365b6 100644 --- a/src/components/chat/message-attachments.ts +++ b/src/components/chat/message-attachments.ts @@ -9,6 +9,7 @@ import IgcIconComponent from '../icon/icon.js'; import { registerIconFromText } from '../icon/icon.registry.js'; import type { ChatState } from './chat-state.js'; import { styles } from './themes/message-attachments.base.css.js'; +import { styles as shared } from './themes/shared/message-attachments.common.css.js'; import { closeIcon, fileIcon, @@ -26,7 +27,7 @@ import { export default class IgcMessageAttachmentsComponent extends LitElement { public static readonly tagName = 'igc-message-attachments'; - public static override styles = styles; + public static override styles = [styles, shared]; @consume({ context: chatContext, subscribe: true }) private _chatState?: ChatState; @@ -71,7 +72,7 @@ export default class IgcMessageAttachmentsComponent extends LitElement { } private renderAttachmentHeaderText(attachment: IgcMessageAttachment) { - return html`
+ return html`
${this._chatState?.options?.templates?.attachmentHeaderTemplate ? this._chatState.options.templates.attachmentHeaderTemplate( this.attachments @@ -83,23 +84,23 @@ export default class IgcMessageAttachmentsComponent extends LitElement { ? html`` : html``} - ${attachment.name} + ${attachment.name} `}
`; } private renderAttachmentHeaderActions() { - return html`
+ return html`
${this._chatState?.options?.templates?.attachmentActionsTemplate ? this._chatState.options.templates.attachmentActionsTemplate( this.attachments @@ -119,7 +120,7 @@ export default class IgcMessageAttachmentsComponent extends LitElement { ${attachment.type === 'image' || attachment.file?.type.startsWith('image/') ? html` ${attachment.name}` @@ -139,7 +140,7 @@ export default class IgcMessageAttachmentsComponent extends LitElement { @igcClosing=${(ev: CustomEvent) => this.handleToggle(ev, attachment)} @igcOpening=${(ev: CustomEvent) => this.handleToggle(ev, attachment)} > -
+
${this.renderAttachmentHeaderText(attachment)} ${this.renderAttachmentHeaderActions()}
@@ -151,7 +152,7 @@ export default class IgcMessageAttachmentsComponent extends LitElement { protected override render() { return html` -
+
${this._chatState?.options?.templates?.attachmentTemplate ? this._chatState.options.templates.attachmentTemplate( this.attachments diff --git a/src/components/chat/themes/chat.base.scss b/src/components/chat/themes/chat.base.scss index 3da85e3c9..8a0f95948 100644 --- a/src/components/chat/themes/chat.base.scss +++ b/src/components/chat/themes/chat.base.scss @@ -2,93 +2,53 @@ @use 'styles/utilities' as *; :host { + width: 100%; + height: 600px; + overflow: hidden; + box-shadow: 0 8px 24px #1f1f1f; + display: flex; + flex-direction: column; +} + +[part='chat-container'] { + display: flex; + flex-direction: column; + align-items: center; + height: 100%; + gap: rem(40px); + + > * { + max-width: rem(760px); width: 100%; - height: 600px; - border-radius: 12px; - overflow: hidden; - box-shadow: 0 8px 24px #1f1f1f; - display: flex; - flex-direction: column; } - - .chat-container { - display: flex; - flex-direction: column; - height: 100%; - } - - .header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px; - background-color: #edeff0; - } - - .empty-state { - height: 100%; - } - - .info { - display: flex; - align-items: center; - gap: 12px; - } - - .avatar { - width: 40px; - height: 40px; - border-radius: 50%; - object-fit: cover; - } - - .avatar-container { - position: relative; - } - - .status-indicator { - position: absolute; - bottom: 0; - right: 0; - width: 12px; - height: 12px; - border-radius: 50%; - background-color: #30D158; - border: 2px solid white; - } - - .actions { - display: flex; - gap: 16px; - } - - .action-button { - background: none; - border: none; - color: #0A84FF; - cursor: pointer; - font-size: 1.2rem; - display: flex; - align-items: center; - justify-content: center; - width: 40px; - height: 40px; - border-radius: 50%; - transition: white 0.2s; - } - - .action-button:hover { - background-color: #E5E5EA; - } - - .suggestions-container { - display: flex; - justify-content: end; - - igc-chip::part(base) { - background-color: transparent; - border: 1px solid var(--ig-primary-500); - color: var(--ig-primary-500); - } - - } \ No newline at end of file +} + +[part='empty-state'] { + height: 100%; +} + +[part='header'] { + display: flex; + align-items: center; + justify-content: space-between; + padding: rem(16px) rem(13px); + gap: rem(16px); + box-shadow: var(--ig-elevation-4); + max-width: 100%; +} + +[part='title'] { + @include type-style('h6') { + margin: 0; + }; +} + +[part='suggestions-container'] { + display: flex; + flex-direction: column; + gap: rem(12px); + + igc-chip { + --ig-size: 3; + } +} diff --git a/src/components/chat/themes/dark/_themes.scss b/src/components/chat/themes/dark/_themes.scss new file mode 100644 index 000000000..ac7c2a0a1 --- /dev/null +++ b/src/components/chat/themes/dark/_themes.scss @@ -0,0 +1,7 @@ +@use 'styles/utilities' as *; +@use 'igniteui-theming/sass/themes/schemas/components/dark/chat' as *; + +$material: digest-schema($dark-material-chat); +$bootstrap: digest-schema($dark-bootstrap-chat); +$fluent: digest-schema($dark-fluent-chat); +$indigo: digest-schema($dark-indigo-chat); diff --git a/src/components/chat/themes/dark/chat.bootstrap.scss b/src/components/chat/themes/dark/chat.bootstrap.scss new file mode 100644 index 000000000..8ae2decc7 --- /dev/null +++ b/src/components/chat/themes/dark/chat.bootstrap.scss @@ -0,0 +1,9 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; +@use '../light/themes' as light; + +$theme: $bootstrap; + +:host { + @include css-vars-from-theme(diff(light.$base, $theme), 'ig-chat'); +} diff --git a/src/components/chat/themes/dark/chat.fluent.scss b/src/components/chat/themes/dark/chat.fluent.scss new file mode 100644 index 000000000..de7767075 --- /dev/null +++ b/src/components/chat/themes/dark/chat.fluent.scss @@ -0,0 +1,9 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; +@use '../light/themes' as light; + +$theme: $fluent; + +:host { + @include css-vars-from-theme(diff(light.$base, $theme), 'ig-chat'); +} diff --git a/src/components/chat/themes/dark/chat.indigo.scss b/src/components/chat/themes/dark/chat.indigo.scss new file mode 100644 index 000000000..20a6f366e --- /dev/null +++ b/src/components/chat/themes/dark/chat.indigo.scss @@ -0,0 +1,9 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; +@use '../light/themes' as light; + +$theme: $indigo; + +:host { + @include css-vars-from-theme(diff(light.$base, $theme), 'ig-chat'); +} diff --git a/src/components/chat/themes/dark/chat.material.scss b/src/components/chat/themes/dark/chat.material.scss new file mode 100644 index 000000000..2f8acca66 --- /dev/null +++ b/src/components/chat/themes/dark/chat.material.scss @@ -0,0 +1,9 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; +@use '../light/themes' as light; + +$theme: $material; + +:host { + @include css-vars-from-theme(diff(light.$base, $theme), 'ig-chat'); +} diff --git a/src/components/chat/themes/input.base.scss b/src/components/chat/themes/input.base.scss index 4654f0ea0..010029b55 100644 --- a/src/components/chat/themes/input.base.scss +++ b/src/components/chat/themes/input.base.scss @@ -1,134 +1,36 @@ @use 'styles/common/component'; @use 'styles/utilities' as *; -:host { - display: block; - padding: 12px 16px; - border-top: 1px solid #E5E5EA; -} - -igc-file-input{ - width: fit-content; -} - -igc-file-input::part(file-names){ - display: none; -} - -.input-container { +[part='buttons-container'] { display: flex; - flex-direction: column; - gap: 12px; -} - -.input-container.dragging{ - background-color: black; - border: 2px dashed #0A84FF; -} - -.input-wrapper { - flex: 1; - position: relative; - border-radius: 24px; - overflow: hidden; - transition: box-shadow 0.2s; - - igc-textarea { - padding-top: 3px; - padding-bottom: 3px; - } - - igc-textarea::part(input) { - max-height: 115px; - } + align-items: center; + margin-block: rem(8px) rem(16px); } -.buttons-container { - display: flex; - justify-content: space-between; +[part='send-button'] { + margin-inline-start: auto; } -.input-button { +[part='attachments'] { display: flex; - align-items: center; - justify-content: center; - width: 2rem; - height: 2rem; - border-radius: 50%; - margin-left: 0.25rem; - background: transparent; - border: none; - outline: none; - cursor: pointer; - color: #8E8E93; - transition: all 0.2s ease; + gap: rem(8px); + margin-block-end: rem(8px); } -.input-button:hover { - color: #0A84FF; - background-color: #a1a1a1; +igc-file-input { + width: fit-content; + margin-inline-end: auto; } -.text-input { - width: 100%; - border: none; - padding: 12px 16px; - font-size: 0.95rem; - line-height: 1.5; - outline: none; - resize: none; - max-height: 120px; - font-family: inherit; +igc-file-input::part(file-names) { + display: none; } -.attachment-button, -.send-button { - display: flex; - align-items: center; - justify-content: center; - width: 40px; - height: 40px; - border-radius: 50%; - background-color: transparent; +igc-file-input::part(container) { + background: transparent; border: none; - cursor: pointer; - color: #0A84FF; - transition: white 0.2s; -} - -.attachment-button:hover, -.send-button:hover { - background-color: #8E8E93; -} -.attachment-wrapper { - max-width: 200px; - margin-right: 5px; - float: left; - - igc-chip { - border: 1px solid #E5E5EA; - border-radius: 5px; + &::after { + display: none; } } - -.attachment-name { - font-size: small; - font-style: italic; - margin: 0 5px; -} - -.send-button { - background-color: #0A84FF; - color: white; -} - -.send-button:hover { - background-color: #5AC8FA; -} - -.send-button:disabled { - background-color: #C7C7CC; - cursor: not-allowed; -} - diff --git a/src/components/chat/themes/light/_themes.scss b/src/components/chat/themes/light/_themes.scss new file mode 100644 index 000000000..e82b549d6 --- /dev/null +++ b/src/components/chat/themes/light/_themes.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'igniteui-theming/sass/themes/schemas/components/light/chat' as *; + +$base: digest-schema($light-chat); +$material: digest-schema($material-chat); +$bootstrap: digest-schema($bootstrap-chat); +$fluent: digest-schema($fluent-chat); +$indigo: digest-schema($indigo-chat); diff --git a/src/components/chat/themes/light/chat.bootstrap.scss b/src/components/chat/themes/light/chat.bootstrap.scss new file mode 100644 index 000000000..d9580a4b5 --- /dev/null +++ b/src/components/chat/themes/light/chat.bootstrap.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $bootstrap; + +:host { + @include css-vars-from-theme(diff($base, $theme), 'ig-chat'); +} diff --git a/src/components/chat/themes/light/chat.fluent.scss b/src/components/chat/themes/light/chat.fluent.scss new file mode 100644 index 000000000..d1c3e4abb --- /dev/null +++ b/src/components/chat/themes/light/chat.fluent.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $fluent; + +:host { + @include css-vars-from-theme(diff($base, $theme), 'ig-chat'); +} diff --git a/src/components/chat/themes/light/chat.indigo.scss b/src/components/chat/themes/light/chat.indigo.scss new file mode 100644 index 000000000..c0a2f2917 --- /dev/null +++ b/src/components/chat/themes/light/chat.indigo.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $indigo; + +:host { + @include css-vars-from-theme(diff($base, $theme), 'ig-chat'); +} diff --git a/src/components/chat/themes/light/chat.material.scss b/src/components/chat/themes/light/chat.material.scss new file mode 100644 index 000000000..6a388bd8d --- /dev/null +++ b/src/components/chat/themes/light/chat.material.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $material; + +:host { + @include css-vars-from-theme(diff($base, $theme), 'ig-chat'); +} diff --git a/src/components/chat/themes/light/chat.shared.scss b/src/components/chat/themes/light/chat.shared.scss new file mode 100644 index 000000000..d2e557ef4 --- /dev/null +++ b/src/components/chat/themes/light/chat.shared.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $base; + +:host { + @include css-vars-from-theme($theme, 'ig-chat'); +} diff --git a/src/components/chat/themes/message-list.base.scss b/src/components/chat/themes/message-list.base.scss index 756711238..7e6f6cb2d 100644 --- a/src/components/chat/themes/message-list.base.scss +++ b/src/components/chat/themes/message-list.base.scss @@ -3,92 +3,16 @@ :host { display: block; - flex: 1; overflow-y: auto; - padding: 16px; + scrollbar-width: none; } -.message-list { - display: flex; - flex-direction: column; - gap: 12px; -} - -.day-separator { - display: flex; - align-items: center; - margin: 16px 0; - color: #636366; - font-size: 0.8rem; -} - -.day-separator::before, -.day-separator::after { - content: ''; - flex: 1; - height: 1px; - background-color: #a5a5a5; - margin: 0 8px; +:host(::-webkit-scrollbar) { + display: none; } -igc-chat-message.active { - border: 2px solid #7f8386; - } - -.typing-indicator { +[part='message-list'] { display: flex; - align-items: center; - gap: 4px; - padding: 8px; - margin-top: 8px; - animation: fadeIn 0.3s ease-in; -} - -.typing-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background-color: #7e7e81; - opacity: 0.6; -} - -.typing-dot:nth-child(1) { - animation: bounce 1.2s infinite 0s; -} - -.typing-dot:nth-child(2) { - animation: bounce 1.2s infinite 0.2s; -} - -.typing-dot:nth-child(3) { - animation: bounce 1.2s infinite 0.4s; -} - -@keyframes fade-in { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -@keyframes bounce { - 0%, - 80%, - 100% { - transform: translateY(0); - } - - 40% { - transform: translateY(-6px); - } + flex-direction: column; + gap: rem(24px); } - -@media (prefers-color-scheme: dark) { - .day-separator::before, - .day-separator::after { - background-color: #525253; - } -} \ No newline at end of file diff --git a/src/components/chat/themes/message.base.scss b/src/components/chat/themes/message.base.scss index 7db6d67c7..ed26ae5bd 100644 --- a/src/components/chat/themes/message.base.scss +++ b/src/components/chat/themes/message.base.scss @@ -1,104 +1,26 @@ @use 'styles/common/component'; @use 'styles/utilities' as *; -:host { - display: block; - - --message-max-width: 75%; - } - - .message-container { - display: flex; - justify-content: flex-start; - align-items: flex-end; - gap: 8px; - margin-bottom: 4px; - animation: fadeIn 0.2s ease-out; - } - - .message-container.sent { - display: flex; - flex-direction: row-reverse; - } - - .avatar { - width: 32px; - height: 32px; - border-radius: 50%; - object-fit: cover; - flex-shrink: 0; - opacity: 0; - } - - .message-container.show-avatar .avatar { - opacity: 1; - } - - .message-container pre { - background-color: black; - padding: 8px; - border-radius: 4px; - overflow-x: auto; - margin: 8px 0; - } - - .message-container code { - font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; - font-size: 0.9em; - padding: 2px 4px; - border-radius: 4px; - } - - .bubble { - display: block; - padding: 12px 16px; - border-radius: 18px; - color: black; - word-break: break-all; - font-weight: 400; - line-height: 1.4; - position: relative; - transition: all 0.2s ease; - width: fit-content; - } - - .sent { - border-radius: 18px 18px 4px; - } - - .sent .bubble { - background-color: #E5E5EA; - } - - .received .bubble { - border-radius: 18px 18px 18px 4px; - } - - .meta { - display: flex; - font-size: 0.7rem; - color: #636366; - margin-top: 4px; - opacity: 0.8; - } - - .sent .meta { - justify-content: flex-end; - } - - .time { - margin-right: 4px; - } - - @keyframes fade-in { - from { - opacity: 0; - transform: translateY(10px); - } - - to { - opacity: 1; - transform: translateY(0); - } - } - +[part~='message-container'] { + display: flex; + justify-content: flex-start; + align-items: flex-end; +} + +[part~='sent'] { + max-width: rem(576px); + margin-inline-start: auto; + padding: rem(12px) rem(16px); + border-radius: rem(24px) rem(24px) 0; +} + +[part~='bubble'] { + @include type-style('body-1') { + margin: 0; + }; + + p { + margin: 0; + padding: 0; + } +} diff --git a/src/components/chat/themes/shared/chat-message.common.scss b/src/components/chat/themes/shared/chat-message.common.scss new file mode 100644 index 000000000..4823cf270 --- /dev/null +++ b/src/components/chat/themes/shared/chat-message.common.scss @@ -0,0 +1,12 @@ +@use 'styles/utilities' as *; +@use '../light/themes' as *; + +$theme: $material; + +[part~='sent'] { + background: var-get($theme, 'message-background'); +} + +[part~='bubble'] { + color: var-get($theme, 'message-color'); +} \ No newline at end of file diff --git a/src/components/chat/themes/shared/chat.common.scss b/src/components/chat/themes/shared/chat.common.scss new file mode 100644 index 000000000..82bf4c57d --- /dev/null +++ b/src/components/chat/themes/shared/chat.common.scss @@ -0,0 +1,13 @@ +@use 'styles/utilities' as *; +@use '../light/themes' as *; + +$theme: $material; + +.chat-container { + background: var-get($theme, 'background'); +} + +[part='header'] { + background: var-get($theme, 'header-background'); + color: var-get($theme, 'header-color'); +} diff --git a/src/components/chat/themes/shared/message-attachments.common.scss b/src/components/chat/themes/shared/message-attachments.common.scss new file mode 100644 index 000000000..72342626f --- /dev/null +++ b/src/components/chat/themes/shared/message-attachments.common.scss @@ -0,0 +1,30 @@ +@use 'styles/utilities' as *; +@use '../light/themes' as *; + +$theme: $material; + +:host { + border: rem(1px) solid var-get($theme, 'image-border'); + border-radius: rem(4px); +} + +igc-expansion-panel { + border-radius: rem(4px); +} + +igc-expansion-panel::part(header) { + background: var-get($theme, 'image-background'); + padding: rem(8px) rem(12px) rem(8px) rem(16px); +} + +[part='details'] { + display: flex; + align-items: center; + gap: rem(8px); +} + +[part='file-name'] { + @include type-style('body-2') { + margin: 0; + }; +} diff --git a/src/components/chat/themes/themes.ts b/src/components/chat/themes/themes.ts new file mode 100644 index 000000000..c433effde --- /dev/null +++ b/src/components/chat/themes/themes.ts @@ -0,0 +1,52 @@ +import { css } from 'lit'; + +import type { Themes } from '../../../theming/types.js'; +// Dark Overrides +import { styles as bootstrapDark } from './dark/chat.bootstrap.css.js'; +import { styles as fluentDark } from './dark/chat.fluent.css.js'; +import { styles as indigoDark } from './dark/chat.indigo.css.js'; +import { styles as materialDark } from './dark/chat.material.css.js'; +// Light Overrides +import { styles as bootstrapLight } from './light/chat.bootstrap.css.js'; +import { styles as fluentLight } from './light/chat.fluent.css.js'; +import { styles as indigoLight } from './light/chat.indigo.css.js'; +import { styles as materialLight } from './light/chat.material.css.js'; +import { styles as shared } from './light/chat.shared.css.js'; + +const light = { + shared: css` + ${shared} + `, + bootstrap: css` + ${bootstrapLight} + `, + material: css` + ${materialLight} + `, + fluent: css` + ${fluentLight} + `, + indigo: css` + ${indigoLight} + `, +}; + +const dark = { + shared: css` + ${shared} + `, + bootstrap: css` + ${bootstrapDark} + `, + material: css` + ${materialDark} + `, + fluent: css` + ${fluentDark} + `, + indigo: css` + ${indigoDark} + `, +}; + +export const all: Themes = { light, dark }; diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 60c27029c..f903bca28 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -145,7 +145,7 @@ const ai_chat_options = { }; const chat_options = { - disableAutoScroll: true, + disableAutoScroll: false, disableAttachments: true, }; From 4a296aa28bdb85876841586e5736788b22b5d161 Mon Sep 17 00:00:00 2001 From: Galina Edinakova Date: Thu, 24 Jul 2025 11:52:20 +0300 Subject: [PATCH 083/252] chore(*): JSDoc for all types in types.ts --- src/components/chat/types.ts | 161 ++++++++++++++++++++++++++++++++++- 1 file changed, 158 insertions(+), 3 deletions(-) diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts index c1c2eaae0..baab9adfa 100644 --- a/src/components/chat/types.ts +++ b/src/components/chat/types.ts @@ -2,58 +2,213 @@ import type { TemplateResult } from 'lit'; // export type IgcMessageAttachmentType = 'image' | 'file'; +/** + * Represents a single chat message in the conversation. + */ export interface IgcMessage { + /** + * A unique identifier for the message. + */ id: string; + + /** + * The textual content of the message. + */ text: string; + + /** + * The identifier or name of the sender of the message. + */ sender: string; + + /** + * The timestamp indicating when the message was sent. + */ timestamp: Date; + + /** + * Optional list of attachments associated with the message, + * such as images, files, or links. + */ attachments?: IgcMessageAttachment[]; } +/** + * Represents an attachment associated with a chat message. + */ export interface IgcMessageAttachment { + /** + * A unique identifier for the attachment. + */ id: string; + + /** + * The display name of the attachment (e.g. file name). + */ name: string; + + /** + * The URL from which the attachment can be downloaded or viewed. + * Typically used for attachments stored on a server or CDN. + */ url?: string; + + /** + * The actual File object, if the attachment was provided locally (e.g. via upload). + */ file?: File; + + /** + * The MIME type or a custom type identifier for the attachment (e.g. "image/png", "pdf", "audio"). + */ type?: string; + + /** + * Optional URL to a thumbnail preview of the attachment (e.g. for images or videos). + */ thumbnail?: string; } +/** + * A function type used to render a group of attachments in a chat message. + * + * This allows consumers to customize how message attachments are displayed + * (e.g. rendering thumbnails, file icons, or download links). + * + * @param {IgcMessageAttachment[]} attachments - The list of attachments to render. + * @returns {TemplateResult} A Lit `TemplateResult` representing the rendered attachments. + */ export type AttachmentTemplate = ( attachments: IgcMessageAttachment[] ) => TemplateResult; + +/** + * A function type used to render a single chat message. + * + * This allows consumers to fully customize the display of a message, + * including its text, sender info, timestamp, and any attachments. + * + * @param {IgcMessage} message - The chat message to render. + * @returns {TemplateResult} A Lit `TemplateResult` representing the rendered message. + */ export type MessageTemplate = (message: IgcMessage) => TemplateResult; -export type MarkdownRenderer = (text: string) => TemplateResult; +// export type MarkdownRenderer = (text: string) => TemplateResult; + +/** + * Configuration options for customizing the behavior and appearance of the chat component. + */ export type IgcChatOptions = { + /** + * The ID of the current user. Used to differentiate between incoming and outgoing messages. + */ currentUserId?: string; + /** + * Whether to hide user avatars in the message list. + * Defaults to `false`. + */ hideAvatar?: boolean; + /** + * Whether to hide message timestamps. + * Defaults to `false`. + */ hideTimestamp?: boolean; + /** + * Whether to hide sender usernames in the message list. + * Defaults to `false`. + */ hideUserName?: boolean; + /** + * If `true`, prevents the chat from automatically scrolling to the latest message. + */ disableAutoScroll?: boolean; + /** + * If `true`, disables the ability to upload and send attachments. + * Defaults to `false`. + */ disableAttachments?: boolean; + /** + * Indicates whether the other user is currently typing or composing a message. + */ isComposing?: boolean; /** * The accepted files that could be attached. - * Defines the file types as a list of comma-separated values that the file input should accept. + * Defines the file types as a list of comma-separated values (e.g. "image/*,.pdf") that the file input should accept. */ acceptedFiles?: string; + /** + * Optional header text to display at the top of the chat component. + */ headerText?: string; + /** + * Suggested text snippets or quick replies that can be shown as user-selectable options. + */ suggestions?: string[]; + /** + * A set of template override functions used to customize rendering of messages, attachments, etc. + */ templates?: IgcChatTemplates; - markdownRenderer?: MarkdownRenderer; }; +/** + * A collection of template functions used to customize different parts of the chat component. + * Each template allows you to override the rendering of a specific part of the component. + */ export type IgcChatTemplates = { + /** + * Template for rendering an attachment in a message. + */ attachmentTemplate?: AttachmentTemplate; + + /** + * Template for rendering a custom header above the attachment in a message. + */ attachmentHeaderTemplate?: AttachmentTemplate; + + /** + * Template for rendering custom action buttons or controls related to an attachment + * (e.g. download, preview, delete). + */ attachmentActionsTemplate?: AttachmentTemplate; + + /** + * Template for rendering the main content of an attachment, such as a thumbnail or file preview. + */ attachmentContentTemplate?: AttachmentTemplate; + + /** + * Template for rendering a single chat message. + * Use this to customize message layout, formatting, or metadata. + */ messageTemplate?: MessageTemplate; + + /** + * Template for rendering message-specific actions such as edit, delete, reply, etc. + */ messageActionsTemplate?: MessageTemplate; + + /** + * Template used to show an indicator when the other user is typing (e.g. “User is typing...”). + */ composingIndicatorTemplate?: TemplateResult; + + /** + * Template for customizing the text input element (usually a `