import { DeesElement, property, html, customElement, type TemplateResult, cssManager, css, unsafeCSS, state, } from '@design.estate/dees-element'; // Import design tokens import { colors, bdTheme } from './00colors.js'; import { spacing, radius, shadows, transitions } from './00tokens.js'; import { fontFamilies, typography } from './00fonts.js'; import { SioDropdownMenu, type IDropdownMenuItem } from './sio-dropdown-menu.js'; import { SioMessageInput } from './sio-message-input.js'; // Make sure components are loaded SioDropdownMenu; SioMessageInput; // Types export interface IAttachment { id: string; name: string; size: number; type: string; url: string; thumbnailUrl?: string; } export interface IMessage { id: string; text: string; sender: 'user' | 'support'; time: string; status?: 'sending' | 'sent' | 'delivered' | 'read'; attachments?: IAttachment[]; } export interface IConversationData { id: string; title: string; messages: IMessage[]; } declare global { interface HTMLElementTagNameMap { 'sio-conversation-view': SioConversationView; } } @customElement('sio-conversation-view') export class SioConversationView extends DeesElement { public static demo = () => html` `; @property({ type: Object }) public accessor conversation: IConversationData | null = null; @state() private accessor isTyping: boolean = false; @state() private accessor isDragging: boolean = false; @state() private accessor uploadingFiles: Map = new Map(); @state() private accessor pendingAttachments: IAttachment[] = []; private dropdownMenuItems: IDropdownMenuItem[] = [ { id: 'mute', label: 'Mute notifications', icon: 'bell-off' }, { id: 'pin', label: 'Pin conversation', icon: 'pin' }, { id: 'search', label: 'Search in chat', icon: 'search' }, { id: 'divider1', label: '', divider: true }, { id: 'export', label: 'Export chat', icon: 'download' }, { id: 'archive', label: 'Archive conversation', icon: 'archive' }, { id: 'divider2', label: '', divider: true }, { id: 'clear', label: 'Clear history', icon: 'trash-2', destructive: true } ]; public static styles = [ cssManager.defaultStyles, css` :host { display: flex; flex-direction: column; height: 100%; background: ${bdTheme('background')}; font-family: ${unsafeCSS(fontFamilies.sans)}; } .header { padding: ${unsafeCSS(spacing["4"])}; border-bottom: 1px solid ${bdTheme('border')}; background: ${bdTheme('background')}; display: flex; align-items: center; gap: ${unsafeCSS(spacing["3"])}; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); position: sticky; top: 0; z-index: 10; overflow: visible; } .back-button { display: none; } @media (max-width: 600px) { .back-button { display: block; } } .header-title { font-size: 1.125rem; line-height: 1.5; font-weight: 600; margin: 0; color: ${bdTheme('foreground')}; flex: 1; } .header-actions { display: flex; gap: ${unsafeCSS(spacing["2"])}; position: relative; overflow: visible; } .messages-container { flex: 1; overflow-y: auto; padding: ${unsafeCSS(spacing["4"])}; display: flex; flex-direction: column; gap: ${unsafeCSS(spacing["3"])}; } .message { display: flex; align-items: flex-start; gap: ${unsafeCSS(spacing["3"])}; max-width: 70%; } .message.user { align-self: flex-end; flex-direction: row-reverse; } .message-bubble { padding: ${unsafeCSS(spacing["2.5"])} ${unsafeCSS(spacing["3.5"])}; border-radius: ${unsafeCSS(radius["2xl"])}; font-size: 0.9375rem; line-height: 1.6; position: relative; box-shadow: ${unsafeCSS(shadows.sm)}; max-width: 100%; word-wrap: break-word; } .message.support .message-bubble { background: ${bdTheme('secondary')}; color: ${bdTheme('secondaryForeground')}; border-bottom-left-radius: ${unsafeCSS(spacing["1"])}; border: 1px solid ${bdTheme('border')}; } .message.user .message-bubble { background: ${bdTheme('primary')}; color: ${bdTheme('primaryForeground')}; border-bottom-right-radius: ${unsafeCSS(spacing["1"])}; } .message-time { font-size: 0.75rem; line-height: 1.5; color: ${bdTheme('mutedForeground')}; margin-top: ${unsafeCSS(spacing["1"])}; } .message.user .message-time { text-align: right; } .typing-indicator { display: flex; align-items: center; gap: ${unsafeCSS(spacing["1"])}; padding: ${unsafeCSS(spacing["2"])} ${unsafeCSS(spacing["3"])}; background: ${bdTheme('muted')}; border-radius: ${unsafeCSS(radius.lg)}; width: fit-content; } .typing-dot { width: 8px; height: 8px; background: ${bdTheme('mutedForeground')}; border-radius: 50%; animation: typing 1.4s infinite; } .typing-dot:nth-child(2) { animation-delay: 0.2s; } .typing-dot:nth-child(3) { animation-delay: 0.4s; } @keyframes typing { 0%, 60%, 100% { opacity: 0.3; transform: scale(0.8); } 30% { opacity: 1; transform: scale(1); } } .input-container { padding: ${unsafeCSS(spacing["4"])}; border-top: 1px solid ${bdTheme('border')}; background: ${bdTheme('background')}; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); } .empty-state { flex: 1; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: ${unsafeCSS(spacing["4"])}; padding: ${unsafeCSS(spacing["8"])}; text-align: center; color: ${bdTheme('mutedForeground')}; animation: fadeIn 500ms ease-out; } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } .empty-icon { font-size: 64px; opacity: 0.5; } /* Scrollbar styling */ .messages-container::-webkit-scrollbar { width: 6px; } .messages-container::-webkit-scrollbar-track { background: transparent; } .messages-container::-webkit-scrollbar-thumb { background: ${bdTheme('border')}; border-radius: 3px; } .messages-container::-webkit-scrollbar-thumb:hover { background: ${bdTheme('mutedForeground')}; } /* File drop zone */ .messages-container { position: relative; } .drop-overlay { position: absolute; inset: 0; background: ${bdTheme('background')}95; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; z-index: 100; pointer-events: none; opacity: 0; transition: opacity 200ms ease; } .drop-overlay.active { opacity: 1; pointer-events: all; } .drop-zone { padding: ${unsafeCSS(spacing["8"])}; border: 2px dashed ${bdTheme('border')}; border-radius: ${unsafeCSS(radius.xl)}; background: ${bdTheme('card')}; text-align: center; transition: ${unsafeCSS(transitions.all)}; } .drop-overlay.active .drop-zone { border-color: ${bdTheme('primary')}; background: ${bdTheme('accent')}; transform: scale(1.02); } .drop-icon { font-size: 48px; color: ${bdTheme('primary')}; margin-bottom: ${unsafeCSS(spacing["4"])}; } .drop-text { font-size: 1.125rem; font-weight: 500; color: ${bdTheme('foreground')}; margin-bottom: ${unsafeCSS(spacing["2"])}; } .drop-hint { font-size: 0.875rem; color: ${bdTheme('mutedForeground')}; } /* File attachments */ .message-attachments { margin-top: ${unsafeCSS(spacing["2"])}; display: flex; flex-wrap: wrap; gap: ${unsafeCSS(spacing["2"])}; } .attachment-image { max-width: 200px; max-height: 200px; border-radius: ${unsafeCSS(radius.lg)}; overflow: hidden; cursor: pointer; transition: ${unsafeCSS(transitions.all)}; box-shadow: ${unsafeCSS(shadows.sm)}; } .attachment-image:hover { transform: scale(1.02); box-shadow: ${unsafeCSS(shadows.md)}; } .attachment-image img { width: 100%; height: 100%; object-fit: cover; } .attachment-file { display: flex; align-items: center; gap: ${unsafeCSS(spacing["2"])}; padding: ${unsafeCSS(spacing["2"])} ${unsafeCSS(spacing["3"])}; background: ${bdTheme('secondary')}; border: 1px solid ${bdTheme('border')}; border-radius: ${unsafeCSS(radius.md)}; font-size: 0.875rem; cursor: pointer; transition: ${unsafeCSS(transitions.all)}; } .attachment-file:hover { background: ${bdTheme('accent')}; } .attachment-name { font-weight: 500; color: ${bdTheme('foreground')}; } .attachment-size { color: ${bdTheme('mutedForeground')}; font-size: 0.75rem; } /* Pending attachments */ .pending-attachments { padding: ${unsafeCSS(spacing["2"])} ${unsafeCSS(spacing["3"])}; background: ${bdTheme('secondary')}; border: 1px solid ${bdTheme('border')}; border-radius: ${unsafeCSS(radius.lg)}; margin-bottom: ${unsafeCSS(spacing["2"])}; } .pending-attachment { display: flex; align-items: center; gap: ${unsafeCSS(spacing["2"])}; padding: ${unsafeCSS(spacing["1"])} 0; } .pending-attachment-info { flex: 1; min-width: 0; } .pending-attachment-name { font-size: 0.875rem; font-weight: 500; color: ${bdTheme('foreground')}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .pending-attachment-size { font-size: 0.75rem; color: ${bdTheme('mutedForeground')}; } .remove-attachment { padding: ${unsafeCSS(spacing["1"])}; cursor: pointer; color: ${bdTheme('mutedForeground')}; transition: ${unsafeCSS(transitions.all)}; } .remove-attachment:hover { color: ${bdTheme('destructive')}; } `, ]; public render(): TemplateResult { if (!this.conversation) { return html`

