214 lines
9.2 KiB
TypeScript
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 & 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; },
|
|
};
|