Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m4s
Details
CI/CD / deploy (push) Successful in 2m4s
Details
This commit is contained in:
commit
694f47e363
|
|
@ -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' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
|
@ -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[];
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('folk-feeds-dashboard', FolkFeedsDashboard);
|
||||||
|
|
@ -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(/&/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;
|
||||||
|
}
|
||||||
|
|
@ -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, '&').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) => ` <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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue