diff --git a/modules/rchats/components/folk-chat-app.ts b/modules/rchats/components/folk-chat-app.ts new file mode 100644 index 00000000..91cec335 --- /dev/null +++ b/modules/rchats/components/folk-chat-app.ts @@ -0,0 +1,1093 @@ +/** + * — 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, '"'); +} + +/** Simple markdown: **bold**, *italic*, `code`, [text](url) */ +function renderMarkdown(text: string): string { + let html = escapeHtml(text); + html = html.replace(/\*\*(.+?)\*\*/g, '$1'); + html = html.replace(/\*(.+?)\*/g, '$1'); + html = html.replace(/`(.+?)`/g, '$1'); + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + html = html.replace(/@([a-zA-Z0-9_.-]+)/g, '@$1'); + 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 | 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 = {}; + 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 = {}; + 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 = {}; + 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 = ` + +
+ ${this.renderSidebar()} + ${this.renderMainArea(myDid, hasAuth)} + ${this.threadRootId ? this.renderThreadPanel(myDid, hasAuth) : ''} +
`; + + this.attachEventListeners(); + } + + private renderSidebar(): string { + return ` +`; + } + + 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 ` +
+
+

# ${escapeHtml(channelName)}

+ ${activeChannel?.description ? `${escapeHtml(activeChannel.description)}` : ''} +
+
+ ${this.loading ? '
Loading messages...
' : ''} + ${!this.loading && this.messages.length === 0 ? ` +
+
💬
+

No messages yet. Start the conversation!

+
` : ''} + ${this.messages.map(m => this.renderMessage(m, myDid)).join('')} +
+ ${this.typingUsers.length > 0 ? ` +
${this.typingUsers.join(', ')} typing...
` : ''} + ${hasAuth ? ` +
+ + +
` : ` +
+

Sign in to send messages

