Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m3s
Details
CI/CD / deploy (push) Successful in 2m3s
Details
This commit is contained in:
commit
3f333ee125
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