Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0b0c82e
feat(chat): add initial chat implementation
teodosiah May 2, 2025
f402883
feat(chat): fix lint errors
teodosiah May 2, 2025
6db7573
feat(chat): fix lint errors in scss files
teodosiah May 5, 2025
af7f561
Merge branch 'master' into thristodorova/add-chat-component
teodosiah May 5, 2025
005d57e
feat(chat): add basic emoji picker component
teodosiah May 5, 2025
079b654
feat(chat): add basic message reaction implementation
teodosiah May 7, 2025
82fda4d
feat(chat): add third user to the story
teodosiah May 7, 2025
8243452
feat(chat): rename bool props and default values to false
teodosiah May 8, 2025
3a7a125
feat(chat): expose props for hiding avatar, username, meta data
teodosiah May 8, 2025
9fde895
feat(chat): change scrollBottom prop default val & name
teodosiah May 9, 2025
bebfb9a
feat(chat): refactor message reaction logic
teodosiah May 9, 2025
daba416
feat(chat): emit event for typing change and add side by side chats s…
igdmdimitrov May 9, 2025
3c9f1e5
chore(*): update headers
igdmdimitrov May 9, 2025
e175353
feat(chat): fix typing and scrolling in message list
teodosiah May 13, 2025
c5f00c5
feat(chat): expose header slots & remove header component
teodosiah May 14, 2025
6c27d41
feat(chat): display attachments in expansion panel
teodosiah May 14, 2025
590ab0b
feat(chat): expose attachment header click event
teodosiah May 15, 2025
e5e62a3
Merge branch 'master' into thristodorova/add-chat-component
teodosiah May 15, 2025
88b7b91
feat(chat): fix lint error
teodosiah May 15, 2025
ac34a40
feat(chat): fix lint error in message scss file
teodosiah May 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
222 changes: 222 additions & 0 deletions src/components/chat/chat-input.ts
Original file line number Diff line number Diff line change
@@ -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`
<div class="input-container">
${this.disableAttachments
? ''
: html`
<igc-file-input multiple @igcChange=${this.handleFileUpload}>
<igc-icon
slot="file-selector-text"
name="attachment"
collection="material"
></igc-icon>
</igc-file-input>
`}

<div class="input-wrapper">
<igc-textarea
class="text-input"
placeholder="Type a message..."
rows="1"
.value=${this.inputValue}
@input=${this.handleInput}
@keydown=${this.handleKeyDown}
></igc-textarea>
</div>

<div class="buttons-container">
${this.disableEmojis
? ''
: html`
<igc-emoji-picker
@emoji-selected=${this.addEmoji}
></igc-emoji-picker>
`}

<igc-icon-button
name="send-message"
collection="material"
variant="contained"
class="small"
?disabled=${!this.inputValue.trim() &&
this.attachments.length === 0}
@click=${this.sendMessage}
></igc-icon-button>
</div>
</div>
<div>
${this.attachments?.map(
(attachment, index) => html`
<div class="attachment-wrapper">
<igc-chip
removable
@igcRemove=${() => this.removeAttachment(index)}
>
<span class="attachment-name">${attachment.name}</span>
</igc-chip>
</div>
`
)}
</div>
`;
}
}

declare global {
interface HTMLElementTagNameMap {
'igc-chat-input': IgcChatInputComponent;
}
}
153 changes: 153 additions & 0 deletions src/components/chat/chat-message-list.ts
Original file line number Diff line number Diff line change
@@ -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`
<div class="message-list">
${repeat(
groupedMessages,
(group) => group.date,
(group) => html`
<div class="day-separator">${group.date}</div>
${repeat(
group.messages,
(message) => message.id,
(message) => html`
<igc-chat-message
.message=${message}
.user=${this.user}
.disableReactions=${this.disableReactions}
.hideAvatar=${this.hideAvatar}
.hideUserName=${this.hideUserName}
.hideMetaData=${this.hideMetaData}
></igc-chat-message>
`
)}
`
)}
${this.typingUsers.filter((u) => u !== this.user).length > 0
? html`
<div class="typing-indicator">
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
</div>
`
: ''}
</div>
`;
}
}

declare global {
interface HTMLElementTagNameMap {
'igc-chat-message-list': IgcChatMessageListComponent;
}
}
Loading