diff --git a/modules/rfeeds/adapters/index.ts b/modules/rfeeds/adapters/index.ts new file mode 100644 index 00000000..703f753c --- /dev/null +++ b/modules/rfeeds/adapters/index.ts @@ -0,0 +1,54 @@ +/** + * Adapter registry — maps moduleId to adapter function + defaults. + */ + +import type { AdapterFn } from './types'; +import type { ActivityModuleConfig } from '../schemas'; +import { rcalAdapter } from './rcal-adapter'; +import { rtasksAdapter } from './rtasks-adapter'; +import { rdocsAdapter } from './rdocs-adapter'; +import { rnotesAdapter } from './rnotes-adapter'; +import { rsocialsAdapter } from './rsocials-adapter'; +import { rwalletAdapter } from './rwallet-adapter'; +import { rphotosAdapter } from './rphotos-adapter'; +import { rfilesAdapter } from './rfiles-adapter'; + +export interface AdapterEntry { + adapter: AdapterFn; + defaults: ActivityModuleConfig; +} + +export const adapterRegistry: Record = { + rcal: { + adapter: rcalAdapter, + defaults: { enabled: true, label: 'Calendar', color: '#f59e0b' }, + }, + rtasks: { + adapter: rtasksAdapter, + defaults: { enabled: true, label: 'Tasks', color: '#8b5cf6' }, + }, + rdocs: { + adapter: rdocsAdapter, + defaults: { enabled: true, label: 'Docs', color: '#3b82f6' }, + }, + rnotes: { + adapter: rnotesAdapter, + defaults: { enabled: false, label: 'Vaults', color: '#a78bfa' }, + }, + rsocials: { + adapter: rsocialsAdapter, + defaults: { enabled: false, label: 'Socials', color: '#ec4899' }, + }, + rwallet: { + adapter: rwalletAdapter, + defaults: { enabled: false, label: 'Wallet', color: '#10b981' }, + }, + rphotos: { + adapter: rphotosAdapter, + defaults: { enabled: false, label: 'Photos', color: '#f97316' }, + }, + rfiles: { + adapter: rfilesAdapter, + defaults: { enabled: false, label: 'Files', color: '#64748b' }, + }, +}; diff --git a/modules/rfeeds/adapters/rcal-adapter.ts b/modules/rfeeds/adapters/rcal-adapter.ts new file mode 100644 index 00000000..853afb96 --- /dev/null +++ b/modules/rfeeds/adapters/rcal-adapter.ts @@ -0,0 +1,31 @@ +import type { AdapterFn } from './types'; +import type { ActivityCacheEntry } from '../schemas'; + +export const rcalAdapter: AdapterFn = (ctx) => { + const docId = `${ctx.space}:cal:events`; + const doc = ctx.syncServer.getDoc(docId); + if (!doc?.events) return []; + + const entries: ActivityCacheEntry[] = []; + for (const evt of Object.values(doc.events) as any[]) { + if (!evt?.title) continue; + const ts = evt.createdAt || evt.startTime || 0; + if (ts < ctx.since) continue; + + const id = `rcal:${ctx.space}:${evt.id}`; + entries.push({ + id, + moduleId: 'rcal', + itemType: 'event', + title: evt.title, + summary: (evt.description || '').slice(0, 280), + url: `${ctx.baseUrl}/${ctx.space}/rcal`, + author: '', + publishedAt: ts, + reshared: false, + syncedAt: Date.now(), + }); + } + + return entries.sort((a, b) => b.publishedAt - a.publishedAt).slice(0, 20); +}; diff --git a/modules/rfeeds/adapters/rdocs-adapter.ts b/modules/rfeeds/adapters/rdocs-adapter.ts new file mode 100644 index 00000000..5cf54c26 --- /dev/null +++ b/modules/rfeeds/adapters/rdocs-adapter.ts @@ -0,0 +1,35 @@ +import type { AdapterFn } from './types'; +import type { ActivityCacheEntry } from '../schemas'; + +export const rdocsAdapter: AdapterFn = (ctx) => { + const prefix = `${ctx.space}:notes:notebooks:`; + const nbIds = ctx.syncServer.listDocs().filter((id: string) => id.startsWith(prefix)); + const entries: ActivityCacheEntry[] = []; + + for (const nbDocId of nbIds) { + const doc = ctx.syncServer.getDoc(nbDocId); + if (!doc?.notes) continue; + + for (const note of Object.values(doc.notes) as any[]) { + if (!note?.title) continue; + const ts = note.updatedAt || note.createdAt || 0; + if (ts < ctx.since) continue; + + const id = `rdocs:${ctx.space}:${note.id}`; + entries.push({ + id, + moduleId: 'rdocs', + itemType: 'note', + title: note.title, + summary: (note.contentPlain || note.summary || '').slice(0, 280), + url: `${ctx.baseUrl}/${ctx.space}/rdocs`, + author: note.authorId || '', + publishedAt: ts, + reshared: false, + syncedAt: Date.now(), + }); + } + } + + return entries.sort((a, b) => b.publishedAt - a.publishedAt).slice(0, 20); +}; diff --git a/modules/rfeeds/adapters/rfiles-adapter.ts b/modules/rfeeds/adapters/rfiles-adapter.ts new file mode 100644 index 00000000..4f5e6949 --- /dev/null +++ b/modules/rfeeds/adapters/rfiles-adapter.ts @@ -0,0 +1,59 @@ +import type { AdapterFn } from './types'; +import type { ActivityCacheEntry } from '../schemas'; + +export const rfilesAdapter: AdapterFn = (ctx) => { + const prefix = `${ctx.space}:files:cards:`; + const cardIds = ctx.syncServer.listDocs().filter((id: string) => id.startsWith(prefix)); + const entries: ActivityCacheEntry[] = []; + + for (const cardDocId of cardIds) { + const doc = ctx.syncServer.getDoc(cardDocId); + if (!doc) continue; + + // Media files + if (doc.files) { + for (const file of Object.values(doc.files) as any[]) { + if (!file?.originalFilename && !file?.title) continue; + const ts = file.createdAt || file.updatedAt || 0; + if (ts < ctx.since) continue; + + entries.push({ + id: `rfiles:${ctx.space}:file:${file.id}`, + moduleId: 'rfiles', + itemType: 'file', + title: file.title || file.originalFilename, + summary: (file.description || `${file.mimeType || 'file'} upload`).slice(0, 280), + url: `${ctx.baseUrl}/${ctx.space}/rfiles`, + author: file.uploadedBy || '', + publishedAt: ts, + reshared: false, + syncedAt: Date.now(), + }); + } + } + + // Memory cards + if (doc.cards) { + for (const card of Object.values(doc.cards) as any[]) { + if (!card?.title) continue; + const ts = card.createdAt || card.updatedAt || 0; + if (ts < ctx.since) continue; + + entries.push({ + id: `rfiles:${ctx.space}:card:${card.id}`, + moduleId: 'rfiles', + itemType: 'memory-card', + title: card.title, + summary: (card.body || '').slice(0, 280), + url: `${ctx.baseUrl}/${ctx.space}/rfiles`, + author: card.createdBy || '', + publishedAt: ts, + reshared: false, + syncedAt: Date.now(), + }); + } + } + } + + return entries.sort((a, b) => b.publishedAt - a.publishedAt).slice(0, 20); +}; diff --git a/modules/rfeeds/adapters/rnotes-adapter.ts b/modules/rfeeds/adapters/rnotes-adapter.ts new file mode 100644 index 00000000..ddee0c8e --- /dev/null +++ b/modules/rfeeds/adapters/rnotes-adapter.ts @@ -0,0 +1,35 @@ +import type { AdapterFn } from './types'; +import type { ActivityCacheEntry } from '../schemas'; + +export const rnotesAdapter: AdapterFn = (ctx) => { + const prefix = `${ctx.space}:rnotes:vaults:`; + const vaultIds = ctx.syncServer.listDocs().filter((id: string) => id.startsWith(prefix)); + const entries: ActivityCacheEntry[] = []; + + for (const vaultDocId of vaultIds) { + const doc = ctx.syncServer.getDoc(vaultDocId); + if (!doc?.notes) continue; + + for (const note of Object.values(doc.notes) as any[]) { + if (!note?.path) continue; + const ts = note.lastModifiedAt || 0; + if (ts < ctx.since) continue; + + const id = `rnotes:${ctx.space}:${note.path}`; + entries.push({ + id, + moduleId: 'rnotes', + itemType: 'vault-note', + title: note.title || note.path.split('/').pop() || note.path, + summary: `Vault note: ${note.path}`.slice(0, 280), + url: `${ctx.baseUrl}/${ctx.space}/rnotes`, + author: '', + publishedAt: ts, + reshared: false, + syncedAt: Date.now(), + }); + } + } + + return entries.sort((a, b) => b.publishedAt - a.publishedAt).slice(0, 20); +}; diff --git a/modules/rfeeds/adapters/rphotos-adapter.ts b/modules/rfeeds/adapters/rphotos-adapter.ts new file mode 100644 index 00000000..82080ec0 --- /dev/null +++ b/modules/rfeeds/adapters/rphotos-adapter.ts @@ -0,0 +1,56 @@ +import type { AdapterFn } from './types'; +import type { ActivityCacheEntry } from '../schemas'; + +export const rphotosAdapter: AdapterFn = (ctx) => { + const docId = `${ctx.space}:photos:albums`; + const doc = ctx.syncServer.getDoc(docId); + if (!doc) return []; + + const entries: ActivityCacheEntry[] = []; + + // Shared albums + if (doc.sharedAlbums) { + for (const album of Object.values(doc.sharedAlbums) as any[]) { + if (!album?.name) continue; + const ts = album.sharedAt || 0; + if (ts < ctx.since) continue; + + entries.push({ + id: `rphotos:${ctx.space}:album:${album.id || album.name}`, + moduleId: 'rphotos', + itemType: 'album', + title: album.name, + summary: (album.description || `Shared album: ${album.name}`).slice(0, 280), + url: `${ctx.baseUrl}/${ctx.space}/rphotos`, + author: album.sharedBy || '', + publishedAt: ts, + reshared: false, + syncedAt: Date.now(), + }); + } + } + + // Photo annotations + if (doc.annotations) { + for (const ann of Object.values(doc.annotations) as any[]) { + if (!ann?.note) continue; + const ts = ann.createdAt || 0; + if (ts < ctx.since) continue; + + entries.push({ + id: `rphotos:${ctx.space}:ann:${ann.assetId || ann.id}`, + moduleId: 'rphotos', + itemType: 'photo-annotation', + title: 'Photo annotation', + summary: ann.note.slice(0, 280), + url: `${ctx.baseUrl}/${ctx.space}/rphotos`, + author: ann.authorDid || '', + publishedAt: ts, + reshared: false, + syncedAt: Date.now(), + }); + } + } + + return entries.sort((a, b) => b.publishedAt - a.publishedAt).slice(0, 20); +}; diff --git a/modules/rfeeds/adapters/rsocials-adapter.ts b/modules/rfeeds/adapters/rsocials-adapter.ts new file mode 100644 index 00000000..1e148d40 --- /dev/null +++ b/modules/rfeeds/adapters/rsocials-adapter.ts @@ -0,0 +1,57 @@ +import type { AdapterFn } from './types'; +import type { ActivityCacheEntry } from '../schemas'; + +export const rsocialsAdapter: AdapterFn = (ctx) => { + const docId = `${ctx.space}:socials:data`; + const doc = ctx.syncServer.getDoc(docId); + if (!doc) return []; + + const entries: ActivityCacheEntry[] = []; + + // Threads + if (doc.threads) { + for (const thread of Object.values(doc.threads) as any[]) { + if (!thread?.title) continue; + const ts = thread.updatedAt || thread.createdAt || 0; + if (ts < ctx.since) continue; + + entries.push({ + id: `rsocials:${ctx.space}:thread:${thread.id}`, + moduleId: 'rsocials', + itemType: 'thread', + title: thread.title, + summary: (thread.tweets?.[0] || '').slice(0, 280), + url: `${ctx.baseUrl}/${ctx.space}/rsocials`, + author: thread.handle || '', + publishedAt: ts, + reshared: false, + syncedAt: Date.now(), + }); + } + } + + // Campaigns + if (doc.campaigns) { + for (const campaign of Object.values(doc.campaigns) as any[]) { + if (!campaign?.title) continue; + const ts = campaign.updatedAt || campaign.createdAt || 0; + if (ts < ctx.since) continue; + + const platforms = (campaign.platforms || []).join(', '); + entries.push({ + id: `rsocials:${ctx.space}:campaign:${campaign.id}`, + moduleId: 'rsocials', + itemType: 'campaign', + title: campaign.title, + summary: (`${campaign.description || ''} — ${platforms}`).slice(0, 280), + url: `${ctx.baseUrl}/${ctx.space}/rsocials`, + author: '', + publishedAt: ts, + reshared: false, + syncedAt: Date.now(), + }); + } + } + + return entries.sort((a, b) => b.publishedAt - a.publishedAt).slice(0, 20); +}; diff --git a/modules/rfeeds/adapters/rtasks-adapter.ts b/modules/rfeeds/adapters/rtasks-adapter.ts new file mode 100644 index 00000000..76be70f1 --- /dev/null +++ b/modules/rfeeds/adapters/rtasks-adapter.ts @@ -0,0 +1,37 @@ +import type { AdapterFn } from './types'; +import type { ActivityCacheEntry } from '../schemas'; + +export const rtasksAdapter: AdapterFn = (ctx) => { + const prefix = `${ctx.space}:tasks:boards:`; + const boardIds = ctx.syncServer.listDocs().filter((id: string) => id.startsWith(prefix)); + const entries: ActivityCacheEntry[] = []; + + for (const boardDocId of boardIds) { + const doc = ctx.syncServer.getDoc(boardDocId); + if (!doc?.tasks) continue; + + for (const task of Object.values(doc.tasks) as any[]) { + if (!task?.title) continue; + const ts = task.updatedAt || task.createdAt || 0; + if (ts < ctx.since) continue; + + const id = `rtasks:${ctx.space}:${task.id}`; + const status = task.status ? ` [${task.status}]` : ''; + const priority = task.priority ? ` (${task.priority})` : ''; + entries.push({ + id, + moduleId: 'rtasks', + itemType: 'task', + title: task.title, + summary: (`${task.description || ''}${status}${priority}`).slice(0, 280), + url: `${ctx.baseUrl}/${ctx.space}/rtasks`, + author: task.createdBy || '', + publishedAt: ts, + reshared: false, + syncedAt: Date.now(), + }); + } + } + + return entries.sort((a, b) => b.publishedAt - a.publishedAt).slice(0, 20); +}; diff --git a/modules/rfeeds/adapters/rwallet-adapter.ts b/modules/rfeeds/adapters/rwallet-adapter.ts new file mode 100644 index 00000000..9c7841ef --- /dev/null +++ b/modules/rfeeds/adapters/rwallet-adapter.ts @@ -0,0 +1,56 @@ +import type { AdapterFn } from './types'; +import type { ActivityCacheEntry } from '../schemas'; + +export const rwalletAdapter: AdapterFn = (ctx) => { + const docId = `${ctx.space}:wallet:treasury`; + const doc = ctx.syncServer.getDoc(docId); + if (!doc) return []; + + const entries: ActivityCacheEntry[] = []; + + // Watched addresses + if (doc.watchedAddresses) { + for (const addr of Object.values(doc.watchedAddresses) as any[]) { + if (!addr?.address) continue; + const ts = addr.addedAt || 0; + if (ts < ctx.since) continue; + + entries.push({ + id: `rwallet:${ctx.space}:addr:${addr.address}`, + moduleId: 'rwallet', + itemType: 'address', + title: addr.label || `${addr.address.slice(0, 8)}…`, + summary: `Watching ${addr.chain || 'unknown'} address ${addr.address.slice(0, 12)}…`.slice(0, 280), + url: `${ctx.baseUrl}/${ctx.space}/rwallet`, + author: addr.addedBy || '', + publishedAt: ts, + reshared: false, + syncedAt: Date.now(), + }); + } + } + + // Transaction annotations + if (doc.annotations) { + for (const ann of Object.values(doc.annotations) as any[]) { + if (!ann?.note) continue; + const ts = ann.createdAt || 0; + if (ts < ctx.since) continue; + + entries.push({ + id: `rwallet:${ctx.space}:ann:${ann.txHash || ann.id}`, + moduleId: 'rwallet', + itemType: 'annotation', + title: `TX annotation`, + summary: ann.note.slice(0, 280), + url: `${ctx.baseUrl}/${ctx.space}/rwallet`, + author: ann.authorDid || '', + publishedAt: ts, + reshared: false, + syncedAt: Date.now(), + }); + } + } + + return entries.sort((a, b) => b.publishedAt - a.publishedAt).slice(0, 20); +}; diff --git a/modules/rfeeds/adapters/types.ts b/modules/rfeeds/adapters/types.ts new file mode 100644 index 00000000..ab2022d2 --- /dev/null +++ b/modules/rfeeds/adapters/types.ts @@ -0,0 +1,21 @@ +/** + * Activity adapter contract. + * + * Each adapter is a pure function that reads Automerge docs from syncServer + * and returns activity entries. No side effects, no module imports. + */ + +import type { ActivityCacheEntry } from '../schemas'; + +export interface AdapterContext { + /** SyncServer instance for reading docs */ + syncServer: { getDoc(docId: string): any; listDocs(): string[] }; + /** Space slug */ + space: string; + /** Only return entries newer than this timestamp */ + since: number; + /** Base URL for generating links (e.g. https://demo.rspace.online) */ + baseUrl: string; +} + +export type AdapterFn = (ctx: AdapterContext) => ActivityCacheEntry[]; diff --git a/modules/rfeeds/components/feeds.css b/modules/rfeeds/components/feeds.css new file mode 100644 index 00000000..8eae37b8 --- /dev/null +++ b/modules/rfeeds/components/feeds.css @@ -0,0 +1,9 @@ +/* rFeeds dashboard styles */ +folk-feeds-dashboard { + display: block; + max-width: 720px; + margin: 0 auto; + padding: 16px; + font-family: system-ui, -apple-system, sans-serif; + color: #e2e8f0; +} diff --git a/modules/rfeeds/components/folk-feeds-dashboard.ts b/modules/rfeeds/components/folk-feeds-dashboard.ts new file mode 100644 index 00000000..82eda53d --- /dev/null +++ b/modules/rfeeds/components/folk-feeds-dashboard.ts @@ -0,0 +1,728 @@ +/** + * — RSS feed timeline + source management + activity + profiles. + * + * Subscribes to Automerge doc for real-time updates. + * Falls back to REST API when offline runtime unavailable. + */ + +import { feedsSchema, feedsDocId } from '../schemas'; +import type { FeedsDoc, FeedSource, FeedItem, ManualPost, ActivityModuleConfig } from '../schemas'; +import type { DocumentId } from '../../../shared/local-first/document'; + +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; +} + +interface UserProfile { + did: string; + displayName: string; + bio: string; + publishModules: string[]; + createdAt: number; + updatedAt: number; +} + +export class FolkFeedsDashboard extends HTMLElement { + private _space = 'demo'; + private _doc: FeedsDoc | null = null; + private _timeline: TimelineEntry[] = []; + private _sources: FeedSource[] = []; + private _offlineUnsub: (() => void) | null = null; + private _subscribedDocIds: string[] = []; + private _tab: 'timeline' | 'sources' | 'modules' | 'profile' = 'timeline'; + private _loading = true; + private _activityConfig: Record = {}; + private _profile: UserProfile | null = null; + + static get observedAttributes() { return ['space']; } + + connectedCallback() { + if (!this.shadowRoot) this.attachShadow({ mode: 'open' }); + this._space = this.getAttribute('space') || 'demo'; + this.render(); + this.subscribeOffline(); + } + + disconnectedCallback() { + this._offlineUnsub?.(); + this._offlineUnsub = null; + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; + } + + attributeChangedCallback(name: string, _old: string, val: string) { + if (name === 'space') this._space = val; + } + + private async subscribeOffline() { + let runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime) { + await new Promise(r => setTimeout(r, 200)); + runtime = (window as any).__rspaceOfflineRuntime; + } + if (!runtime) { await this.loadFromAPI(); return; } + if (!runtime.isInitialized && runtime.init) { + try { await runtime.init(); } catch { /* already init'd */ } + } + if (!runtime.isInitialized) { await this.loadFromAPI(); return; } + + try { + const docId = feedsDocId(this._space) as DocumentId; + const doc = await runtime.subscribe(docId, feedsSchema); + this._subscribedDocIds.push(docId); + this.renderFromDoc(doc); + + this._offlineUnsub = runtime.onChange(docId, (updated: any) => { + this.renderFromDoc(updated); + }); + } catch { + await this.loadFromAPI(); + } + } + + private async loadFromAPI() { + try { + const base = (window as any).__rspaceBase || ''; + const [tlRes, srcRes, actRes] = await Promise.all([ + fetch(`${base}/${this._space}/rfeeds/api/timeline?limit=50`), + fetch(`${base}/${this._space}/rfeeds/api/sources`), + fetch(`${base}/${this._space}/rfeeds/api/activity/config`), + ]); + if (tlRes.ok) this._timeline = await tlRes.json(); + if (srcRes.ok) this._sources = await srcRes.json(); + if (actRes.ok) this._activityConfig = await actRes.json(); + } catch { /* offline */ } + + // Load profile if authenticated + await this.loadProfile(); + + this._loading = false; + this.render(); + } + + private async loadProfile() { + const token = this.getToken(); + if (!token) return; + try { + const base = (window as any).__rspaceBase || ''; + const res = await fetch(`${base}/${this._space}/rfeeds/api/profile`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) this._profile = await res.json(); + } catch { /* ignore */ } + } + + private renderFromDoc(doc: FeedsDoc) { + if (!doc) return; + this._doc = doc; + this._loading = false; + + // Build timeline from doc + const entries: TimelineEntry[] = []; + if (doc.items) { + 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, + }); + } + } + if (doc.posts) { + 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, + }); + } + } + // Activity cache entries + if (doc.activityCache) { + for (const entry of Object.values(doc.activityCache)) { + const config = doc.settings?.activityModules?.[entry.moduleId]; + if (config && !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 || entry.moduleId, + sourceColor: config?.color || '#94a3b8', + publishedAt: entry.publishedAt, + reshared: entry.reshared, + moduleId: entry.moduleId, + itemType: entry.itemType, + }); + } + } + entries.sort((a, b) => b.publishedAt - a.publishedAt); + this._timeline = entries; + this._sources = doc.sources ? Object.values(doc.sources).sort((a, b) => b.addedAt - a.addedAt) : []; + + // Extract activity config from doc + if (doc.settings?.activityModules) { + this._activityConfig = { ...doc.settings.activityModules }; + } + // Extract profile + if (doc.userProfiles) { + const token = this.getToken(); + if (token) { + try { + const payload = JSON.parse(atob(token.split('.')[1])); + const did = payload.did || payload.sub; + if (did && doc.userProfiles[did]) { + this._profile = doc.userProfiles[did] as any; + } + } catch { /* ignore */ } + } + } + + this.render(); + } + + private getToken(): string | null { + try { + const raw = localStorage.getItem('encryptid-session'); + if (!raw) return null; + const parsed = JSON.parse(raw); + return parsed?.token || parsed?.jwt || null; + } catch { return null; } + } + + private async addSource() { + const root = this.shadowRoot!; + const urlInput = root.querySelector('#add-source-url'); + const nameInput = root.querySelector('#add-source-name'); + if (!urlInput?.value) return; + + const token = this.getToken(); + if (!token) { alert('Sign in to add feeds'); return; } + + const base = (window as any).__rspaceBase || ''; + const res = await fetch(`${base}/${this._space}/rfeeds/api/sources`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ url: urlInput.value, name: nameInput?.value || undefined }), + }); + + if (res.ok) { + urlInput.value = ''; + if (nameInput) nameInput.value = ''; + if (!this._doc) await this.loadFromAPI(); + } else { + const err = await res.json().catch(() => ({ error: 'Failed' })); + alert(err.error || 'Failed to add source'); + } + } + + private async deleteSource(id: string) { + if (!confirm('Remove this feed source and all its items?')) return; + const token = this.getToken(); + if (!token) return; + const base = (window as any).__rspaceBase || ''; + await fetch(`${base}/${this._space}/rfeeds/api/sources/${id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + if (!this._doc) await this.loadFromAPI(); + } + + private async syncSource(id: string) { + const token = this.getToken(); + if (!token) return; + const base = (window as any).__rspaceBase || ''; + const btn = this.shadowRoot?.querySelector(`[data-sync="${id}"]`); + if (btn) { btn.disabled = true; btn.textContent = 'Syncing\u2026'; } + + const res = await fetch(`${base}/${this._space}/rfeeds/api/sources/${id}/sync`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + const result = await res.json().catch(() => ({})); + + if (btn) { + btn.disabled = false; + btn.textContent = result.added ? `+${result.added}` : 'Sync'; + setTimeout(() => { btn.textContent = 'Sync'; }, 2000); + } + if (!this._doc) await this.loadFromAPI(); + } + + private async toggleReshare(id: string, type: 'item' | 'activity') { + const token = this.getToken(); + if (!token) return; + const base = (window as any).__rspaceBase || ''; + const endpoint = type === 'activity' + ? `${base}/${this._space}/rfeeds/api/activity/${encodeURIComponent(id)}/reshare` + : `${base}/${this._space}/rfeeds/api/items/${id}/reshare`; + await fetch(endpoint, { + method: 'PATCH', + headers: { Authorization: `Bearer ${token}` }, + }); + if (!this._doc) await this.loadFromAPI(); + } + + private async createPost() { + const root = this.shadowRoot!; + const textarea = root.querySelector('#post-content'); + if (!textarea?.value.trim()) return; + + const token = this.getToken(); + if (!token) { alert('Sign in to post'); return; } + + const base = (window as any).__rspaceBase || ''; + const res = await fetch(`${base}/${this._space}/rfeeds/api/posts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ content: textarea.value }), + }); + + if (res.ok) { + textarea.value = ''; + if (!this._doc) await this.loadFromAPI(); + } + } + + private async importOPML() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.opml,.xml'; + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) return; + const token = this.getToken(); + if (!token) { alert('Sign in to import'); return; } + + const opml = await file.text(); + const base = (window as any).__rspaceBase || ''; + const res = await fetch(`${base}/${this._space}/rfeeds/api/sources/import-opml`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ opml }), + }); + const result = await res.json().catch(() => ({})); + if (result.added) alert(`Imported ${result.added} feeds`); + if (!this._doc) await this.loadFromAPI(); + }; + input.click(); + } + + private async syncActivity() { + const token = this.getToken(); + if (!token) { alert('Sign in to sync activity'); return; } + + const btn = this.shadowRoot?.querySelector('#sync-activity-btn'); + if (btn) { btn.disabled = true; btn.textContent = 'Syncing\u2026'; } + + const base = (window as any).__rspaceBase || ''; + const res = await fetch(`${base}/${this._space}/rfeeds/api/activity/sync`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + const result = await res.json().catch(() => ({})); + + if (btn) { + btn.disabled = false; + btn.textContent = result.synced ? `Synced ${result.synced}` : 'Sync Activity'; + setTimeout(() => { btn.textContent = 'Sync Activity'; }, 3000); + } + if (!this._doc) await this.loadFromAPI(); + } + + private async toggleModule(moduleId: string, enabled: boolean) { + const token = this.getToken(); + if (!token) return; + + const current = this._activityConfig[moduleId] || { enabled: false, label: moduleId, color: '#94a3b8' }; + const base = (window as any).__rspaceBase || ''; + const res = await fetch(`${base}/${this._space}/rfeeds/api/activity/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ modules: { [moduleId]: { ...current, enabled } } }), + }); + if (res.ok) { + this._activityConfig = await res.json(); + this.render(); + } + } + + private async saveProfile() { + const token = this.getToken(); + if (!token) { alert('Sign in to save profile'); return; } + + const root = this.shadowRoot!; + const displayName = root.querySelector('#profile-name')?.value || ''; + const bio = root.querySelector('#profile-bio')?.value || ''; + const publishModules: string[] = []; + root.querySelectorAll('[data-publish-module]').forEach((cb) => { + if (cb.checked) publishModules.push(cb.dataset.publishModule!); + }); + + const base = (window as any).__rspaceBase || ''; + const res = await fetch(`${base}/${this._space}/rfeeds/api/profile`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ displayName, bio, publishModules }), + }); + if (res.ok) { + this._profile = await res.json(); + this.render(); + // Flash save button + const btn = this.shadowRoot?.querySelector('#save-profile-btn'); + if (btn) { btn.textContent = 'Saved!'; setTimeout(() => { btn.textContent = 'Save Profile'; }, 2000); } + } + } + + private timeAgo(ts: number): string { + const diff = Date.now() - ts; + if (diff < 60_000) return 'just now'; + if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`; + if (diff < 2592000_000) return `${Math.floor(diff / 86400_000)}d ago`; + return new Date(ts).toLocaleDateString(); + } + + private render() { + const root = this.shadowRoot!; + const activityCount = this._timeline.filter(e => e.type === 'activity').length; + + root.innerHTML = ` + + +
+ + + + +
+ +${this._loading ? '
Loading feeds\u2026
' : + this._tab === 'timeline' ? this.renderTimeline() : + this._tab === 'sources' ? this.renderSources() : + this._tab === 'modules' ? this.renderModules() : + this.renderProfile()} +`; + + // Event listeners + root.querySelectorAll('.tab').forEach((btn) => { + btn.addEventListener('click', () => { + this._tab = (btn as HTMLElement).dataset.tab as any; + this.render(); + }); + }); + + const postBtn = root.querySelector('#post-btn'); + postBtn?.addEventListener('click', () => this.createPost()); + + const addBtn = root.querySelector('#add-source-btn'); + addBtn?.addEventListener('click', () => this.addSource()); + + const importBtn = root.querySelector('#import-opml-btn'); + importBtn?.addEventListener('click', () => this.importOPML()); + + const syncActBtn = root.querySelector('#sync-activity-btn'); + syncActBtn?.addEventListener('click', () => this.syncActivity()); + + const saveProfileBtn = root.querySelector('#save-profile-btn'); + saveProfileBtn?.addEventListener('click', () => this.saveProfile()); + + root.querySelectorAll('[data-sync]').forEach((btn) => { + btn.addEventListener('click', () => this.syncSource((btn as HTMLElement).dataset.sync!)); + }); + + root.querySelectorAll('[data-delete-source]').forEach((btn) => { + btn.addEventListener('click', () => this.deleteSource((btn as HTMLElement).dataset.deleteSource!)); + }); + + root.querySelectorAll('[data-reshare]').forEach((btn) => { + const el = btn as HTMLElement; + btn.addEventListener('click', () => this.toggleReshare(el.dataset.reshare!, (el.dataset.reshareType as any) || 'item')); + }); + + root.querySelectorAll('[data-toggle-module]').forEach((input) => { + input.addEventListener('change', () => { + const el = input as HTMLInputElement; + this.toggleModule(el.dataset.toggleModule!, el.checked); + }); + }); + } + + private renderTimeline(): string { + const composer = ` +
+ + +
`; + + if (!this._timeline.length) { + return composer + '
No feed items yet. Add some sources in the Sources tab!
'; + } + + const items = this._timeline.slice(0, 100).map((e) => ` +
+
+ ${this.escHtml(e.sourceName)} + + ${e.type === 'activity' && e.itemType ? `${this.escHtml(e.itemType)} ` : ''} + ${this.timeAgo(e.publishedAt)} + +
+ ${e.title ? `
${e.url ? `${this.escHtml(e.title)}` : this.escHtml(e.title)}
` : ''} +
${this.escHtml(e.summary)}
+ +
`).join(''); + + return composer + items; + } + + private renderSources(): string { + const addForm = ` +
+ + + + +
`; + + if (!this._sources.length) { + return addForm + '
No feed sources yet. Add one above!
'; + } + + const cards = this._sources.map((s) => { + const dotClass = s.lastError ? 'err' : s.lastFetchedAt ? 'ok' : 'pending'; + return ` +
+
+
+
${this.escHtml(s.name)}
+
${this.escHtml(s.url)}
+
+
+ + +
+
+
+ + ${s.itemCount} items + ${s.lastFetchedAt ? 'Last sync ' + this.timeAgo(s.lastFetchedAt) : 'Never synced'} + ${s.enabled ? 'Active' : 'Paused'} +
+ ${s.lastError ? `
${this.escHtml(s.lastError)}
` : ''} +
`; + }).join(''); + + return addForm + cards; + } + + private renderModules(): string { + const moduleIds = ['rcal', 'rtasks', 'rdocs', 'rnotes', 'rsocials', 'rwallet', 'rphotos', 'rfiles']; + const defaultConfigs: Record = { + rcal: { label: 'Calendar', color: '#f59e0b' }, + rtasks: { label: 'Tasks', color: '#8b5cf6' }, + rdocs: { label: 'Docs', color: '#3b82f6' }, + rnotes: { label: 'Vaults', color: '#a78bfa' }, + rsocials: { label: 'Socials', color: '#ec4899' }, + rwallet: { label: 'Wallet', color: '#10b981' }, + rphotos: { label: 'Photos', color: '#f97316' }, + rfiles: { label: 'Files', color: '#64748b' }, + }; + + const rows = moduleIds.map((id) => { + const config = this._activityConfig[id] || { enabled: false, ...defaultConfigs[id] }; + const label = config.label || defaultConfigs[id]?.label || id; + const color = config.color || defaultConfigs[id]?.color || '#94a3b8'; + return ` +
+ +
+ + +
+
`; + }).join(''); + + return ` +
+
Activity Modules
+ +
+

Enable modules to pull their activity into the timeline. Admin only.

+${rows}`; + } + + private renderProfile(): string { + const token = this.getToken(); + if (!token) { + return '
Sign in to manage your feed profile.
'; + } + + const p = this._profile; + const moduleIds = ['rcal', 'rtasks', 'rdocs', 'rnotes', 'rsocials', 'rwallet', 'rphotos', 'rfiles', 'posts']; + const labels: Record = { + rcal: 'Calendar', rtasks: 'Tasks', rdocs: 'Docs', rnotes: 'Vaults', + rsocials: 'Socials', rwallet: 'Wallet', rphotos: 'Photos', rfiles: 'Files', posts: 'Manual Posts', + }; + const publishSet = new Set(p?.publishModules || []); + + const checkboxes = moduleIds.map((id) => ` +`).join(''); + + // Personal feed URL + let feedUrlHtml = ''; + if (p?.did) { + const host = window.location.origin; + const feedUrl = `${host}/${this._space}/rfeeds/user/${encodeURIComponent(p.did)}/feed.xml`; + feedUrlHtml = ` +
+ Your personal feed: ${this.escHtml(feedUrl)} +
Others can subscribe to this URL from any RSS reader or rFeeds space. +
`; + } + + return ` +
+
+ + +
+
+ + +
+
+ +
${checkboxes}
+
+ + ${feedUrlHtml} +
`; + } + + private escHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } +} + +customElements.define('folk-feeds-dashboard', FolkFeedsDashboard); diff --git a/modules/rfeeds/lib/feed-parser.ts b/modules/rfeeds/lib/feed-parser.ts new file mode 100644 index 00000000..d9e5614d --- /dev/null +++ b/modules/rfeeds/lib/feed-parser.ts @@ -0,0 +1,162 @@ +/** + * RSS 2.0 + Atom 1.0 parser. + * + * Uses fast-xml-parser. Detects format by root element ( vs ). + * Returns normalized FeedItem-shaped objects. + */ + +import { XMLParser } from 'fast-xml-parser'; + +export interface ParsedFeedItem { + guid: string; + title: string; + url: string; + summary: string; + publishedAt: number; + author: string; +} + +export interface ParsedFeed { + title: string; + description: string; + items: ParsedFeedItem[]; +} + +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + textNodeName: '#text', + isArray: (name) => ['item', 'entry'].includes(name), +}); + +function stripHtml(html: string): string { + return html + .replace(/<[^>]*>/g, '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function truncate(s: string, max: number): string { + if (s.length <= max) return s; + return s.slice(0, max - 1) + '\u2026'; +} + +function parseDate(raw: string | number | undefined): number { + if (!raw) return Date.now(); + const d = new Date(raw); + return isNaN(d.getTime()) ? Date.now() : d.getTime(); +} + +function textOf(node: any): string { + if (!node) return ''; + if (typeof node === 'string') return node; + if (typeof node === 'number') return String(node); + if (node['#text']) return String(node['#text']); + return ''; +} + +function parseRSS(rss: any): ParsedFeed { + const channel = rss.rss?.channel || rss.channel || {}; + const items = channel.item || []; + + return { + title: textOf(channel.title) || '', + description: textOf(channel.description) || '', + items: items.map((item: any) => ({ + guid: textOf(item.guid) || item.link || crypto.randomUUID(), + title: textOf(item.title) || '', + url: textOf(item.link) || '', + summary: truncate(stripHtml(textOf(item.description) || textOf(item['content:encoded']) || ''), 500), + publishedAt: parseDate(item.pubDate), + author: textOf(item.author) || textOf(item['dc:creator']) || '', + })), + }; +} + +function parseAtom(feed: any): ParsedFeed { + const root = feed.feed || feed; + const entries = root.entry || []; + + return { + title: textOf(root.title) || '', + description: textOf(root.subtitle) || '', + items: entries.map((entry: any) => { + const link = Array.isArray(entry.link) + ? entry.link.find((l: any) => l['@_rel'] === 'alternate' || !l['@_rel']) + : entry.link; + const href = link?.['@_href'] || textOf(link) || ''; + + return { + guid: textOf(entry.id) || href || crypto.randomUUID(), + title: textOf(entry.title) || '', + url: href, + summary: truncate(stripHtml(textOf(entry.summary) || textOf(entry.content) || ''), 500), + publishedAt: parseDate(entry.published || entry.updated), + author: textOf(entry.author?.name) || '', + }; + }), + }; +} + +export function parseFeed(xml: string): ParsedFeed { + const parsed = parser.parse(xml); + + // Detect format + if (parsed.rss || parsed.channel) { + return parseRSS(parsed); + } + if (parsed.feed) { + return parseAtom(parsed); + } + + // Fallback: try RSS-like structure + if (parsed['rdf:RDF']?.item) { + return { + title: textOf(parsed['rdf:RDF'].channel?.title) || '', + description: textOf(parsed['rdf:RDF'].channel?.description) || '', + items: (parsed['rdf:RDF'].item || []).map((item: any) => ({ + guid: textOf(item.link) || crypto.randomUUID(), + title: textOf(item.title) || '', + url: textOf(item.link) || '', + summary: truncate(stripHtml(textOf(item.description) || ''), 500), + publishedAt: parseDate(item['dc:date']), + author: textOf(item['dc:creator']) || '', + })), + }; + } + + throw new Error('Unrecognized feed format'); +} + +/** + * Parse OPML XML into a list of feed URLs with titles. + */ +export function parseOPML(xml: string): { url: string; name: string }[] { + const parsed = parser.parse(xml); + const results: { url: string; name: string }[] = []; + + function walk(node: any) { + if (!node) return; + const arr = Array.isArray(node) ? node : [node]; + for (const item of arr) { + const url = item['@_xmlUrl'] || item['@_xmlurl']; + if (url) { + results.push({ + url, + name: item['@_title'] || item['@_text'] || url, + }); + } + if (item.outline) walk(item.outline); + } + } + + const body = parsed.opml?.body || parsed.body; + if (body?.outline) walk(body.outline); + return results; +} diff --git a/modules/rfeeds/mod.ts b/modules/rfeeds/mod.ts new file mode 100644 index 00000000..ffa38f6a --- /dev/null +++ b/modules/rfeeds/mod.ts @@ -0,0 +1,977 @@ +/** + * 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"; + +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], + + onInit: async ({ syncServer }) => { + _syncServer = syncServer; + startSyncLoop(); + }, + + onSpaceCreate: async ({ spaceSlug }) => { + if (_syncServer) ensureDoc(spaceSlug); + }, +}; diff --git a/modules/rfeeds/schemas.ts b/modules/rfeeds/schemas.ts new file mode 100644 index 00000000..2ccee2d4 --- /dev/null +++ b/modules/rfeeds/schemas.ts @@ -0,0 +1,157 @@ +/** + * rFeeds Automerge document schemas. + * + * Granularity: one Automerge document per space. + * DocId format: {space}:rfeeds:data + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Feed source ── + +export interface FeedSource { + id: string; + url: string; + name: string; + color: string; + enabled: boolean; + lastFetchedAt: number; + lastError: string | null; + itemCount: number; + intervalMs: number; // default 300_000 (5 min) + addedBy: string; // DID of who added it + addedAt: number; +} + +// ── Imported feed item ── + +export interface FeedItem { + id: string; + sourceId: string; + guid: string; + title: string; + url: string; + summary: string; // 500 chars max, HTML stripped + author: string; + publishedAt: number; + importedAt: number; + reshared: boolean; // included in outbound Atom feed +} + +// ── Manual post ── + +export interface ManualPost { + id: string; + content: string; + authorDid: string; + authorName: string; + createdAt: number; + updatedAt: number; +} + +// ── Activity cache (Phase 2) ── + +export interface ActivityCacheEntry { + id: string; // deterministic: `${moduleId}:${space}:${itemId}` + moduleId: string; // 'rcal' | 'rtasks' | 'rdocs' | etc + itemType: string; // 'event' | 'task' | 'note' | 'thread' | 'file' | etc + title: string; + summary: string; // 280 char max + url: string; // e.g. https://demo.rspace.online/rcal + author: string; + publishedAt: number; + reshared: boolean; + syncedAt: number; +} + +export interface ActivityModuleConfig { + enabled: boolean; + label: string; + color: string; +} + +// ── User feed profiles (Phase 2) ── + +export interface UserFeedProfile { + did: string; + displayName: string; + bio: string; + publishModules: string[]; // which moduleIds appear in personal feed + createdAt: number; + updatedAt: number; +} + +// ── Settings ── + +export interface FeedsSettings { + feedTitle: string; + feedDescription: string; + activityModules: Record; +} + +// ── Document root ── + +export interface FeedsDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + sources: Record; + items: Record; + posts: Record; + activityCache: Record; + userProfiles: Record; + settings: FeedsSettings; +} + +// ── Schema registration ── + +export const feedsSchema: DocSchema = { + module: 'rfeeds', + collection: 'data', + version: 2, + init: (): FeedsDoc => ({ + meta: { + module: 'rfeeds', + collection: 'data', + version: 2, + spaceSlug: '', + createdAt: Date.now(), + }, + sources: {}, + items: {}, + posts: {}, + activityCache: {}, + userProfiles: {}, + settings: { + feedTitle: '', + feedDescription: '', + activityModules: {}, + }, + }), + migrate: (doc: any): any => { + if (!doc.activityCache) doc.activityCache = {}; + if (!doc.userProfiles) doc.userProfiles = {}; + if (!doc.settings.activityModules) doc.settings.activityModules = {}; + if (doc.meta) doc.meta.version = 2; + return doc; + }, +}; + +// ── Helpers ── + +export function feedsDocId(space: string) { + return `${space}:rfeeds:data` as const; +} + +/** Max items retained per source */ +export const MAX_ITEMS_PER_SOURCE = 200; + +/** Max activity cache entries */ +export const MAX_ACTIVITY_CACHE = 500; + +/** Default sync interval */ +export const DEFAULT_SYNC_INTERVAL_MS = 5 * 60 * 1000; diff --git a/package.json b/package.json index b587fb77..beff7509 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "cron-parser": "^5.5.0", + "fast-xml-parser": "^4.5.0", "h3-js": "^4.4.0", "hono": "^4.11.7", "imapflow": "^1.0.170", diff --git a/server/shell.ts b/server/shell.ts index 58acb41d..7a605906 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -56,6 +56,7 @@ const FAVICON_BADGE_MAP: Record = { ragents: { badge: "r🤖", color: "#6ee7b7" }, rids: { badge: "r🪪", color: "#6ee7b7" }, rcred: { badge: "r⭐", color: "#d97706" }, + rfeeds: { badge: "r📡", color: "#67e8f9" }, rstack: { badge: "r✨", color: "#c4b5fd" }, }; diff --git a/vite.config.ts b/vite.config.ts index 7794ef5e..8a021b3c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1524,6 +1524,31 @@ export default defineConfig({ }, }); + // ── rFeeds: folk-feeds-dashboard ── + mkdirSync(resolve(__dirname, "dist/modules/rfeeds"), { recursive: true }); + await wasmBuild({ + configFile: false, + root: resolve(__dirname, "modules/rfeeds/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rfeeds"), + lib: { + entry: resolve(__dirname, "modules/rfeeds/components/folk-feeds-dashboard.ts"), + formats: ["es"], + fileName: () => "folk-feeds-dashboard.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-feeds-dashboard.js", + }, + }, + }, + }); + copyFileSync( + resolve(__dirname, "modules/rfeeds/components/feeds.css"), + resolve(__dirname, "dist/modules/rfeeds/feeds.css"), + ); + // ── Generate content hashes for cache-busting ── const { readdirSync, readFileSync, writeFileSync, statSync: statSync2 } = await import("node:fs"); const { createHash } = await import("node:crypto");