1094 lines
31 KiB
TypeScript
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
/** 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">×</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);
|