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

This commit is contained in:
Jeff Emmett 2026-04-16 08:47:42 -04:00
commit 694f47e363
18 changed files with 2501 additions and 0 deletions

View File

@ -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<string, AdapterEntry> = {
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' },
},
};

View File

@ -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<any>(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);
};

View File

@ -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<any>(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);
};

View File

@ -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<any>(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);
};

View File

@ -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<any>(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);
};

View File

@ -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<any>(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);
};

View File

@ -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<any>(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);
};

View File

@ -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<any>(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);
};

View File

@ -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<any>(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);
};

View File

@ -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<T>(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[];

View File

@ -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;
}

View File

@ -0,0 +1,728 @@
/**
* <folk-feeds-dashboard> 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<string, ActivityModuleConfig> = {};
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<HTMLInputElement>('#add-source-url');
const nameInput = root.querySelector<HTMLInputElement>('#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<HTMLButtonElement>(`[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<HTMLTextAreaElement>('#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<HTMLButtonElement>('#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<HTMLInputElement>('#profile-name')?.value || '';
const bio = root.querySelector<HTMLTextAreaElement>('#profile-bio')?.value || '';
const publishModules: string[] = [];
root.querySelectorAll<HTMLInputElement>('[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<HTMLButtonElement>('#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 = `
<style>
:host { display:block; max-width:720px; margin:0 auto; padding:16px; font-family:system-ui,-apple-system,sans-serif; color:#e2e8f0; }
* { box-sizing:border-box; }
.tabs { display:flex; gap:4px; margin-bottom:16px; flex-wrap:wrap; }
.tab { padding:8px 16px; border:1px solid #334155; border-radius:8px; background:transparent; color:#94a3b8; cursor:pointer; font-size:14px; }
.tab.active { background:#1e293b; color:#e2e8f0; border-color:#475569; }
.tab:hover { background:#1e293b; }
.composer { display:flex; gap:8px; margin-bottom:20px; }
.composer textarea { flex:1; background:#0f172a; border:1px solid #334155; border-radius:8px; padding:10px; color:#e2e8f0; resize:none; font-size:14px; min-height:60px; }
.composer textarea:focus { outline:none; border-color:#67e8f9; }
.btn { padding:8px 14px; border:none; border-radius:6px; cursor:pointer; font-size:13px; font-weight:500; }
.btn-primary { background:#0891b2; color:#fff; }
.btn-primary:hover { background:#06b6d4; }
.btn-sm { padding:4px 10px; font-size:12px; }
.btn-ghost { background:transparent; border:1px solid #334155; color:#94a3b8; }
.btn-ghost:hover { background:#1e293b; color:#e2e8f0; }
.btn-danger { background:#7f1d1d; color:#fca5a5; }
.btn-danger:hover { background:#991b1b; }
/* Timeline items */
.entry { padding:14px 16px; border:1px solid #1e293b; border-radius:10px; margin-bottom:10px; background:#0f172a; transition:border-color .15s; }
.entry:hover { border-color:#334155; }
.entry-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:6px; gap:8px; }
.source-badge { font-size:11px; padding:2px 8px; border-radius:99px; font-weight:500; white-space:nowrap; }
.entry-title { font-size:15px; font-weight:600; color:#f1f5f9; }
.entry-title a { color:inherit; text-decoration:none; }
.entry-title a:hover { text-decoration:underline; }
.entry-summary { font-size:13px; color:#94a3b8; line-height:1.5; margin-top:4px; }
.entry-meta { display:flex; gap:12px; align-items:center; margin-top:8px; font-size:12px; color:#64748b; }
.reshare-btn { cursor:pointer; padding:2px 8px; border-radius:4px; border:1px solid #334155; background:transparent; color:#64748b; font-size:11px; }
.reshare-btn.active { background:#164e63; border-color:#0891b2; color:#67e8f9; }
.reshare-btn:hover { border-color:#475569; }
.type-badge { font-size:10px; padding:1px 6px; border-radius:4px; background:#1e293b; color:#64748b; text-transform:uppercase; letter-spacing:0.5px; }
/* Sources */
.source-card { padding:14px 16px; border:1px solid #1e293b; border-radius:10px; margin-bottom:10px; background:#0f172a; }
.source-card-header { display:flex; justify-content:space-between; align-items:center; }
.source-name { font-size:14px; font-weight:600; color:#f1f5f9; }
.source-url { font-size:12px; color:#64748b; word-break:break-all; margin-top:2px; }
.source-status { font-size:12px; color:#64748b; margin-top:6px; display:flex; gap:12px; align-items:center; }
.source-error { color:#f87171; font-size:12px; margin-top:4px; }
.source-actions { display:flex; gap:6px; }
.add-source { display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap; }
.add-source input { flex:1; min-width:200px; background:#0f172a; border:1px solid #334155; border-radius:8px; padding:8px 12px; color:#e2e8f0; font-size:13px; }
.add-source input:focus { outline:none; border-color:#67e8f9; }
.add-source input::placeholder { color:#475569; }
.status-dot { width:8px; height:8px; border-radius:50%; display:inline-block; }
.status-dot.ok { background:#22c55e; }
.status-dot.err { background:#ef4444; }
.status-dot.pending { background:#eab308; }
.empty { text-align:center; color:#475569; padding:40px 0; font-size:14px; }
.loading { text-align:center; color:#475569; padding:40px 0; }
.toolbar { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; }
/* Modules config */
.module-row { display:flex; align-items:center; gap:12px; padding:10px 14px; border:1px solid #1e293b; border-radius:8px; margin-bottom:8px; background:#0f172a; }
.module-row label { flex:1; font-size:14px; color:#e2e8f0; cursor:pointer; display:flex; align-items:center; gap:8px; }
.module-color { width:12px; height:12px; border-radius:50%; }
.toggle { position:relative; width:36px; height:20px; }
.toggle input { opacity:0; width:0; height:0; }
.toggle .slider { position:absolute; cursor:pointer; top:0; left:0; right:0; bottom:0; background:#334155; border-radius:10px; transition:.2s; }
.toggle .slider:before { content:""; position:absolute; height:16px; width:16px; left:2px; bottom:2px; background:#94a3b8; border-radius:50%; transition:.2s; }
.toggle input:checked + .slider { background:#0891b2; }
.toggle input:checked + .slider:before { transform:translateX(16px); background:#fff; }
/* Profile */
.profile-form { display:flex; flex-direction:column; gap:12px; }
.profile-form label { font-size:13px; color:#94a3b8; }
.profile-form input, .profile-form textarea { background:#0f172a; border:1px solid #334155; border-radius:8px; padding:8px 12px; color:#e2e8f0; font-size:14px; }
.profile-form input:focus, .profile-form textarea:focus { outline:none; border-color:#67e8f9; }
.profile-form textarea { resize:vertical; min-height:60px; }
.publish-modules { display:flex; flex-wrap:wrap; gap:8px; margin-top:4px; }
.publish-modules label { display:flex; align-items:center; gap:6px; padding:4px 10px; border:1px solid #334155; border-radius:6px; font-size:13px; color:#94a3b8; cursor:pointer; }
.publish-modules label:has(input:checked) { border-color:#0891b2; color:#67e8f9; background:#164e6322; }
.publish-modules input { accent-color:#0891b2; }
.feed-url { margin-top:12px; padding:10px 14px; background:#0f172a; border:1px solid #334155; border-radius:8px; font-size:12px; color:#64748b; word-break:break-all; }
.feed-url a { color:#67e8f9; }
.section-title { font-size:13px; font-weight:600; color:#94a3b8; text-transform:uppercase; letter-spacing:0.5px; margin-bottom:8px; margin-top:16px; }
</style>
<div class="tabs">
<button class="tab ${this._tab === 'timeline' ? 'active' : ''}" data-tab="timeline">Timeline</button>
<button class="tab ${this._tab === 'sources' ? 'active' : ''}" data-tab="sources">Sources (${this._sources.length})</button>
<button class="tab ${this._tab === 'modules' ? 'active' : ''}" data-tab="modules">Modules${activityCount ? ` (${activityCount})` : ''}</button>
<button class="tab ${this._tab === 'profile' ? 'active' : ''}" data-tab="profile">My Profile</button>
</div>
${this._loading ? '<div class="loading">Loading feeds\u2026</div>' :
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 = `
<div class="composer">
<textarea id="post-content" placeholder="Write a post\u2026" rows="2"></textarea>
<button class="btn btn-primary" id="post-btn">Post</button>
</div>`;
if (!this._timeline.length) {
return composer + '<div class="empty">No feed items yet. Add some sources in the Sources tab!</div>';
}
const items = this._timeline.slice(0, 100).map((e) => `
<div class="entry">
<div class="entry-header">
<span class="source-badge" style="background:${e.sourceColor}22; color:${e.sourceColor}">${this.escHtml(e.sourceName)}</span>
<span style="font-size:12px;color:#64748b">
${e.type === 'activity' && e.itemType ? `<span class="type-badge">${this.escHtml(e.itemType)}</span> ` : ''}
${this.timeAgo(e.publishedAt)}
</span>
</div>
${e.title ? `<div class="entry-title">${e.url ? `<a href="${this.escHtml(e.url)}" target="_blank" rel="noopener">${this.escHtml(e.title)}</a>` : this.escHtml(e.title)}</div>` : ''}
<div class="entry-summary">${this.escHtml(e.summary)}</div>
<div class="entry-meta">
${e.author ? `<span>${this.escHtml(e.author)}</span>` : ''}
${e.type === 'item' ? `<button class="reshare-btn ${e.reshared ? 'active' : ''}" data-reshare="${e.id}" data-reshare-type="item">${e.reshared ? 'Shared' : 'Reshare'}</button>` : ''}
${e.type === 'activity' ? `<button class="reshare-btn ${e.reshared ? 'active' : ''}" data-reshare="${e.id}" data-reshare-type="activity">${e.reshared ? 'Shared' : 'Reshare'}</button>` : ''}
</div>
</div>`).join('');
return composer + items;
}
private renderSources(): string {
const addForm = `
<div class="add-source">
<input type="url" id="add-source-url" placeholder="Feed URL (RSS or Atom)">
<input type="text" id="add-source-name" placeholder="Display name (optional)" style="max-width:180px">
<button class="btn btn-primary" id="add-source-btn">Add Feed</button>
<button class="btn btn-ghost" id="import-opml-btn">Import OPML</button>
</div>`;
if (!this._sources.length) {
return addForm + '<div class="empty">No feed sources yet. Add one above!</div>';
}
const cards = this._sources.map((s) => {
const dotClass = s.lastError ? 'err' : s.lastFetchedAt ? 'ok' : 'pending';
return `
<div class="source-card">
<div class="source-card-header">
<div>
<div class="source-name" style="color:${s.color}">${this.escHtml(s.name)}</div>
<div class="source-url">${this.escHtml(s.url)}</div>
</div>
<div class="source-actions">
<button class="btn btn-sm btn-ghost" data-sync="${s.id}">Sync</button>
<button class="btn btn-sm btn-danger" data-delete-source="${s.id}">\u2715</button>
</div>
</div>
<div class="source-status">
<span class="status-dot ${dotClass}"></span>
<span>${s.itemCount} items</span>
<span>${s.lastFetchedAt ? 'Last sync ' + this.timeAgo(s.lastFetchedAt) : 'Never synced'}</span>
<span>${s.enabled ? 'Active' : 'Paused'}</span>
</div>
${s.lastError ? `<div class="source-error">${this.escHtml(s.lastError)}</div>` : ''}
</div>`;
}).join('');
return addForm + cards;
}
private renderModules(): string {
const moduleIds = ['rcal', 'rtasks', 'rdocs', 'rnotes', 'rsocials', 'rwallet', 'rphotos', 'rfiles'];
const defaultConfigs: Record<string, { label: string; color: string }> = {
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 `
<div class="module-row">
<label>
<span class="module-color" style="background:${color}"></span>
${this.escHtml(label)}
</label>
<div class="toggle">
<input type="checkbox" data-toggle-module="${id}" ${config.enabled ? 'checked' : ''}>
<span class="slider"></span>
</div>
</div>`;
}).join('');
return `
<div class="toolbar">
<div class="section-title">Activity Modules</div>
<button class="btn btn-sm btn-primary" id="sync-activity-btn">Sync Activity</button>
</div>
<p style="font-size:13px;color:#64748b;margin:0 0 12px">Enable modules to pull their activity into the timeline. Admin only.</p>
${rows}`;
}
private renderProfile(): string {
const token = this.getToken();
if (!token) {
return '<div class="empty">Sign in to manage your feed profile.</div>';
}
const p = this._profile;
const moduleIds = ['rcal', 'rtasks', 'rdocs', 'rnotes', 'rsocials', 'rwallet', 'rphotos', 'rfiles', 'posts'];
const labels: Record<string, string> = {
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) => `
<label>
<input type="checkbox" data-publish-module="${id}" ${publishSet.has(id) ? 'checked' : ''}>
${labels[id] || id}
</label>`).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 = `
<div class="feed-url">
Your personal feed: <a href="${this.escHtml(feedUrl)}" target="_blank">${this.escHtml(feedUrl)}</a>
<br><span style="color:#475569">Others can subscribe to this URL from any RSS reader or rFeeds space.</span>
</div>`;
}
return `
<div class="profile-form">
<div>
<label>Display Name</label>
<input type="text" id="profile-name" value="${this.escHtml(p?.displayName || '')}" placeholder="Your name">
</div>
<div>
<label>Bio</label>
<textarea id="profile-bio" placeholder="A short bio\u2026" rows="2">${this.escHtml(p?.bio || '')}</textarea>
</div>
<div>
<label>Publish to Personal Feed</label>
<div class="publish-modules">${checkboxes}</div>
</div>
<button class="btn btn-primary" id="save-profile-btn">Save Profile</button>
${feedUrlHtml}
</div>`;
}
private escHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
}
customElements.define('folk-feeds-dashboard', FolkFeedsDashboard);

View File

@ -0,0 +1,162 @@
/**
* RSS 2.0 + Atom 1.0 parser.
*
* Uses fast-xml-parser. Detects format by root element (<rss> vs <feed>).
* 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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/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;
}

977
modules/rfeeds/mod.ts Normal file
View File

@ -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<FeedsDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<FeedsDoc>(), "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<FeedsDoc>(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<FeedsDoc>(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<EncryptIDClaims | null> {
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<string>();
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<FeedsDoc>(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<FeedsDoc>(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<FeedsDoc>(feedsDocId(space))!;
const count = Object.values(updatedDoc.items).filter((i) => i.sourceId === sourceId).length;
_syncServer!.changeDoc<FeedsDoc>(feedsDocId(space), "update item count", (d) => {
if (d.sources[sourceId]) d.sources[sourceId].itemCount = count;
});
return { added: newItems.length };
} catch (err: any) {
_syncServer!.changeDoc<FeedsDoc>(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<FeedsDoc>(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<FeedsDoc>(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<string, boolean>();
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<FeedsDoc>(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<typeof setInterval> | 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<FeedsDoc>(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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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) => ` <entry>
<id>urn:rspace:${escapeXml(space)}:${escapeXml(e.id)}</id>
<title>${escapeXml(e.title)}</title>
<summary>${escapeXml(e.summary)}</summary>
${e.url ? `<link href="${escapeXml(e.url)}" rel="alternate"/>` : ''}
<author><name>${escapeXml(e.author)}</name></author>
<updated>${new Date(e.publishedAt).toISOString()}</updated>
</entry>`).join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>${escapeXml(title)}</title>
<subtitle>${escapeXml(description)}</subtitle>
<link href="${escapeXml(feedUrl)}" rel="self" type="application/atom+xml"/>
<link href="${escapeXml(siteUrl)}" rel="alternate"/>
<id>urn:rspace:${escapeXml(space)}:rfeeds</id>
<updated>${new Date(latest).toISOString()}</updated>
<generator>rSpace rFeeds</generator>
${atomEntries}
</feed>`;
}
// ── 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) => ` <entry>
<id>urn:rspace:${escapeXml(space)}:user:${escapeXml(did)}:${escapeXml(e.id)}</id>
<title>${escapeXml(e.title)}</title>
<summary>${escapeXml(e.summary)}</summary>
${e.url ? `<link href="${escapeXml(e.url)}" rel="alternate"/>` : ''}
<author><name>${escapeXml(e.author || profile.displayName || did)}</name></author>
<updated>${new Date(e.publishedAt).toISOString()}</updated>
</entry>`).join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>${escapeXml(title)}</title>
<subtitle>${escapeXml(description)}</subtitle>
<link href="${escapeXml(feedUrl)}" rel="self" type="application/atom+xml"/>
<link href="${escapeXml(siteUrl)}" rel="alternate"/>
<id>urn:rspace:${escapeXml(space)}:user:${escapeXml(did)}</id>
<updated>${new Date(latest).toISOString()}</updated>
<generator>rSpace rFeeds</generator>
${atomEntries}
</feed>`;
}
// ── 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: `<folk-feeds-dashboard space="${dataSpace}"></folk-feeds-dashboard>`,
scripts: `<script type="module" src="/modules/rfeeds/folk-feeds-dashboard.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rfeeds/feeds.css">`,
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<FeedsDoc>(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<FeedsDoc>(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<FeedsDoc>(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<FeedsDoc>(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<FeedsDoc>(feedsDocId(dataSpace))!;
const exists = Object.values(doc.sources).some((s) => s.url === feed.url);
if (exists) continue;
const id = crypto.randomUUID();
_syncServer!.changeDoc<FeedsDoc>(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<FeedsDoc>(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<FeedsDoc>(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<FeedsDoc>(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<FeedsDoc>(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<FeedsDoc>(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<FeedsDoc>(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<string, ActivityModuleConfig> = {};
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<string, ActivityModuleConfig> }>();
if (!body.modules) return c.json({ error: "modules required" }, 400);
_syncServer!.changeDoc<FeedsDoc>(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<FeedsDoc>(feedsDocId(dataSpace))!;
const result: Record<string, ActivityModuleConfig> = {};
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<FeedsDoc>(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<FeedsDoc>(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<FeedsDoc>(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);
},
};

157
modules/rfeeds/schemas.ts Normal file
View File

@ -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<string, ActivityModuleConfig>;
}
// ── Document root ──
export interface FeedsDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
sources: Record<string, FeedSource>;
items: Record<string, FeedItem>;
posts: Record<string, ManualPost>;
activityCache: Record<string, ActivityCacheEntry>;
userProfiles: Record<string, UserFeedProfile>;
settings: FeedsSettings;
}
// ── Schema registration ──
export const feedsSchema: DocSchema<FeedsDoc> = {
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;

View File

@ -47,6 +47,7 @@
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"cron-parser": "^5.5.0", "cron-parser": "^5.5.0",
"fast-xml-parser": "^4.5.0",
"h3-js": "^4.4.0", "h3-js": "^4.4.0",
"hono": "^4.11.7", "hono": "^4.11.7",
"imapflow": "^1.0.170", "imapflow": "^1.0.170",

View File

@ -56,6 +56,7 @@ const FAVICON_BADGE_MAP: Record<string, { badge: string; color: string }> = {
ragents: { badge: "r🤖", color: "#6ee7b7" }, ragents: { badge: "r🤖", color: "#6ee7b7" },
rids: { badge: "r🪪", color: "#6ee7b7" }, rids: { badge: "r🪪", color: "#6ee7b7" },
rcred: { badge: "r⭐", color: "#d97706" }, rcred: { badge: "r⭐", color: "#d97706" },
rfeeds: { badge: "r📡", color: "#67e8f9" },
rstack: { badge: "r✨", color: "#c4b5fd" }, rstack: { badge: "r✨", color: "#c4b5fd" },
}; };

View File

@ -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 ── // ── Generate content hashes for cache-busting ──
const { readdirSync, readFileSync, writeFileSync, statSync: statSync2 } = await import("node:fs"); const { readdirSync, readFileSync, writeFileSync, statSync: statSync2 } = await import("node:fs");
const { createHash } = await import("node:crypto"); const { createHash } = await import("node:crypto");