/** * rFeeds module — community RSS dashboard. * * Subscribe to external RSS/Atom feeds, write manual posts, * ingest rApp activity via adapters, manage user feed profiles, * expose combined activity as public Atom 1.0 feed. */ 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 { SyncServer } from "../../server/local-first/sync-server"; import { verifyToken, extractToken } from "../../server/auth"; import type { EncryptIDClaims } from "../../server/auth"; import { resolveCallerRole, roleAtLeast } from "../../server/spaces"; import type { SpaceRoleString } from "../../server/spaces"; import { feedsSchema, feedsDocId, MAX_ITEMS_PER_SOURCE, MAX_ACTIVITY_CACHE, DEFAULT_SYNC_INTERVAL_MS } from "./schemas"; import type { FeedsDoc, FeedSource, FeedItem, ManualPost, ActivityCacheEntry, ActivityModuleConfig, UserFeedProfile } from "./schemas"; import { parseFeed, parseOPML } from "./lib/feed-parser"; import { adapterRegistry } from "./adapters/index"; import { renderLanding } from "./landing"; let _syncServer: SyncServer | null = null; const routes = new Hono(); // ── Automerge doc management ── function ensureDoc(space: string): FeedsDoc { const docId = feedsDocId(space); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), "init rfeeds", (d) => { const init = feedsSchema.init(); d.meta = init.meta; d.meta.spaceSlug = space; d.sources = {}; d.items = {}; d.posts = {}; d.activityCache = {}; d.userProfiles = {}; d.settings = init.settings; }); _syncServer!.setDoc(docId, doc); } // Migrate v1 docs missing new fields if (!doc.activityCache || !doc.userProfiles || !doc.settings.activityModules) { _syncServer!.changeDoc(docId, "migrate to v2", (d) => { if (!d.activityCache) d.activityCache = {} as any; if (!d.userProfiles) d.userProfiles = {} as any; if (!d.settings.activityModules) d.settings.activityModules = {} as any; if (d.meta) d.meta.version = 2; }); doc = _syncServer!.getDoc(docId)!; } return doc; } // ── Auth helpers ── async function requireMember(c: any, space: string): Promise<{ claims: EncryptIDClaims; role: SpaceRoleString } | null> { const token = extractToken(c.req.raw); if (!token) { c.json({ error: "Unauthorized" }, 401); return null; } let claims: EncryptIDClaims; try { claims = await verifyToken(token); } catch { c.json({ error: "Invalid token" }, 401); return null; } const resolved = await resolveCallerRole(space, claims); if (!resolved || !roleAtLeast(resolved.role, "member")) { c.json({ error: "Forbidden" }, 403); return null; } return { claims, role: resolved.role }; } async function requireAuth(c: any): Promise { const token = extractToken(c.req.raw); if (!token) { c.json({ error: "Unauthorized" }, 401); return null; } try { return await verifyToken(token); } catch { c.json({ error: "Invalid token" }, 401); return null; } } // ── Fetch + sync a single source ── async function syncSource(space: string, sourceId: string): Promise<{ added: number; error?: string }> { const doc = ensureDoc(space); const source = doc.sources[sourceId]; if (!source) return { added: 0, error: "Source not found" }; try { const res = await fetch(source.url, { signal: AbortSignal.timeout(15_000), headers: { "User-Agent": "rSpace-Feeds/1.0" }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const xml = await res.text(); const parsed = parseFeed(xml); // Collect existing guids for this source const existingGuids = new Set(); for (const item of Object.values(doc.items)) { if (item.sourceId === sourceId) existingGuids.add(item.guid); } // Filter new items const newItems = parsed.items.filter((i) => !existingGuids.has(i.guid)); if (newItems.length === 0) { // Update lastFetchedAt only _syncServer!.changeDoc(feedsDocId(space), "sync: no new items", (d) => { if (d.sources[sourceId]) { d.sources[sourceId].lastFetchedAt = Date.now(); d.sources[sourceId].lastError = null; } }); return { added: 0 }; } _syncServer!.changeDoc(feedsDocId(space), `sync: add ${newItems.length} items`, (d) => { for (const item of newItems) { const id = crypto.randomUUID(); d.items[id] = { id, sourceId, guid: item.guid, title: item.title, url: item.url, summary: item.summary, author: item.author, publishedAt: item.publishedAt, importedAt: Date.now(), reshared: false, }; } if (d.sources[sourceId]) { d.sources[sourceId].lastFetchedAt = Date.now(); d.sources[sourceId].lastError = null; } }); // Prune excess items per source pruneSourceItems(space, sourceId); // Update item count const updatedDoc = _syncServer!.getDoc(feedsDocId(space))!; const count = Object.values(updatedDoc.items).filter((i) => i.sourceId === sourceId).length; _syncServer!.changeDoc(feedsDocId(space), "update item count", (d) => { if (d.sources[sourceId]) d.sources[sourceId].itemCount = count; }); return { added: newItems.length }; } catch (err: any) { _syncServer!.changeDoc(feedsDocId(space), "sync error", (d) => { if (d.sources[sourceId]) { d.sources[sourceId].lastFetchedAt = Date.now(); d.sources[sourceId].lastError = err.message || "Unknown error"; } }); return { added: 0, error: err.message }; } } function pruneSourceItems(space: string, sourceId: string) { const doc = _syncServer!.getDoc(feedsDocId(space))!; const sourceItems = Object.values(doc.items) .filter((i) => i.sourceId === sourceId) .sort((a, b) => b.publishedAt - a.publishedAt); if (sourceItems.length <= MAX_ITEMS_PER_SOURCE) return; const toRemove = sourceItems.slice(MAX_ITEMS_PER_SOURCE); _syncServer!.changeDoc(feedsDocId(space), "prune old items", (d) => { for (const item of toRemove) { delete d.items[item.id]; } }); } // ── Activity sync (Phase 2) ── function getBaseUrl(): string { return process.env.BASE_URL || 'https://rspace.online'; } function resolveModuleConfig(doc: FeedsDoc, moduleId: string): ActivityModuleConfig { const saved = doc.settings.activityModules?.[moduleId]; if (saved) return saved; const entry = adapterRegistry[moduleId]; return entry?.defaults || { enabled: false, label: moduleId, color: '#94a3b8' }; } function runActivitySync(space: string, baseUrl: string): { synced: number; modules: string[] } { if (!_syncServer) return { synced: 0, modules: [] }; const doc = ensureDoc(space); const since = Date.now() - 7 * 24 * 60 * 60 * 1000; // 7 days // Collect existing reshared flags to preserve const resharedFlags = new Map(); if (doc.activityCache) { for (const [id, entry] of Object.entries(doc.activityCache)) { if (entry.reshared) resharedFlags.set(id, true); } } const allEntries: ActivityCacheEntry[] = []; const syncedModules: string[] = []; for (const [moduleId, { adapter }] of Object.entries(adapterRegistry)) { const config = resolveModuleConfig(doc, moduleId); if (!config.enabled) continue; try { const entries = adapter({ syncServer: _syncServer, space, since, baseUrl, }); // Preserve reshared flags for (const entry of entries) { if (resharedFlags.has(entry.id)) entry.reshared = true; } allEntries.push(...entries); syncedModules.push(moduleId); } catch (err) { console.error(`[rFeeds] Activity adapter ${moduleId} failed for ${space}:`, err); } } if (allEntries.length === 0 && syncedModules.length === 0) return { synced: 0, modules: [] }; // Sort by time, keep newest up to cap allEntries.sort((a, b) => b.publishedAt - a.publishedAt); const capped = allEntries.slice(0, MAX_ACTIVITY_CACHE); _syncServer!.changeDoc(feedsDocId(space), `activity sync: ${capped.length} entries`, (d) => { // Replace cache entirely with fresh data d.activityCache = {} as any; for (const entry of capped) { d.activityCache[entry.id] = entry; } }); return { synced: capped.length, modules: syncedModules }; } // ── Background sync loop ── const SYNC_INTERVAL_MS = 5 * 60 * 1000; let _syncTimer: ReturnType | null = null; async function runBackgroundSync() { if (!_syncServer) return; const docIds = _syncServer.listDocs().filter((id: string) => id.endsWith(':rfeeds:data')); const baseUrl = getBaseUrl(); for (const docId of docIds) { const space = docId.split(':')[0]; const doc = _syncServer.getDoc(docId); if (!doc?.sources) continue; // RSS source sync for (const source of Object.values(doc.sources)) { if (!source.enabled) continue; const interval = source.intervalMs || DEFAULT_SYNC_INTERVAL_MS; if (source.lastFetchedAt && source.lastFetchedAt + interval > Date.now()) continue; try { await syncSource(space, source.id); } catch (err) { console.error(`[rFeeds] Background sync failed for ${space}/${source.name}:`, err); } } // Activity adapter sync try { runActivitySync(space, baseUrl); } catch (err) { console.error(`[rFeeds] Activity sync failed for ${space}:`, err); } } } function startSyncLoop() { if (_syncTimer) return; // Initial sync after 45s startup delay setTimeout(() => { runBackgroundSync().catch((err) => console.error('[rFeeds] Background sync error:', err)); }, 45_000); _syncTimer = setInterval(() => { runBackgroundSync().catch((err) => console.error('[rFeeds] Background sync error:', err)); }, SYNC_INTERVAL_MS); } // ── Timeline helper ── interface TimelineEntry { type: 'item' | 'post' | 'activity'; id: string; title: string; url: string; summary: string; author: string; sourceName: string; sourceColor: string; publishedAt: number; reshared: boolean; moduleId?: string; itemType?: string; } function buildTimeline(doc: FeedsDoc, limit: number, before?: number): TimelineEntry[] { const entries: TimelineEntry[] = []; for (const item of Object.values(doc.items)) { const source = doc.sources[item.sourceId]; if (!source?.enabled) continue; entries.push({ type: 'item', id: item.id, title: item.title, url: item.url, summary: item.summary, author: item.author, sourceName: source?.name || 'Unknown', sourceColor: source?.color || '#94a3b8', publishedAt: item.publishedAt, reshared: item.reshared, }); } for (const post of Object.values(doc.posts)) { entries.push({ type: 'post', id: post.id, title: '', url: '', summary: post.content, author: post.authorName || post.authorDid, sourceName: 'Manual Post', sourceColor: '#67e8f9', publishedAt: post.createdAt, reshared: true, // manual posts always in atom feed }); } // Activity cache entries if (doc.activityCache) { for (const entry of Object.values(doc.activityCache)) { const config = resolveModuleConfig(doc, entry.moduleId); if (!config.enabled) continue; entries.push({ type: 'activity', id: entry.id, title: entry.title, url: entry.url, summary: entry.summary, author: entry.author, sourceName: config.label, sourceColor: config.color, publishedAt: entry.publishedAt, reshared: entry.reshared, moduleId: entry.moduleId, itemType: entry.itemType, }); } } entries.sort((a, b) => b.publishedAt - a.publishedAt); if (before) { const idx = entries.findIndex((e) => e.publishedAt < before); if (idx === -1) return []; return entries.slice(idx, idx + limit); } return entries.slice(0, limit); } // ── Atom 1.0 generator ── function escapeXml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function generateAtom(space: string, doc: FeedsDoc, baseUrl: string): string { const title = doc.settings.feedTitle || `${space} — rFeeds`; const description = doc.settings.feedDescription || `Community feed for ${space}`; const feedUrl = `${baseUrl}/${space}/rfeeds/feed.xml`; const siteUrl = baseUrl; // Collect reshared items + all manual posts + reshared activity const entries: { id: string; title: string; url: string; summary: string; author: string; publishedAt: number }[] = []; for (const item of Object.values(doc.items)) { if (!item.reshared) continue; entries.push(item); } for (const post of Object.values(doc.posts)) { entries.push({ id: post.id, title: `Post by ${post.authorName || 'member'}`, url: '', summary: post.content, author: post.authorName || post.authorDid, publishedAt: post.createdAt, }); } // Include reshared activity cache entries if (doc.activityCache) { for (const entry of Object.values(doc.activityCache)) { if (!entry.reshared) continue; entries.push({ id: entry.id, title: entry.title, url: entry.url, summary: entry.summary, author: entry.author, publishedAt: entry.publishedAt, }); } } entries.sort((a, b) => b.publishedAt - a.publishedAt); const latest = entries[0]?.publishedAt || Date.now(); const atomEntries = entries.slice(0, 100).map((e) => ` urn:rspace:${escapeXml(space)}:${escapeXml(e.id)} ${escapeXml(e.title)} ${escapeXml(e.summary)} ${e.url ? `` : ''} ${escapeXml(e.author)} ${new Date(e.publishedAt).toISOString()} `).join('\n'); return ` ${escapeXml(title)} ${escapeXml(description)} urn:rspace:${escapeXml(space)}:rfeeds ${new Date(latest).toISOString()} rSpace rFeeds ${atomEntries} `; } // ── User feed Atom generator ── function generateUserAtom(space: string, did: string, profile: UserFeedProfile, entries: ActivityCacheEntry[], baseUrl: string): string { const title = `${profile.displayName || did.slice(0, 16)} — rFeeds`; const description = profile.bio || `Personal feed for ${profile.displayName || did}`; const feedUrl = `${baseUrl}/${space}/rfeeds/user/${encodeURIComponent(did)}/feed.xml`; const siteUrl = baseUrl; entries.sort((a, b) => b.publishedAt - a.publishedAt); const latest = entries[0]?.publishedAt || Date.now(); const atomEntries = entries.slice(0, 100).map((e) => ` urn:rspace:${escapeXml(space)}:user:${escapeXml(did)}:${escapeXml(e.id)} ${escapeXml(e.title)} ${escapeXml(e.summary)} ${e.url ? `` : ''} ${escapeXml(e.author || profile.displayName || did)} ${new Date(e.publishedAt).toISOString()} `).join('\n'); return ` ${escapeXml(title)} ${escapeXml(description)} urn:rspace:${escapeXml(space)}:user:${escapeXml(did)} ${new Date(latest).toISOString()} rSpace rFeeds ${atomEntries} `; } // ── Routes ── // Page route routes.get("/", (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c as any).get("effectiveSpace") || space; ensureDoc(dataSpace); return c.html(renderShell({ title: `rFeeds — ${space}`, moduleId: "rfeeds", spaceSlug: space, body: ``, scripts: ``, styles: ``, modules: getModuleInfoList(), })); }); // Atom feed (public, no auth) routes.get("/feed.xml", (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c as any).get("effectiveSpace") || space; const doc = ensureDoc(dataSpace); const proto = c.req.header("x-forwarded-proto") || "https"; const host = c.req.header("x-forwarded-host") || c.req.header("host") || "rspace.online"; const baseUrl = `${proto}://${host}`; c.header("Content-Type", "application/atom+xml; charset=utf-8"); c.header("Cache-Control", "public, max-age=300"); return c.body(generateAtom(space, doc, baseUrl)); }); // Timeline (public read) routes.get("/api/timeline", (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c as any).get("effectiveSpace") || space; const doc = ensureDoc(dataSpace); const limit = Math.min(parseInt(c.req.query("limit") || "50"), 200); const before = c.req.query("before") ? parseInt(c.req.query("before")!) : undefined; return c.json(buildTimeline(doc, limit, before)); }); // Sources (public read) routes.get("/api/sources", (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c as any).get("effectiveSpace") || space; const doc = ensureDoc(dataSpace); const sources = Object.values(doc.sources).sort((a, b) => b.addedAt - a.addedAt); return c.json(sources); }); // Add source (member+) routes.post("/api/sources", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c as any).get("effectiveSpace") || space; const auth = await requireMember(c, dataSpace); if (!auth) return; const body = await c.req.json<{ url: string; name?: string; color?: string }>(); if (!body.url) return c.json({ error: "url required" }, 400); // Validate URL try { new URL(body.url); } catch { return c.json({ error: "Invalid URL" }, 400); } const id = crypto.randomUUID(); const source: FeedSource = { id, url: body.url, name: body.name || new URL(body.url).hostname, color: body.color || '#94a3b8', enabled: true, lastFetchedAt: 0, lastError: null, itemCount: 0, intervalMs: DEFAULT_SYNC_INTERVAL_MS, addedBy: (auth.claims.did as string) || (auth.claims.sub as string), addedAt: Date.now(), }; _syncServer!.changeDoc(feedsDocId(dataSpace), "add feed source", (d) => { d.sources[id] = source; }); // Trigger initial sync in background syncSource(dataSpace, id).catch(() => {}); return c.json(source, 201); }); // Update source (member+) routes.put("/api/sources/:id", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c as any).get("effectiveSpace") || space; const auth = await requireMember(c, dataSpace); if (!auth) return; const sourceId = c.req.param("id"); const body = await c.req.json<{ name?: string; color?: string; enabled?: boolean; intervalMs?: number }>(); const doc = ensureDoc(dataSpace); if (!doc.sources[sourceId]) return c.json({ error: "Not found" }, 404); _syncServer!.changeDoc(feedsDocId(dataSpace), "update feed source", (d) => { const s = d.sources[sourceId]; if (!s) return; if (body.name !== undefined) s.name = body.name; if (body.color !== undefined) s.color = body.color; if (body.enabled !== undefined) s.enabled = body.enabled; if (body.intervalMs !== undefined) s.intervalMs = body.intervalMs; }); const updated = _syncServer!.getDoc(feedsDocId(dataSpace))!; return c.json(updated.sources[sourceId]); }); // Delete source (member+) routes.delete("/api/sources/:id", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c as any).get("effectiveSpace") || space; const auth = await requireMember(c, dataSpace); if (!auth) return; const sourceId = c.req.param("id"); const doc = ensureDoc(dataSpace); if (!doc.sources[sourceId]) return c.json({ error: "Not found" }, 404); // Remove source + its items _syncServer!.changeDoc(feedsDocId(dataSpace), "delete feed source", (d) => { delete d.sources[sourceId]; for (const [itemId, item] of Object.entries(d.items)) { if (item.sourceId === sourceId) delete d.items[itemId]; } }); return c.json({ ok: true }); }); // Force sync one source (member+) routes.post("/api/sources/:id/sync", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c as any).get("effectiveSpace") || space; const auth = await requireMember(c, dataSpace); if (!auth) return; const sourceId = c.req.param("id"); const result = await syncSource(dataSpace, sourceId); return c.json(result); }); // Import OPML (member+) routes.post("/api/sources/import-opml", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c as any).get("effectiveSpace") || space; const auth = await requireMember(c, dataSpace); if (!auth) return; const body = await c.req.json<{ opml: string }>(); if (!body.opml) return c.json({ error: "opml required" }, 400); let feeds: { url: string; name: string }[]; try { feeds = parseOPML(body.opml); } catch (err: any) { return c.json({ error: `Invalid OPML: ${err.message}` }, 400); } const added: string[] = []; const did = (auth.claims.did as string) || (auth.claims.sub as string); for (const feed of feeds) { // Skip duplicates const doc = _syncServer!.getDoc(feedsDocId(dataSpace))!; const exists = Object.values(doc.sources).some((s) => s.url === feed.url); if (exists) continue; const id = crypto.randomUUID(); _syncServer!.changeDoc(feedsDocId(dataSpace), `import: ${feed.name}`, (d) => { d.sources[id] = { id, url: feed.url, name: feed.name, color: '#94a3b8', enabled: true, lastFetchedAt: 0, lastError: null, itemCount: 0, intervalMs: DEFAULT_SYNC_INTERVAL_MS, addedBy: did, addedAt: Date.now(), }; }); added.push(feed.name); // Background sync each syncSource(dataSpace, id).catch(() => {}); } return c.json({ added: added.length, names: added }); }); // Create manual post (member+) routes.post("/api/posts", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c as any).get("effectiveSpace") || space; const auth = await requireMember(c, dataSpace); if (!auth) return; const body = await c.req.json<{ content: string }>(); if (!body.content?.trim()) return c.json({ error: "content required" }, 400); const id = crypto.randomUUID(); const did = (auth.claims.did as string) || (auth.claims.sub as string); _syncServer!.changeDoc(feedsDocId(dataSpace), "create post", (d) => { d.posts[id] = { id, content: body.content.trim().slice(0, 2000), authorDid: did, authorName: (auth.claims as any).displayName || did.slice(0, 12), createdAt: Date.now(), updatedAt: Date.now(), }; }); const doc = _syncServer!.getDoc(feedsDocId(dataSpace))!; return c.json(doc.posts[id], 201); }); // Edit own post (author only) routes.put("/api/posts/:id", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c as any).get("effectiveSpace") || space; const auth = await requireMember(c, dataSpace); if (!auth) return; const postId = c.req.param("id"); const doc = ensureDoc(dataSpace); const post = doc.posts[postId]; if (!post) return c.json({ error: "Not found" }, 404); const callerDid = (auth.claims.did as string) || (auth.claims.sub as string); if (post.authorDid !== callerDid) return c.json({ error: "Forbidden" }, 403); const body = await c.req.json<{ content: string }>(); if (!body.content?.trim()) return c.json({ error: "content required" }, 400); _syncServer!.changeDoc(feedsDocId(dataSpace), "edit post", (d) => { if (d.posts[postId]) { d.posts[postId].content = body.content.trim().slice(0, 2000); d.posts[postId].updatedAt = Date.now(); } }); const updated = _syncServer!.getDoc(feedsDocId(dataSpace))!; return c.json(updated.posts[postId]); }); // Delete post (author or admin) routes.delete("/api/posts/:id", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c as any).get("effectiveSpace") || space; const auth = await requireMember(c, dataSpace); if (!auth) return; const postId = c.req.param("id"); const doc = ensureDoc(dataSpace); const post = doc.posts[postId]; if (!post) return c.json({ error: "Not found" }, 404); const callerDid = (auth.claims.did as string) || (auth.claims.sub as string); const isAuthor = post.authorDid === callerDid; const isAdmin = roleAtLeast(auth.role, "admin"); if (!isAuthor && !isAdmin) return c.json({ error: "Forbidden" }, 403); _syncServer!.changeDoc(feedsDocId(dataSpace), "delete post", (d) => { delete d.posts[postId]; }); return c.json({ ok: true }); }); // Toggle reshare on item (member+) routes.patch("/api/items/:id/reshare", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c as any).get("effectiveSpace") || space; const auth = await requireMember(c, dataSpace); if (!auth) return; const itemId = c.req.param("id"); const doc = ensureDoc(dataSpace); if (!doc.items[itemId]) return c.json({ error: "Not found" }, 404); const current = doc.items[itemId].reshared; _syncServer!.changeDoc(feedsDocId(dataSpace), "toggle reshare", (d) => { if (d.items[itemId]) d.items[itemId].reshared = !current; }); return c.json({ reshared: !current }); }); // ── Activity routes (Phase 2) ── // Activity module config (public read) routes.get("/api/activity/config", (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c as any).get("effectiveSpace") || space; const doc = ensureDoc(dataSpace); const config: Record = {}; for (const moduleId of Object.keys(adapterRegistry)) { config[moduleId] = resolveModuleConfig(doc, moduleId); } return c.json(config); }); // Update activity module config (admin) routes.put("/api/activity/config", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c as any).get("effectiveSpace") || space; const auth = await requireMember(c, dataSpace); if (!auth) return; if (!roleAtLeast(auth.role, "admin")) return c.json({ error: "Admin required" }, 403); const body = await c.req.json<{ modules: Record }>(); if (!body.modules) return c.json({ error: "modules required" }, 400); _syncServer!.changeDoc(feedsDocId(dataSpace), "update activity config", (d) => { for (const [moduleId, config] of Object.entries(body.modules)) { if (!adapterRegistry[moduleId]) continue; d.settings.activityModules[moduleId] = config; } }); const updated = _syncServer!.getDoc(feedsDocId(dataSpace))!; const result: Record = {}; for (const moduleId of Object.keys(adapterRegistry)) { result[moduleId] = resolveModuleConfig(updated, moduleId); } return c.json(result); }); // Trigger on-demand activity sync (member+) routes.post("/api/activity/sync", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c as any).get("effectiveSpace") || space; const auth = await requireMember(c, dataSpace); if (!auth) return; const baseUrl = getBaseUrl(); const result = runActivitySync(dataSpace, baseUrl); return c.json(result); }); // Toggle reshare on activity cache entry (member+) routes.patch("/api/activity/:id/reshare", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c as any).get("effectiveSpace") || space; const auth = await requireMember(c, dataSpace); if (!auth) return; const entryId = decodeURIComponent(c.req.param("id")); const doc = ensureDoc(dataSpace); if (!doc.activityCache?.[entryId]) return c.json({ error: "Not found" }, 404); const current = doc.activityCache[entryId].reshared; _syncServer!.changeDoc(feedsDocId(dataSpace), "toggle activity reshare", (d) => { if (d.activityCache[entryId]) d.activityCache[entryId].reshared = !current; }); return c.json({ reshared: !current }); }); // ── User profile routes (Phase 2) ── // Own profile (auth required) routes.get("/api/profile", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c as any).get("effectiveSpace") || space; const claims = await requireAuth(c); if (!claims) return; const did = (claims.did as string) || (claims.sub as string); const doc = ensureDoc(dataSpace); const profile = doc.userProfiles?.[did] || null; return c.json(profile); }); // Update own profile (auth required) routes.put("/api/profile", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c as any).get("effectiveSpace") || space; const claims = await requireAuth(c); if (!claims) return; const did = (claims.did as string) || (claims.sub as string); const body = await c.req.json<{ displayName?: string; bio?: string; publishModules?: string[] }>(); const doc = ensureDoc(dataSpace); const existing = doc.userProfiles?.[did]; const now = Date.now(); _syncServer!.changeDoc(feedsDocId(dataSpace), "update user profile", (d) => { if (!d.userProfiles[did]) { d.userProfiles[did] = { did, displayName: body.displayName || (claims as any).displayName || did.slice(0, 16), bio: body.bio || '', publishModules: body.publishModules || [], createdAt: now, updatedAt: now, }; } else { if (body.displayName !== undefined) d.userProfiles[did].displayName = body.displayName; if (body.bio !== undefined) d.userProfiles[did].bio = body.bio; if (body.publishModules !== undefined) d.userProfiles[did].publishModules = body.publishModules as any; d.userProfiles[did].updatedAt = now; } }); const updated = _syncServer!.getDoc(feedsDocId(dataSpace))!; return c.json(updated.userProfiles[did]); }); // Read any user's profile (public) routes.get("/api/profile/:did", (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c as any).get("effectiveSpace") || space; const did = decodeURIComponent(c.req.param("did")); const doc = ensureDoc(dataSpace); const profile = doc.userProfiles?.[did]; if (!profile) return c.json({ error: "Profile not found" }, 404); return c.json(profile); }); // Personal Atom feed (public) routes.get("/user/:did/feed.xml", (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c as any).get("effectiveSpace") || space; const did = decodeURIComponent(c.req.param("did")); const doc = ensureDoc(dataSpace); const profile = doc.userProfiles?.[did]; if (!profile) return c.text("Profile not found", 404); const proto = c.req.header("x-forwarded-proto") || "https"; const host = c.req.header("x-forwarded-host") || c.req.header("host") || "rspace.online"; const baseUrl = `${proto}://${host}`; // Collect activity entries matching user's published modules const publishSet = new Set(profile.publishModules || []); const entries: ActivityCacheEntry[] = []; if (doc.activityCache) { for (const entry of Object.values(doc.activityCache)) { if (!publishSet.has(entry.moduleId)) continue; entries.push(entry); } } // Also include user's manual posts as pseudo-entries if (publishSet.has('posts') || publishSet.size === 0) { for (const post of Object.values(doc.posts)) { if (post.authorDid !== did) continue; entries.push({ id: `post:${post.id}`, moduleId: 'posts', itemType: 'post', title: `Post by ${post.authorName || 'member'}`, summary: post.content.slice(0, 280), url: '', author: post.authorName || post.authorDid, publishedAt: post.createdAt, reshared: true, syncedAt: post.updatedAt, }); } } c.header("Content-Type", "application/atom+xml; charset=utf-8"); c.header("Cache-Control", "public, max-age=300"); return c.body(generateUserAtom(space, did, profile, entries, baseUrl)); }); // ── Module export ── export const feedsModule: RSpaceModule = { id: "rfeeds", name: "rFeeds", icon: "\uD83D\uDCE1", description: "Community RSS dashboard — subscribe, curate, republish", routes, scoping: { defaultScope: "space", userConfigurable: false }, docSchemas: [feedsSchema as any], standaloneDomain: "rfeeds.online", landingPage: renderLanding, onInit: async ({ syncServer }) => { _syncServer = syncServer; startSyncLoop(); }, onSpaceCreate: async ({ spaceSlug }) => { if (_syncServer) ensureDoc(spaceSlug); }, };