+
`} +
`; + } + + 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 ` +
+
${escapeHtml(msg.authorName.charAt(0).toUpperCase())}
+
+
+ ${escapeHtml(msg.authorName)} + ${timeAgo(msg.createdAt)} + ${msg.editedAt ? '(edited)' : ''} +
+ + + + ${isOwn ? `` : ''} +
+
+
${renderMarkdown(msg.content)}
+ ${this.renderTransclusions(msg.transclusions || [])} + ${reactionKeys.length > 0 ? ` +
+ ${reactionKeys.map(emoji => { + const dids = reactions[emoji] || []; + const active = dids.includes(myDid); + return ``; + }).join('')} +
` : ''} +
+
`; + } + + private renderTransclusions(transclusions: Transclusion[]): string { + if (!transclusions || transclusions.length === 0) return ''; + return `
${transclusions.map(t => { + const title = t.snapshot?.title || `${t.module}/${t.objectId || t.docId}`; + const summary = t.snapshot?.summary || ''; + if (t.display === 'link') { + return `${escapeHtml(title)}`; + } + return ` +
+
${escapeHtml(t.module)}
+
${escapeHtml(title)}
+ ${summary ? `
${escapeHtml(summary)}
` : ''} +
`; + }).join('')}
`; + } + + private renderThreadPanel(myDid: string, hasAuth: boolean): string { + const rootMsg = this.messages.find(m => m.id === this.threadRootId); + return ` +`; + } + + // ── 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 => + `` + ).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); diff --git a/modules/rchats/mod.ts b/modules/rchats/mod.ts index c1a449d9..ef8d404a 100644 --- a/modules/rchats/mod.ts +++ b/modules/rchats/mod.ts @@ -1,23 +1,30 @@ /** - * rChats module — encrypted community messaging. + * rChats module — encrypted community messaging with channels, threads, DMs. * - * Stub module: landing page + "Coming Soon" dashboard. - * Real chat functionality (Automerge CRDT, channels, threads) will come later. + * Local-first via Automerge CRDT. One doc per channel, directory doc per space. + * DMs as private two-member channels with deterministic doc IDs. */ import { Hono } from "hono"; import * as Automerge from "@automerge/automerge"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; -import type { RSpaceModule } from "../../shared/module"; +import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module"; import { verifyToken, extractToken } from "../../server/auth"; import { resolveCallerRole } from "../../server/spaces"; import type { SpaceRoleString } from "../../server/spaces"; import { filterByVisibility, filterArrayByVisibility } from "../../shared/membrane"; +import { notify } from '../../server/notification-service'; import { renderLanding } from "./landing"; import type { SyncServer } from '../../server/local-first/sync-server'; -import { chatsDirectorySchema, chatChannelSchema, chatsDirectoryDocId, chatChannelDocId } from './schemas'; -import type { ChatsDirectoryDoc, ChatChannelDoc, ChannelInfo, ChatMessage } from './schemas'; +import { + chatsDirectorySchema, chatChannelSchema, + chatsDirectoryDocId, chatChannelDocId, dmChannelDocId, +} from './schemas'; +import type { + ChatsDirectoryDoc, ChatChannelDoc, ChannelInfo, ChatMessage, + Transclusion, ThreadMeta, +} from './schemas'; let _syncServer: SyncServer | null = null; @@ -39,8 +46,8 @@ function ensureDirectoryDoc(space: string): ChatsDirectoryDoc { return doc; } -function ensureChannelDoc(space: string, channelId: string): ChatChannelDoc { - const docId = chatChannelDocId(space, channelId); +function ensureChannelDoc(space: string, channelId: string, isDm = false): ChatChannelDoc { + const docId = isDm ? channelId : chatChannelDocId(space, channelId); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init channel', (d) => { @@ -48,19 +55,58 @@ function ensureChannelDoc(space: string, channelId: string): ChatChannelDoc { Object.assign(d, init); d.meta.spaceSlug = space; d.channelId = channelId; + d.isDm = isDm; }); _syncServer!.setDoc(docId, doc); } return doc; } +/** Seed "general" channel if none exist */ +function seedGeneralChannel(space: string) { + if (!_syncServer) return; + const dir = ensureDirectoryDoc(space); + if (Object.keys(dir.channels || {}).length > 0) return; + + const id = 'general'; + const dirDocId = chatsDirectoryDocId(space); + _syncServer.changeDoc(dirDocId, 'seed general channel', (d) => { + d.channels[id] = { + id, name: 'general', description: 'General discussion', + isPrivate: false, isDm: false, createdBy: null, + createdAt: Date.now(), updatedAt: Date.now(), + }; + }); + ensureChannelDoc(space, id); +} + +/** Extract @mentions from message content */ +function extractMentions(content: string): string[] { + const matches = content.match(/@([a-zA-Z0-9_.-]+)/g); + return matches ? matches.map(m => m.slice(1)) : []; +} + +// ── Auth helper ── + +async function requireAuth(c: any): Promise<{ claims: any; role: SpaceRoleString; space: string } | null> { + const token = extractToken(c.req.raw.headers); + if (!token) { c.json({ error: "Authentication required" }, 401); return null; } + let claims; + try { claims = await verifyToken(token); } catch { c.json({ error: "Invalid token" }, 401); return null; } + if (!_syncServer) { c.json({ error: "Not initialized" }, 503); return null; } + const space = c.req.param("space") || "demo"; + let role: SpaceRoleString = 'viewer'; + const resolved = await resolveCallerRole(space, claims); + if (resolved) role = resolved.role; + return { claims, role, space }; +} + // ── CRUD: Channels ── routes.get("/api/channels", async (c) => { if (!_syncServer) return c.json({ channels: [] }); const space = c.req.param("space") || "demo"; - // Resolve caller role for membrane filtering let callerRole: SpaceRoleString = 'viewer'; const token = extractToken(c.req.raw.headers); if (token) { @@ -73,24 +119,29 @@ routes.get("/api/channels", async (c) => { const doc = ensureDirectoryDoc(space); const visibleChannels = filterByVisibility(doc.channels || {}, callerRole); - return c.json({ channels: Object.values(visibleChannels) }); + // Filter out DM channels from the public list + const publicChannels = Object.values(visibleChannels).filter(ch => !ch.isDm); + return c.json({ channels: publicChannels }); }); routes.post("/api/channels", async (c) => { - const token = extractToken(c.req.raw.headers); - if (!token) return c.json({ error: "Authentication required" }, 401); - try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - if (!_syncServer) return c.json({ error: "Not initialized" }, 503); - const space = c.req.param("space") || "demo"; + const auth = await requireAuth(c); + if (!auth) return c.res; + const { claims, space } = auth; const { name, description = "", isPrivate = false } = await c.req.json(); if (!name) return c.json({ error: "name required" }, 400); const id = crypto.randomUUID(); const docId = chatsDirectoryDocId(space); ensureDirectoryDoc(space); - _syncServer.changeDoc(docId, `create channel ${id}`, (d) => { - d.channels[id] = { id, name, description, isPrivate, createdBy: null, createdAt: Date.now(), updatedAt: Date.now() }; + _syncServer!.changeDoc(docId, `create channel ${id}`, (d) => { + d.channels[id] = { + id, name, description, isPrivate, isDm: false, + createdBy: claims.did || claims.sub || null, + createdAt: Date.now(), updatedAt: Date.now(), + }; }); - const updated = _syncServer.getDoc(docId)!; + ensureChannelDoc(space, id); + const updated = _syncServer!.getDoc(docId)!; return c.json(updated.channels[id], 201); }); @@ -101,7 +152,6 @@ routes.get("/api/channels/:channelId/messages", async (c) => { const space = c.req.param("space") || "demo"; const channelId = c.req.param("channelId"); - // Resolve caller role for membrane filtering let callerRole: SpaceRoleString = 'viewer'; const token = extractToken(c.req.raw.headers); if (token) { @@ -113,48 +163,457 @@ routes.get("/api/channels/:channelId/messages", async (c) => { } const doc = ensureChannelDoc(space, channelId); + // Only return top-level messages (not thread replies) const messages = filterArrayByVisibility( - Object.values(doc.messages || {}), callerRole, + Object.values(doc.messages || {}).filter(m => !m.threadId), + callerRole, ).sort((a, b) => a.createdAt - b.createdAt); return c.json({ messages }); }); routes.post("/api/channels/:channelId/messages", async (c) => { - const token = extractToken(c.req.raw.headers); - if (!token) return c.json({ error: "Authentication required" }, 401); - let claims; - try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - if (!_syncServer) return c.json({ error: "Not initialized" }, 503); - const space = c.req.param("space") || "demo"; + const auth = await requireAuth(c); + if (!auth) return c.res; + const { claims, space } = auth; const channelId = c.req.param("channelId"); - const { content, replyTo = null } = await c.req.json(); + const { content, replyTo = null, transclusions = [] } = await c.req.json(); if (!content) return c.json({ error: "content required" }, 400); + const id = crypto.randomUUID(); const docId = chatChannelDocId(space, channelId); ensureChannelDoc(space, channelId); - _syncServer.changeDoc(docId, `add message ${id}`, (d) => { - d.messages[id] = { id, channelId, authorId: claims.sub || '', authorName: (claims.displayName as string) || claims.username || 'Anonymous', content, replyTo, editedAt: null, createdAt: Date.now() }; + + const authorDid = claims.did || claims.sub || ''; + const authorName = (claims.displayName as string) || claims.username || 'Anonymous'; + + _syncServer!.changeDoc(docId, `add message ${id}`, (d) => { + d.messages[id] = { + id, channelId, + authorId: authorDid, + authorName, + content, replyTo, + reactions: {}, transclusions: transclusions || [], + editedAt: null, createdAt: Date.now(), + }; }); - const updated = _syncServer.getDoc(docId)!; + + // Notify channel members (skip sender) + const updated = _syncServer!.getDoc(docId)!; + const memberDids = Object.values(updated.members || {}) + .map(m => m.userId).filter(did => did !== authorDid); + for (const userDid of memberDids) { + notify({ + userDid, category: 'module', eventType: 'chat_message', + title: `${authorName} in #${channelId}`, + body: content.slice(0, 200), + spaceSlug: space, moduleId: 'rchats', + actionUrl: `/${space}/rchats?channel=${channelId}`, + actorDid: authorDid, actorUsername: authorName, + }).catch(() => {}); + } + + // Notify @mentions + const mentions = extractMentions(content); + for (const mention of mentions) { + // mentions are usernames — find DID from members + const member = Object.values(updated.members || {}).find(m => m.displayName === mention); + if (member && member.userId !== authorDid) { + notify({ + userDid: member.userId, category: 'module', eventType: 'chat_mention', + title: `${authorName} mentioned you in #${channelId}`, + body: content.slice(0, 200), + spaceSlug: space, moduleId: 'rchats', + actionUrl: `/${space}/rchats?channel=${channelId}`, + actorDid: authorDid, actorUsername: authorName, + }).catch(() => {}); + } + } + return c.json(updated.messages[id], 201); }); +// ── Edit message ── + +routes.put("/api/channels/:channelId/messages/:msgId", async (c) => { + const auth = await requireAuth(c); + if (!auth) return c.res; + const { claims, space } = auth; + const channelId = c.req.param("channelId"); + const msgId = c.req.param("msgId"); + const { content } = await c.req.json(); + if (!content) return c.json({ error: "content required" }, 400); + + const docId = chatChannelDocId(space, channelId); + const doc = ensureChannelDoc(space, channelId); + if (!doc.messages[msgId]) return c.json({ error: "Not found" }, 404); + + const authorDid = claims.did || claims.sub || ''; + if (doc.messages[msgId].authorId !== authorDid) { + return c.json({ error: "Can only edit your own messages" }, 403); + } + + _syncServer!.changeDoc(docId, `edit message ${msgId}`, (d) => { + d.messages[msgId].content = content; + d.messages[msgId].editedAt = Date.now(); + }); + const updated = _syncServer!.getDoc(docId)!; + return c.json(updated.messages[msgId]); +}); + +// ── Delete message ── + routes.delete("/api/channels/:channelId/messages/:msgId", async (c) => { - const token = extractToken(c.req.raw.headers); - if (!token) return c.json({ error: "Authentication required" }, 401); - try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - if (!_syncServer) return c.json({ error: "Not initialized" }, 503); - const space = c.req.param("space") || "demo"; + const auth = await requireAuth(c); + if (!auth) return c.res; + const { space } = auth; const channelId = c.req.param("channelId"); const msgId = c.req.param("msgId"); const docId = chatChannelDocId(space, channelId); const doc = ensureChannelDoc(space, channelId); if (!doc.messages[msgId]) return c.json({ error: "Not found" }, 404); - _syncServer.changeDoc(docId, `delete message ${msgId}`, (d) => { delete d.messages[msgId]; }); + _syncServer!.changeDoc(docId, `delete message ${msgId}`, (d) => { delete d.messages[msgId]; }); return c.json({ ok: true }); }); -// ── Hub page (Coming Soon dashboard) ── +// ── Reactions ── + +routes.post("/api/channels/:channelId/messages/:msgId/react", async (c) => { + const auth = await requireAuth(c); + if (!auth) return c.res; + const { claims, space } = auth; + const channelId = c.req.param("channelId"); + const msgId = c.req.param("msgId"); + const { emoji } = await c.req.json(); + if (!emoji) return c.json({ error: "emoji required" }, 400); + + const docId = chatChannelDocId(space, channelId); + const doc = ensureChannelDoc(space, channelId); + if (!doc.messages[msgId]) return c.json({ error: "Not found" }, 404); + + const userDid = claims.did || claims.sub || ''; + + _syncServer!.changeDoc(docId, `react ${emoji} on ${msgId}`, (d) => { + const msg = d.messages[msgId]; + if (!msg.reactions) msg.reactions = {} as any; + if (!msg.reactions[emoji]) msg.reactions[emoji] = [] as any; + const existing = msg.reactions[emoji] as unknown as string[]; + const idx = existing.indexOf(userDid); + if (idx >= 0) { + existing.splice(idx, 1); + if (existing.length === 0) delete msg.reactions[emoji]; + } else { + existing.push(userDid); + } + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json({ reactions: updated.messages[msgId]?.reactions || {} }); +}); + +// ── Threads ── + +routes.get("/api/channels/:channelId/threads/:threadId", async (c) => { + if (!_syncServer) return c.json({ messages: [] }); + const space = c.req.param("space") || "demo"; + const channelId = c.req.param("channelId"); + const threadId = c.req.param("threadId"); + + let callerRole: SpaceRoleString = 'viewer'; + const token = extractToken(c.req.raw.headers); + if (token) { + try { + const claims = await verifyToken(token); + const resolved = await resolveCallerRole(space, claims); + if (resolved) callerRole = resolved.role; + } catch {} + } + + const doc = ensureChannelDoc(space, channelId); + // Return root message + all replies + const rootMsg = doc.messages[threadId]; + const replies = filterArrayByVisibility( + Object.values(doc.messages || {}).filter(m => m.threadId === threadId), + callerRole, + ).sort((a, b) => a.createdAt - b.createdAt); + + const thread = doc.threads?.[threadId] || null; + return c.json({ rootMessage: rootMsg || null, replies, thread }); +}); + +routes.post("/api/channels/:channelId/messages/:msgId/thread", async (c) => { + const auth = await requireAuth(c); + if (!auth) return c.res; + const { claims, space } = auth; + const channelId = c.req.param("channelId"); + const msgId = c.req.param("msgId"); + const { content, transclusions = [] } = await c.req.json(); + if (!content) return c.json({ error: "content required" }, 400); + + const docId = chatChannelDocId(space, channelId); + const doc = ensureChannelDoc(space, channelId); + if (!doc.messages[msgId]) return c.json({ error: "Root message not found" }, 404); + + const id = crypto.randomUUID(); + const authorDid = claims.did || claims.sub || ''; + const authorName = (claims.displayName as string) || claims.username || 'Anonymous'; + + _syncServer!.changeDoc(docId, `thread reply ${id}`, (d) => { + d.messages[id] = { + id, channelId, + authorId: authorDid, authorName, + content, replyTo: null, threadId: msgId, + reactions: {}, transclusions: transclusions || [], + editedAt: null, createdAt: Date.now(), + }; + // Update thread metadata + if (!d.threads) d.threads = {} as any; + if (!d.threads[msgId]) { + d.threads[msgId] = { + participantDids: [] as any, + lastActivity: Date.now(), + replyCount: 0, + }; + } + const thread = d.threads[msgId]; + thread.lastActivity = Date.now(); + thread.replyCount = (thread.replyCount || 0) + 1; + const participants = thread.participantDids as unknown as string[]; + if (!participants.includes(authorDid)) { + participants.push(authorDid); + } + }); + + // Notify thread participants (skip sender) + const updated = _syncServer!.getDoc(docId)!; + const threadMeta = updated.threads?.[msgId]; + if (threadMeta) { + const participantDids = (threadMeta.participantDids || []).filter((d: string) => d !== authorDid); + // Also notify root message author + const rootAuthor = updated.messages[msgId]?.authorId; + if (rootAuthor && rootAuthor !== authorDid && !participantDids.includes(rootAuthor)) { + participantDids.push(rootAuthor); + } + for (const userDid of participantDids) { + notify({ + userDid, category: 'module', eventType: 'chat_message', + title: `${authorName} replied in thread`, + body: content.slice(0, 200), + spaceSlug: space, moduleId: 'rchats', + actionUrl: `/${space}/rchats?channel=${channelId}&thread=${msgId}`, + actorDid: authorDid, actorUsername: authorName, + }).catch(() => {}); + } + } + + return c.json(updated.messages[id], 201); +}); + +// ── Pins ── + +routes.post("/api/channels/:channelId/pins", async (c) => { + const auth = await requireAuth(c); + if (!auth) return c.res; + const { space } = auth; + const channelId = c.req.param("channelId"); + const { messageId } = await c.req.json(); + if (!messageId) return c.json({ error: "messageId required" }, 400); + + const docId = chatChannelDocId(space, channelId); + const doc = ensureChannelDoc(space, channelId); + if (!doc.messages[messageId]) return c.json({ error: "Message not found" }, 404); + + _syncServer!.changeDoc(docId, `toggle pin ${messageId}`, (d) => { + if (!d.pins) d.pins = [] as any; + const pins = d.pins as unknown as string[]; + const idx = pins.indexOf(messageId); + if (idx >= 0) { + pins.splice(idx, 1); + } else { + pins.push(messageId); + } + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json({ pins: updated.pins || [] }); +}); + +// ── DMs ── + +routes.get("/api/dm", async (c) => { + const auth = await requireAuth(c); + if (!auth) return c.res; + const { claims, space } = auth; + const myDid = claims.did || claims.sub || ''; + + // Scan directory for DM channels involving this user + const dir = ensureDirectoryDoc(space); + const dmChannels = Object.values(dir.channels || {}) + .filter(ch => ch.isDm && ch.id.includes(myDid)); + + return c.json({ channels: dmChannels }); +}); + +routes.get("/api/dm/:targetDid", async (c) => { + const auth = await requireAuth(c); + if (!auth) return c.res; + const { claims, space } = auth; + const myDid = claims.did || claims.sub || ''; + const targetDid = c.req.param("targetDid"); + + if (myDid === targetDid) return c.json({ error: "Cannot DM yourself" }, 400); + + const dmDocId = dmChannelDocId(space, myDid, targetDid); + const channelId = `dm:${[myDid, targetDid].sort().join('+')}`; + + // Ensure DM channel exists in directory + const dirDocId = chatsDirectoryDocId(space); + ensureDirectoryDoc(space); + const dir = _syncServer!.getDoc(dirDocId)!; + if (!dir.channels[channelId]) { + _syncServer!.changeDoc(dirDocId, `create DM ${channelId}`, (d) => { + d.channels[channelId] = { + id: channelId, name: `DM`, description: '', + isPrivate: true, isDm: true, createdBy: myDid, + createdAt: Date.now(), updatedAt: Date.now(), + }; + }); + } + + // Ensure channel doc exists + let doc = _syncServer!.getDoc(dmDocId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init DM channel', (d) => { + const init = chatChannelSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + d.channelId = channelId; + d.isDm = true; + d.members[myDid] = { userId: myDid, displayName: claims.displayName || claims.username || 'User', joinedAt: Date.now() }; + d.members[targetDid] = { userId: targetDid, displayName: targetDid, joinedAt: Date.now() }; + }); + _syncServer!.setDoc(dmDocId, doc); + } + + return c.json({ + channelId, + docId: dmDocId, + messages: Object.values(doc.messages || {}).sort((a, b) => a.createdAt - b.createdAt), + members: Object.values(doc.members || {}), + }); +}); + +routes.post("/api/dm/:targetDid/messages", async (c) => { + const auth = await requireAuth(c); + if (!auth) return c.res; + const { claims, space } = auth; + const myDid = claims.did || claims.sub || ''; + const targetDid = c.req.param("targetDid"); + const { content, transclusions = [] } = await c.req.json(); + if (!content) return c.json({ error: "content required" }, 400); + + const dmDocId = dmChannelDocId(space, myDid, targetDid); + const channelId = `dm:${[myDid, targetDid].sort().join('+')}`; + + // Ensure DM exists first (reuse GET logic) + let doc = _syncServer!.getDoc(dmDocId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init DM channel', (d) => { + const init = chatChannelSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + d.channelId = channelId; + d.isDm = true; + }); + _syncServer!.setDoc(dmDocId, doc); + } + + const id = crypto.randomUUID(); + const authorName = (claims.displayName as string) || claims.username || 'Anonymous'; + + _syncServer!.changeDoc(dmDocId, `DM message ${id}`, (d) => { + d.messages[id] = { + id, channelId, + authorId: myDid, authorName, + content, replyTo: null, + reactions: {}, transclusions: transclusions || [], + editedAt: null, createdAt: Date.now(), + }; + }); + + // Notify recipient + notify({ + userDid: targetDid, category: 'module', eventType: 'chat_dm', + title: `DM from ${authorName}`, + body: content.slice(0, 200), + spaceSlug: space, moduleId: 'rchats', + actionUrl: `/${space}/rchats?dm=${myDid}`, + actorDid: myDid, actorUsername: authorName, + }).catch(() => {}); + + const updated = _syncServer!.getDoc(dmDocId)!; + return c.json(updated.messages[id], 201); +}); + +// ── Members (join/leave) ── + +routes.post("/api/channels/:channelId/join", async (c) => { + const auth = await requireAuth(c); + if (!auth) return c.res; + const { claims, space } = auth; + const channelId = c.req.param("channelId"); + const docId = chatChannelDocId(space, channelId); + ensureChannelDoc(space, channelId); + const userDid = claims.did || claims.sub || ''; + _syncServer!.changeDoc(docId, `join ${userDid}`, (d) => { + d.members[userDid] = { + userId: userDid, + displayName: (claims.displayName as string) || claims.username || 'Anonymous', + joinedAt: Date.now(), + }; + }); + return c.json({ ok: true }); +}); + +routes.post("/api/channels/:channelId/leave", async (c) => { + const auth = await requireAuth(c); + if (!auth) return c.res; + const { claims, space } = auth; + const channelId = c.req.param("channelId"); + const docId = chatChannelDocId(space, channelId); + const userDid = claims.did || claims.sub || ''; + _syncServer!.changeDoc(docId, `leave ${userDid}`, (d) => { + delete d.members[userDid]; + }); + return c.json({ ok: true }); +}); + +// ── Unread count ── + +routes.get("/api/unread-count", async (c) => { + if (!_syncServer) return c.json({ count: 0, channels: {} }); + const space = c.req.param("space") || "demo"; + const sinceParam = c.req.query("since"); + const since = sinceParam ? parseInt(sinceParam, 10) : 0; + + const dir = ensureDirectoryDoc(space); + const result: Record = {}; + let total = 0; + + for (const ch of Object.values(dir.channels || {})) { + if (ch.isDm) continue; + const doc = _syncServer!.getDoc(chatChannelDocId(space, ch.id)); + if (!doc?.messages) continue; + const count = Object.values(doc.messages).filter(m => !m.threadId && m.createdAt > since).length; + if (count > 0) { + result[ch.id] = count; + total += count; + } + } + + return c.json({ count: total, channels: result }); +}); + +// ── Hub page (active chat UI) ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; @@ -163,49 +622,8 @@ routes.get("/", (c) => { moduleId: "rchats", spaceSlug: space, modules: getModuleInfoList(), - styles: ``, - body: `
-

