rspace-online/modules/rchats/mod.ts

214 lines
9.2 KiB
TypeScript

/**
* 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<ChatsDirectoryDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ChatsDirectoryDoc>(), '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<ChatChannelDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ChatChannelDoc>(), '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<ChatsDirectoryDoc>(docId, `create channel ${id}`, (d) => {
d.channels[id] = { id, name, description, isPrivate, createdBy: null, createdAt: Date.now(), updatedAt: Date.now() };
});
const updated = _syncServer.getDoc<ChatsDirectoryDoc>(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<ChatChannelDoc>(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<ChatChannelDoc>(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<ChatChannelDoc>(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: `<style>
.rs-hub{max-width:720px;margin:2rem auto;padding:0 1.5rem}
.rs-hub h1{font-size:1.8rem;margin-bottom:.5rem;color:var(--rs-text-primary)}
.rs-hub p{color:var(--rs-text-secondary);margin-bottom:2rem}
.rs-coming{display:flex;flex-direction:column;align-items:center;gap:1.5rem;padding:3rem 1.5rem;border-radius:16px;background:var(--rs-bg-surface);border:1px solid var(--rs-border);text-align:center}
.rs-coming .coming-icon{font-size:3rem}
.rs-coming h2{font-size:1.4rem;color:var(--rs-text-primary);margin:0}
.rs-coming p{color:var(--rs-text-secondary);max-width:480px;margin:0}
.rs-coming .coming-badge{display:inline-block;padding:4px 12px;border-radius:8px;background:rgba(99,102,241,0.15);color:var(--rs-accent,#6366f1);font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em}
.rs-features{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1rem;margin-top:2rem}
.rs-feature{padding:1.25rem;border-radius:12px;background:var(--rs-bg-surface);border:1px solid var(--rs-border)}
.rs-feature h3{font-size:1rem;margin:0 0 .5rem;color:var(--rs-text-primary)}
.rs-feature p{font-size:.85rem;color:var(--rs-text-secondary);margin:0}
@media(max-width:600px){.rs-hub{margin:1rem auto;padding:0 .75rem}}
</style>`,
body: `<div class="rs-hub">
<h1>rChats</h1>
<p>Encrypted community messaging — channels, threads, and bridges</p>
<div class="rs-coming">
<span class="coming-icon">🗨️</span>
<span class="coming-badge">Coming Soon</span>
<h2>Encrypted Community Chat</h2>
<p>Real-time messaging with channels and threads, end-to-end encrypted via EncryptID. Local-first with Automerge CRDTs — works offline, syncs seamlessly.</p>
</div>
<div class="rs-features">
<div class="rs-feature">
<h3>🔐 E2E Encrypted</h3>
<p>Messages encrypted with EncryptID passkeys. The server never sees plaintext.</p>
</div>
<div class="rs-feature">
<h3>💬 Channels &amp; Threads</h3>
<p>Organize conversations by topic. Threaded replies keep the main feed clean.</p>
</div>
<div class="rs-feature">
<h3>🔗 Chat Bridges</h3>
<p>Connect Slack, Discord, Matrix, Telegram, and Mattermost into one unified view.</p>
</div>
<div class="rs-feature">
<h3>📡 Local-First</h3>
<p>Built on Automerge CRDTs. Send messages offline and sync when reconnected.</p>
</div>
</div>
</div>`,
}));
});
// ── 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<ChatChannelDoc>(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; },
};