diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts new file mode 100644 index 000000000..1c74f9414 --- /dev/null +++ b/src/components/chat/chat-input.ts @@ -0,0 +1,222 @@ +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, + 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: 'disable-attachments' }) + public disableAttachments = false; + + @property({ type: Boolean, attribute: 'disable-emojis' }) + public disableEmojis = false; + + @query('textarea') + private textInputElement!: HTMLTextAreaElement; + + @state() + private inputValue = ''; + + @state() + private attachments: IgcMessageAttachment[] = []; + + constructor() { + super(); + 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(); + } else { + const typingEvent = new CustomEvent('typing-change', { + detail: { isTyping: true }, + }); + this.dispatchEvent(typingEvent); + // wait 3 seconds and dispatch a stop-typing event + setTimeout(() => { + const stopTypingEvent = new CustomEvent('typing-change', { + detail: { isTyping: false }, + }); + this.dispatchEvent(stopTypingEvent); + }, 3000); + } + } + + private 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 addEmoji(e: CustomEvent) { + const emoji = e.detail.emoji; + this.inputValue += emoji; + + // Focus back on input after selecting an emoji + this.updateComplete.then(() => { + const textarea = this.shadowRoot?.querySelector('textarea'); + if (textarea) { + textarea.focus(); + } + }); + } + + private handleFileUpload(e: Event) { + const input = (e.target as any).input as HTMLInputElement; + if (!input.files || input.files.length === 0) return; + + 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.disableAttachments + ? '' + : html` + + + + `} + +
+ +
+ +
+ ${this.disableEmojis + ? '' + : 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..0c6108e60 --- /dev/null +++ b/src/components/chat/chat-message-list.ts @@ -0,0 +1,153 @@ +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, attribute: false }) + public user: IgcUser | undefined; + + @property({ reflect: true, attribute: false }) + public messages: IgcMessage[] = []; + + @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: 'disable-auto-scroll' }) + public disableAutoScroll = false; + + @property({ type: Boolean, attribute: 'disable-reactions' }) + public disableReactions = false; + + 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 scrollToBottom() { + requestAnimationFrame(() => { + const container = this.shadowRoot?.host as HTMLElement; + if (container) { + container.scrollTop = container.scrollHeight; + } + }); + } + + protected override updated() { + if (!this.disableAutoScroll) { + this.scrollToBottom(); + } + } + + protected override firstUpdated() { + if (!this.disableAutoScroll) { + 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.filter((u) => u !== this.user).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..153b19920 --- /dev/null +++ b/src/components/chat/chat-message.ts @@ -0,0 +1,147 @@ +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'; +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, attribute: false }) + public message: IgcMessage | undefined; + + @property({ reflect: true, attribute: false }) + public user: IgcUser | undefined; + + @property({ type: Boolean, attribute: 'hide-avatar' }) + public hideAvatar = false; + + @property({ type: Boolean, attribute: 'hide-user-name' }) + public hideUserName = false; + + @property({ type: Boolean, attribute: 'hide-meta-data' }) + public hideMetaData = false; + + @property({ type: Boolean, attribute: 'disable-reactions' }) + public disableReactions = false; + + private formatTime(date: Date | undefined): string | undefined { + return date?.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + + private renderStatusIcon(status: string) { + if (status === 'sent') { + return '✓'; + } + + if (status === 'delivered') { + return '✓✓'; + } + + if (status === 'read') { + return '✓✓'; + } + return ''; + } + + private handleAddReaction(e: CustomEvent) { + const { emojiId } = e.detail; + + this.dispatchEvent( + new CustomEvent('add-reaction', { + detail: { messageId: this.message?.id, emojiId }, + bubbles: true, + composed: true, + }) + ); + } + + private isCurrentUser() { + return this.message?.sender.id === this.user?.id; + } + + protected override render() { + const sender = this.message?.sender; + const containerClass = `message-container ${this.isCurrentUser() ? 'sent' : 'received'}`; + + return html` +
+ ${this.hideAvatar + ? '' + : html` + `} + +
+ ${this.hideUserName || this.isCurrentUser() + ? '' + : html`${ifDefined(sender?.name)}`} + ${this.message?.text.trim() + ? html`
${this.message?.text}
` + : ''} + ${this.message?.attachments && this.message?.attachments.length > 0 + ? html` + ` + : ''} + ${this.hideMetaData + ? '' + : html` +
+ ${this.formatTime(this.message?.timestamp)} + ${this.isCurrentUser() + ? html`${this.renderStatusIcon( + this.message?.status || 'sent' + )}` + : ''} +
+ `} +
+ ${this.disableReactions + ? '' + : html``} +
+ `; + } +} + +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..b8565d120 --- /dev/null +++ b/src/components/chat/chat.ts @@ -0,0 +1,243 @@ +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 IgcChatInputComponent from './chat-input.js'; +import IgcChatMessageListComponent from './chat-message-list.js'; +import { styles } from './themes/chat.base.css.js'; +import type { + IgcMessage, + IgcMessageAttachment, + IgcMessageReaction, + IgcUser, +} from './types.js'; + +export interface IgcChatComponentEventMap { + igcMessageSend: CustomEvent; + igcTypingChange: CustomEvent; + igcAttachmentClick: CustomEvent; +} + +export interface IgcTypingChangeEventArgs { + user: IgcUser; + isTyping: boolean; +} + +/** + * + * @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, + IgcChatInputComponent, + IgcChatMessageListComponent, + IgcButtonComponent + ); + } + + @property({ attribute: false }) + public user: IgcUser | undefined; + + @property({ reflect: true, attribute: false }) + public messages: IgcMessage[] = []; + + @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: 'disable-auto-scroll' }) + public disableAutoScroll = false; + + @property({ type: Boolean, attribute: 'disable-reactions' }) + public disableReactions = false; + + @property({ type: Boolean, attribute: 'disable-attachments' }) + public disableAttachments = false; + + @property({ type: Boolean, attribute: 'disable-emojis' }) + public disableEmojis = false; + + @property({ type: String, attribute: 'header-text', reflect: true }) + public headerText = ''; + + public override connectedCallback() { + super.connectedCallback(); + this.addEventListener( + 'add-reaction', + this.handleAddReaction as EventListener + ); + this.addEventListener( + 'message-send', + this.handleSendMessage as EventListener + ); + this.addEventListener( + 'attachment-click', + this.handleAttachmentClick as EventListener + ); + } + + public override disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener( + 'message-send', + this.handleSendMessage as EventListener + ); + this.removeEventListener( + 'add-reaction', + this.handleAddReaction as EventListener + ); + this.removeEventListener( + 'attachment-click', + this.handleAttachmentClick 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('igcMessageSend', { detail: newMessage }); + } + + private handleTypingChange(e: CustomEvent) { + const isTyping = e.detail.isTyping; + const user = this.user; + if (!user) return; + const typingArgs = { user, isTyping }; + this.emitEvent('igcTypingChange', { detail: typingArgs }); + } + + private handleAddReaction(e: CustomEvent) { + const { messageId, emojiId } = e.detail; + + if (!messageId) return; + + this.messages = this.messages.map((message) => { + if (message.id === messageId) { + const userReaction = message.reactions?.find( + (r) => this.user && r.users.includes(this.user.id) + ); + + if (userReaction) { + // Remove reaction + message.reactions?.forEach((r) => { + if (r.id === userReaction.id) { + r.users = (r.users ?? []).filter((id) => id !== this.user?.id); + } + }); + + message.reactions = + message.reactions?.filter((r) => r.users.length > 0) || []; + } + + const existingReaction = message.reactions?.find( + (r) => r.id === emojiId + ); + + if (existingReaction && userReaction?.id !== emojiId) { + // Update existing reaction + message.reactions?.forEach((r) => { + if (r.id === emojiId) { + if (this.user) { + r.users.push(this.user.id); + } + } + }); + + message.reactions = [...(message.reactions || [])]; + } + + if (!existingReaction && userReaction?.id !== emojiId) { + // Create new reaction + const newReaction: IgcMessageReaction = { + id: emojiId, + users: this.user ? [this.user.id] : [], + }; + + message.reactions = [...(message.reactions || []), newReaction]; + } + } + + return { ...message }; + }); + } + + private handleAttachmentClick(e: CustomEvent) { + const attachmentArgs = e.detail.attachment; + this.emitEvent('igcAttachmentClick', { detail: attachmentArgs }); + } + + protected override render() { + return html` +
+
+
+ + ${this.headerText} +
+ + + +
+ + + +
+ `; + } +} + +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..7dbfff193 --- /dev/null +++ b/src/components/chat/emoji-picker.ts @@ -0,0 +1,418 @@ +import { LitElement, html } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import IgcButtonComponent from '../button/button.js'; +import IgcIconButtonComponent from '../button/icon-button.js'; +import { addRootClickHandler } from '../common/controllers/root-click.js'; +import { watch } from '../common/decorators/watch.js'; +import { registerComponent } from '../common/definitions/register.js'; +import { registerIconFromText } from '../icon/icon.registry.js'; +import IgcInputComponent from '../input/input.js'; +import IgcPopoverComponent from '../popover/popover.js'; +import { styles } from './themes/emoji-picker.base.css.js'; +import { emojiPickerIcon } from './types.js'; + +export const EMOJI_CATEGORIES = [ + { + name: 'Smileys & Emotion', + icon: '😊', + emojis: [ + { id: 'grinning_face', emoji: '😀', name: 'Grinning Face' }, + { + id: 'grinning_face_with_big_eyes', + emoji: '😃', + name: 'Grinning Face with Big Eyes', + }, + { + id: 'grinning_face_with_smiling_eyes', + emoji: '😄', + name: 'Grinning Face with Smiling Eyes', + }, + { + id: 'beaming_face_with_smiling_eyes', + emoji: '😁', + name: 'Beaming Face with Smiling Eyes', + }, + { + id: 'grinning_squinting_face', + emoji: '😆', + name: 'Grinning Squinting Face', + }, + { + id: 'grinning_face_with_sweat', + emoji: '😅', + name: 'Grinning Face with Sweat', + }, + { + id: 'rolling_on_the_floor_laughing', + emoji: '🤣', + name: 'Rolling on the Floor Laughing', + }, + { + id: 'face_with_tears_of_joy', + emoji: '😂', + name: 'Face with Tears of Joy', + }, + { + id: 'slightly_smiling_face', + emoji: '🙂', + name: 'Slightly Smiling Face', + }, + { id: 'upside_down_face', emoji: '🙃', name: 'Upside-Down Face' }, + { id: 'winking_face', emoji: '😉', name: 'Winking Face' }, + { + id: 'smiling_face_with_smiling_eyes', + emoji: '😊', + name: 'Smiling Face with Smiling Eyes', + }, + { + id: 'smiling_face_with_halo', + emoji: '😇', + name: 'Smiling Face with Halo', + }, + { + id: 'smiling_face_with_heart_eyes', + emoji: '😍', + name: 'Smiling Face with Heart-Eyes', + }, + { + id: 'smiling_face_with_hearts', + emoji: '🥰', + name: 'Smiling Face with Hearts', + }, + { id: 'face_blowing_a_kiss', emoji: '😘', name: 'Face Blowing a Kiss' }, + ], + }, + { + name: 'People & Body', + icon: '👋', + emojis: [ + { id: 'waving_hand', emoji: '👋', name: 'Waving Hand' }, + { id: 'raised_back_of_hand', emoji: '🤚', name: 'Raised Back of Hand' }, + { + id: 'hand_with_fingers_splayed', + emoji: '🖐️', + name: 'Hand with Fingers Splayed', + }, + { id: 'raised_hand', emoji: '✋', name: 'Raised Hand' }, + { id: 'vulcan_salute', emoji: '🖖', name: 'Vulcan Salute' }, + { id: 'ok_hand', emoji: '👌', name: 'OK Hand' }, + { id: 'pinched_fingers', emoji: '🤌', name: 'Pinched Fingers' }, + { id: 'pinching_hand', emoji: '🤏', name: 'Pinching Hand' }, + { id: 'victory_hand', emoji: '✌️', name: 'Victory Hand' }, + { id: 'crossed_fingers', emoji: '🤞', name: 'Crossed Fingers' }, + { id: 'love_you_gesture', emoji: '🤟', name: 'Love-You Gesture' }, + { id: 'sign_of_the_horns', emoji: '🤘', name: 'Sign of the Horns' }, + { id: 'call_me_hand', emoji: '🤙', name: 'Call Me Hand' }, + { + id: 'backhand_index_pointing_left', + emoji: '👈', + name: 'Backhand Index Pointing Left', + }, + { + id: 'backhand_index_pointing_right', + emoji: '👉', + name: 'Backhand Index Pointing Right', + }, + { id: 'thumbs_up', emoji: '👍', name: 'Thumbs Up' }, + ], + }, + { + name: 'Animals & Nature', + icon: '🐶', + emojis: [ + { id: 'dog_face', emoji: '🐶', name: 'Dog Face' }, + { id: 'cat_face', emoji: '🐱', name: 'Cat Face' }, + { id: 'mouse_face', emoji: '🐭', name: 'Mouse Face' }, + { id: 'hamster_face', emoji: '🐹', name: 'Hamster Face' }, + { id: 'rabbit_face', emoji: '🐰', name: 'Rabbit Face' }, + { id: 'fox_face', emoji: '🦊', name: 'Fox Face' }, + { id: 'bear_face', emoji: '🐻', name: 'Bear Face' }, + { id: 'panda_face', emoji: '🐼', name: 'Panda Face' }, + { id: 'koala_face', emoji: '🐨', name: 'Koala Face' }, + { id: 'tiger_face', emoji: '🐯', name: 'Tiger Face' }, + { id: 'lion_face', emoji: '🦁', name: 'Lion Face' }, + { id: 'cow_face', emoji: '🐮', name: 'Cow Face' }, + { id: 'pig_face', emoji: '🐷', name: 'Pig Face' }, + { id: 'frog_face', emoji: '🐸', name: 'Frog Face' }, + { id: 'monkey_face', emoji: '🐵', name: 'Monkey Face' }, + { id: 'chicken', emoji: '🐔', name: 'Chicken' }, + ], + }, + { + name: 'Food & Drink', + icon: '🍔', + emojis: [ + { id: 'red_apple', emoji: '🍎', name: 'Red Apple' }, + { id: 'pear', emoji: '🍐', name: 'Pear' }, + { id: 'orange', emoji: '🍊', name: 'Orange' }, + { id: 'lemon', emoji: '🍋', name: 'Lemon' }, + { id: 'banana', emoji: '🍌', name: 'Banana' }, + { id: 'watermelon', emoji: '🍉', name: 'Watermelon' }, + { id: 'grapes', emoji: '🍇', name: 'Grapes' }, + { id: 'strawberry', emoji: '🍓', name: 'Strawberry' }, + { id: 'blueberries', emoji: '🫐', name: 'Blueberries' }, + { id: 'melon', emoji: '🍈', name: 'Melon' }, + { id: 'cherries', emoji: '🍒', name: 'Cherries' }, + { id: 'peach', emoji: '🍑', name: 'Peach' }, + { id: 'mango', emoji: '🥭', name: 'Mango' }, + { id: 'pineapple', emoji: '🍍', name: 'Pineapple' }, + { id: 'coconut', emoji: '🥥', name: 'Coconut' }, + { id: 'kiwi_fruit', emoji: '🥝', name: 'Kiwi Fruit' }, + ], + }, + { + name: 'Travel & Places', + icon: '🚗', + emojis: [ + { id: 'car', emoji: '🚗', name: 'Car' }, + { id: 'taxi', emoji: '🚕', name: 'Taxi' }, + { + id: 'sport_utility_vehicle', + emoji: '🚙', + name: 'Sport Utility Vehicle', + }, + { id: 'bus', emoji: '🚌', name: 'Bus' }, + { id: 'trolleybus', emoji: '🚎', name: 'Trolleybus' }, + { id: 'racing_car', emoji: '🏎️', name: 'Racing Car' }, + { id: 'police_car', emoji: '🚓', name: 'Police Car' }, + { id: 'ambulance', emoji: '🚑', name: 'Ambulance' }, + { id: 'fire_engine', emoji: '🚒', name: 'Fire Engine' }, + { id: 'minibus', emoji: '🚐', name: 'Minibus' }, + { id: 'pickup_truck', emoji: '🛻', name: 'Pickup Truck' }, + { id: 'delivery_truck', emoji: '🚚', name: 'Delivery Truck' }, + { id: 'articulated_lorry', emoji: '🚛', name: 'Articulated Lorry' }, + { id: 'tractor', emoji: '🚜', name: 'Tractor' }, + { id: 'kick_scooter', emoji: '🛴', name: 'Kick Scooter' }, + { id: 'bicycle', emoji: '🚲', name: 'Bicycle' }, + ], + }, + { + name: 'Activities', + icon: '⚽', + emojis: [ + { id: 'soccer_ball', emoji: '⚽', name: 'Soccer Ball' }, + { id: 'basketball', emoji: '🏀', name: 'Basketball' }, + { id: 'american_football', emoji: '🏈', name: 'American Football' }, + { id: 'baseball', emoji: '⚾', name: 'Baseball' }, + { id: 'softball', emoji: '🥎', name: 'Softball' }, + { id: 'tennis', emoji: '🎾', name: 'Tennis' }, + { id: 'volleyball', emoji: '🏐', name: 'Volleyball' }, + { id: 'rugby_football', emoji: '🏉', name: 'Rugby Football' }, + { id: 'flying_disc', emoji: '🥏', name: 'Flying Disc' }, + { id: 'pool_8_ball', emoji: '🎱', name: 'Pool 8 Ball' }, + { id: 'yo_yo', emoji: '🪀', name: 'Yo-Yo' }, + { id: 'ping_pong', emoji: '🏓', name: 'Ping Pong' }, + { id: 'badminton', emoji: '🏸', name: 'Badminton' }, + { id: 'ice_hockey', emoji: '🏒', name: 'Ice Hockey' }, + { id: 'field_hockey', emoji: '🏑', name: 'Field Hockey' }, + { id: 'lacrosse', emoji: '🥍', name: 'Lacrosse' }, + ], + }, + { + name: 'Objects', + icon: '💡', + emojis: [ + { id: 'watch', emoji: '⌚', name: 'Watch' }, + { id: 'mobile_phone', emoji: '📱', name: 'Mobile Phone' }, + { + id: 'mobile_phone_with_arrow', + emoji: '📲', + name: 'Mobile Phone with Arrow', + }, + { id: 'laptop', emoji: '💻', name: 'Laptop' }, + { id: 'keyboard', emoji: '⌨️', name: 'Keyboard' }, + { id: 'desktop_computer', emoji: '🖥️', name: 'Desktop Computer' }, + { id: 'printer', emoji: '🖨️', name: 'Printer' }, + { id: 'computer_mouse', emoji: '🖱️', name: 'Computer Mouse' }, + { id: 'trackball', emoji: '🖲️', name: 'Trackball' }, + { id: 'joystick', emoji: '🕹️', name: 'Joystick' }, + { id: 'clamp', emoji: '🗜️', name: 'Clamp' }, + { id: 'computer_disk', emoji: '💽', name: 'Computer Disk' }, + { id: 'floppy_disk', emoji: '💾', name: 'Floppy Disk' }, + { id: 'optical_disk', emoji: '💿', name: 'Optical Disk' }, + { id: 'dvd', emoji: '📀', name: 'DVD' }, + { id: 'videocassette', emoji: '📼', name: 'Videocassette' }, + ], + }, + { + name: 'Symbols', + icon: '❤️', + emojis: [ + { id: 'red_heart', emoji: '❤️', name: 'Red Heart' }, + { id: 'orange_heart', emoji: '🧡', name: 'Orange Heart' }, + { id: 'yellow_heart', emoji: '💛', name: 'Yellow Heart' }, + { id: 'green_heart', emoji: '💚', name: 'Green Heart' }, + { id: 'blue_heart', emoji: '💙', name: 'Blue Heart' }, + { id: 'purple_heart', emoji: '💜', name: 'Purple Heart' }, + { id: 'black_heart', emoji: '🖤', name: 'Black Heart' }, + { id: 'white_heart', emoji: '🤍', name: 'White Heart' }, + { id: 'brown_heart', emoji: '🤎', name: 'Brown Heart' }, + { id: 'broken_heart', emoji: '💔', name: 'Broken Heart' }, + { id: 'heart_exclamation', emoji: '❣️', name: 'Heart Exclamation' }, + { id: 'two_hearts', emoji: '💕', name: 'Two Hearts' }, + { id: 'revolving_hearts', emoji: '💞', name: 'Revolving Hearts' }, + { id: 'beating_heart', emoji: '💓', name: 'Beating Heart' }, + { id: 'growing_heart', emoji: '💗', name: 'Growing Heart' }, + { id: 'sparkling_heart', emoji: '💖', name: 'Sparkling Heart' }, + ], + }, +]; + +/** + * + * @element igc-emoji-picker + * + */ +export default class IgcEmojiPickerComponent extends LitElement { + /** @private */ + public static readonly tagName = 'igc-emoji-picker'; + + public static override styles = styles; + + protected _rootClickController = addRootClickHandler(this); + + /* blazorSuppress */ + public static register() { + registerComponent( + IgcEmojiPickerComponent, + IgcPopoverComponent, + IgcIconButtonComponent, + IgcButtonComponent, + IgcInputComponent + ); + } + + /** + * Sets the open state of the component. + * @attr + */ + @property({ type: Boolean, reflect: true }) + public open = false; + + @state() + private _target?: HTMLElement; + + @query('slot[name="target"]', true) + protected trigger!: HTMLSlotElement; + + @state() + private _activeCategory = 0; + + @watch('open', { waitUntilFirstUpdate: true }) + protected openStateChange() { + this._rootClickController.update(); + + if (!this.open) { + this._target = undefined; + this._rootClickController.update({ target: undefined }); + } + } + + constructor() { + super(); + this._rootClickController.update({ hideCallback: this.handleClosing }); + registerIconFromText('target', emojiPickerIcon, 'material'); + } + + protected handleClosing() { + this.hide(); + } + + public async hide(): Promise { + if (!this.open) { + return false; + } + + this.open = false; + + return true; + } + + protected handleAnchorClick() { + this.open = !this.open; + } + + private handleCategoryChange(index: number) { + this._activeCategory = index; + } + + private handleEmojiClick(emojiId: string, emoji: string) { + this.dispatchEvent( + new CustomEvent('emoji-selected', { + detail: { emojiId, emoji }, + bubbles: true, + composed: true, + }) + ); + this.hide(); + } + + private getFilteredEmojis() { + return EMOJI_CATEGORIES[this._activeCategory].emojis; + } + + protected override render() { + const filteredEmojis = this.getFilteredEmojis(); + + return html` + +
e.stopPropagation()} + > +
+ ${EMOJI_CATEGORIES.map( + (category, index) => html` + this.handleCategoryChange(index)} + title=${category.name} + > + ${category.icon} + + ` + )} +
+ +
+ ${filteredEmojis.map( + (emoji) => html` + this.handleEmojiClick(emoji.id, emoji.emoji)}> + ${emoji.emoji} + + ` + )} + ${filteredEmojis.length === 0 + ? html`
+ No emojis found +
` + : ''} +
+
+
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-chat-igc-emoji-picker': IgcEmojiPickerComponent; + } +} diff --git a/src/components/chat/message-attachments.ts b/src/components/chat/message-attachments.ts new file mode 100644 index 000000000..58f7dbaef --- /dev/null +++ b/src/components/chat/message-attachments.ts @@ -0,0 +1,163 @@ +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, + imageIcon, + moreIcon, + previewIcon, +} 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, + IgcExpansionPanelComponent + ); + } + @property({ type: Array }) + attachments: IgcMessageAttachment[] = []; + + @property({ type: String }) + previewImage = ''; + + constructor() { + 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 { + 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 = ''; + } + + 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` +
+ ${this.attachments.map( + (attachment) => html` + + this.handleToggle(ev, attachment)} + @igcOpening=${(ev: CustomEvent) => + this.handleToggle(ev, attachment)} + > +
+
+ ${attachment.type === 'image' + ? html`` + : html``} + ${attachment.name} +
+
+ ${attachment.type === 'image' + ? html` this.openImagePreview(attachment.url)} + >` + : ''} + +
+
+ + ${attachment.type === 'image' + ? html` ${attachment.name}` + : ''} +
+ ` + )} +
+ + ${this.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..1e6665ba0 --- /dev/null +++ b/src/components/chat/message-reactions.ts @@ -0,0 +1,113 @@ +import { LitElement, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import IgcButtonComponent from '../button/button.js'; +import IgcIconButtonComponent from '../button/icon-button.js'; +import { registerComponent } from '../common/definitions/register.js'; +import IgcEmojiPickerComponent, { EMOJI_CATEGORIES } from './emoji-picker.js'; +import { styles } from './themes/reaction.base.css'; +import type { IgcMessageReaction } from './types.js'; + +/** + * + * @element igc-message-reactions + * + */ +export class IgcMessageReactionsComponent extends LitElement { + /** @private */ + public static readonly tagName = 'igc-message-reactions'; + + public static override styles = styles; + + /* blazorSuppress */ + public static register() { + registerComponent( + IgcMessageReactionsComponent, + IgcButtonComponent, + IgcIconButtonComponent, + IgcEmojiPickerComponent + ); + } + + @property({ type: Array }) + reactions: IgcMessageReaction[] = []; + + @property({ type: String }) + messageId = ''; + + @property({ type: String }) + currentUserId = ''; + + public override connectedCallback() { + super.connectedCallback(); + } + + public override disconnectedCallback() { + super.disconnectedCallback(); + } + + private addEmoji(e: CustomEvent) { + const { emojiId } = e.detail; + this.toggleReaction(emojiId); + } + + private hasUserReacted(reaction: IgcMessageReaction): boolean { + return reaction.users.includes(this.currentUserId); + } + + private toggleReaction(emojiId: string) { + this.dispatchEvent( + new CustomEvent('add-reaction', { + detail: { emojiId }, + bubbles: true, + composed: true, + }) + ); + } + + private getReactionById(reaction: IgcMessageReaction) { + for (const category of EMOJI_CATEGORIES) { + const foundReaction = category.emojis.find( + (emoji) => emoji.id === reaction.id + ); + if (foundReaction) { + return { + id: foundReaction.id, + emoji: foundReaction.emoji, + count: reaction.users.length, + users: reaction.users, + }; + } + } + return undefined; + } + + protected override render() { + return html` +
+ ${this.reactions?.map((_reaction) => { + const reaction = this.getReactionById(_reaction); + return reaction + ? html` + this.toggleReaction(reaction.id)} + > + ${reaction.emoji} + ${reaction.count} + + ` + : html``; + })} + + +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-message-reactions': IgcMessageReactionsComponent; + } +} diff --git a/src/components/chat/themes/chat.base.scss b/src/components/chat/themes/chat.base.scss new file mode 100644 index 000000000..51f4be0b0 --- /dev/null +++ b/src/components/chat/themes/chat.base.scss @@ -0,0 +1,77 @@ +@use 'styles/common/component'; +@use 'styles/utilities' as *; + +:host { + width: 100%; + height: 600px; + border-radius: 12px; + overflow: hidden; + box-shadow: 0 8px 24px #1f1f1f; + display: flex; + flex-direction: column; + } + + .chat-container { + display: flex; + flex-direction: column; + height: 100%; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px; + } + + .info { + display: flex; + align-items: center; + gap: 12px; + } + + .avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + } + + .avatar-container { + position: relative; + } + + .status-indicator { + position: absolute; + bottom: 0; + right: 0; + width: 12px; + height: 12px; + border-radius: 50%; + background-color: #30D158; + border: 2px solid white; + } + + .actions { + display: flex; + gap: 16px; + } + + .action-button { + background: none; + border: none; + color: #0A84FF; + cursor: pointer; + font-size: 1.2rem; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + transition: white 0.2s; + } + + .action-button:hover { + background-color: #E5E5EA; + } \ No newline at end of file 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..c74ddd036 --- /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: thin; + } + + .emoji-categories::-webkit-scrollbar { + display: none; + } + + .category-button { + background: transparent; + border: none; + font-size: 1.25rem; + width: 2rem; + height: 2rem; + border-radius: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + margin-right: 0.25rem; + padding: 0; + + igc-button::part(base){ + display: none; + } + } + + .category-button.active { + background-color: #cfcfcf; + } + + .category-button:hover { + background-color: #cfcfcf; + } + + .emoji-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.25rem; + padding: 0.5rem; + height: 150px; + overflow: auto; + } + + .emoji-button { + font-size: 1.5rem; + background: none; + border: none; + cursor: pointer; + border-radius: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + height: 2rem; + transition: transform 0.1s; + } + + .emoji-button:hover { + background-color: #cfcfcf; + transform: scale(1.1); + } + + .emoji-search { + padding: 0.5rem; + border-bottom: 1px solid #a0a0a0; + } + + .search-input { + width: 100%; + padding: 0.5rem; + border-radius: 0.25rem; + border: 1px solid #85878a; + font-size: 0.875rem; + } + + .search-input:focus { + outline: none; + border-color:#0A84FF; + } diff --git a/src/components/chat/themes/input.base.scss b/src/components/chat/themes/input.base.scss new file mode 100644 index 000000000..d7f45ea67 --- /dev/null +++ b/src/components/chat/themes/input.base.scss @@ -0,0 +1,127 @@ +@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: 0 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; +} + 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..69203bb99 --- /dev/null +++ b/src/components/chat/themes/message-attachments.base.scss @@ -0,0 +1,109 @@ +@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; + inset: 0; + background-color: #1f1f1f; + 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: #1f1f1f; + width: 2rem; + height: 2rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border: none; +} + +.close-overlay:hover { + background: #1f1f1f; +} + +.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/themes/message-list.base.scss b/src/components/chat/themes/message-list.base.scss new file mode 100644 index 000000000..c14dda941 --- /dev/null +++ b/src/components/chat/themes/message-list.base.scss @@ -0,0 +1,90 @@ +@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 fade-in { + 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..42a6c7956 --- /dev/null +++ b/src/components/chat/themes/message.base.scss @@ -0,0 +1,114 @@ +@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-all; + font-weight: 400; + line-height: 1.4; + position: relative; + transition: all 0.2s ease; + } + + .sent .bubble { + border-radius: 18px 18px 4px; + 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 #1f1f1f; + display: flex; + align-items: center; + z-index: 1; + } + + @keyframes fade-in { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: translateY(0); + } + } diff --git a/src/components/chat/themes/reaction.base.scss b/src/components/chat/themes/reaction.base.scss new file mode 100644 index 000000000..f2f5275dc --- /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: transparent; + color: white; +} + +.reaction-button:hover { + background-color: transparent; +} + +.reaction-button.active:hover { + background-color: transparent; + opacity: 0.9; +} + +.emoji { + margin-right: 0.25rem; +} + +.count { + font-size: 0.75rem; +} + +.add-reaction { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem; + background-color: transparent; + border-radius: 1rem; + font-size: 0.75rem; + cursor: pointer; + border: 1px dashed #7b7b7e; + color: #5b5b5c; + height: 1.5rem; + width: 1.5rem; +} + +.add-reaction:hover { + background-color: #b8b8b9; + color: #636366; +} + +.emoji-picker-container { + position: absolute; + margin-top: 0.25rem; + z-index: 10; +} \ No newline at end of file diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts new file mode 100644 index 000000000..00f04b070 --- /dev/null +++ b/src/components/chat/types.ts @@ -0,0 +1,52 @@ +export type IgcMessageStatusType = 'sent' | 'delivered' | 'read'; +export type IgcMessageAttachmentType = 'image' | 'file'; + +export interface IgcUser { + id: string; + name: string; + avatar: string; + status?: any; + //isTyping?: boolean; +} + +export interface IgcMessage { + id: string; + text: string; + sender: IgcUser; + timestamp: Date; + chatId?: string; + status?: IgcMessageStatusType; + attachments?: IgcMessageAttachment[]; + reactions?: IgcMessageReaction[]; +} + +export interface IgcMessageAttachment { + id: string; + type: IgcMessageAttachmentType; + url: string; + name: string; + size?: number; + thumbnail?: string; +} + +export interface IgcMessageReaction { + id: string; + users: string[]; +} + +export const emojiPickerIcon = + ''; +export const attachmentIcon = + ''; +export const sendButtonIcon = + ''; +export const closeIcon = + ''; +export const fileIcon = + ''; +export const imageIcon = + ''; +export const moreIcon = + ''; +export const previewIcon = + ''; 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..8e7edfd03 --- /dev/null +++ b/stories/chat.stories.ts @@ -0,0 +1,223 @@ +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: { + 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' } }, + }, + disableAutoScroll: { + type: 'boolean', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + disableReactions: { + type: 'boolean', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + disableAttachments: { + type: 'boolean', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + disableEmojis: { + type: 'boolean', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + headerText: { + type: 'string', + control: 'text', + table: { defaultValue: { summary: '' } }, + }, + }, + args: { + hideAvatar: false, + hideUserName: false, + hideMetaData: false, + disableAutoScroll: false, + disableReactions: false, + disableAttachments: false, + disableEmojis: false, + headerText: '', + }, +}; + +export default metadata; + +interface IgcChatArgs { + hideAvatar: boolean; + hideUserName: boolean; + hideMetaData: boolean; + disableAutoScroll: boolean; + disableReactions: boolean; + disableAttachments: boolean; + disableEmojis: boolean; + headerText: string; +} +type Story = StoryObj; + +// endregion + +const userJohn: any = { + id: 'user1', + name: 'John', + avatar: 'https://www.infragistics.com/angular-demos/assets/images/men/1.jpg', +}; + +const userRichard: any = { + id: 'user2', + name: 'Richard', + avatar: 'https://www.infragistics.com/angular-demos/assets/images/men/2.jpg', +}; + +const userSam: any = { + id: 'user3', + name: 'Sam', + avatar: 'https://www.infragistics.com/angular-demos/assets/images/men/3.jpg', +}; + +const messages: any[] = [ + { + id: '1', + text: 'Hey there! How are you doing today?', + sender: userRichard, + timestamp: new Date(2025, 4, 5), + status: 'read', + }, + { + id: '2', + text: "I'm doing well, thanks for asking! How about you?", + sender: userJohn, + timestamp: new Date(Date.now() - 3500000), + status: 'read', + }, + { + id: '3', + text: 'Pretty good! I was wondering if you wanted to grab coffee sometime this week?', + sender: userRichard, + timestamp: new Date(Date.now() - 3400000), + status: 'read', + reactions: [ + { + id: 'red_heart', + users: ['user3'], + }, + ], + }, + { + id: '4', + text: 'Hi guys! I just joined the chat.', + sender: userSam, + timestamp: new Date(Date.now() - 3300000), + status: 'read', + attachments: [ + { + id: 'men3_img', + type: 'image', + url: 'https://www.infragistics.com/angular-demos/assets/images/men/3.jpg', + name: 'men3.png', + }, + ], + }, +]; + +export const Basic: Story = { + render: (args) => html` + + + `, +}; + +function handleMessageEntered(e: CustomEvent) { + const newMessage = e.detail; + messages.push(newMessage); + const chatElements = document.querySelectorAll('igc-chat'); + chatElements.forEach((chat) => { + chat.messages = [...messages]; + }); +} + +function handleTypingChange(e: CustomEvent) { + const user = e.detail.user; + const isTyping = e.detail.isTyping; + const chatElements = document.querySelectorAll('igc-chat'); + chatElements.forEach((chat) => { + if (!isTyping) { + chat.typingUsers = chat.typingUsers.filter((u) => u !== user); + } else if (isTyping && !chat.typingUsers.includes(user)) { + chat.typingUsers = [...chat.typingUsers, user]; + } + }); +} + +export const SideBySide: Story = { + render: (args) => html` +
+ + + + +
+ `, +};