From a2dbf4533aad24dcec3c9b063dd1136733890416 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 15 Apr 2026 11:15:33 -0400 Subject: [PATCH] feat(rchats): add global chat widget + unread count endpoint Persistent chat panel accessible from any page via header icon. Sliding right panel (360px) with channel selector, message feed, composer, and unread badge. REST polling with localStorage state persistence. Includes unread-count API endpoint for badge updates. Co-Authored-By: Claude Opus 4.6 --- modules/rchats/components/folk-chat-app.ts | 1093 +++++++++++++++++++ modules/rchats/mod.ts | 585 ++++++++-- modules/rchats/schemas.ts | 42 +- modules/rdata/components/data.css | 3 +- modules/rdata/components/folk-data-cloud.ts | 436 ++++++++ modules/rdata/mod.ts | 10 +- server/mcp-tools/rchats.ts | 105 +- server/notification-service.ts | 4 +- shared/components/rstack-chat-widget.ts | 648 +++++++++++ vite.config.ts | 40 + website/shell.ts | 2 + 11 files changed, 2874 insertions(+), 94 deletions(-) create mode 100644 modules/rchats/components/folk-chat-app.ts create mode 100644 modules/rdata/components/folk-data-cloud.ts create mode 100644 shared/components/rstack-chat-widget.ts 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();