/** * — RSS feed timeline + source management + activity + profiles. * * Subscribes to Automerge doc for real-time updates. * Falls back to REST API when offline runtime unavailable. */ import { feedsSchema, feedsDocId } from '../schemas'; import type { FeedsDoc, FeedSource, FeedItem, ManualPost, ActivityModuleConfig } from '../schemas'; import type { DocumentId } from '../../../shared/local-first/document'; interface TimelineEntry { type: 'item' | 'post' | 'activity'; id: string; title: string; url: string; summary: string; author: string; sourceName: string; sourceColor: string; publishedAt: number; reshared: boolean; moduleId?: string; itemType?: string; } interface UserProfile { did: string; displayName: string; bio: string; publishModules: string[]; createdAt: number; updatedAt: number; } export class FolkFeedsDashboard extends HTMLElement { private _space = 'demo'; private _doc: FeedsDoc | null = null; private _timeline: TimelineEntry[] = []; private _sources: FeedSource[] = []; private _offlineUnsub: (() => void) | null = null; private _subscribedDocIds: string[] = []; private _tab: 'timeline' | 'sources' | 'modules' | 'profile' = 'timeline'; private _loading = true; private _activityConfig: Record = {}; private _profile: UserProfile | null = null; static get observedAttributes() { return ['space']; } connectedCallback() { if (!this.shadowRoot) this.attachShadow({ mode: 'open' }); this._space = this.getAttribute('space') || 'demo'; this.render(); this.subscribeOffline(); } disconnectedCallback() { this._offlineUnsub?.(); this._offlineUnsub = null; const runtime = (window as any).__rspaceOfflineRuntime; if (runtime) { for (const id of this._subscribedDocIds) runtime.unsubscribe(id); } this._subscribedDocIds = []; } attributeChangedCallback(name: string, _old: string, val: string) { if (name === 'space') this._space = val; } private async subscribeOffline() { let runtime = (window as any).__rspaceOfflineRuntime; if (!runtime) { await new Promise(r => setTimeout(r, 200)); runtime = (window as any).__rspaceOfflineRuntime; } if (!runtime) { await this.loadFromAPI(); return; } if (!runtime.isInitialized && runtime.init) { try { await runtime.init(); } catch { /* already init'd */ } } if (!runtime.isInitialized) { await this.loadFromAPI(); return; } try { const docId = feedsDocId(this._space) as DocumentId; const doc = await runtime.subscribe(docId, feedsSchema); this._subscribedDocIds.push(docId); this.renderFromDoc(doc); this._offlineUnsub = runtime.onChange(docId, (updated: any) => { this.renderFromDoc(updated); }); } catch { await this.loadFromAPI(); } } private async loadFromAPI() { try { const base = (window as any).__rspaceBase || ''; const [tlRes, srcRes, actRes] = await Promise.all([ fetch(`${base}/${this._space}/rfeeds/api/timeline?limit=50`), fetch(`${base}/${this._space}/rfeeds/api/sources`), fetch(`${base}/${this._space}/rfeeds/api/activity/config`), ]); if (tlRes.ok) this._timeline = await tlRes.json(); if (srcRes.ok) this._sources = await srcRes.json(); if (actRes.ok) this._activityConfig = await actRes.json(); } catch { /* offline */ } // Load profile if authenticated await this.loadProfile(); this._loading = false; this.render(); } private async loadProfile() { const token = this.getToken(); if (!token) return; try { const base = (window as any).__rspaceBase || ''; const res = await fetch(`${base}/${this._space}/rfeeds/api/profile`, { headers: { Authorization: `Bearer ${token}` }, }); if (res.ok) this._profile = await res.json(); } catch { /* ignore */ } } private renderFromDoc(doc: FeedsDoc) { if (!doc) return; this._doc = doc; this._loading = false; // Build timeline from doc const entries: TimelineEntry[] = []; if (doc.items) { for (const item of Object.values(doc.items)) { const source = doc.sources?.[item.sourceId]; if (!source?.enabled) continue; entries.push({ type: 'item', id: item.id, title: item.title, url: item.url, summary: item.summary, author: item.author, sourceName: source?.name || 'Unknown', sourceColor: source?.color || '#94a3b8', publishedAt: item.publishedAt, reshared: item.reshared, }); } } if (doc.posts) { for (const post of Object.values(doc.posts)) { entries.push({ type: 'post', id: post.id, title: '', url: '', summary: post.content, author: post.authorName || post.authorDid, sourceName: 'Manual Post', sourceColor: '#67e8f9', publishedAt: post.createdAt, reshared: true, }); } } // Activity cache entries if (doc.activityCache) { for (const entry of Object.values(doc.activityCache)) { const config = doc.settings?.activityModules?.[entry.moduleId]; if (config && !config.enabled) continue; entries.push({ type: 'activity', id: entry.id, title: entry.title, url: entry.url, summary: entry.summary, author: entry.author, sourceName: config?.label || entry.moduleId, sourceColor: config?.color || '#94a3b8', publishedAt: entry.publishedAt, reshared: entry.reshared, moduleId: entry.moduleId, itemType: entry.itemType, }); } } entries.sort((a, b) => b.publishedAt - a.publishedAt); this._timeline = entries; this._sources = doc.sources ? Object.values(doc.sources).sort((a, b) => b.addedAt - a.addedAt) : []; // Extract activity config from doc if (doc.settings?.activityModules) { this._activityConfig = { ...doc.settings.activityModules }; } // Extract profile if (doc.userProfiles) { const token = this.getToken(); if (token) { try { const payload = JSON.parse(atob(token.split('.')[1])); const did = payload.did || payload.sub; if (did && doc.userProfiles[did]) { this._profile = doc.userProfiles[did] as any; } } catch { /* ignore */ } } } this.render(); } private getToken(): string | null { try { const raw = localStorage.getItem('encryptid-session'); if (!raw) return null; const parsed = JSON.parse(raw); return parsed?.token || parsed?.jwt || null; } catch { return null; } } private async addSource() { const root = this.shadowRoot!; const urlInput = root.querySelector('#add-source-url'); const nameInput = root.querySelector('#add-source-name'); if (!urlInput?.value) return; const token = this.getToken(); if (!token) { alert('Sign in to add feeds'); return; } const base = (window as any).__rspaceBase || ''; const res = await fetch(`${base}/${this._space}/rfeeds/api/sources`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ url: urlInput.value, name: nameInput?.value || undefined }), }); if (res.ok) { urlInput.value = ''; if (nameInput) nameInput.value = ''; if (!this._doc) await this.loadFromAPI(); } else { const err = await res.json().catch(() => ({ error: 'Failed' })); alert(err.error || 'Failed to add source'); } } private async deleteSource(id: string) { if (!confirm('Remove this feed source and all its items?')) return; const token = this.getToken(); if (!token) return; const base = (window as any).__rspaceBase || ''; await fetch(`${base}/${this._space}/rfeeds/api/sources/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }); if (!this._doc) await this.loadFromAPI(); } private async syncSource(id: string) { const token = this.getToken(); if (!token) return; const base = (window as any).__rspaceBase || ''; const btn = this.shadowRoot?.querySelector(`[data-sync="${id}"]`); if (btn) { btn.disabled = true; btn.textContent = 'Syncing\u2026'; } const res = await fetch(`${base}/${this._space}/rfeeds/api/sources/${id}/sync`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, }); const result = await res.json().catch(() => ({})); if (btn) { btn.disabled = false; btn.textContent = result.added ? `+${result.added}` : 'Sync'; setTimeout(() => { btn.textContent = 'Sync'; }, 2000); } if (!this._doc) await this.loadFromAPI(); } private async toggleReshare(id: string, type: 'item' | 'activity') { const token = this.getToken(); if (!token) return; const base = (window as any).__rspaceBase || ''; const endpoint = type === 'activity' ? `${base}/${this._space}/rfeeds/api/activity/${encodeURIComponent(id)}/reshare` : `${base}/${this._space}/rfeeds/api/items/${id}/reshare`; await fetch(endpoint, { method: 'PATCH', headers: { Authorization: `Bearer ${token}` }, }); if (!this._doc) await this.loadFromAPI(); } private async createPost() { const root = this.shadowRoot!; const textarea = root.querySelector('#post-content'); if (!textarea?.value.trim()) return; const token = this.getToken(); if (!token) { alert('Sign in to post'); return; } const base = (window as any).__rspaceBase || ''; const res = await fetch(`${base}/${this._space}/rfeeds/api/posts`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ content: textarea.value }), }); if (res.ok) { textarea.value = ''; if (!this._doc) await this.loadFromAPI(); } } private async importOPML() { const input = document.createElement('input'); input.type = 'file'; input.accept = '.opml,.xml'; input.onchange = async () => { const file = input.files?.[0]; if (!file) return; const token = this.getToken(); if (!token) { alert('Sign in to import'); return; } const opml = await file.text(); const base = (window as any).__rspaceBase || ''; const res = await fetch(`${base}/${this._space}/rfeeds/api/sources/import-opml`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ opml }), }); const result = await res.json().catch(() => ({})); if (result.added) alert(`Imported ${result.added} feeds`); if (!this._doc) await this.loadFromAPI(); }; input.click(); } private async syncActivity() { const token = this.getToken(); if (!token) { alert('Sign in to sync activity'); return; } const btn = this.shadowRoot?.querySelector('#sync-activity-btn'); if (btn) { btn.disabled = true; btn.textContent = 'Syncing\u2026'; } const base = (window as any).__rspaceBase || ''; const res = await fetch(`${base}/${this._space}/rfeeds/api/activity/sync`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, }); const result = await res.json().catch(() => ({})); if (btn) { btn.disabled = false; btn.textContent = result.synced ? `Synced ${result.synced}` : 'Sync Activity'; setTimeout(() => { btn.textContent = 'Sync Activity'; }, 3000); } if (!this._doc) await this.loadFromAPI(); } private async toggleModule(moduleId: string, enabled: boolean) { const token = this.getToken(); if (!token) return; const current = this._activityConfig[moduleId] || { enabled: false, label: moduleId, color: '#94a3b8' }; const base = (window as any).__rspaceBase || ''; const res = await fetch(`${base}/${this._space}/rfeeds/api/activity/config`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ modules: { [moduleId]: { ...current, enabled } } }), }); if (res.ok) { this._activityConfig = await res.json(); this.render(); } } private async saveProfile() { const token = this.getToken(); if (!token) { alert('Sign in to save profile'); return; } const root = this.shadowRoot!; const displayName = root.querySelector('#profile-name')?.value || ''; const bio = root.querySelector('#profile-bio')?.value || ''; const publishModules: string[] = []; root.querySelectorAll('[data-publish-module]').forEach((cb) => { if (cb.checked) publishModules.push(cb.dataset.publishModule!); }); const base = (window as any).__rspaceBase || ''; const res = await fetch(`${base}/${this._space}/rfeeds/api/profile`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ displayName, bio, publishModules }), }); if (res.ok) { this._profile = await res.json(); this.render(); // Flash save button const btn = this.shadowRoot?.querySelector('#save-profile-btn'); if (btn) { btn.textContent = 'Saved!'; setTimeout(() => { btn.textContent = 'Save Profile'; }, 2000); } } } private timeAgo(ts: number): string { const diff = Date.now() - ts; if (diff < 60_000) return 'just now'; if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`; if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`; if (diff < 2592000_000) return `${Math.floor(diff / 86400_000)}d ago`; return new Date(ts).toLocaleDateString(); } private render() { const root = this.shadowRoot!; const activityCount = this._timeline.filter(e => e.type === 'activity').length; root.innerHTML = `
${this._loading ? '
Loading feeds\u2026
' : this._tab === 'timeline' ? this.renderTimeline() : this._tab === 'sources' ? this.renderSources() : this._tab === 'modules' ? this.renderModules() : this.renderProfile()} `; // Event listeners root.querySelectorAll('.tab').forEach((btn) => { btn.addEventListener('click', () => { this._tab = (btn as HTMLElement).dataset.tab as any; this.render(); }); }); const postBtn = root.querySelector('#post-btn'); postBtn?.addEventListener('click', () => this.createPost()); const addBtn = root.querySelector('#add-source-btn'); addBtn?.addEventListener('click', () => this.addSource()); const importBtn = root.querySelector('#import-opml-btn'); importBtn?.addEventListener('click', () => this.importOPML()); const syncActBtn = root.querySelector('#sync-activity-btn'); syncActBtn?.addEventListener('click', () => this.syncActivity()); const saveProfileBtn = root.querySelector('#save-profile-btn'); saveProfileBtn?.addEventListener('click', () => this.saveProfile()); root.querySelectorAll('[data-sync]').forEach((btn) => { btn.addEventListener('click', () => this.syncSource((btn as HTMLElement).dataset.sync!)); }); root.querySelectorAll('[data-delete-source]').forEach((btn) => { btn.addEventListener('click', () => this.deleteSource((btn as HTMLElement).dataset.deleteSource!)); }); root.querySelectorAll('[data-reshare]').forEach((btn) => { const el = btn as HTMLElement; btn.addEventListener('click', () => this.toggleReshare(el.dataset.reshare!, (el.dataset.reshareType as any) || 'item')); }); root.querySelectorAll('[data-toggle-module]').forEach((input) => { input.addEventListener('change', () => { const el = input as HTMLInputElement; this.toggleModule(el.dataset.toggleModule!, el.checked); }); }); } private renderTimeline(): string { const composer = `
`; if (!this._timeline.length) { return composer + '
No feed items yet. Add some sources in the Sources tab!
'; } const items = this._timeline.slice(0, 100).map((e) => `
${this.escHtml(e.sourceName)} ${e.type === 'activity' && e.itemType ? `${this.escHtml(e.itemType)} ` : ''} ${this.timeAgo(e.publishedAt)}
${e.title ? `
${e.url ? `${this.escHtml(e.title)}` : this.escHtml(e.title)}
` : ''}
${this.escHtml(e.summary)}
`).join(''); return composer + items; } private renderSources(): string { const addForm = `
`; if (!this._sources.length) { return addForm + '
No feed sources yet. Add one above!
'; } const cards = this._sources.map((s) => { const dotClass = s.lastError ? 'err' : s.lastFetchedAt ? 'ok' : 'pending'; return `
${this.escHtml(s.name)}
${this.escHtml(s.url)}
${s.itemCount} items ${s.lastFetchedAt ? 'Last sync ' + this.timeAgo(s.lastFetchedAt) : 'Never synced'} ${s.enabled ? 'Active' : 'Paused'}
${s.lastError ? `
${this.escHtml(s.lastError)}
` : ''}
`; }).join(''); return addForm + cards; } private renderModules(): string { const moduleIds = ['rcal', 'rtasks', 'rdocs', 'rnotes', 'rsocials', 'rwallet', 'rphotos', 'rfiles']; const defaultConfigs: Record = { rcal: { label: 'Calendar', color: '#f59e0b' }, rtasks: { label: 'Tasks', color: '#8b5cf6' }, rdocs: { label: 'Docs', color: '#3b82f6' }, rnotes: { label: 'Vaults', color: '#a78bfa' }, rsocials: { label: 'Socials', color: '#ec4899' }, rwallet: { label: 'Wallet', color: '#10b981' }, rphotos: { label: 'Photos', color: '#f97316' }, rfiles: { label: 'Files', color: '#64748b' }, }; const rows = moduleIds.map((id) => { const config = this._activityConfig[id] || { enabled: false, ...defaultConfigs[id] }; const label = config.label || defaultConfigs[id]?.label || id; const color = config.color || defaultConfigs[id]?.color || '#94a3b8'; return `
`; }).join(''); return `
Activity Modules

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

${rows}`; } private renderProfile(): string { const token = this.getToken(); if (!token) { return '
Sign in to manage your feed profile.
'; } const p = this._profile; const moduleIds = ['rcal', 'rtasks', 'rdocs', 'rnotes', 'rsocials', 'rwallet', 'rphotos', 'rfiles', 'posts']; const labels: Record = { rcal: 'Calendar', rtasks: 'Tasks', rdocs: 'Docs', rnotes: 'Vaults', rsocials: 'Socials', rwallet: 'Wallet', rphotos: 'Photos', rfiles: 'Files', posts: 'Manual Posts', }; const publishSet = new Set(p?.publishModules || []); const checkboxes = moduleIds.map((id) => ` `).join(''); // Personal feed URL let feedUrlHtml = ''; if (p?.did) { const host = window.location.origin; const feedUrl = `${host}/${this._space}/rfeeds/user/${encodeURIComponent(p.did)}/feed.xml`; feedUrlHtml = `
Your personal feed: ${this.escHtml(feedUrl)}
Others can subscribe to this URL from any RSS reader or rFeeds space.
`; } return `
${checkboxes}
${feedUrlHtml}
`; } private escHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } } customElements.define('folk-feeds-dashboard', FolkFeedsDashboard);