feat(rchats): add global chat widget + unread count endpoint
Persistent chat panel accessible from any page via header icon. Sliding right panel (360px) with channel selector, message feed, composer, and unread badge. REST polling with localStorage state persistence. Includes unread-count API endpoint for badge updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0f6b5ecd8d
commit
a2dbf4533a
File diff suppressed because it is too large
Load Diff
|
|
@ -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.
|
||||
* Real chat functionality (Automerge CRDT, channels, threads) will come later.
|
||||
* 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 } 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 } from './schemas';
|
||||
import type { ChatsDirectoryDoc, ChatChannelDoc, ChannelInfo, ChatMessage } from './schemas';
|
||||
import {
|
||||
chatsDirectorySchema, chatChannelSchema,
|
||||
chatsDirectoryDocId, chatChannelDocId, dmChannelDocId,
|
||||
} from './schemas';
|
||||
import type {
|
||||
ChatsDirectoryDoc, ChatChannelDoc, ChannelInfo, ChatMessage,
|
||||
Transclusion, ThreadMeta,
|
||||
} from './schemas';
|
||||
|
||||
let _syncServer: SyncServer | null = null;
|
||||
|
||||
|
|
@ -39,8 +46,8 @@ function ensureDirectoryDoc(space: string): ChatsDirectoryDoc {
|
|||
return doc;
|
||||
}
|
||||
|
||||
function ensureChannelDoc(space: string, channelId: string): ChatChannelDoc {
|
||||
const docId = chatChannelDocId(space, channelId);
|
||||
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) => {
|
||||
|
|
@ -48,19 +55,58 @@ function ensureChannelDoc(space: string, channelId: string): ChatChannelDoc {
|
|||
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";
|
||||
|
||||
// Resolve caller role for membrane filtering
|
||||
let callerRole: SpaceRoleString = 'viewer';
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (token) {
|
||||
|
|
@ -73,24 +119,29 @@ routes.get("/api/channels", async (c) => {
|
|||
|
||||
const doc = ensureDirectoryDoc(space);
|
||||
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) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
|
||||
const space = c.req.param("space") || "demo";
|
||||
const 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, createdBy: null, createdAt: Date.now(), updatedAt: Date.now() };
|
||||
_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(),
|
||||
};
|
||||
});
|
||||
const updated = _syncServer.getDoc<ChatsDirectoryDoc>(docId)!;
|
||||
ensureChannelDoc(space, id);
|
||||
const updated = _syncServer!.getDoc<ChatsDirectoryDoc>(docId)!;
|
||||
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 channelId = c.req.param("channelId");
|
||||
|
||||
// Resolve caller role for membrane filtering
|
||||
let callerRole: SpaceRoleString = 'viewer';
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (token) {
|
||||
|
|
@ -113,48 +163,457 @@ routes.get("/api/channels/:channelId/messages", async (c) => {
|
|||
}
|
||||
|
||||
const doc = ensureChannelDoc(space, channelId);
|
||||
// Only return top-level messages (not thread replies)
|
||||
const messages = filterArrayByVisibility(
|
||||
Object.values(doc.messages || {}), callerRole,
|
||||
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 token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
let claims;
|
||||
try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
|
||||
const space = c.req.param("space") || "demo";
|
||||
const auth = await requireAuth(c);
|
||||
if (!auth) return c.res;
|
||||
const { claims, space } = auth;
|
||||
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);
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const docId = chatChannelDocId(space, channelId);
|
||||
ensureChannelDoc(space, channelId);
|
||||
_syncServer.changeDoc<ChatChannelDoc>(docId, `add message ${id}`, (d) => {
|
||||
d.messages[id] = { id, channelId, authorId: claims.sub || '', authorName: (claims.displayName as string) || claims.username || 'Anonymous', content, replyTo, editedAt: null, createdAt: Date.now() };
|
||||
|
||||
const 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);
|
||||
});
|
||||
|
||||
// ── 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 token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
|
||||
const space = c.req.param("space") || "demo";
|
||||
const 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]; });
|
||||
_syncServer!.changeDoc<ChatChannelDoc>(docId, `delete message ${msgId}`, (d) => { delete d.messages[msgId]; });
|
||||
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) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
|
|
@ -163,49 +622,8 @@ routes.get("/", (c) => {
|
|||
moduleId: "rchats",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
styles: `<style>
|
||||
.rs-hub{max-width:720px;margin:2rem auto;padding:0 1.5rem}
|
||||
.rs-hub h1{font-size:1.8rem;margin-bottom:.5rem;color:var(--rs-text-primary)}
|
||||
.rs-hub p{color:var(--rs-text-secondary);margin-bottom:2rem}
|
||||
.rs-coming{display:flex;flex-direction:column;align-items:center;gap:1.5rem;padding:3rem 1.5rem;border-radius:16px;background:var(--rs-bg-surface);border:1px solid var(--rs-border);text-align:center}
|
||||
.rs-coming .coming-icon{font-size:3rem}
|
||||
.rs-coming h2{font-size:1.4rem;color:var(--rs-text-primary);margin:0}
|
||||
.rs-coming p{color:var(--rs-text-secondary);max-width:480px;margin:0}
|
||||
.rs-coming .coming-badge{display:inline-block;padding:4px 12px;border-radius:8px;background:rgba(99,102,241,0.15);color:var(--rs-accent,#6366f1);font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em}
|
||||
.rs-features{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1rem;margin-top:2rem}
|
||||
.rs-feature{padding:1.25rem;border-radius:12px;background:var(--rs-bg-surface);border:1px solid var(--rs-border)}
|
||||
.rs-feature h3{font-size:1rem;margin:0 0 .5rem;color:var(--rs-text-primary)}
|
||||
.rs-feature p{font-size:.85rem;color:var(--rs-text-secondary);margin:0}
|
||||
@media(max-width:600px){.rs-hub{margin:1rem auto;padding:0 .75rem}}
|
||||
</style>`,
|
||||
body: `<div class="rs-hub">
|
||||
<h1>rChats</h1>
|
||||
<p>Encrypted community messaging — channels, threads, and bridges</p>
|
||||
<div class="rs-coming">
|
||||
<span class="coming-icon">🗨️</span>
|
||||
<span class="coming-badge">Coming Soon</span>
|
||||
<h2>Encrypted Community Chat</h2>
|
||||
<p>Real-time messaging with channels and threads, end-to-end encrypted via EncryptID. Local-first with Automerge CRDTs — works offline, syncs seamlessly.</p>
|
||||
</div>
|
||||
<div class="rs-features">
|
||||
<div class="rs-feature">
|
||||
<h3>🔐 E2E Encrypted</h3>
|
||||
<p>Messages encrypted with EncryptID passkeys. The server never sees plaintext.</p>
|
||||
</div>
|
||||
<div class="rs-feature">
|
||||
<h3>💬 Channels & Threads</h3>
|
||||
<p>Organize conversations by topic. Threaded replies keep the main feed clean.</p>
|
||||
</div>
|
||||
<div class="rs-feature">
|
||||
<h3>🔗 Chat Bridges</h3>
|
||||
<p>Connect Slack, Discord, Matrix, Telegram, and Mattermost into one unified view.</p>
|
||||
</div>
|
||||
<div class="rs-feature">
|
||||
<h3>📡 Local-First</h3>
|
||||
<p>Built on Automerge CRDTs. Send messages offline and sync when reconnected.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>`,
|
||||
body: `<folk-chat-app space="${space}"></folk-chat-app>`,
|
||||
scripts: `<script type="module" src="/modules/rchats/folk-chat-app.js?v=1"></script>`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -230,14 +648,19 @@ export function getRecentMessagesForMI(space: string, limit = 5): { id: string;
|
|||
export const chatsModule: RSpaceModule = {
|
||||
id: "rchats",
|
||||
name: "rChats",
|
||||
icon: "🗨️",
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,10 +4,29 @@
|
|||
* Granularity: one directory doc per space + one doc per channel.
|
||||
* DocId format: {space}:chats:channels (directory)
|
||||
* {space}:chats:channel:{channelId} (messages)
|
||||
* {space}:chats:dm:{sortedDid1+Did2} (DM channels)
|
||||
*/
|
||||
|
||||
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 ──
|
||||
|
||||
export interface ChannelInfo {
|
||||
|
|
@ -15,6 +34,7 @@ export interface ChannelInfo {
|
|||
name: string;
|
||||
description: string;
|
||||
isPrivate: boolean;
|
||||
isDm: boolean;
|
||||
createdBy: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
|
|
@ -45,6 +65,9 @@ export interface ChatMessage {
|
|||
authorName: string;
|
||||
content: string;
|
||||
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;
|
||||
createdAt: number;
|
||||
visibility?: import('../../shared/membrane').ObjectVisibility;
|
||||
|
|
@ -61,6 +84,9 @@ export interface ChatChannelDoc {
|
|||
channelId: string;
|
||||
messages: Record<string, ChatMessage>;
|
||||
members: Record<string, Member>;
|
||||
threads: Record<string, ThreadMeta>; // rootMessageId → thread metadata
|
||||
pins: string[]; // pinned message IDs
|
||||
isDm: boolean;
|
||||
}
|
||||
|
||||
// ── Schema registration ──
|
||||
|
|
@ -68,12 +94,12 @@ export interface ChatChannelDoc {
|
|||
export const chatsDirectorySchema: DocSchema<ChatsDirectoryDoc> = {
|
||||
module: 'chats',
|
||||
collection: 'channels',
|
||||
version: 1,
|
||||
version: 2,
|
||||
init: (): ChatsDirectoryDoc => ({
|
||||
meta: {
|
||||
module: 'chats',
|
||||
collection: 'channels',
|
||||
version: 1,
|
||||
version: 2,
|
||||
spaceSlug: '',
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
|
|
@ -84,18 +110,21 @@ export const chatsDirectorySchema: DocSchema<ChatsDirectoryDoc> = {
|
|||
export const chatChannelSchema: DocSchema<ChatChannelDoc> = {
|
||||
module: 'chats',
|
||||
collection: 'channel',
|
||||
version: 1,
|
||||
version: 2,
|
||||
init: (): ChatChannelDoc => ({
|
||||
meta: {
|
||||
module: 'chats',
|
||||
collection: 'channel',
|
||||
version: 1,
|
||||
version: 2,
|
||||
spaceSlug: '',
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
channelId: '',
|
||||
messages: {},
|
||||
members: {},
|
||||
threads: {},
|
||||
pins: [],
|
||||
isDm: false,
|
||||
}),
|
||||
};
|
||||
|
||||
|
|
@ -108,3 +137,8 @@ export function chatsDirectoryDocId(space: string) {
|
|||
export function chatChannelDocId(space: string, channelId: string) {
|
||||
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]}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/* Data module — layout wrapper */
|
||||
folk-analytics-view,
|
||||
folk-content-tree {
|
||||
folk-content-tree,
|
||||
folk-data-cloud {
|
||||
display: block;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
private escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -241,18 +241,22 @@ routes.get("/api/content-tree", (c) => {
|
|||
|
||||
const DATA_TABS = [
|
||||
{ id: "tree", label: "Content Tree", icon: "🌳" },
|
||||
{ id: "cloud", label: "Cloud", icon: "☁️" },
|
||||
{ id: "analytics", label: "Analytics", icon: "📊" },
|
||||
] as const;
|
||||
|
||||
const DATA_TAB_IDS = new Set(DATA_TABS.map((t) => t.id));
|
||||
|
||||
function renderDataPage(space: string, activeTab: string, isSubdomain: boolean) {
|
||||
const isTree = activeTab === "tree";
|
||||
const body = isTree
|
||||
const body = activeTab === "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>`;
|
||||
const scripts = isTree
|
||||
const scripts = activeTab === "tree"
|
||||
? `<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>`;
|
||||
|
||||
return renderShell({
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@
|
|||
* MCP tools for rChats (multiplayer chat channels).
|
||||
* 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 { z } from "zod";
|
||||
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 { resolveAccess, accessDeniedResponse } from "./_auth";
|
||||
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 => ({
|
||||
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,
|
||||
}));
|
||||
|
||||
|
|
@ -59,7 +61,7 @@ export function registerChatsTools(server: McpServer, syncServer: SyncServer) {
|
|||
return {
|
||||
content: [{
|
||||
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" }) }] };
|
||||
|
||||
let messages = filterArrayByVisibility(Object.values(doc.messages || {}), access.role)
|
||||
.filter(m => !m.threadId) // Only top-level messages
|
||||
.sort((a, b) => b.createdAt - a.createdAt)
|
||||
.slice(0, limit || 50);
|
||||
|
||||
const result = messages.map(m => ({
|
||||
id: m.id, authorName: m.authorName,
|
||||
content: m.content, replyTo: m.replyTo,
|
||||
reactions: m.reactions || {},
|
||||
editedAt: m.editedAt, createdAt: m.createdAt,
|
||||
}));
|
||||
|
||||
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 }) }] };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,9 @@ export type NotificationEventType =
|
|||
// Commitment (rTime)
|
||||
| 'commitment_requested' | 'commitment_accepted' | 'commitment_declined'
|
||||
// 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 {
|
||||
userDid: string;
|
||||
|
|
|
|||
|
|
@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
// ── 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">×</button>
|
||||
</div>
|
||||
<div class="message-feed">${feedHTML}</div>
|
||||
${composerHTML}
|
||||
<a class="full-link" href="/${this.#escapeHtml(space)}/rchats">Open rChats →</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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
|
@ -756,6 +756,26 @@ export default defineConfig({
|
|||
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
|
||||
await wasmBuild({
|
||||
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
|
||||
mkdirSync(resolve(__dirname, "dist/modules/rdata"), { recursive: true });
|
||||
copyFileSync(
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { RStackModuleSetup } from "../shared/components/rstack-module-setup";
|
|||
import { RStackOfflineIndicator } from "../shared/components/rstack-offline-indicator";
|
||||
import { RStackSharePanel } from "../shared/components/rstack-share-panel";
|
||||
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 { RStackModuleComments } from "../shared/components/rstack-module-comments";
|
||||
import { RStackUserDashboard } from "../shared/components/rstack-user-dashboard";
|
||||
|
|
@ -42,6 +43,7 @@ RStackModuleSetup.define();
|
|||
RStackOfflineIndicator.define();
|
||||
RStackSharePanel.define();
|
||||
RStackCommentBell.define();
|
||||
RStackChatWidget.define();
|
||||
RStackCollabOverlay.define();
|
||||
RStackModuleComments.define();
|
||||
RStackUserDashboard.define();
|
||||
|
|
|
|||
Loading…
Reference in New Issue