rspace-online/modules/rchats/components/folk-chat-app.ts

1094 lines
31 KiB
TypeScript

/**
* <folk-chat-app> — local-first chat with channels, threads, DMs, reactions.
*
* Single web component per space, loaded on /{space}/rchats.
* Subscribes to Automerge CRDT docs for real-time sync.
*/
import {
chatChannelSchema, chatsDirectorySchema,
chatsDirectoryDocId, chatChannelDocId, dmChannelDocId,
type ChatChannelDoc, type ChatsDirectoryDoc, type ChatMessage,
type ChannelInfo, type Transclusion,
} from '../schemas';
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
// ── Helpers ──
function timeAgo(ts: number): string {
const diff = Date.now() - ts;
if (diff < 60_000) return 'just now';
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
return new Date(ts).toLocaleDateString();
}
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/** Simple markdown: **bold**, *italic*, `code`, [text](url) */
function renderMarkdown(text: string): string {
let html = escapeHtml(text);
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
html = html.replace(/`(.+?)`/g, '<code>$1</code>');
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
html = html.replace(/@([a-zA-Z0-9_.-]+)/g, '<span class="mention">@$1</span>');
return html;
}
function getAuthToken(): string {
try {
const stored = localStorage.getItem('rspace_auth');
if (stored) {
const parsed = JSON.parse(stored);
return parsed.token || '';
}
} catch {}
return '';
}
function getMyDid(): string {
try {
const stored = localStorage.getItem('rspace_auth');
if (stored) {
const parsed = JSON.parse(stored);
return parsed.did || parsed.userId || '';
}
} catch {}
return '';
}
function getMyName(): string {
try {
const stored = localStorage.getItem('rspace_auth');
if (stored) {
const parsed = JSON.parse(stored);
return parsed.displayName || parsed.username || 'Anonymous';
}
} catch {}
return 'Anonymous';
}
// ── Component ──
class FolkChatApp extends HTMLElement {
private shadow: ShadowRoot;
private space = '';
private _offlineUnsubs: (() => void)[] = [];
private _stopPresence: (() => void) | null = null;
// State
private channels: ChannelInfo[] = [];
private activeChannelId = '';
private messages: ChatMessage[] = [];
private threadRootId: string | null = null;
private threadMessages: ChatMessage[] = [];
private dmChannels: ChannelInfo[] = [];
private isDmView = false;
private activeDmDocId = '';
private loading = false;
private composerText = '';
private threadComposerText = '';
private showCreateChannel = false;
private showEmojiPicker: string | null = null; // msgId
private typingUsers: string[] = [];
private _typingTimeout: ReturnType<typeof setTimeout> | null = null;
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.space = this.getAttribute('space') || 'demo';
// Parse URL params for deep links
const params = new URLSearchParams(window.location.search);
const channelParam = params.get('channel');
const dmParam = params.get('dm');
const threadParam = params.get('thread');
if (dmParam) {
this.isDmView = true;
}
this.render();
this.loadChannels().then(() => {
if (channelParam && this.channels.find(c => c.id === channelParam)) {
this.selectChannel(channelParam);
} else if (this.channels.length > 0) {
this.selectChannel(this.channels[0].id);
}
if (threadParam) {
this.openThread(threadParam);
}
});
this.loadDmChannels();
this._stopPresence = startPresenceHeartbeat(() => ({
module: 'rchats',
context: this.activeChannelId || 'channels',
}));
}
disconnectedCallback() {
for (const unsub of this._offlineUnsubs) unsub();
this._offlineUnsubs = [];
this._stopPresence?.();
}
// ── Data Loading ──
private async loadChannels() {
try {
const token = getAuthToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`/${this.space}/rchats/api/channels`, { headers });
const data = await res.json();
this.channels = (data.channels || []).filter((c: ChannelInfo) => !c.isDm);
this.render();
} catch { this.channels = []; }
}
private async loadDmChannels() {
try {
const token = getAuthToken();
if (!token) return;
const res = await fetch(`/${this.space}/rchats/api/dm`, {
headers: { 'Authorization': `Bearer ${token}` },
});
const data = await res.json();
this.dmChannels = data.channels || [];
this.render();
} catch {}
}
private async loadMessages(channelId: string) {
this.loading = true;
this.render();
try {
const token = getAuthToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`/${this.space}/rchats/api/channels/${channelId}/messages`, { headers });
const data = await res.json();
this.messages = data.messages || [];
} catch { this.messages = []; }
this.loading = false;
this.render();
this.scrollToBottom();
}
private async loadThreadMessages(threadId: string) {
try {
const token = getAuthToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`/${this.space}/rchats/api/channels/${this.activeChannelId}/threads/${threadId}`, { headers });
const data = await res.json();
this.threadMessages = data.replies || [];
this.render();
} catch {}
}
// ── Actions ──
private selectChannel(channelId: string) {
this.activeChannelId = channelId;
this.isDmView = false;
this.threadRootId = null;
this.threadMessages = [];
this.loadMessages(channelId);
// Subscribe to offline runtime for real-time sync
this.subscribeToChannel(channelId);
}
private async subscribeToChannel(channelId: string) {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
try {
const docId = `${this.space}:chats:channel:${channelId}`;
await runtime.subscribe(docId, chatChannelSchema);
const unsub = runtime.onChange(docId, (doc: ChatChannelDoc) => {
this.messages = Object.values(doc.messages || {})
.filter(m => !m.threadId)
.sort((a, b) => a.createdAt - b.createdAt);
this.render();
this.scrollToBottom();
});
this._offlineUnsubs.push(unsub);
} catch {}
}
private openThread(msgId: string) {
this.threadRootId = msgId;
this.threadComposerText = '';
this.loadThreadMessages(msgId);
}
private closeThread() {
this.threadRootId = null;
this.threadMessages = [];
this.threadComposerText = '';
this.render();
}
private async sendMessage() {
const content = this.composerText.trim();
if (!content) return;
const token = getAuthToken();
if (!token) return;
this.composerText = '';
this.render();
try {
await fetch(`/${this.space}/rchats/api/channels/${this.activeChannelId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ content }),
});
await this.loadMessages(this.activeChannelId);
} catch {}
}
private async sendThreadReply() {
const content = this.threadComposerText.trim();
if (!content || !this.threadRootId) return;
const token = getAuthToken();
if (!token) return;
this.threadComposerText = '';
this.render();
try {
await fetch(`/${this.space}/rchats/api/channels/${this.activeChannelId}/messages/${this.threadRootId}/thread`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ content }),
});
await this.loadThreadMessages(this.threadRootId);
} catch {}
}
private async toggleReaction(msgId: string, emoji: string) {
const token = getAuthToken();
if (!token) return;
try {
await fetch(`/${this.space}/rchats/api/channels/${this.activeChannelId}/messages/${msgId}/react`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ emoji }),
});
await this.loadMessages(this.activeChannelId);
} catch {}
}
private async createChannel() {
const nameInput = this.shadow.querySelector('#new-channel-name') as HTMLInputElement;
const descInput = this.shadow.querySelector('#new-channel-desc') as HTMLInputElement;
if (!nameInput?.value.trim()) return;
const token = getAuthToken();
if (!token) return;
try {
const res = await fetch(`/${this.space}/rchats/api/channels`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ name: nameInput.value.trim(), description: descInput?.value || '' }),
});
if (res.ok) {
const channel = await res.json();
this.showCreateChannel = false;
await this.loadChannels();
this.selectChannel(channel.id);
}
} catch {}
}
private async joinChannel(channelId: string) {
const token = getAuthToken();
if (!token) return;
try {
await fetch(`/${this.space}/rchats/api/channels/${channelId}/join`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
});
} catch {}
}
private async deleteMessage(msgId: string) {
const token = getAuthToken();
if (!token) return;
try {
await fetch(`/${this.space}/rchats/api/channels/${this.activeChannelId}/messages/${msgId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` },
});
await this.loadMessages(this.activeChannelId);
} catch {}
}
private async togglePin(msgId: string) {
const token = getAuthToken();
if (!token) return;
try {
await fetch(`/${this.space}/rchats/api/channels/${this.activeChannelId}/pins`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ messageId: msgId }),
});
await this.loadMessages(this.activeChannelId);
} catch {}
}
// ── Scroll ──
private scrollToBottom() {
requestAnimationFrame(() => {
const feed = this.shadow.querySelector('.message-feed');
if (feed) feed.scrollTop = feed.scrollHeight;
});
}
// ── Rendering ──
private render() {
const myDid = getMyDid();
const hasAuth = !!getAuthToken();
this.shadow.innerHTML = `
<style>${STYLES}</style>
<div class="chat-layout ${this.threadRootId ? 'has-thread' : ''}">
${this.renderSidebar()}
${this.renderMainArea(myDid, hasAuth)}
${this.threadRootId ? this.renderThreadPanel(myDid, hasAuth) : ''}
</div>`;
this.attachEventListeners();
}
private renderSidebar(): string {
return `
<aside class="sidebar">
<div class="sidebar-header">
<h2>Channels</h2>
<button class="btn-icon" id="btn-create-channel" title="Create channel">+</button>
</div>
${this.showCreateChannel ? `
<div class="create-channel-form">
<input type="text" id="new-channel-name" placeholder="Channel name" maxlength="40">
<input type="text" id="new-channel-desc" placeholder="Description (optional)" maxlength="120">
<div class="form-actions">
<button class="btn-sm btn-primary" id="btn-confirm-create">Create</button>
<button class="btn-sm btn-ghost" id="btn-cancel-create">Cancel</button>
</div>
</div>` : ''}
<ul class="channel-list">
${this.channels.map(ch => `
<li class="channel-item ${ch.id === this.activeChannelId && !this.isDmView ? 'active' : ''}"
data-channel="${ch.id}">
<span class="channel-hash">#</span>
<span class="channel-name">${escapeHtml(ch.name)}</span>
${ch.isPrivate ? '<span class="channel-lock">🔒</span>' : ''}
</li>`).join('')}
</ul>
<div class="sidebar-section">
<h3>Direct Messages</h3>
</div>
<ul class="channel-list dm-list">
${this.dmChannels.map(ch => `
<li class="channel-item dm-item ${ch.id === this.activeChannelId && this.isDmView ? 'active' : ''}"
data-dm="${ch.id}">
<span class="dm-avatar">👤</span>
<span class="channel-name">${escapeHtml(ch.name || 'DM')}</span>
</li>`).join('')}
</ul>
</aside>`;
}
private renderMainArea(myDid: string, hasAuth: boolean): string {
const activeChannel = this.channels.find(c => c.id === this.activeChannelId);
const channelName = activeChannel?.name || this.activeChannelId || 'Select a channel';
return `
<main class="main-area">
<div class="channel-header">
<h2><span class="channel-hash">#</span> ${escapeHtml(channelName)}</h2>
${activeChannel?.description ? `<span class="channel-desc">${escapeHtml(activeChannel.description)}</span>` : ''}
</div>
<div class="message-feed">
${this.loading ? '<div class="loading">Loading messages...</div>' : ''}
${!this.loading && this.messages.length === 0 ? `
<div class="empty-state">
<div class="empty-icon">💬</div>
<p>No messages yet. Start the conversation!</p>
</div>` : ''}
${this.messages.map(m => this.renderMessage(m, myDid)).join('')}
</div>
${this.typingUsers.length > 0 ? `
<div class="typing-indicator">${this.typingUsers.join(', ')} typing...</div>` : ''}
${hasAuth ? `
<div class="composer">
<textarea class="composer-input" id="composer-main"
placeholder="Message #${escapeHtml(channelName)}..."
rows="1">${escapeHtml(this.composerText)}</textarea>
<button class="btn-send" id="btn-send" title="Send (Enter)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 2L11 13M22 2L15 22L11 13M22 2L2 9L11 13"/>
</svg>
</button>
</div>` : `
<div class="composer auth-prompt">
<p>Sign in to send messages</p>
</div>`}
</main>`;
}
private renderMessage(msg: ChatMessage, myDid: string): string {
const isOwn = msg.authorId === myDid;
const reactions = msg.reactions || {};
const reactionKeys = Object.keys(reactions);
const threadMeta = this.messages.find(m => m.id === msg.id); // for thread count
const hasThread = this.messages.some(m => m.replyTo === msg.id) ||
(msg as any)._threadReplyCount > 0;
return `
<div class="message ${isOwn ? 'own' : ''}" data-msg-id="${msg.id}">
<div class="msg-avatar">${escapeHtml(msg.authorName.charAt(0).toUpperCase())}</div>
<div class="msg-body">
<div class="msg-header">
<span class="msg-author">${escapeHtml(msg.authorName)}</span>
<span class="msg-time">${timeAgo(msg.createdAt)}</span>
${msg.editedAt ? '<span class="msg-edited">(edited)</span>' : ''}
<div class="msg-actions">
<button class="msg-action" data-action="react" data-msg="${msg.id}" title="React">😀</button>
<button class="msg-action" data-action="thread" data-msg="${msg.id}" title="Reply in thread">💬</button>
<button class="msg-action" data-action="pin" data-msg="${msg.id}" title="Pin">📌</button>
${isOwn ? `<button class="msg-action" data-action="delete" data-msg="${msg.id}" title="Delete">🗑</button>` : ''}
</div>
</div>
<div class="msg-content">${renderMarkdown(msg.content)}</div>
${this.renderTransclusions(msg.transclusions || [])}
${reactionKeys.length > 0 ? `
<div class="msg-reactions">
${reactionKeys.map(emoji => {
const dids = reactions[emoji] || [];
const active = dids.includes(myDid);
return `<button class="reaction-chip ${active ? 'active' : ''}"
data-action="react-toggle" data-msg="${msg.id}" data-emoji="${emoji}">
${emoji} ${dids.length}
</button>`;
}).join('')}
</div>` : ''}
</div>
</div>`;
}
private renderTransclusions(transclusions: Transclusion[]): string {
if (!transclusions || transclusions.length === 0) return '';
return `<div class="transclusions">${transclusions.map(t => {
const title = t.snapshot?.title || `${t.module}/${t.objectId || t.docId}`;
const summary = t.snapshot?.summary || '';
if (t.display === 'link') {
return `<a class="transclusion-link" href="/${this.space}/${t.module}" title="${escapeHtml(title)}">${escapeHtml(title)}</a>`;
}
return `
<div class="transclusion-card">
<div class="tc-module">${escapeHtml(t.module)}</div>
<div class="tc-title">${escapeHtml(title)}</div>
${summary ? `<div class="tc-summary">${escapeHtml(summary)}</div>` : ''}
</div>`;
}).join('')}</div>`;
}
private renderThreadPanel(myDid: string, hasAuth: boolean): string {
const rootMsg = this.messages.find(m => m.id === this.threadRootId);
return `
<aside class="thread-panel">
<div class="thread-header">
<h3>Thread</h3>
<button class="btn-icon" id="btn-close-thread" title="Close">&times;</button>
</div>
<div class="thread-feed">
${rootMsg ? this.renderMessage(rootMsg, myDid) : ''}
<div class="thread-divider">${this.threadMessages.length} ${this.threadMessages.length === 1 ? 'reply' : 'replies'}</div>
${this.threadMessages.map(m => this.renderMessage(m, myDid)).join('')}
</div>
${hasAuth ? `
<div class="composer thread-composer">
<textarea class="composer-input" id="composer-thread"
placeholder="Reply in thread..."
rows="1">${escapeHtml(this.threadComposerText)}</textarea>
<button class="btn-send" id="btn-send-thread" title="Send reply">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 2L11 13M22 2L15 22L11 13M22 2L2 9L11 13"/>
</svg>
</button>
</div>` : ''}
</aside>`;
}
// ── Event Listeners ──
private attachEventListeners() {
// Channel selection
this.shadow.querySelectorAll('.channel-item[data-channel]').forEach(el => {
el.addEventListener('click', () => {
const channelId = (el as HTMLElement).dataset.channel!;
this.selectChannel(channelId);
});
});
// Create channel
this.shadow.getElementById('btn-create-channel')?.addEventListener('click', () => {
this.showCreateChannel = !this.showCreateChannel;
this.render();
if (this.showCreateChannel) {
setTimeout(() => this.shadow.getElementById('new-channel-name')?.focus(), 50);
}
});
this.shadow.getElementById('btn-confirm-create')?.addEventListener('click', () => this.createChannel());
this.shadow.getElementById('btn-cancel-create')?.addEventListener('click', () => {
this.showCreateChannel = false;
this.render();
});
// Main composer
const mainComposer = this.shadow.getElementById('composer-main') as HTMLTextAreaElement;
if (mainComposer) {
mainComposer.addEventListener('input', () => {
this.composerText = mainComposer.value;
this.autoResize(mainComposer);
});
mainComposer.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.sendMessage();
}
});
}
this.shadow.getElementById('btn-send')?.addEventListener('click', () => this.sendMessage());
// Thread composer
const threadComposer = this.shadow.getElementById('composer-thread') as HTMLTextAreaElement;
if (threadComposer) {
threadComposer.addEventListener('input', () => {
this.threadComposerText = threadComposer.value;
this.autoResize(threadComposer);
});
threadComposer.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.sendThreadReply();
}
});
}
this.shadow.getElementById('btn-send-thread')?.addEventListener('click', () => this.sendThreadReply());
// Close thread
this.shadow.getElementById('btn-close-thread')?.addEventListener('click', () => this.closeThread());
// Message actions
this.shadow.querySelectorAll('[data-action]').forEach(el => {
el.addEventListener('click', (e) => {
const action = (el as HTMLElement).dataset.action!;
const msgId = (el as HTMLElement).dataset.msg!;
const emoji = (el as HTMLElement).dataset.emoji;
switch (action) {
case 'thread':
this.openThread(msgId);
break;
case 'react':
this.showQuickReactions(msgId, el as HTMLElement);
break;
case 'react-toggle':
if (emoji) this.toggleReaction(msgId, emoji);
break;
case 'pin':
this.togglePin(msgId);
break;
case 'delete':
if (confirm('Delete this message?')) this.deleteMessage(msgId);
break;
}
e.stopPropagation();
});
});
}
private autoResize(textarea: HTMLTextAreaElement) {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
}
private showQuickReactions(msgId: string, anchor: HTMLElement) {
const quickEmojis = ['👍', '❤️', '😂', '🎉', '🤔', '👀'];
// Remove existing picker
this.shadow.querySelector('.emoji-picker')?.remove();
const picker = document.createElement('div');
picker.className = 'emoji-picker';
picker.innerHTML = quickEmojis.map(e =>
`<button class="emoji-btn" data-emoji="${e}">${e}</button>`
).join('');
picker.style.position = 'absolute';
const rect = anchor.getBoundingClientRect();
const shadowRect = this.shadow.host.getBoundingClientRect();
picker.style.top = (rect.bottom - shadowRect.top) + 'px';
picker.style.left = (rect.left - shadowRect.left) + 'px';
picker.querySelectorAll('.emoji-btn').forEach(btn => {
btn.addEventListener('click', () => {
const emoji = (btn as HTMLElement).dataset.emoji!;
this.toggleReaction(msgId, emoji);
picker.remove();
});
});
// Close on outside click
const closeHandler = (e: Event) => {
if (!picker.contains(e.target as Node)) {
picker.remove();
document.removeEventListener('click', closeHandler);
}
};
setTimeout(() => document.addEventListener('click', closeHandler), 10);
this.shadow.querySelector('.chat-layout')?.appendChild(picker);
}
}
// ── Styles ──
const STYLES = `
:host {
display: block;
height: calc(100vh - 60px);
font-family: var(--rs-font, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
color: var(--rs-text-primary, #e2e8f0);
background: var(--rs-bg-base, #0f172a);
--chat-sidebar-w: 240px;
--chat-thread-w: 340px;
--chat-accent: var(--rs-accent, #6366f1);
--chat-surface: var(--rs-bg-surface, #1e293b);
--chat-border: var(--rs-border, #334155);
--chat-text-secondary: var(--rs-text-secondary, #94a3b8);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
.chat-layout {
display: grid;
grid-template-columns: var(--chat-sidebar-w) 1fr;
height: 100%;
position: relative;
}
.chat-layout.has-thread {
grid-template-columns: var(--chat-sidebar-w) 1fr var(--chat-thread-w);
}
/* ── Sidebar ── */
.sidebar {
background: var(--chat-surface);
border-right: 1px solid var(--chat-border);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 12px 8px;
}
.sidebar-header h2 {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--chat-text-secondary);
font-weight: 600;
}
.sidebar-section {
padding: 16px 12px 4px;
}
.sidebar-section h3 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--chat-text-secondary);
font-weight: 600;
}
.btn-icon {
background: none;
border: none;
color: var(--chat-text-secondary);
font-size: 1.2rem;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
line-height: 1;
}
.btn-icon:hover { background: rgba(255,255,255,0.08); color: var(--rs-text-primary, #e2e8f0); }
.channel-list {
list-style: none;
padding: 0 4px;
}
.channel-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
color: var(--chat-text-secondary);
transition: background 0.15s;
}
.channel-item:hover { background: rgba(255,255,255,0.06); color: var(--rs-text-primary, #e2e8f0); }
.channel-item.active { background: rgba(99,102,241,0.15); color: var(--chat-accent); font-weight: 600; }
.channel-hash { opacity: 0.5; font-weight: 700; }
.channel-lock { font-size: 0.7rem; opacity: 0.5; }
.channel-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dm-avatar { font-size: 0.85rem; }
.create-channel-form {
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.create-channel-form input {
background: var(--rs-bg-base, #0f172a);
border: 1px solid var(--chat-border);
border-radius: 6px;
padding: 6px 10px;
color: var(--rs-text-primary, #e2e8f0);
font-size: 0.85rem;
outline: none;
}
.create-channel-form input:focus { border-color: var(--chat-accent); }
.form-actions { display: flex; gap: 6px; }
.btn-sm {
padding: 4px 12px;
border-radius: 6px;
font-size: 0.8rem;
cursor: pointer;
border: none;
}
.btn-primary { background: var(--chat-accent); color: white; }
.btn-primary:hover { opacity: 0.9; }
.btn-ghost { background: transparent; color: var(--chat-text-secondary); border: 1px solid var(--chat-border); }
.btn-ghost:hover { background: rgba(255,255,255,0.06); }
/* ── Main Area ── */
.main-area {
display: flex;
flex-direction: column;
min-width: 0;
}
.channel-header {
display: flex;
align-items: baseline;
gap: 12px;
padding: 12px 20px;
border-bottom: 1px solid var(--chat-border);
background: var(--chat-surface);
}
.channel-header h2 { font-size: 1.1rem; font-weight: 700; }
.channel-desc { font-size: 0.82rem; color: var(--chat-text-secondary); }
.message-feed {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 2px;
}
.loading, .empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--chat-text-secondary);
gap: 8px;
}
.empty-icon { font-size: 2.5rem; opacity: 0.4; }
.empty-state p { font-size: 0.9rem; }
/* ── Messages ── */
.message {
display: flex;
gap: 10px;
padding: 6px 8px;
border-radius: 6px;
transition: background 0.1s;
}
.message:hover { background: rgba(255,255,255,0.03); }
.message:hover .msg-actions { opacity: 1; }
.msg-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--chat-accent);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.85rem;
color: white;
flex-shrink: 0;
}
.msg-body { flex: 1; min-width: 0; }
.msg-header {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 2px;
}
.msg-author { font-weight: 600; font-size: 0.9rem; }
.msg-time { font-size: 0.75rem; color: var(--chat-text-secondary); }
.msg-edited { font-size: 0.7rem; color: var(--chat-text-secondary); font-style: italic; }
.msg-actions {
margin-left: auto;
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s;
}
.msg-action {
background: none;
border: none;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
font-size: 0.8rem;
line-height: 1;
}
.msg-action:hover { background: rgba(255,255,255,0.1); }
.msg-content {
font-size: 0.9rem;
line-height: 1.5;
word-break: break-word;
}
.msg-content code {
background: rgba(0,0,0,0.3);
padding: 1px 4px;
border-radius: 3px;
font-size: 0.85em;
}
.msg-content a { color: var(--chat-accent); text-decoration: none; }
.msg-content a:hover { text-decoration: underline; }
.msg-content .mention { color: var(--chat-accent); font-weight: 600; cursor: pointer; }
/* ── Reactions ── */
.msg-reactions {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.reaction-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.8rem;
cursor: pointer;
background: rgba(255,255,255,0.06);
border: 1px solid transparent;
color: var(--rs-text-primary, #e2e8f0);
transition: all 0.15s;
}
.reaction-chip:hover { background: rgba(255,255,255,0.1); }
.reaction-chip.active { border-color: var(--chat-accent); background: rgba(99,102,241,0.15); }
.emoji-picker {
display: flex;
gap: 4px;
padding: 6px 8px;
background: var(--chat-surface);
border: 1px solid var(--chat-border);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 100;
}
.emoji-btn {
background: none;
border: none;
font-size: 1.2rem;
cursor: pointer;
padding: 4px;
border-radius: 4px;
line-height: 1;
}
.emoji-btn:hover { background: rgba(255,255,255,0.1); }
/* ── Transclusions ── */
.transclusions { margin-top: 6px; display: flex; flex-direction: column; gap: 4px; }
.transclusion-card {
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--chat-border);
background: rgba(255,255,255,0.03);
max-width: 360px;
}
.tc-module { font-size: 0.7rem; text-transform: uppercase; color: var(--chat-accent); letter-spacing: 0.04em; margin-bottom: 2px; }
.tc-title { font-size: 0.85rem; font-weight: 600; }
.tc-summary { font-size: 0.8rem; color: var(--chat-text-secondary); margin-top: 2px; }
.transclusion-link {
color: var(--chat-accent);
text-decoration: none;
font-size: 0.85rem;
}
.transclusion-link:hover { text-decoration: underline; }
/* ── Composer ── */
.composer {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 12px 20px 16px;
border-top: 1px solid var(--chat-border);
background: var(--chat-surface);
}
.composer-input {
flex: 1;
background: var(--rs-bg-base, #0f172a);
border: 1px solid var(--chat-border);
border-radius: 8px;
padding: 10px 14px;
color: var(--rs-text-primary, #e2e8f0);
font-size: 0.9rem;
font-family: inherit;
resize: none;
outline: none;
min-height: 40px;
max-height: 120px;
line-height: 1.4;
}
.composer-input:focus { border-color: var(--chat-accent); }
.composer-input::placeholder { color: var(--chat-text-secondary); }
.btn-send {
background: var(--chat-accent);
border: none;
border-radius: 8px;
padding: 8px 12px;
cursor: pointer;
color: white;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.15s;
}
.btn-send:hover { opacity: 0.85; }
.auth-prompt {
justify-content: center;
}
.auth-prompt p { color: var(--chat-text-secondary); font-size: 0.85rem; }
.typing-indicator {
padding: 4px 20px;
font-size: 0.78rem;
color: var(--chat-text-secondary);
font-style: italic;
}
/* ── Thread Panel ── */
.thread-panel {
border-left: 1px solid var(--chat-border);
background: var(--chat-surface);
display: flex;
flex-direction: column;
}
.thread-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--chat-border);
}
.thread-header h3 { font-size: 1rem; font-weight: 700; }
.thread-feed {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.thread-divider {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.75rem;
color: var(--chat-text-secondary);
margin: 12px 0 8px;
}
.thread-divider::before, .thread-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--chat-border);
}
.thread-composer {
border-top: 1px solid var(--chat-border);
}
/* ── Responsive ── */
@media (max-width: 768px) {
:host { --chat-sidebar-w: 0px; }
.sidebar { display: none; }
.chat-layout.has-thread { grid-template-columns: 1fr; }
.thread-panel {
position: fixed;
top: 60px;
right: 0;
bottom: 0;
width: 100%;
z-index: 50;
}
}
`;
customElements.define('folk-chat-app', FolkChatApp);