Files
catalog/ts_web/elements/sio-conversation-view.ts
2025-07-14 17:26:57 +00:00

877 lines
24 KiB
TypeScript

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';
// Make sure components are loaded
SioDropdownMenu;
// 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`
<sio-conversation-view style="width: 600px; height: 600px;"></sio-conversation-view>
`;
@property({ type: Object })
public conversation: IConversationData | null = null;
@state()
private messageText: string = '';
@state()
private isTyping: boolean = false;
@state()
private isDragging: boolean = false;
@state()
private uploadingFiles: Map<string, { file: File; progress: number }> = new Map();
@state()
private 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);
}
.input-wrapper {
display: flex;
gap: ${unsafeCSS(spacing["2"])};
align-items: flex-end;
}
.message-input {
flex: 1;
min-height: 42px;
max-height: 120px;
padding: ${unsafeCSS(spacing["2.5"])} ${unsafeCSS(spacing[3])};
background: ${bdTheme('secondary')};
border: 1px solid ${bdTheme('border')};
border-radius: ${unsafeCSS(radius.xl)};
font-size: 0.9375rem;
color: ${bdTheme('foreground')};
outline: none;
resize: none;
font-family: ${unsafeCSS(fontFamilies.sans)};
line-height: 1.5;
transition: ${unsafeCSS(transitions.all)};
}
.message-input::placeholder {
color: ${bdTheme('mutedForeground')};
font-size: 0.875rem;
}
.message-input:focus {
border-color: ${bdTheme('ring')};
background: ${bdTheme('background')};
box-shadow: 0 0 0 3px ${bdTheme('ring')}15;
}
.input-actions {
display: flex;
gap: ${unsafeCSS(spacing["1"])};
}
.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-radius: ${unsafeCSS(radius.md)};
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')};
}
.file-input {
display: none;
}
`,
];
public render(): TemplateResult {
if (!this.conversation) {
return html`
<div class="empty-state">
<sio-icon class="empty-icon" icon="message-square"></sio-icon>
<h3>Select a conversation</h3>
<p>Choose a conversation from the sidebar to start messaging</p>
</div>
`;
}
return html`
<div class="header">
<sio-button
class="back-button"
type="ghost"
size="sm"
@click=${this.handleBack}
>
<sio-icon icon="arrow-left" size="16"></sio-icon>
</sio-button>
<h3 class="header-title">${this.conversation.title}</h3>
<div class="header-actions">
<sio-button type="ghost" size="sm">
<sio-icon icon="phone" size="16"></sio-icon>
</sio-button>
<sio-dropdown-menu
.items=${this.dropdownMenuItems}
@item-selected=${this.handleDropdownAction}
>
<sio-button type="ghost" size="sm">
<sio-icon icon="more-vertical" size="16"></sio-icon>
</sio-button>
</sio-dropdown-menu>
</div>
</div>
<div class="messages-container" id="messages"
@dragover=${this.handleDragOver}
@dragleave=${this.handleDragLeave}
@drop=${this.handleDrop}
@dragenter=${this.handleDragOver}
>
<div class="drop-overlay ${this.isDragging ? 'active' : ''}"
@drop=${this.handleDrop}
@dragover=${(e: DragEvent) => e.preventDefault()}
>
<div class="drop-zone">
<sio-icon class="drop-icon" icon="upload-cloud"></sio-icon>
<div class="drop-text">Drop files here</div>
<div class="drop-hint">Images and documents up to 10MB</div>
</div>
</div>
${this.conversation.messages.map((msg, index) => html`
<div class="message ${msg.sender}" style="animation-delay: ${index * 50}ms">
<div class="message-content">
<div class="message-bubble">
${msg.text}
</div>
${msg.attachments && msg.attachments.length > 0 ? html`
<div class="message-attachments">
${msg.attachments.map(attachment =>
this.isImage(attachment.type) ? html`
<div class="attachment-image" @click=${() => this.openImage(attachment)}>
<img src="${attachment.url}" alt="${attachment.name}" />
</div>
` : html`
<div class="attachment-file" @click=${() => this.downloadFile(attachment)}>
<sio-icon icon="file" size="16"></sio-icon>
<div>
<div class="attachment-name">${attachment.name}</div>
<div class="attachment-size">${this.formatFileSize(attachment.size)}</div>
</div>
</div>
`
)}
</div>
` : ''}
<div class="message-time">${msg.time}</div>
</div>
</div>
`)}
${this.isTyping ? html`
<div class="message support">
<div class="typing-indicator">
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
</div>
</div>
` : ''}
</div>
<div class="input-container">
${this.pendingAttachments.length > 0 ? html`
<div class="pending-attachments">
${this.pendingAttachments.map(attachment => html`
<div class="pending-attachment">
<sio-icon icon="${this.getFileIcon(attachment.type)}" size="16"></sio-icon>
<div class="pending-attachment-info">
<div class="pending-attachment-name">${attachment.name}</div>
<div class="pending-attachment-size">${this.formatFileSize(attachment.size)}</div>
</div>
<div class="remove-attachment" @click=${() => this.removeAttachment(attachment.id)}>
<sio-icon icon="x" size="16"></sio-icon>
</div>
</div>
`)}
</div>
` : ''}
<div class="input-wrapper">
<textarea
class="message-input"
placeholder="Type a message..."
.value=${this.messageText}
@input=${this.handleInput}
@keydown=${this.handleKeyDown}
rows="1"
></textarea>
<div class="input-actions">
<input
type="file"
class="file-input"
id="fileInput"
multiple
accept="image/*,.pdf,.doc,.docx,.txt"
@change=${this.handleFileSelect}
/>
<sio-button type="ghost" size="sm" @click=${(e: Event) => {
e.preventDefault();
e.stopPropagation();
this.openFileSelector();
}}>
<sio-icon icon="paperclip" size="16"></sio-icon>
</sio-button>
<sio-button
type="primary"
size="sm"
?disabled=${!this.messageText.trim() && this.pendingAttachments.length === 0}
@click=${this.sendMessage}
>
<sio-icon icon="send" size="16"></sio-icon>
</sio-button>
</div>
</div>
</div>
`;
}
private handleBack() {
this.dispatchEvent(new CustomEvent('back', {
bubbles: true,
composed: true
}));
}
private handleInput(e: Event) {
const textarea = e.target as HTMLTextAreaElement;
this.messageText = textarea.value;
// Auto-resize textarea
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
}
private handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.sendMessage();
}
}
private sendMessage() {
if (!this.messageText.trim() && this.pendingAttachments.length === 0) return;
const message: IMessage = {
id: Date.now().toString(),
text: this.messageText.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 input and attachments
this.messageText = '';
this.pendingAttachments = [];
const textarea = this.shadowRoot?.querySelector('.message-input') as HTMLTextAreaElement;
if (textarea) {
textarea.style.height = 'auto';
}
// Simulate typing indicator (remove in production)
setTimeout(() => {
this.isTyping = true;
setTimeout(() => {
this.isTyping = false;
}, 2000);
}, 1000);
}
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<string> {
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) {
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);
}
}