rChats

-

Encrypted community messaging — channels, threads, and bridges

-
- 🗨️ - Coming Soon -

Encrypted Community Chat

-

Real-time messaging with channels and threads, end-to-end encrypted via EncryptID. Local-first with Automerge CRDTs — works offline, syncs seamlessly.

-
-
-
-

🔐 E2E Encrypted

-

Messages encrypted with EncryptID passkeys. The server never sees plaintext.

-
-
-

💬 Channels & Threads

-

Organize conversations by topic. Threaded replies keep the main feed clean.

-
-
-

🔗 Chat Bridges

-

Connect Slack, Discord, Matrix, Telegram, and Mattermost into one unified view.

-
-
-

📡 Local-First

-

Built on Automerge CRDTs. Send messages offline and sync when reconnected.

-
-
-
`, + body: ``, + scripts: ``, })); }); @@ -230,14 +648,19 @@ export function getRecentMessagesForMI(space: string, limit = 5): { id: string; export const chatsModule: RSpaceModule = { id: "rchats", name: "rChats", - icon: "🗨️", + icon: "\u{1F4AC}", description: "Encrypted community messaging", scoping: { defaultScope: "space", userConfigurable: false }, docSchemas: [ { pattern: '{space}:chats:channels', description: 'Channel directory per space', init: chatsDirectorySchema.init }, { pattern: '{space}:chats:channel:{channelId}', description: 'Messages per channel', init: chatChannelSchema.init }, + { pattern: '{space}:chats:dm:{did1}+{did2}', description: 'DM channel', init: chatChannelSchema.init }, ], routes, landingPage: renderLanding, async onInit(ctx) { _syncServer = ctx.syncServer; }, + async onSpaceCreate(ctx: SpaceLifecycleContext) { + if (!_syncServer) return; + seedGeneralChannel(ctx.spaceSlug); + }, }; diff --git a/modules/rchats/schemas.ts b/modules/rchats/schemas.ts index c84aa2bb..9d4cca44 100644 --- a/modules/rchats/schemas.ts +++ b/modules/rchats/schemas.ts @@ -4,10 +4,29 @@ * Granularity: one directory doc per space + one doc per channel. * DocId format: {space}:chats:channels (directory) * {space}:chats:channel:{channelId} (messages) + * {space}:chats:dm:{sortedDid1+Did2} (DM channels) */ import type { DocSchema } from '../../shared/local-first/document'; +// ── Transclusion: structured ref to any rSpace object ── + +export interface Transclusion { + module: string; // "rtasks" | "rcal" | "rdocs" | etc. + docId: string; + objectId?: string; + display: 'inline' | 'card' | 'link'; + snapshot?: { title: string; summary?: string; capturedAt: number }; +} + +// ── Thread metadata ── + +export interface ThreadMeta { + participantDids: string[]; + lastActivity: number; + replyCount: number; +} + // ── Document types ── export interface ChannelInfo { @@ -15,6 +34,7 @@ export interface ChannelInfo { name: string; description: string; isPrivate: boolean; + isDm: boolean; createdBy: string | null; createdAt: number; updatedAt: number; @@ -45,6 +65,9 @@ export interface ChatMessage { authorName: string; content: string; replyTo: string | null; + threadId?: string; // points to root message ID if this is a thread reply + reactions: Record; // emoji → DID[] + transclusions: Transclusion[]; editedAt: number | null; createdAt: number; visibility?: import('../../shared/membrane').ObjectVisibility; @@ -61,6 +84,9 @@ export interface ChatChannelDoc { channelId: string; messages: Record; members: Record; + threads: Record; // rootMessageId → thread metadata + pins: string[]; // pinned message IDs + isDm: boolean; } // ── Schema registration ── @@ -68,12 +94,12 @@ export interface ChatChannelDoc { export const chatsDirectorySchema: DocSchema = { module: 'chats', collection: 'channels', - version: 1, + version: 2, init: (): ChatsDirectoryDoc => ({ meta: { module: 'chats', collection: 'channels', - version: 1, + version: 2, spaceSlug: '', createdAt: Date.now(), }, @@ -84,18 +110,21 @@ export const chatsDirectorySchema: DocSchema = { export const chatChannelSchema: DocSchema = { module: 'chats', collection: 'channel', - version: 1, + version: 2, init: (): ChatChannelDoc => ({ meta: { module: 'chats', collection: 'channel', - version: 1, + version: 2, spaceSlug: '', createdAt: Date.now(), }, channelId: '', messages: {}, members: {}, + threads: {}, + pins: [], + isDm: false, }), }; @@ -108,3 +137,8 @@ export function chatsDirectoryDocId(space: string) { export function chatChannelDocId(space: string, channelId: string) { return `${space}:chats:channel:${channelId}` as const; } + +export function dmChannelDocId(space: string, did1: string, did2: string): string { + const sorted = [did1, did2].sort(); + return `${space}:chats:dm:${sorted[0]}+${sorted[1]}`; +} diff --git a/modules/rdata/components/data.css b/modules/rdata/components/data.css index c21c7381..067b0316 100644 --- a/modules/rdata/components/data.css +++ b/modules/rdata/components/data.css @@ -1,6 +1,7 @@ /* Data module — layout wrapper */ folk-analytics-view, -folk-content-tree { +folk-content-tree, +folk-data-cloud { display: block; padding: 1.5rem; } diff --git a/modules/rdata/components/folk-data-cloud.ts b/modules/rdata/components/folk-data-cloud.ts new file mode 100644 index 00000000..39700371 --- /dev/null +++ b/modules/rdata/components/folk-data-cloud.ts @@ -0,0 +1,436 @@ +/** + * folk-data-cloud — Concentric-ring SVG visualization of data objects + * across user spaces, grouped by visibility level (private/permissioned/public). + * + * Two-level interaction: click space bubble → detail panel with modules, + * click module row → navigate to that module page. + */ + +import { startPresenceHeartbeat } from '../../../shared/collab-presence'; + +interface SpaceInfo { + slug: string; + name: string; + visibility: string; + role?: string; + relationship?: string; +} + +interface ModuleSummary { + id: string; + name: string; + icon: string; + docCount: number; +} + +interface SpaceBubble extends SpaceInfo { + docCount: number; + modules: ModuleSummary[]; +} + +type Ring = "private" | "permissioned" | "public"; + +const RING_CONFIG: Record = { + private: { color: "#ef4444", label: "Private", radius: 0.28 }, + permissioned: { color: "#eab308", label: "Permissioned", radius: 0.54 }, + public: { color: "#22c55e", label: "Public", radius: 0.80 }, +}; + +const RINGS: Ring[] = ["private", "permissioned", "public"]; + +const DEMO_SPACES: SpaceBubble[] = [ + { slug: "personal", name: "Personal", visibility: "private", role: "owner", relationship: "owner", docCount: 14, modules: [ + { id: "notes", name: "rNotes", icon: "📝", docCount: 5 }, + { id: "tasks", name: "rTasks", icon: "📋", docCount: 4 }, + { id: "cal", name: "rCal", icon: "📅", docCount: 3 }, + { id: "wallet", name: "rWallet", icon: "💰", docCount: 2 }, + ]}, + { slug: "my-project", name: "Side Project", visibility: "private", role: "owner", relationship: "owner", docCount: 8, modules: [ + { id: "docs", name: "rDocs", icon: "📓", docCount: 3 }, + { id: "tasks", name: "rTasks", icon: "📋", docCount: 5 }, + ]}, + { slug: "team-alpha", name: "Team Alpha", visibility: "permissioned", role: "owner", relationship: "owner", docCount: 22, modules: [ + { id: "docs", name: "rDocs", icon: "📓", docCount: 6 }, + { id: "vote", name: "rVote", icon: "🗳", docCount: 4 }, + { id: "flows", name: "rFlows", icon: "🌊", docCount: 3 }, + { id: "tasks", name: "rTasks", icon: "📋", docCount: 5 }, + { id: "cal", name: "rCal", icon: "📅", docCount: 4 }, + ]}, + { slug: "dao-gov", name: "DAO Governance", visibility: "permissioned", relationship: "member", docCount: 11, modules: [ + { id: "vote", name: "rVote", icon: "🗳", docCount: 7 }, + { id: "flows", name: "rFlows", icon: "🌊", docCount: 4 }, + ]}, + { slug: "demo", name: "Demo Space", visibility: "public", relationship: "demo", docCount: 18, modules: [ + { id: "notes", name: "rNotes", icon: "📝", docCount: 3 }, + { id: "vote", name: "rVote", icon: "🗳", docCount: 2 }, + { id: "tasks", name: "rTasks", icon: "📋", docCount: 4 }, + { id: "cal", name: "rCal", icon: "📅", docCount: 3 }, + { id: "wallet", name: "rWallet", icon: "💰", docCount: 1 }, + { id: "flows", name: "rFlows", icon: "🌊", docCount: 5 }, + ]}, + { slug: "open-commons", name: "Open Commons", visibility: "public", relationship: "other", docCount: 9, modules: [ + { id: "docs", name: "rDocs", icon: "📓", docCount: 4 }, + { id: "pubs", name: "rPubs", icon: "📰", docCount: 5 }, + ]}, +]; + +class FolkDataCloud extends HTMLElement { + private shadow: ShadowRoot; + private space = "demo"; + private spaces: SpaceBubble[] = []; + private loading = true; + private isDemo = false; + private selected: string | null = null; + private hoveredSlug: string | null = null; + private width = 600; + private height = 600; + private _stopPresence: (() => void) | null = null; + private _resizeObserver: ResizeObserver | null = null; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + this.space = this.getAttribute("space") || "demo"; + this._resizeObserver = new ResizeObserver((entries) => { + const w = entries[0]?.contentRect.width || 600; + this.width = Math.min(w, 800); + this.height = this.width; + if (!this.loading) this.render(); + }); + this._resizeObserver.observe(this); + this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rdata', context: 'Cloud' })); + this.loadData(); + } + + disconnectedCallback() { + this._stopPresence?.(); + this._resizeObserver?.disconnect(); + } + + private async loadData() { + this.loading = true; + this.render(); + + const token = localStorage.getItem("rspace_auth"); + if (!token) { + this.isDemo = true; + this.spaces = DEMO_SPACES; + this.loading = false; + this.render(); + return; + } + + try { + const spacesResp = await fetch("/api/spaces", { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(8000), + }); + if (!spacesResp.ok) throw new Error("spaces fetch failed"); + const spacesData: { spaces: SpaceInfo[] } = await spacesResp.json(); + + // Fetch content-tree for each space in parallel + const base = window.location.pathname.replace(/\/(tree|analytics|cloud)?\/?$/, ""); + const bubbles: SpaceBubble[] = await Promise.all( + spacesData.spaces.map(async (sp) => { + try { + const treeResp = await fetch( + `${base}/api/content-tree?space=${encodeURIComponent(sp.slug)}`, + { signal: AbortSignal.timeout(8000) } + ); + if (!treeResp.ok) return { ...sp, docCount: 0, modules: [] }; + const tree = await treeResp.json(); + const modules: ModuleSummary[] = (tree.modules || []).map((m: any) => ({ + id: m.id, + name: m.name, + icon: m.icon, + docCount: m.collections.reduce((s: number, c: any) => s + c.items.length, 0), + })); + const docCount = modules.reduce((s, m) => s + m.docCount, 0); + return { ...sp, docCount, modules }; + } catch { + return { ...sp, docCount: 0, modules: [] }; + } + }) + ); + + this.spaces = bubbles; + this.isDemo = false; + } catch { + this.isDemo = true; + this.spaces = DEMO_SPACES; + } + + this.loading = false; + this.render(); + } + + private groupByRing(): Record { + const groups: Record = { private: [], permissioned: [], public: [] }; + for (const sp of this.spaces) { + const ring = (sp.visibility as Ring) || "private"; + (groups[ring] || groups.private).push(sp); + } + return groups; + } + + private isMobile(): boolean { + return this.width < 500; + } + + private render() { + const selected = this.selected ? this.spaces.find(s => s.slug === this.selected) : null; + + this.shadow.innerHTML = ` + +
+ ${this.isDemo ? `
Sign in to see your data cloud
` : ""} + ${this.loading ? this.renderLoading() : this.renderSVG()} + ${selected ? this.renderDetailPanel(selected) : ""} +
+ `; + + this.attachEvents(); + } + + private renderLoading(): string { + const cx = this.width / 2; + const cy = this.height / 2; + return ` + + ${RINGS.map(ring => { + const r = RING_CONFIG[ring].radius * (this.width / 2) * 0.9; + return ``; + }).join("")} + Loading your data cloud… + + `; + } + + private renderSVG(): string { + const groups = this.groupByRing(); + const cx = this.width / 2; + const cy = this.height / 2; + const scale = (this.width / 2) * 0.9; + const mobile = this.isMobile(); + const bubbleR = mobile ? 20 : 28; + const maxDocCount = Math.max(1, ...this.spaces.map(s => s.docCount)); + + let svg = ``; + + // Render rings (outer to inner so inner draws on top) + for (const ring of [...RINGS].reverse()) { + const cfg = RING_CONFIG[ring]; + const r = cfg.radius * scale; + svg += ``; + + // Ring label at top + const labelY = cy - r - 8; + svg += `${cfg.label}`; + } + + // Render bubbles per ring + for (const ring of RINGS) { + const cfg = RING_CONFIG[ring]; + const ringR = cfg.radius * scale; + const ringSpaces = groups[ring]; + if (ringSpaces.length === 0) continue; + + const angleStep = (2 * Math.PI) / ringSpaces.length; + const startAngle = -Math.PI / 2; // Start from top + + for (let i = 0; i < ringSpaces.length; i++) { + const sp = ringSpaces[i]; + const angle = startAngle + i * angleStep; + const bx = cx + ringR * Math.cos(angle); + const by = cy + ringR * Math.sin(angle); + + // Scale bubble size by doc count (min 60%, max 100%) + const sizeScale = 0.6 + 0.4 * (sp.docCount / maxDocCount); + const r = bubbleR * sizeScale; + const isSelected = this.selected === sp.slug; + const isHovered = this.hoveredSlug === sp.slug; + const strokeW = isSelected ? 3 : (isHovered ? 2.5 : 1.5); + const fillOpacity = isSelected ? 0.25 : (isHovered ? 0.18 : 0.1); + + // Bubble circle + svg += ``; + if (isSelected) { + svg += ` + + `; + } + svg += ``; + + // Label + const label = mobile ? sp.name.slice(0, 6) : (sp.name.length > 12 ? sp.name.slice(0, 11) + "…" : sp.name); + svg += `${this.esc(label)}`; + + // Doc count badge + svg += `${sp.docCount}`; + + // Tooltip (title element) + svg += `${this.esc(sp.name)} — ${sp.docCount} doc${sp.docCount !== 1 ? "s" : ""} (${sp.visibility})`; + svg += ``; + } + } + + // Center label + const totalDocs = this.spaces.reduce((s, sp) => s + sp.docCount, 0); + svg += `${totalDocs}`; + svg += `total documents`; + + svg += ``; + return svg; + } + + private renderDetailPanel(sp: SpaceBubble): string { + const ring = (sp.visibility as Ring) || "private"; + const cfg = RING_CONFIG[ring]; + const visBadgeColor = cfg.color; + + return ` +
+
+ ${this.esc(sp.name)} + ${sp.visibility} + ${sp.docCount} doc${sp.docCount !== 1 ? "s" : ""} +
+ ${sp.modules.length === 0 + ? `
No documents in this space
` + : `
+ ${sp.modules.map(m => ` +
+ ${m.icon} + ${this.esc(m.name)} + ${m.docCount} +
+ `).join("")} +
` + } +
+ `; + } + + private attachEvents() { + // Bubble click — toggle selection + for (const g of this.shadow.querySelectorAll(".dc-bubble")) { + const slug = g.dataset.slug!; + + g.addEventListener("click", () => { + this.selected = this.selected === slug ? null : slug; + this.render(); + }); + + g.addEventListener("mouseenter", () => { + this.hoveredSlug = slug; + // Update stroke without full re-render for perf + const circle = g.querySelector("circle:not([stroke-dasharray='4 3'])") as SVGCircleElement; + if (circle) circle.setAttribute("stroke-width", "2.5"); + }); + + g.addEventListener("mouseleave", () => { + this.hoveredSlug = null; + const circle = g.querySelector("circle:not([stroke-dasharray='4 3'])") as SVGCircleElement; + if (circle && this.selected !== slug) circle.setAttribute("stroke-width", "1.5"); + }); + } + + // Module row click — navigate + for (const row of this.shadow.querySelectorAll(".dc-panel__mod")) { + row.addEventListener("click", () => { + const spaceSlug = row.dataset.navSpace!; + const modId = row.dataset.navMod!; + const modPath = modId.startsWith("r") ? modId : `r${modId}`; + window.location.href = `/${spaceSlug}/${modPath}`; + }); + } + } + + private esc(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); + } + + private escAttr(s: string): string { + return s.replace(/&/g, "&").replace(/"/g, """).replace(/ { const DATA_TABS = [ { id: "tree", label: "Content Tree", icon: "🌳" }, + { id: "cloud", label: "Cloud", icon: "☁️" }, { id: "analytics", label: "Analytics", icon: "📊" }, ] as const; const DATA_TAB_IDS = new Set(DATA_TABS.map((t) => t.id)); function renderDataPage(space: string, activeTab: string, isSubdomain: boolean) { - const isTree = activeTab === "tree"; - const body = isTree + const body = activeTab === "tree" ? `` + : activeTab === "cloud" + ? `` : ``; - const scripts = isTree + const scripts = activeTab === "tree" ? `` + : activeTab === "cloud" + ? `` : ``; return renderShell({ diff --git a/server/mcp-tools/rchats.ts b/server/mcp-tools/rchats.ts index bfda683b..2e399da9 100644 --- a/server/mcp-tools/rchats.ts +++ b/server/mcp-tools/rchats.ts @@ -2,13 +2,14 @@ * MCP tools for rChats (multiplayer chat channels). * forceAuth=true — chat messages are always sensitive. * - * Tools: rchats_list_channels, rchats_get_channel, rchats_list_messages + * Tools: rchats_list_channels, rchats_get_channel, rchats_list_messages, + * rchats_list_thread_messages, rchats_list_dms, rchats_send_message */ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import type { SyncServer } from "../local-first/sync-server"; -import { chatsDirectoryDocId, chatChannelDocId } from "../../modules/rchats/schemas"; +import { chatsDirectoryDocId, chatChannelDocId, dmChannelDocId } from "../../modules/rchats/schemas"; import type { ChatsDirectoryDoc, ChatChannelDoc } from "../../modules/rchats/schemas"; import { resolveAccess, accessDeniedResponse } from "./_auth"; import { filterArrayByVisibility } from "../../shared/membrane"; @@ -30,7 +31,8 @@ export function registerChatsTools(server: McpServer, syncServer: SyncServer) { const channels = filterArrayByVisibility(Object.values(doc.channels || {}), access.role).map(ch => ({ id: ch.id, name: ch.name, description: ch.description, - isPrivate: ch.isPrivate, createdBy: ch.createdBy, + isPrivate: ch.isPrivate, isDm: ch.isDm, + createdBy: ch.createdBy, createdAt: ch.createdAt, updatedAt: ch.updatedAt, })); @@ -59,7 +61,7 @@ export function registerChatsTools(server: McpServer, syncServer: SyncServer) { return { content: [{ type: "text" as const, - text: JSON.stringify({ channelId: doc.channelId, members, messageCount }, null, 2), + text: JSON.stringify({ channelId: doc.channelId, members, messageCount, isDm: doc.isDm }, null, 2), }], }; }, @@ -82,16 +84,111 @@ export function registerChatsTools(server: McpServer, syncServer: SyncServer) { if (!doc) return { content: [{ type: "text" as const, text: JSON.stringify({ error: "Channel not found" }) }] }; let messages = filterArrayByVisibility(Object.values(doc.messages || {}), access.role) + .filter(m => !m.threadId) // Only top-level messages .sort((a, b) => b.createdAt - a.createdAt) .slice(0, limit || 50); const result = messages.map(m => ({ id: m.id, authorName: m.authorName, content: m.content, replyTo: m.replyTo, + reactions: m.reactions || {}, editedAt: m.editedAt, createdAt: m.createdAt, })); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; }, ); + + server.tool( + "rchats_list_thread_messages", + "List messages in a thread (replies to a root message)", + { + space: z.string().describe("Space slug"), + token: z.string().describe("JWT auth token"), + channel_id: z.string().describe("Channel ID"), + thread_id: z.string().describe("Root message ID of the thread"), + }, + async ({ space, token, channel_id, thread_id }) => { + const access = await resolveAccess(token, space, false, true); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(chatChannelDocId(space, channel_id)); + if (!doc) return { content: [{ type: "text" as const, text: JSON.stringify({ error: "Channel not found" }) }] }; + + const rootMsg = doc.messages?.[thread_id]; + const replies = filterArrayByVisibility( + Object.values(doc.messages || {}).filter(m => m.threadId === thread_id), + access.role, + ).sort((a, b) => a.createdAt - b.createdAt); + + const result = { + rootMessage: rootMsg ? { id: rootMsg.id, authorName: rootMsg.authorName, content: rootMsg.content, createdAt: rootMsg.createdAt } : null, + replies: replies.map(m => ({ + id: m.id, authorName: m.authorName, + content: m.content, createdAt: m.createdAt, + })), + replyCount: replies.length, + }; + + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + }, + ); + + server.tool( + "rchats_list_dms", + "List DM channels for the authenticated user", + { + space: z.string().describe("Space slug"), + token: z.string().describe("JWT auth token"), + }, + async ({ space, token }) => { + const access = await resolveAccess(token, space, false, true); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(chatsDirectoryDocId(space)); + if (!doc) return { content: [{ type: "text" as const, text: JSON.stringify({ dms: [] }) }] }; + + const userDid = String(access.claims?.did || access.claims?.sub || ''); + const dms = Object.values(doc.channels || {}) + .filter(ch => ch.isDm && ch.id.includes(userDid)) + .map(ch => ({ id: ch.id, createdAt: ch.createdAt })); + + return { content: [{ type: "text" as const, text: JSON.stringify(dms, null, 2) }] }; + }, + ); + + server.tool( + "rchats_send_message", + "Send a message to a chat channel (write operation — requires auth token)", + { + space: z.string().describe("Space slug"), + token: z.string().describe("JWT auth token"), + channel_id: z.string().describe("Channel ID"), + content: z.string().describe("Message content"), + }, + async ({ space, token, channel_id, content }) => { + const access = await resolveAccess(token, space, true, true); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docId = chatChannelDocId(space, channel_id); + let doc = syncServer.getDoc(docId); + if (!doc) return { content: [{ type: "text" as const, text: JSON.stringify({ error: "Channel not found" }) }] }; + + const id = crypto.randomUUID(); + const authorDid = String(access.claims?.did || access.claims?.sub || ''); + const authorName = String(access.claims?.username || 'MCP'); + syncServer.changeDoc(docId, `mcp send message ${id}`, (d) => { + d.messages[id] = { + id, channelId: channel_id, + authorId: authorDid, + authorName, + content, replyTo: null, + reactions: {}, transclusions: [], + editedAt: null, createdAt: Date.now(), + }; + }); + + return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, messageId: id }) }] }; + }, + ); } diff --git a/server/notification-service.ts b/server/notification-service.ts index da6fcd58..b9fcc2b6 100644 --- a/server/notification-service.ts +++ b/server/notification-service.ts @@ -88,7 +88,9 @@ export type NotificationEventType = // Commitment (rTime) | 'commitment_requested' | 'commitment_accepted' | 'commitment_declined' // Payment - | 'payment_sent' | 'payment_received' | 'payment_request_fulfilled'; + | 'payment_sent' | 'payment_received' | 'payment_request_fulfilled' + // Chat + | 'chat_message' | 'chat_mention' | 'chat_dm'; export interface NotifyOptions { userDid: string; diff --git a/shared/components/rstack-chat-widget.ts b/shared/components/rstack-chat-widget.ts new file mode 100644 index 00000000..2056d2f9 --- /dev/null +++ b/shared/components/rstack-chat-widget.ts @@ -0,0 +1,648 @@ +/** + * — Global chat panel in the header. + * + * Shows a chat icon + unread badge. Click toggles a right-side sliding panel + * with channel selector, message feed, and composer. Works on every page. + */ + +import { getSession } from "./rstack-identity"; + +const POLL_CLOSED_MS = 30_000; // badge poll when panel closed +const POLL_OPEN_MS = 10_000; // message poll when panel open (REST fallback) + +interface Channel { id: string; name: string; description: string } +interface Message { + id: string; channelId: string; + authorId: string; authorName: string; + content: string; createdAt: number; + editedAt: number | null; + reactions: Record; +} + +export class RStackChatWidget extends HTMLElement { + #shadow: ShadowRoot; + #open = false; + #channels: Channel[] = []; + #activeChannelId = ""; + #messages: Message[] = []; + #unreadCount = 0; + #composerText = ""; + #sending = false; + #loading = false; + #badgeTimer: ReturnType | null = null; + #msgTimer: ReturnType | null = null; + + constructor() { + super(); + this.#shadow = this.attachShadow({ mode: "open" }); + } + + get #space(): string { + return document.body?.getAttribute("data-space-slug") || ""; + } + + get #token(): string | null { + return getSession()?.accessToken ?? null; + } + + get #basePath(): string { + return `/${this.#space}/rchats`; + } + + // ── Lifecycle ── + + connectedCallback() { + // Restore persisted state + try { + this.#open = localStorage.getItem("rspace_chat_open") === "1"; + this.#activeChannelId = localStorage.getItem(`rspace_chat_channel_${this.#space}`) || ""; + } catch {} + + this.#render(); + this.#fetchUnreadCount(); + this.#badgeTimer = setInterval(() => this.#fetchUnreadCount(), POLL_CLOSED_MS); + + if (this.#open) this.#openPanel(); + + document.addEventListener("auth-change", this.#onAuthChange); + } + + disconnectedCallback() { + if (this.#badgeTimer) clearInterval(this.#badgeTimer); + if (this.#msgTimer) clearInterval(this.#msgTimer); + document.removeEventListener("auth-change", this.#onAuthChange); + } + + #onAuthChange = () => { + this.#unreadCount = 0; + this.#messages = []; + this.#channels = []; + this.#render(); + this.#fetchUnreadCount(); + }; + + // ── Data fetching ── + + async #fetchUnreadCount() { + if (!this.#space) return; + const since = this.#getLastRead(); + try { + const res = await fetch(`${this.#basePath}/api/unread-count?since=${since}`); + if (res.ok) { + const data = await res.json(); + this.#unreadCount = data.count || 0; + this.#render(); + } + } catch {} + } + + async #fetchChannels() { + if (!this.#space) return; + try { + const headers: Record = {}; + if (this.#token) headers.Authorization = `Bearer ${this.#token}`; + const res = await fetch(`${this.#basePath}/api/channels`, { headers }); + if (res.ok) { + const data = await res.json(); + this.#channels = data.channels || []; + if (!this.#activeChannelId && this.#channels.length > 0) { + this.#activeChannelId = this.#channels[0].id; + } + } + } catch {} + } + + async #fetchMessages() { + if (!this.#space || !this.#activeChannelId) return; + try { + const headers: Record = {}; + if (this.#token) headers.Authorization = `Bearer ${this.#token}`; + const res = await fetch(`${this.#basePath}/api/channels/${this.#activeChannelId}/messages`, { headers }); + if (res.ok) { + const data = await res.json(); + this.#messages = (data.messages || []).slice(-100); // last 100 + this.#markChannelRead(); + this.#render(); + this.#scrollToBottom(); + } + } catch {} + } + + async #sendMessage() { + if (!this.#composerText.trim() || !this.#token || this.#sending) return; + this.#sending = true; + this.#render(); + + try { + // Auto-join channel + await fetch(`${this.#basePath}/api/channels/${this.#activeChannelId}/join`, { + method: "POST", + headers: { Authorization: `Bearer ${this.#token}` }, + }).catch(() => {}); + + const res = await fetch(`${this.#basePath}/api/channels/${this.#activeChannelId}/messages`, { + method: "POST", + headers: { + Authorization: `Bearer ${this.#token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ content: this.#composerText.trim() }), + }); + if (res.ok) { + this.#composerText = ""; + await this.#fetchMessages(); + } + } catch {} + this.#sending = false; + this.#render(); + // Re-focus composer + const input = this.#shadow.querySelector(".composer-input"); + input?.focus(); + } + + // ── Panel toggle ── + + async #openPanel() { + this.#open = true; + try { localStorage.setItem("rspace_chat_open", "1"); } catch {} + this.#loading = true; + this.#render(); + + await this.#fetchChannels(); + if (this.#activeChannelId) { + await this.#fetchMessages(); + } + this.#loading = false; + this.#render(); + this.#scrollToBottom(); + + // Start message polling + if (this.#msgTimer) clearInterval(this.#msgTimer); + this.#msgTimer = setInterval(() => this.#fetchMessages(), POLL_OPEN_MS); + } + + #closePanel() { + this.#open = false; + try { localStorage.setItem("rspace_chat_open", "0"); } catch {} + if (this.#msgTimer) { clearInterval(this.#msgTimer); this.#msgTimer = null; } + this.#render(); + } + + #togglePanel() { + if (this.#open) this.#closePanel(); + else this.#openPanel(); + } + + async #switchChannel(id: string) { + this.#activeChannelId = id; + try { localStorage.setItem(`rspace_chat_channel_${this.#space}`, id); } catch {} + this.#messages = []; + this.#render(); + await this.#fetchMessages(); + } + + // ── Last-read tracking ── + + #getLastRead(): number { + try { + const key = `rspace_chat_last_read_${this.#space}`; + return parseInt(localStorage.getItem(key) || "0", 10) || 0; + } catch { return 0; } + } + + #markChannelRead() { + try { + const key = `rspace_chat_last_read_${this.#space}`; + localStorage.setItem(key, String(Date.now())); + } catch {} + this.#unreadCount = 0; + } + + // ── Helpers ── + + #scrollToBottom() { + requestAnimationFrame(() => { + const feed = this.#shadow.querySelector(".message-feed"); + if (feed) feed.scrollTop = feed.scrollHeight; + }); + } + + #timeAgo(ts: number): string { + const diff = Date.now() - ts; + const mins = Math.floor(diff / 60_000); + if (mins < 1) return "now"; + if (mins < 60) return `${mins}m`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h`; + return `${Math.floor(hrs / 24)}d`; + } + + #escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + + // ── Render ── + + #render() { + const space = this.#space; + if (!space) { this.#shadow.innerHTML = ""; return; } + + const badge = this.#unreadCount > 0 + ? `${this.#unreadCount > 99 ? "99+" : this.#unreadCount}` + : ""; + + let panelHTML = ""; + if (this.#open) { + // Channel selector + const channelOptions = this.#channels.map(ch => + `` + ).join(""); + + // Messages + let feedHTML: string; + if (this.#loading) { + feedHTML = `
Loading...
`; + } else if (this.#messages.length === 0) { + feedHTML = `
No messages yet. Start the conversation!
`; + } else { + feedHTML = this.#messages.map(m => { + const reactions = Object.entries(m.reactions || {}) + .filter(([, users]) => users.length > 0) + .map(([emoji, users]) => `${emoji} ${users.length}`) + .join(""); + return `
+
+ ${this.#escapeHtml(m.authorName)} + ${this.#timeAgo(m.createdAt)} +
+
${this.#escapeHtml(m.content)}
+ ${reactions ? `
${reactions}
` : ""} +
`; + }).join(""); + } + + // Composer + const session = getSession(); + const composerHTML = session + ? `
+ + +
` + : ``; + + panelHTML = ` +
+
+ + +
+
${feedHTML}
+ ${composerHTML} + Open rChats → +
`; + } + + this.#shadow.innerHTML = ` + +
+ + ${panelHTML} +
+ `; + + this.#bindEvents(); + } + + #bindEvents() { + // Toggle button + this.#shadow.querySelector(".chat-btn")?.addEventListener("click", (e) => { + e.stopPropagation(); + this.#togglePanel(); + }); + + // Close button + this.#shadow.querySelector(".close-btn")?.addEventListener("click", (e) => { + e.stopPropagation(); + this.#closePanel(); + }); + + // Channel selector + this.#shadow.querySelector(".channel-select")?.addEventListener("change", (e) => { + const val = (e.target as HTMLSelectElement).value; + if (val !== this.#activeChannelId) this.#switchChannel(val); + }); + + // Composer input + const input = this.#shadow.querySelector(".composer-input"); + if (input) { + // Sync text on input + input.addEventListener("input", () => { + this.#composerText = input.value; + // Auto-resize + input.style.height = "auto"; + input.style.height = Math.min(input.scrollHeight, 80) + "px"; + }); + // Enter to send, Shift+Enter newline + input.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + this.#sendMessage(); + } + }); + } + + // Send button + this.#shadow.querySelector(".composer-send")?.addEventListener("click", (e) => { + e.stopPropagation(); + this.#sendMessage(); + }); + + // Stop panel clicks from propagating + this.#shadow.querySelector(".panel")?.addEventListener("pointerdown", (e) => e.stopPropagation()); + + // Close on outside click + if (this.#open) { + const closeOnOutside = () => { + if (this.#open) this.#closePanel(); + }; + document.addEventListener("pointerdown", closeOnOutside, { once: true }); + } + } + + static define(tag = "rstack-chat-widget") { + if (!customElements.get(tag)) customElements.define(tag, RStackChatWidget); + } +} + +// ============================================================================ +// STYLES +// ============================================================================ + +const STYLES = ` +:host { + display: inline-flex; + align-items: center; +} + +.widget-wrapper { + position: relative; +} + +.chat-btn { + position: relative; + background: none; + border: none; + color: var(--rs-text-muted, #94a3b8); + cursor: pointer; + padding: 6px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.15s, background 0.15s; +} +.chat-btn:hover, .chat-btn:active { + color: var(--rs-text-primary, #e2e8f0); + background: var(--rs-bg-hover, rgba(255,255,255,0.05)); +} + +.badge { + position: absolute; + top: 2px; + right: 2px; + min-width: 16px; + height: 16px; + border-radius: 8px; + background: #ef4444; + color: white; + font-size: 10px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + padding: 0 4px; + line-height: 1; + pointer-events: none; +} + +/* ── Panel ── */ + +.panel { + position: fixed; + top: 56px; + right: 0; + bottom: 0; + width: 360px; + z-index: 200; + background: var(--rs-bg-surface, #1e293b); + border-left: 1px solid var(--rs-border, rgba(255,255,255,0.1)); + box-shadow: -4px 0 20px rgba(0,0,0,0.25); + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.2s ease; +} +.panel.open { + transform: translateX(0); +} + +.panel-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.1)); + flex-shrink: 0; +} + +.channel-select { + flex: 1; + background: var(--rs-bg-hover, rgba(255,255,255,0.05)); + color: var(--rs-text-primary, #e2e8f0); + border: 1px solid var(--rs-border, rgba(255,255,255,0.1)); + border-radius: 6px; + padding: 6px 8px; + font-size: 0.8rem; + cursor: pointer; + outline: none; +} +.channel-select:focus { + border-color: var(--rs-accent, #06b6d4); +} +.channel-select option { + background: var(--rs-bg-surface, #1e293b); + color: var(--rs-text-primary, #e2e8f0); +} + +.close-btn { + background: none; + border: none; + color: var(--rs-text-muted, #94a3b8); + font-size: 1.25rem; + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; + line-height: 1; + transition: color 0.15s, background 0.15s; +} +.close-btn:hover { + color: var(--rs-text-primary, #e2e8f0); + background: var(--rs-bg-hover, rgba(255,255,255,0.05)); +} + +/* ── Message feed ── */ + +.message-feed { + flex: 1; + overflow-y: auto; + padding: 8px 0; +} + +.empty { + padding: 32px 16px; + text-align: center; + color: var(--rs-text-muted, #94a3b8); + font-size: 0.8rem; +} + +.msg { + padding: 6px 12px; + transition: background 0.1s; +} +.msg:hover { + background: var(--rs-bg-hover, rgba(255,255,255,0.03)); +} + +.msg-header { + display: flex; + align-items: baseline; + gap: 6px; +} + +.msg-author { + font-size: 0.75rem; + font-weight: 600; + color: var(--rs-accent, #06b6d4); +} + +.msg-time { + font-size: 0.65rem; + color: var(--rs-text-muted, #64748b); +} + +.msg-content { + font-size: 0.8rem; + color: var(--rs-text-primary, #e2e8f0); + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; + margin-top: 1px; +} + +.msg-reactions { + display: flex; + gap: 4px; + margin-top: 3px; + flex-wrap: wrap; +} + +.reaction { + font-size: 0.7rem; + background: var(--rs-bg-hover, rgba(255,255,255,0.05)); + border: 1px solid var(--rs-border, rgba(255,255,255,0.1)); + border-radius: 10px; + padding: 1px 6px; + color: var(--rs-text-muted, #94a3b8); +} + +/* ── Composer ── */ + +.composer { + display: flex; + align-items: flex-end; + gap: 6px; + padding: 8px 12px; + border-top: 1px solid var(--rs-border, rgba(255,255,255,0.1)); + flex-shrink: 0; +} + +.composer-input { + flex: 1; + background: var(--rs-bg-hover, rgba(255,255,255,0.05)); + color: var(--rs-text-primary, #e2e8f0); + border: 1px solid var(--rs-border, rgba(255,255,255,0.1)); + border-radius: 8px; + padding: 8px 10px; + font-size: 0.8rem; + font-family: inherit; + resize: none; + outline: none; + min-height: 36px; + max-height: 80px; + line-height: 1.4; +} +.composer-input::placeholder { + color: var(--rs-text-muted, #64748b); +} +.composer-input:focus { + border-color: var(--rs-accent, #06b6d4); +} + +.composer-send { + background: var(--rs-accent, #06b6d4); + border: none; + color: white; + border-radius: 8px; + padding: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.15s; + flex-shrink: 0; +} +.composer-send:hover { opacity: 0.85; } +.composer-send:disabled { opacity: 0.5; cursor: not-allowed; } + +.composer-signin { + padding: 10px 12px; + text-align: center; + color: var(--rs-text-muted, #64748b); + font-size: 0.8rem; + border-top: 1px solid var(--rs-border, rgba(255,255,255,0.1)); + flex-shrink: 0; +} + +/* ── Full page link ── */ + +.full-link { + display: block; + padding: 8px 12px; + text-align: center; + font-size: 0.75rem; + color: var(--rs-accent, #06b6d4); + text-decoration: none; + border-top: 1px solid var(--rs-border, rgba(255,255,255,0.1)); + flex-shrink: 0; + transition: background 0.15s; +} +.full-link:hover { + background: var(--rs-bg-hover, rgba(255,255,255,0.05)); +} + +/* ── Mobile ── */ + +@media (max-width: 640px) { + .panel { + width: 100%; + left: 0; + } +} +`; diff --git a/vite.config.ts b/vite.config.ts index 85d2f916..0c503596 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -756,6 +756,26 @@ export default defineConfig({ resolve(__dirname, "dist/modules/rtasks/tasks.css"), ); + // Build chats module component + await wasmBuild({ + configFile: false, + root: resolve(__dirname, "modules/rchats/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rchats"), + lib: { + entry: resolve(__dirname, "modules/rchats/components/folk-chat-app.ts"), + formats: ["es"], + fileName: () => "folk-chat-app.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-chat-app.js", + }, + }, + }, + }); + // Build trips module component await wasmBuild({ configFile: false, @@ -1211,6 +1231,26 @@ export default defineConfig({ }, }); + // Build data cloud component + await wasmBuild({ + configFile: false, + root: resolve(__dirname, "modules/rdata/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rdata"), + lib: { + entry: resolve(__dirname, "modules/rdata/components/folk-data-cloud.ts"), + formats: ["es"], + fileName: () => "folk-data-cloud.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-data-cloud.js", + }, + }, + }, + }); + // Copy data CSS mkdirSync(resolve(__dirname, "dist/modules/rdata"), { recursive: true }); copyFileSync( diff --git a/website/shell.ts b/website/shell.ts index e89ae4f3..361f6003 100644 --- a/website/shell.ts +++ b/website/shell.ts @@ -18,6 +18,7 @@ import { RStackModuleSetup } from "../shared/components/rstack-module-setup"; import { RStackOfflineIndicator } from "../shared/components/rstack-offline-indicator"; import { RStackSharePanel } from "../shared/components/rstack-share-panel"; import { RStackCommentBell } from "../shared/components/rstack-comment-bell"; +import { RStackChatWidget } from "../shared/components/rstack-chat-widget"; import { RStackCollabOverlay } from "../shared/components/rstack-collab-overlay"; import { RStackModuleComments } from "../shared/components/rstack-module-comments"; import { RStackUserDashboard } from "../shared/components/rstack-user-dashboard"; @@ -42,6 +43,7 @@ RStackModuleSetup.define(); RStackOfflineIndicator.define(); RStackSharePanel.define(); RStackCommentBell.define(); +RStackChatWidget.define(); RStackCollabOverlay.define(); RStackModuleComments.define(); RStackUserDashboard.define();