From 0b0c82e8c92d19b4d2b1183d582f8a87f3db9c28 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Fri, 2 May 2025 16:02:13 +0300 Subject: [PATCH 01/18] 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 02/18] 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 03/18] 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 04/18] 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 05/18] 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 06/18] 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 07/18] 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 08/18] 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 09/18] 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 10/18] 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 11/18] 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 12/18] 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 13/18] 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 14/18] 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 15/18] 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 590ab0b1b5bcc816fe533f5b819115d67ef98f7d Mon Sep 17 00:00:00 2001 From: teodosiah Date: Thu, 15 May 2025 12:23:10 +0300 Subject: [PATCH 16/18] 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 17/18] 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 18/18] 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;