667 lines
23 KiB
TypeScript
667 lines
23 KiB
TypeScript
/**
|
|
* rChats module — encrypted community messaging with channels, threads, DMs.
|
|
*
|
|
* 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, 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, dmChannelDocId,
|
|
} from './schemas';
|
|
import type {
|
|
ChatsDirectoryDoc, ChatChannelDoc, ChannelInfo, ChatMessage,
|
|
Transclusion, ThreadMeta,
|
|
} 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, isDm = false): ChatChannelDoc {
|
|
const docId = isDm ? channelId : 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;
|
|
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<ChatsDirectoryDoc>(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";
|
|
|
|
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 = ensureDirectoryDoc(space);
|
|
const visibleChannels = filterByVisibility(doc.channels || {}, callerRole);
|
|
// 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 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<ChatsDirectoryDoc>(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(),
|
|
};
|
|
});
|
|
ensureChannelDoc(space, id);
|
|
const updated = _syncServer!.getDoc<ChatsDirectoryDoc>(docId)!;
|
|
return c.json(updated.channels[id], 201);
|
|
});
|
|
|
|
// ── CRUD: Messages ──
|
|
|
|
routes.get("/api/channels/:channelId/messages", async (c) => {
|
|
if (!_syncServer) return c.json({ messages: [] });
|
|
const space = c.req.param("space") || "demo";
|
|
const channelId = c.req.param("channelId");
|
|
|
|
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);
|
|
// Only return top-level messages (not thread replies)
|
|
const messages = filterArrayByVisibility(
|
|
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 auth = await requireAuth(c);
|
|
if (!auth) return c.res;
|
|
const { claims, space } = auth;
|
|
const channelId = c.req.param("channelId");
|
|
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);
|
|
|
|
const authorDid = claims.did || claims.sub || '';
|
|
const authorName = (claims.displayName as string) || claims.username || 'Anonymous';
|
|
|
|
_syncServer!.changeDoc<ChatChannelDoc>(docId, `add message ${id}`, (d) => {
|
|
d.messages[id] = {
|
|
id, channelId,
|
|
authorId: authorDid,
|
|
authorName,
|
|
content, replyTo,
|
|
reactions: {}, transclusions: transclusions || [],
|
|
editedAt: null, createdAt: Date.now(),
|
|
};
|
|
});
|
|
|
|
// Notify channel members (skip sender)
|
|
const updated = _syncServer!.getDoc<ChatChannelDoc>(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<ChatChannelDoc>(docId, `edit message ${msgId}`, (d) => {
|
|
d.messages[msgId].content = content;
|
|
d.messages[msgId].editedAt = Date.now();
|
|
});
|
|
const updated = _syncServer!.getDoc<ChatChannelDoc>(docId)!;
|
|
return c.json(updated.messages[msgId]);
|
|
});
|
|
|
|
// ── Delete message ──
|
|
|
|
routes.delete("/api/channels/:channelId/messages/:msgId", async (c) => {
|
|
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<ChatChannelDoc>(docId, `delete message ${msgId}`, (d) => { delete d.messages[msgId]; });
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
// ── 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<ChatChannelDoc>(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<ChatChannelDoc>(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<ChatChannelDoc>(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<ChatChannelDoc>(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<ChatChannelDoc>(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<ChatChannelDoc>(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<ChatsDirectoryDoc>(dirDocId)!;
|
|
if (!dir.channels[channelId]) {
|
|
_syncServer!.changeDoc<ChatsDirectoryDoc>(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<ChatChannelDoc>(dmDocId);
|
|
if (!doc) {
|
|
doc = Automerge.change(Automerge.init<ChatChannelDoc>(), '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<ChatChannelDoc>(dmDocId);
|
|
if (!doc) {
|
|
doc = Automerge.change(Automerge.init<ChatChannelDoc>(), '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<ChatChannelDoc>(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<ChatChannelDoc>(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<ChatChannelDoc>(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<ChatChannelDoc>(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<string, number> = {};
|
|
let total = 0;
|
|
|
|
for (const ch of Object.values(dir.channels || {})) {
|
|
if (ch.isDm) continue;
|
|
const doc = _syncServer!.getDoc<ChatChannelDoc>(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";
|
|
return c.html(renderShell({
|
|
title: `rChats — ${space} | rSpace`,
|
|
moduleId: "rchats",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
body: `<folk-chat-app space="${space}"></folk-chat-app>`,
|
|
scripts: `<script type="module" src="/modules/rchats/folk-chat-app.js?v=1"></script>`,
|
|
}));
|
|
});
|
|
|
|
// ── 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: "\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);
|
|
},
|
|
};
|