Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m3s Details

This commit is contained in:
Jeff Emmett 2026-04-15 11:15:41 -04:00
commit 3f333ee125
11 changed files with 2874 additions and 94 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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. * Local-first via Automerge CRDT. One doc per channel, directory doc per space.
* Real chat functionality (Automerge CRDT, channels, threads) will come later. * DMs as private two-member channels with deterministic doc IDs.
*/ */
import { Hono } from "hono"; import { Hono } from "hono";
import * as Automerge from "@automerge/automerge"; import * as Automerge from "@automerge/automerge";
import { renderShell } from "../../server/shell"; import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; 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 { verifyToken, extractToken } from "../../server/auth";
import { resolveCallerRole } from "../../server/spaces"; import { resolveCallerRole } from "../../server/spaces";
import type { SpaceRoleString } from "../../server/spaces"; import type { SpaceRoleString } from "../../server/spaces";
import { filterByVisibility, filterArrayByVisibility } from "../../shared/membrane"; import { filterByVisibility, filterArrayByVisibility } from "../../shared/membrane";
import { notify } from '../../server/notification-service';
import { renderLanding } from "./landing"; import { renderLanding } from "./landing";
import type { SyncServer } from '../../server/local-first/sync-server'; import type { SyncServer } from '../../server/local-first/sync-server';
import { chatsDirectorySchema, chatChannelSchema, chatsDirectoryDocId, chatChannelDocId } from './schemas'; import {
import type { ChatsDirectoryDoc, ChatChannelDoc, ChannelInfo, ChatMessage } from './schemas'; chatsDirectorySchema, chatChannelSchema,
chatsDirectoryDocId, chatChannelDocId, dmChannelDocId,
} from './schemas';
import type {
ChatsDirectoryDoc, ChatChannelDoc, ChannelInfo, ChatMessage,
Transclusion, ThreadMeta,
} from './schemas';
let _syncServer: SyncServer | null = null; let _syncServer: SyncServer | null = null;
@ -39,8 +46,8 @@ function ensureDirectoryDoc(space: string): ChatsDirectoryDoc {
return doc; return doc;
} }
function ensureChannelDoc(space: string, channelId: string): ChatChannelDoc { function ensureChannelDoc(space: string, channelId: string, isDm = false): ChatChannelDoc {
const docId = chatChannelDocId(space, channelId); const docId = isDm ? channelId : chatChannelDocId(space, channelId);
let doc = _syncServer!.getDoc<ChatChannelDoc>(docId); let doc = _syncServer!.getDoc<ChatChannelDoc>(docId);
if (!doc) { if (!doc) {
doc = Automerge.change(Automerge.init<ChatChannelDoc>(), 'init channel', (d) => { doc = Automerge.change(Automerge.init<ChatChannelDoc>(), 'init channel', (d) => {
@ -48,19 +55,58 @@ function ensureChannelDoc(space: string, channelId: string): ChatChannelDoc {
Object.assign(d, init); Object.assign(d, init);
d.meta.spaceSlug = space; d.meta.spaceSlug = space;
d.channelId = channelId; d.channelId = channelId;
d.isDm = isDm;
}); });
_syncServer!.setDoc(docId, doc); _syncServer!.setDoc(docId, doc);
} }
return 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 ── // ── CRUD: Channels ──
routes.get("/api/channels", async (c) => { routes.get("/api/channels", async (c) => {
if (!_syncServer) return c.json({ channels: [] }); if (!_syncServer) return c.json({ channels: [] });
const space = c.req.param("space") || "demo"; const space = c.req.param("space") || "demo";
// Resolve caller role for membrane filtering
let callerRole: SpaceRoleString = 'viewer'; let callerRole: SpaceRoleString = 'viewer';
const token = extractToken(c.req.raw.headers); const token = extractToken(c.req.raw.headers);
if (token) { if (token) {
@ -73,24 +119,29 @@ routes.get("/api/channels", async (c) => {
const doc = ensureDirectoryDoc(space); const doc = ensureDirectoryDoc(space);
const visibleChannels = filterByVisibility(doc.channels || {}, callerRole); 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) => { routes.post("/api/channels", async (c) => {
const token = extractToken(c.req.raw.headers); const auth = await requireAuth(c);
if (!token) return c.json({ error: "Authentication required" }, 401); if (!auth) return c.res;
try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const { claims, space } = auth;
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(); const { name, description = "", isPrivate = false } = await c.req.json();
if (!name) return c.json({ error: "name required" }, 400); if (!name) return c.json({ error: "name required" }, 400);
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const docId = chatsDirectoryDocId(space); const docId = chatsDirectoryDocId(space);
ensureDirectoryDoc(space); ensureDirectoryDoc(space);
_syncServer.changeDoc<ChatsDirectoryDoc>(docId, `create channel ${id}`, (d) => { _syncServer!.changeDoc<ChatsDirectoryDoc>(docId, `create channel ${id}`, (d) => {
d.channels[id] = { id, name, description, isPrivate, createdBy: null, createdAt: Date.now(), updatedAt: Date.now() }; 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<ChatsDirectoryDoc>(docId)!; ensureChannelDoc(space, id);
const updated = _syncServer!.getDoc<ChatsDirectoryDoc>(docId)!;
return c.json(updated.channels[id], 201); 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 space = c.req.param("space") || "demo";
const channelId = c.req.param("channelId"); const channelId = c.req.param("channelId");
// Resolve caller role for membrane filtering
let callerRole: SpaceRoleString = 'viewer'; let callerRole: SpaceRoleString = 'viewer';
const token = extractToken(c.req.raw.headers); const token = extractToken(c.req.raw.headers);
if (token) { if (token) {
@ -113,48 +163,457 @@ routes.get("/api/channels/:channelId/messages", async (c) => {
} }
const doc = ensureChannelDoc(space, channelId); const doc = ensureChannelDoc(space, channelId);
// Only return top-level messages (not thread replies)
const messages = filterArrayByVisibility( const messages = filterArrayByVisibility(
Object.values(doc.messages || {}), callerRole, Object.values(doc.messages || {}).filter(m => !m.threadId),
callerRole,
).sort((a, b) => a.createdAt - b.createdAt); ).sort((a, b) => a.createdAt - b.createdAt);
return c.json({ messages }); return c.json({ messages });
}); });
routes.post("/api/channels/:channelId/messages", async (c) => { routes.post("/api/channels/:channelId/messages", async (c) => {
const token = extractToken(c.req.raw.headers); const auth = await requireAuth(c);
if (!token) return c.json({ error: "Authentication required" }, 401); if (!auth) return c.res;
let claims; const { claims, space } = auth;
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 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); if (!content) return c.json({ error: "content required" }, 400);
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const docId = chatChannelDocId(space, channelId); const docId = chatChannelDocId(space, channelId);
ensureChannelDoc(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 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(),
};
}); });
const updated = _syncServer.getDoc<ChatChannelDoc>(docId)!;
// 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); 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) => { routes.delete("/api/channels/:channelId/messages/:msgId", async (c) => {
const token = extractToken(c.req.raw.headers); const auth = await requireAuth(c);
if (!token) return c.json({ error: "Authentication required" }, 401); if (!auth) return c.res;
try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const { space } = auth;
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
const space = c.req.param("space") || "demo";
const channelId = c.req.param("channelId"); const channelId = c.req.param("channelId");
const msgId = c.req.param("msgId"); const msgId = c.req.param("msgId");
const docId = chatChannelDocId(space, channelId); const docId = chatChannelDocId(space, channelId);
const doc = ensureChannelDoc(space, channelId); const doc = ensureChannelDoc(space, channelId);
if (!doc.messages[msgId]) return c.json({ error: "Not found" }, 404); if (!doc.messages[msgId]) return c.json({ error: "Not found" }, 404);
_syncServer.changeDoc<ChatChannelDoc>(docId, `delete message ${msgId}`, (d) => { delete d.messages[msgId]; }); _syncServer!.changeDoc<ChatChannelDoc>(docId, `delete message ${msgId}`, (d) => { delete d.messages[msgId]; });
return c.json({ ok: true }); 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<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) => { routes.get("/", (c) => {
const space = c.req.param("space") || "demo"; const space = c.req.param("space") || "demo";
@ -163,49 +622,8 @@ routes.get("/", (c) => {
moduleId: "rchats", moduleId: "rchats",
spaceSlug: space, spaceSlug: space,
modules: getModuleInfoList(), modules: getModuleInfoList(),
styles: `<style> body: `<folk-chat-app space="${space}"></folk-chat-app>`,
.rs-hub{max-width:720px;margin:2rem auto;padding:0 1.5rem} scripts: `<script type="module" src="/modules/rchats/folk-chat-app.js?v=1"></script>`,
.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>`,
})); }));
}); });
@ -230,14 +648,19 @@ export function getRecentMessagesForMI(space: string, limit = 5): { id: string;
export const chatsModule: RSpaceModule = { export const chatsModule: RSpaceModule = {
id: "rchats", id: "rchats",
name: "rChats", name: "rChats",
icon: "🗨️", icon: "\u{1F4AC}",
description: "Encrypted community messaging", description: "Encrypted community messaging",
scoping: { defaultScope: "space", userConfigurable: false }, scoping: { defaultScope: "space", userConfigurable: false },
docSchemas: [ docSchemas: [
{ pattern: '{space}:chats:channels', description: 'Channel directory per space', init: chatsDirectorySchema.init }, { 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:channel:{channelId}', description: 'Messages per channel', init: chatChannelSchema.init },
{ pattern: '{space}:chats:dm:{did1}+{did2}', description: 'DM channel', init: chatChannelSchema.init },
], ],
routes, routes,
landingPage: renderLanding, landingPage: renderLanding,
async onInit(ctx) { _syncServer = ctx.syncServer; }, async onInit(ctx) { _syncServer = ctx.syncServer; },
async onSpaceCreate(ctx: SpaceLifecycleContext) {
if (!_syncServer) return;
seedGeneralChannel(ctx.spaceSlug);
},
}; };

View File

@ -4,10 +4,29 @@
* Granularity: one directory doc per space + one doc per channel. * Granularity: one directory doc per space + one doc per channel.
* DocId format: {space}:chats:channels (directory) * DocId format: {space}:chats:channels (directory)
* {space}:chats:channel:{channelId} (messages) * {space}:chats:channel:{channelId} (messages)
* {space}:chats:dm:{sortedDid1+Did2} (DM channels)
*/ */
import type { DocSchema } from '../../shared/local-first/document'; 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 ── // ── Document types ──
export interface ChannelInfo { export interface ChannelInfo {
@ -15,6 +34,7 @@ export interface ChannelInfo {
name: string; name: string;
description: string; description: string;
isPrivate: boolean; isPrivate: boolean;
isDm: boolean;
createdBy: string | null; createdBy: string | null;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
@ -45,6 +65,9 @@ export interface ChatMessage {
authorName: string; authorName: string;
content: string; content: string;
replyTo: string | null; replyTo: string | null;
threadId?: string; // points to root message ID if this is a thread reply
reactions: Record<string, string[]>; // emoji → DID[]
transclusions: Transclusion[];
editedAt: number | null; editedAt: number | null;
createdAt: number; createdAt: number;
visibility?: import('../../shared/membrane').ObjectVisibility; visibility?: import('../../shared/membrane').ObjectVisibility;
@ -61,6 +84,9 @@ export interface ChatChannelDoc {
channelId: string; channelId: string;
messages: Record<string, ChatMessage>; messages: Record<string, ChatMessage>;
members: Record<string, Member>; members: Record<string, Member>;
threads: Record<string, ThreadMeta>; // rootMessageId → thread metadata
pins: string[]; // pinned message IDs
isDm: boolean;
} }
// ── Schema registration ── // ── Schema registration ──
@ -68,12 +94,12 @@ export interface ChatChannelDoc {
export const chatsDirectorySchema: DocSchema<ChatsDirectoryDoc> = { export const chatsDirectorySchema: DocSchema<ChatsDirectoryDoc> = {
module: 'chats', module: 'chats',
collection: 'channels', collection: 'channels',
version: 1, version: 2,
init: (): ChatsDirectoryDoc => ({ init: (): ChatsDirectoryDoc => ({
meta: { meta: {
module: 'chats', module: 'chats',
collection: 'channels', collection: 'channels',
version: 1, version: 2,
spaceSlug: '', spaceSlug: '',
createdAt: Date.now(), createdAt: Date.now(),
}, },
@ -84,18 +110,21 @@ export const chatsDirectorySchema: DocSchema<ChatsDirectoryDoc> = {
export const chatChannelSchema: DocSchema<ChatChannelDoc> = { export const chatChannelSchema: DocSchema<ChatChannelDoc> = {
module: 'chats', module: 'chats',
collection: 'channel', collection: 'channel',
version: 1, version: 2,
init: (): ChatChannelDoc => ({ init: (): ChatChannelDoc => ({
meta: { meta: {
module: 'chats', module: 'chats',
collection: 'channel', collection: 'channel',
version: 1, version: 2,
spaceSlug: '', spaceSlug: '',
createdAt: Date.now(), createdAt: Date.now(),
}, },
channelId: '', channelId: '',
messages: {}, messages: {},
members: {}, members: {},
threads: {},
pins: [],
isDm: false,
}), }),
}; };
@ -108,3 +137,8 @@ export function chatsDirectoryDocId(space: string) {
export function chatChannelDocId(space: string, channelId: string) { export function chatChannelDocId(space: string, channelId: string) {
return `${space}:chats:channel:${channelId}` as const; 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]}`;
}

View File

@ -1,6 +1,7 @@
/* Data module — layout wrapper */ /* Data module — layout wrapper */
folk-analytics-view, folk-analytics-view,
folk-content-tree { folk-content-tree,
folk-data-cloud {
display: block; display: block;
padding: 1.5rem; padding: 1.5rem;
} }

View File

@ -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<Ring, { color: string; label: string; radius: number }> = {
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<Ring, SpaceBubble[]> {
const groups: Record<Ring, SpaceBubble[]> = { 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 = `
<style>${this.styles()}</style>
<div class="dc">
${this.isDemo ? `<div class="dc-banner">Sign in to see your data cloud</div>` : ""}
${this.loading ? this.renderLoading() : this.renderSVG()}
${selected ? this.renderDetailPanel(selected) : ""}
</div>
`;
this.attachEvents();
}
private renderLoading(): string {
const cx = this.width / 2;
const cy = this.height / 2;
return `
<svg class="dc-svg" viewBox="0 0 ${this.width} ${this.height}" width="${this.width}" height="${this.height}">
${RINGS.map(ring => {
const r = RING_CONFIG[ring].radius * (this.width / 2) * 0.9;
return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none"
stroke="var(--rs-border)" stroke-width="1" stroke-dasharray="4 4" opacity="0.3"/>`;
}).join("")}
<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central"
fill="var(--rs-text-muted)" font-size="14">Loading your data cloud</text>
</svg>
`;
}
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 = `<svg class="dc-svg" viewBox="0 0 ${this.width} ${this.height}" width="${this.width}" height="${this.height}">`;
// 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 += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none"
stroke="${cfg.color}" stroke-width="1.5" stroke-dasharray="6 4" opacity="0.4"/>`;
// Ring label at top
const labelY = cy - r - 8;
svg += `<text x="${cx}" y="${labelY}" text-anchor="middle" fill="${cfg.color}"
font-size="${mobile ? 10 : 12}" font-weight="600" opacity="0.7">${cfg.label}</text>`;
}
// 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 += `<g class="dc-bubble" data-slug="${this.escAttr(sp.slug)}" style="cursor:pointer">`;
if (isSelected) {
svg += `<circle cx="${bx}" cy="${by}" r="${r + 5}" fill="none"
stroke="${cfg.color}" stroke-width="2" stroke-dasharray="4 3" opacity="0.6">
<animate attributeName="stroke-dashoffset" from="0" to="-14" dur="1s" repeatCount="indefinite"/>
</circle>`;
}
svg += `<circle cx="${bx}" cy="${by}" r="${r}" fill="${cfg.color}" fill-opacity="${fillOpacity}"
stroke="${cfg.color}" stroke-width="${strokeW}"/>`;
// Label
const label = mobile ? sp.name.slice(0, 6) : (sp.name.length > 12 ? sp.name.slice(0, 11) + "…" : sp.name);
svg += `<text x="${bx}" y="${by - 2}" text-anchor="middle" dominant-baseline="central"
fill="var(--rs-text-primary)" font-size="${mobile ? 8 : 10}" font-weight="500"
pointer-events="none">${this.esc(label)}</text>`;
// Doc count badge
svg += `<text x="${bx}" y="${by + (mobile ? 9 : 11)}" text-anchor="middle"
fill="${cfg.color}" font-size="${mobile ? 7 : 9}" font-weight="600"
pointer-events="none">${sp.docCount}</text>`;
// Tooltip (title element)
svg += `<title>${this.esc(sp.name)}${sp.docCount} doc${sp.docCount !== 1 ? "s" : ""} (${sp.visibility})</title>`;
svg += `</g>`;
}
}
// Center label
const totalDocs = this.spaces.reduce((s, sp) => s + sp.docCount, 0);
svg += `<text x="${cx}" y="${cy - 6}" text-anchor="middle" fill="var(--rs-text-primary)"
font-size="${mobile ? 16 : 20}" font-weight="700">${totalDocs}</text>`;
svg += `<text x="${cx}" y="${cy + 12}" text-anchor="middle" fill="var(--rs-text-muted)"
font-size="${mobile ? 9 : 11}">total documents</text>`;
svg += `</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 `
<div class="dc-panel">
<div class="dc-panel__header">
<span class="dc-panel__name">${this.esc(sp.name)}</span>
<span class="dc-panel__vis" style="color:${visBadgeColor};border-color:${visBadgeColor}">${sp.visibility}</span>
<span class="dc-panel__count">${sp.docCount} doc${sp.docCount !== 1 ? "s" : ""}</span>
</div>
${sp.modules.length === 0
? `<div class="dc-panel__empty">No documents in this space</div>`
: `<div class="dc-panel__modules">
${sp.modules.map(m => `
<div class="dc-panel__mod" data-nav-space="${this.escAttr(sp.slug)}" data-nav-mod="${this.escAttr(m.id)}">
<span class="dc-panel__mod-icon">${m.icon}</span>
<span class="dc-panel__mod-name">${this.esc(m.name)}</span>
<span class="dc-panel__mod-count">${m.docCount}</span>
</div>
`).join("")}
</div>`
}
</div>
`;
}
private attachEvents() {
// Bubble click — toggle selection
for (const g of this.shadow.querySelectorAll<SVGGElement>(".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<HTMLElement>(".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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
private escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
}
private styles(): string {
return `
:host { display: block; min-height: 60vh; font-family: system-ui, sans-serif; color: var(--rs-text-primary); }
.dc { max-width: 900px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; }
.dc-banner {
width: 100%; text-align: center; padding: 0.5rem;
background: rgba(234, 179, 8, 0.1); border: 1px solid rgba(234, 179, 8, 0.3);
border-radius: 8px; color: #eab308; font-size: 0.85rem; margin-bottom: 1rem;
}
.dc-svg { display: block; margin: 0 auto; max-width: 100%; height: auto; }
/* Detail panel */
.dc-panel {
width: 100%; max-width: 500px; margin-top: 1rem;
background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-border);
border-radius: 10px; padding: 1rem; animation: dc-slideIn 0.2s ease-out;
}
@keyframes dc-slideIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.dc-panel__header {
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem;
padding-bottom: 0.5rem; border-bottom: 1px solid var(--rs-border);
}
.dc-panel__name { font-weight: 600; font-size: 1rem; flex: 1; }
.dc-panel__vis {
font-size: 0.7rem; padding: 0.15rem 0.5rem; border-radius: 10px;
border: 1px solid; text-transform: uppercase; font-weight: 600; letter-spacing: 0.03em;
}
.dc-panel__count { font-size: 0.8rem; color: var(--rs-text-muted); }
.dc-panel__empty {
text-align: center; padding: 1rem; color: var(--rs-text-muted); font-size: 0.85rem;
}
.dc-panel__modules { display: flex; flex-direction: column; gap: 0.25rem; }
.dc-panel__mod {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.5rem 0.6rem; border-radius: 6px; cursor: pointer;
transition: background 0.1s;
}
.dc-panel__mod:hover { background: rgba(34, 211, 238, 0.08); }
.dc-panel__mod-icon { font-size: 1rem; flex-shrink: 0; }
.dc-panel__mod-name { flex: 1; font-size: 0.85rem; }
.dc-panel__mod-count {
padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem;
background: var(--rs-bg-primary, #0f172a); border: 1px solid var(--rs-border);
color: var(--rs-text-muted);
}
@media (max-width: 500px) {
.dc-panel { max-height: 50vh; overflow-y: auto; }
.dc-panel__name { font-size: 0.9rem; }
}
`;
}
}
customElements.define("folk-data-cloud", FolkDataCloud);

View File

@ -241,18 +241,22 @@ routes.get("/api/content-tree", (c) => {
const DATA_TABS = [ const DATA_TABS = [
{ id: "tree", label: "Content Tree", icon: "🌳" }, { id: "tree", label: "Content Tree", icon: "🌳" },
{ id: "cloud", label: "Cloud", icon: "☁️" },
{ id: "analytics", label: "Analytics", icon: "📊" }, { id: "analytics", label: "Analytics", icon: "📊" },
] as const; ] as const;
const DATA_TAB_IDS = new Set(DATA_TABS.map((t) => t.id)); const DATA_TAB_IDS = new Set(DATA_TABS.map((t) => t.id));
function renderDataPage(space: string, activeTab: string, isSubdomain: boolean) { function renderDataPage(space: string, activeTab: string, isSubdomain: boolean) {
const isTree = activeTab === "tree"; const body = activeTab === "tree"
const body = isTree
? `<folk-content-tree space="${space}"></folk-content-tree>` ? `<folk-content-tree space="${space}"></folk-content-tree>`
: activeTab === "cloud"
? `<folk-data-cloud space="${space}"></folk-data-cloud>`
: `<folk-analytics-view space="${space}"></folk-analytics-view>`; : `<folk-analytics-view space="${space}"></folk-analytics-view>`;
const scripts = isTree const scripts = activeTab === "tree"
? `<script type="module" src="/modules/rdata/folk-content-tree.js?v=2"></script>` ? `<script type="module" src="/modules/rdata/folk-content-tree.js?v=2"></script>`
: activeTab === "cloud"
? `<script type="module" src="/modules/rdata/folk-data-cloud.js?v=1"></script>`
: `<script type="module" src="/modules/rdata/folk-analytics-view.js"></script>`; : `<script type="module" src="/modules/rdata/folk-analytics-view.js"></script>`;
return renderShell({ return renderShell({

View File

@ -2,13 +2,14 @@
* MCP tools for rChats (multiplayer chat channels). * MCP tools for rChats (multiplayer chat channels).
* forceAuth=true chat messages are always sensitive. * 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 type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod"; import { z } from "zod";
import type { SyncServer } from "../local-first/sync-server"; 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 type { ChatsDirectoryDoc, ChatChannelDoc } from "../../modules/rchats/schemas";
import { resolveAccess, accessDeniedResponse } from "./_auth"; import { resolveAccess, accessDeniedResponse } from "./_auth";
import { filterArrayByVisibility } from "../../shared/membrane"; 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 => ({ const channels = filterArrayByVisibility(Object.values(doc.channels || {}), access.role).map(ch => ({
id: ch.id, name: ch.name, description: ch.description, 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, createdAt: ch.createdAt, updatedAt: ch.updatedAt,
})); }));
@ -59,7 +61,7 @@ export function registerChatsTools(server: McpServer, syncServer: SyncServer) {
return { return {
content: [{ content: [{
type: "text" as const, 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" }) }] }; if (!doc) return { content: [{ type: "text" as const, text: JSON.stringify({ error: "Channel not found" }) }] };
let messages = filterArrayByVisibility(Object.values(doc.messages || {}), access.role) let messages = filterArrayByVisibility(Object.values(doc.messages || {}), access.role)
.filter(m => !m.threadId) // Only top-level messages
.sort((a, b) => b.createdAt - a.createdAt) .sort((a, b) => b.createdAt - a.createdAt)
.slice(0, limit || 50); .slice(0, limit || 50);
const result = messages.map(m => ({ const result = messages.map(m => ({
id: m.id, authorName: m.authorName, id: m.id, authorName: m.authorName,
content: m.content, replyTo: m.replyTo, content: m.content, replyTo: m.replyTo,
reactions: m.reactions || {},
editedAt: m.editedAt, createdAt: m.createdAt, editedAt: m.editedAt, createdAt: m.createdAt,
})); }));
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; 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<ChatChannelDoc>(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<ChatsDirectoryDoc>(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<ChatChannelDoc>(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<ChatChannelDoc>(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 }) }] };
},
);
} }

View File

@ -88,7 +88,9 @@ export type NotificationEventType =
// Commitment (rTime) // Commitment (rTime)
| 'commitment_requested' | 'commitment_accepted' | 'commitment_declined' | 'commitment_requested' | 'commitment_accepted' | 'commitment_declined'
// Payment // 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 { export interface NotifyOptions {
userDid: string; userDid: string;

View File

@ -0,0 +1,648 @@
/**
* <rstack-chat-widget> 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<string, string[]>;
}
export class RStackChatWidget extends HTMLElement {
#shadow: ShadowRoot;
#open = false;
#channels: Channel[] = [];
#activeChannelId = "";
#messages: Message[] = [];
#unreadCount = 0;
#composerText = "";
#sending = false;
#loading = false;
#badgeTimer: ReturnType<typeof setInterval> | null = null;
#msgTimer: ReturnType<typeof setInterval> | 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<string, string> = {};
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<string, string> = {};
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<HTMLTextAreaElement>(".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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// ── Render ──
#render() {
const space = this.#space;
if (!space) { this.#shadow.innerHTML = ""; return; }
const badge = this.#unreadCount > 0
? `<span class="badge">${this.#unreadCount > 99 ? "99+" : this.#unreadCount}</span>`
: "";
let panelHTML = "";
if (this.#open) {
// Channel selector
const channelOptions = this.#channels.map(ch =>
`<option value="${this.#escapeHtml(ch.id)}" ${ch.id === this.#activeChannelId ? "selected" : ""}>#${this.#escapeHtml(ch.name)}</option>`
).join("");
// Messages
let feedHTML: string;
if (this.#loading) {
feedHTML = `<div class="empty">Loading...</div>`;
} else if (this.#messages.length === 0) {
feedHTML = `<div class="empty">No messages yet. Start the conversation!</div>`;
} else {
feedHTML = this.#messages.map(m => {
const reactions = Object.entries(m.reactions || {})
.filter(([, users]) => users.length > 0)
.map(([emoji, users]) => `<span class="reaction">${emoji} ${users.length}</span>`)
.join("");
return `<div class="msg">
<div class="msg-header">
<span class="msg-author">${this.#escapeHtml(m.authorName)}</span>
<span class="msg-time">${this.#timeAgo(m.createdAt)}</span>
</div>
<div class="msg-content">${this.#escapeHtml(m.content)}</div>
${reactions ? `<div class="msg-reactions">${reactions}</div>` : ""}
</div>`;
}).join("");
}
// Composer
const session = getSession();
const composerHTML = session
? `<div class="composer">
<textarea class="composer-input" placeholder="Type a message..." rows="1">${this.#escapeHtml(this.#composerText)}</textarea>
<button class="composer-send" ${this.#sending ? "disabled" : ""}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 2L11 13"/><path d="M22 2L15 22L11 13L2 9L22 2Z"/></svg>
</button>
</div>`
: `<div class="composer-signin">Sign in to chat</div>`;
panelHTML = `
<div class="panel open">
<div class="panel-header">
<select class="channel-select">${channelOptions}</select>
<button class="close-btn" title="Close">&times;</button>
</div>
<div class="message-feed">${feedHTML}</div>
${composerHTML}
<a class="full-link" href="/${this.#escapeHtml(space)}/rchats">Open rChats &rarr;</a>
</div>`;
}
this.#shadow.innerHTML = `
<style>${STYLES}</style>
<div class="widget-wrapper">
<button class="chat-btn" aria-label="Chat">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
${badge}
</button>
${panelHTML}
</div>
`;
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<HTMLTextAreaElement>(".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;
}
}
`;

View File

@ -756,6 +756,26 @@ export default defineConfig({
resolve(__dirname, "dist/modules/rtasks/tasks.css"), 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 // Build trips module component
await wasmBuild({ await wasmBuild({
configFile: false, 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 // Copy data CSS
mkdirSync(resolve(__dirname, "dist/modules/rdata"), { recursive: true }); mkdirSync(resolve(__dirname, "dist/modules/rdata"), { recursive: true });
copyFileSync( copyFileSync(

View File

@ -18,6 +18,7 @@ import { RStackModuleSetup } from "../shared/components/rstack-module-setup";
import { RStackOfflineIndicator } from "../shared/components/rstack-offline-indicator"; import { RStackOfflineIndicator } from "../shared/components/rstack-offline-indicator";
import { RStackSharePanel } from "../shared/components/rstack-share-panel"; import { RStackSharePanel } from "../shared/components/rstack-share-panel";
import { RStackCommentBell } from "../shared/components/rstack-comment-bell"; 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 { RStackCollabOverlay } from "../shared/components/rstack-collab-overlay";
import { RStackModuleComments } from "../shared/components/rstack-module-comments"; import { RStackModuleComments } from "../shared/components/rstack-module-comments";
import { RStackUserDashboard } from "../shared/components/rstack-user-dashboard"; import { RStackUserDashboard } from "../shared/components/rstack-user-dashboard";
@ -42,6 +43,7 @@ RStackModuleSetup.define();
RStackOfflineIndicator.define(); RStackOfflineIndicator.define();
RStackSharePanel.define(); RStackSharePanel.define();
RStackCommentBell.define(); RStackCommentBell.define();
RStackChatWidget.define();
RStackCollabOverlay.define(); RStackCollabOverlay.define();
RStackModuleComments.define(); RStackModuleComments.define();
RStackUserDashboard.define(); RStackUserDashboard.define();