/** * feed.ts — Activity feed page module. * * Responsibilities: * 1. Fetch /api/feed and render event cards into #content. * 2. Mark single notifications as read via POST /notifications/{id}/read. * 3. Mark all notifications as read via POST /notifications/read-all. * 4. Update the nav badge count in-place without a page reload. * * Registered as: window.MusePages['feed'] */ declare global { interface Window { escHtml: (s: unknown) => string; fmtRelative: (iso: string | null | undefined) => string; apiFetch: (path: string, init?: RequestInit) => Promise; } } // ── Types ───────────────────────────────────────────────────────────────────── interface FeedItem { notif_id: string; event_type: string; actor: string; repo_id?: string; created_at: string; is_read: boolean; } interface EventMeta { icon: string; sentence: (actor: string, repoId: string) => string; } // ── Actor helpers ───────────────────────────────────────────────────────────── function actorHsl(actor: string): string { let hash = 0; for (let i = 0; i < actor.length; i++) { hash = actor.charCodeAt(i) + ((hash << 5) - hash); } return `hsl(${Math.abs(hash) % 360},50%,38%)`; } function actorAvatar(actor: string): string { const bg = actorHsl(actor); const initial = window.escHtml((actor || '?').charAt(0).toUpperCase()); return `
${initial}
`; } function actorLink(actor: string): string { return `${window.escHtml(actor)}`; } function repoLink(repoId: string): string { if (!repoId) return ''; const parts = repoId.split('/'); const label = parts.length >= 2 ? window.escHtml(parts[1]) : window.escHtml(repoId); return `${label}`; } // ── Event metadata ──────────────────────────────────────────────────────────── const EVENT_META: Record = { comment: { icon: '🗨️', sentence: (a, r) => `${actorLink(a)} commented on ${repoLink(r)}` }, mention: { icon: '💬', sentence: (a, r) => `${actorLink(a)} mentioned you in ${repoLink(r)}` }, proposal_opened: { icon: '🔀', sentence: (a, r) => `${actorLink(a)} opened a proposal in ${repoLink(r)}` }, proposal_merged: { icon: '✅', sentence: (a, r) => `${actorLink(a)} merged a proposal in ${repoLink(r)}` }, issue_opened: { icon: '🐛', sentence: (a, r) => `${actorLink(a)} opened an issue in ${repoLink(r)}` }, issue_closed: { icon: '✔️', sentence: (a, r) => `${actorLink(a)} closed an issue in ${repoLink(r)}` }, new_commit: { icon: '🎵', sentence: (a, r) => `${actorLink(a)} committed to ${repoLink(r)}` }, new_follower: { icon: '👤', sentence: (a) => `${actorLink(a)} followed you` }, }; // ── Card renderer ───────────────────────────────────────────────────────────── function eventCard(item: FeedItem): string { const meta = EVENT_META[item.event_type] ?? { icon: '•', sentence: (a: string) => actorLink(a) }; const icon = meta.icon; const sentence = meta.sentence(item.actor, item.repo_id ?? ''); const timestamp = window.fmtRelative(item.created_at); const isUnread = !item.is_read; const unreadStyle = isUnread ? 'border-left:3px solid var(--color-accent);padding-left:calc(var(--space-3) - 3px);' : 'border-left:3px solid transparent;padding-left:calc(var(--space-3) - 3px);opacity:0.75;'; const markReadBtn = isUnread ? `` : ''; return `
${actorAvatar(item.actor)}
${icon} ${sentence} ${window.escHtml(timestamp)} ${markReadBtn}
${isUnread ? '
' : ''}
`; } // ── Mark-read helpers ───────────────────────────────────────────────────────── function decrementNavBadge(): void { const badge = document.getElementById('nav-notif-badge'); if (!badge) return; const current = parseInt(badge.textContent ?? '', 10); if (isNaN(current) || current <= 1) { badge.style.display = 'none'; } else { badge.textContent = String(current - 1); } } async function markOneRead(btn: HTMLElement): Promise { const notifId = btn.dataset.notifId; if (!notifId) return; try { await window.apiFetch('/notifications/' + encodeURIComponent(notifId) + '/read', { method: 'POST' }); const card = document.querySelector(`.comment-item[data-notif-id="${CSS.escape(notifId)}"]`); if (card) { card.style.borderLeft = '3px solid transparent'; card.style.opacity = '0.75'; card.querySelector('.unread-dot')?.remove(); } btn.remove(); decrementNavBadge(); } catch (e) { if ((e as Error).message !== 'auth') btn.style.color = 'var(--color-danger)'; } } async function markAllRead(): Promise { const markAllBtn = document.getElementById('mark-all-read-btn') as HTMLButtonElement | null; if (markAllBtn) markAllBtn.disabled = true; try { await window.apiFetch('/notifications/read-all', { method: 'POST' }); document.querySelectorAll('.comment-item').forEach(card => { card.style.borderLeft = '3px solid transparent'; card.style.opacity = '0.75'; card.querySelector('.unread-dot')?.remove(); card.querySelector('.mark-read-btn')?.remove(); }); const badge = document.getElementById('nav-notif-badge'); if (badge) badge.style.display = 'none'; markAllBtn?.remove(); } catch (e) { if (markAllBtn) markAllBtn.disabled = false; if ((e as Error).message !== 'auth') { const err = document.getElementById('feed-error'); if (err) err.textContent = 'Could not mark all as read: ' + (e as Error).message; } } } // ── Event delegation ────────────────────────────────────────────────────────── function bindActions(): void { document.addEventListener('click', (e) => { const el = (e.target as HTMLElement).closest('[data-action]'); if (!el) return; if (el.dataset.action === 'mark-read') { void markOneRead(el); } else if (el.dataset.action === 'mark-all-read') { void markAllRead(); } }); } // ── Main load ───────────────────────────────────────────────────────────────── async function load(): Promise { const contentEl = document.getElementById('content'); if (!contentEl) return; try { const items = ((await window.apiFetch('/feed?limit=50')) ?? []) as FeedItem[]; if (items.length === 0) { contentEl.innerHTML = `
🎶

Your feed is empty

Follow musicians and watch repos to see their activity here.

Explore repos
`; return; } const hasUnread = items.some(item => !item.is_read); const markAllHtml = hasUnread ? `` : ''; contentEl.innerHTML = `

Activity Feed

${markAllHtml}

${items.map(eventCard).join('')}
`; } catch (e) { if ((e as Error).message !== 'auth' && contentEl) { contentEl.innerHTML = '

✕ ' + window.escHtml((e as Error).message) + '

'; } } } // ── Entry point ─────────────────────────────────────────────────────────────── export function initFeed(): void { bindActions(); void load(); }