/** * rChats module — encrypted community messaging. * * Stub module: landing page + "Coming Soon" dashboard. * Real chat functionality (Automerge CRDT, channels, threads) will come later. */ 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 { verifyToken, extractToken } from "../../server/auth"; 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'; let _syncServer: SyncServer | null = null; const routes = new Hono(); // ── Local-first helpers ── function ensureDirectoryDoc(space: string): ChatsDirectoryDoc { const docId = chatsDirectoryDocId(space); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init chats directory', (d) => { const init = chatsDirectorySchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; }); _syncServer!.setDoc(docId, doc); } return doc; } function ensureChannelDoc(space: string, channelId: string): ChatChannelDoc { const docId = chatChannelDocId(space, channelId); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init channel', (d) => { const init = chatChannelSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; d.channelId = channelId; }); _syncServer!.setDoc(docId, doc); } return doc; } // ── CRUD: Channels ── routes.get("/api/channels", (c) => { if (!_syncServer) return c.json({ channels: [] }); const space = c.req.param("space") || "demo"; const doc = ensureDirectoryDoc(space); return c.json({ channels: Object.values(doc.channels || {}) }); }); 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 { 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() }; }); const updated = _syncServer.getDoc(docId)!; return c.json(updated.channels[id], 201); }); // ── CRUD: Messages ── routes.get("/api/channels/:channelId/messages", (c) => { if (!_syncServer) return c.json({ messages: [] }); const space = c.req.param("space") || "demo"; const channelId = c.req.param("channelId"); const doc = ensureChannelDoc(space, channelId); const messages = Object.values(doc.messages || {}).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 channelId = c.req.param("channelId"); const { content, replyTo = null } = 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 updated = _syncServer.getDoc(docId)!; return c.json(updated.messages[id], 201); }); 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 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]; }); return c.json({ ok: true }); }); // ── Hub page (Coming Soon dashboard) ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ title: `rChats — ${space} | rSpace`, 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.

`, })); }); // ── MI Integration ── export function getRecentMessagesForMI(space: string, limit = 5): { id: string; channel: string; author: string; content: string; createdAt: number }[] { if (!_syncServer) return []; const all: { id: string; channel: string; author: string; content: string; createdAt: number }[] = []; for (const docId of _syncServer.listDocs()) { if (!docId.startsWith(`${space}:chats:channel:`)) continue; const doc = _syncServer.getDoc(docId); if (!doc?.messages) continue; for (const msg of Object.values(doc.messages)) { all.push({ id: msg.id, channel: msg.channelId, author: msg.authorName, content: msg.content.slice(0, 200), createdAt: msg.createdAt }); } } return all.sort((a, b) => b.createdAt - a.createdAt).slice(0, limit); } // ── Module export ── export const chatsModule: RSpaceModule = { id: "rchats", name: "rChats", icon: "🗨️", 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 }, ], routes, landingPage: renderLanding, async onInit(ctx) { _syncServer = ctx.syncServer; }, };