Select a conversation

Choose a conversation from the sidebar to start messaging

`; } return html`

${this.conversation.title}

e.preventDefault()} >
Drop files here
Images and documents up to 10MB
${this.conversation.messages.map((msg, index) => html`
${msg.text}
${msg.attachments && msg.attachments.length > 0 ? html`
${msg.attachments.map(attachment => this.isImage(attachment.type) ? html`
this.openImage(attachment)}> ${attachment.name}
` : attachment.type?.includes('pdf') || attachment.name?.toLowerCase().endsWith('.pdf') ? html`
this.openImage(attachment)}>
${attachment.name}
${this.formatFileSize(attachment.size)}
` : html`
this.downloadFile(attachment)}>
${attachment.name}
${this.formatFileSize(attachment.size)}
` )}
` : ''}
${msg.time}
`)} ${this.isTyping ? html`
` : ''}
${this.pendingAttachments.length > 0 ? html`
${this.pendingAttachments.map(attachment => html`
${attachment.name}
${this.formatFileSize(attachment.size)}
this.removeAttachment(attachment.id)}>
`)}
` : ''}
`; } private handleBack() { this.dispatchEvent(new CustomEvent('back', { bubbles: true, composed: true })); } private handleMessageSend(event: CustomEvent) { const { text, attachments } = event.detail; if (!text.trim() && attachments.length === 0) return; const message: IMessage = { id: Date.now().toString(), text: text.trim(), sender: 'user', time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), status: 'sending', attachments: [...this.pendingAttachments] }; // Dispatch event for parent to handle this.dispatchEvent(new CustomEvent('send-message', { detail: { message }, bubbles: true, composed: true })); // Clear pending attachments this.pendingAttachments = []; // Simulate typing indicator (remove in production) setTimeout(() => { this.isTyping = true; setTimeout(() => { this.isTyping = false; }, 2000); }, 1000); } private handleFilesSelected(event: CustomEvent) { const { files } = event.detail; // Handle files if needed // For now, we're handling attachments separately in the parent component } public updated() { // Scroll to bottom when new messages arrive const container = this.shadowRoot?.querySelector('#messages'); if (container) { container.scrollTop = container.scrollHeight; } } // File handling methods private handleDragOver(e: DragEvent) { e.preventDefault(); e.stopPropagation(); this.isDragging = true; } private handleDragLeave(e: DragEvent) { e.preventDefault(); e.stopPropagation(); // Check if we're actually leaving the messages container const relatedTarget = e.relatedTarget as Node; const container = e.currentTarget as HTMLElement; if (!container.contains(relatedTarget)) { this.isDragging = false; } } private handleDrop(e: DragEvent) { e.preventDefault(); e.stopPropagation(); this.isDragging = false; const files = Array.from(e.dataTransfer?.files || []); if (files.length > 0) { this.processFiles(files); } } private openFileSelector() { const fileInput = this.shadowRoot?.querySelector('#fileInput') as HTMLInputElement; if (fileInput) { fileInput.click(); } } private handleFileSelect(e: Event) { const input = e.target as HTMLInputElement; const files = Array.from(input.files || []); this.processFiles(files); input.value = ''; // Clear input for re-selection } private async processFiles(files: File[]) { const maxSize = 10 * 1024 * 1024; // 10MB const validFiles = files.filter(file => { if (file.size > maxSize) { console.warn(`File ${file.name} exceeds 10MB limit`); return false; } return true; }); for (const file of validFiles) { const id = `${Date.now()}-${Math.random()}`; const url = await this.fileToDataUrl(file); const attachment: IAttachment = { id, name: file.name, size: file.size, type: file.type, url, thumbnailUrl: this.isImage(file.type) ? url : undefined }; this.pendingAttachments = [...this.pendingAttachments, attachment]; } } private fileToDataUrl(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsDataURL(file); }); } private removeAttachment(id: string) { this.pendingAttachments = this.pendingAttachments.filter(a => a.id !== id); } private isImage(type: string): boolean { return type.startsWith('image/'); } private formatFileSize(bytes: number): string { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } private getFileIcon(type: string): string { if (this.isImage(type)) return 'image'; if (type.includes('pdf')) return 'file-text'; if (type.includes('doc')) return 'file-text'; if (type.includes('sheet') || type.includes('excel')) return 'table'; return 'file'; } private openImage(attachment: IAttachment) { // Check if it's actually a PDF if (attachment.type?.includes('pdf') || attachment.name?.toLowerCase().endsWith('.pdf')) { this.dispatchEvent(new CustomEvent('open-file', { detail: { attachment }, bubbles: true, composed: true })); } else { this.dispatchEvent(new CustomEvent('open-image', { detail: { attachment }, bubbles: true, composed: true })); } } private downloadFile(attachment: IAttachment) { const a = document.createElement('a'); a.href = attachment.url; a.download = attachment.name; document.body.appendChild(a); a.click(); document.body.removeChild(a); } private handleDropdownAction(event: CustomEvent) { const { item } = event.detail as { item: IDropdownMenuItem }; // Dispatch event for parent to handle these actions this.dispatchEvent(new CustomEvent('conversation-action', { detail: { action: item.id, conversationId: this.conversation?.id }, bubbles: true, composed: true })); // Log action for demo purposes console.log('Conversation action:', item.id, item.label); // Handle some actions locally for demo switch (item.id) { case 'search': // Could open a search overlay console.log('Opening search...'); break; case 'export': // Export conversation as JSON/text this.exportConversation(); break; } } private exportConversation() { if (!this.conversation) return; const exportData = { conversation: this.conversation.title, exportDate: new Date().toISOString(), messages: this.conversation.messages }; const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${this.conversation.title.replace(/\s+/g, '-')}-${Date.now()}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } }