feed.ts
typescript
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2
feat: add repair-commit wire endpoint (API parity with repa…
Opus 4.8
minor
⚠ breaking
1 day ago
| 1 | /** |
| 2 | * feed.ts — Activity feed page module. |
| 3 | * |
| 4 | * Responsibilities: |
| 5 | * 1. Fetch /api/feed and render event cards into #content. |
| 6 | * 2. Mark single notifications as read via POST /notifications/{id}/read. |
| 7 | * 3. Mark all notifications as read via POST /notifications/read-all. |
| 8 | * 4. Update the nav badge count in-place without a page reload. |
| 9 | * |
| 10 | * Registered as: window.MusePages['feed'] |
| 11 | */ |
| 12 | |
| 13 | declare global { |
| 14 | interface Window { |
| 15 | escHtml: (s: unknown) => string; |
| 16 | fmtRelative: (iso: string | null | undefined) => string; |
| 17 | apiFetch: (path: string, init?: RequestInit) => Promise<unknown>; |
| 18 | } |
| 19 | } |
| 20 | |
| 21 | // ── Types ───────────────────────────────────────────────────────────────────── |
| 22 | |
| 23 | interface FeedItem { |
| 24 | notif_id: string; |
| 25 | event_type: string; |
| 26 | actor: string; |
| 27 | repo_id?: string; |
| 28 | created_at: string; |
| 29 | is_read: boolean; |
| 30 | } |
| 31 | |
| 32 | interface EventMeta { |
| 33 | icon: string; |
| 34 | sentence: (actor: string, repoId: string) => string; |
| 35 | } |
| 36 | |
| 37 | // ── Actor helpers ───────────────────────────────────────────────────────────── |
| 38 | |
| 39 | function actorHsl(actor: string): string { |
| 40 | let hash = 0; |
| 41 | for (let i = 0; i < actor.length; i++) { |
| 42 | hash = actor.charCodeAt(i) + ((hash << 5) - hash); |
| 43 | } |
| 44 | return `hsl(${Math.abs(hash) % 360},50%,38%)`; |
| 45 | } |
| 46 | |
| 47 | function actorAvatar(actor: string): string { |
| 48 | const bg = actorHsl(actor); |
| 49 | const initial = window.escHtml((actor || '?').charAt(0).toUpperCase()); |
| 50 | return `<div class="comment-avatar" style="background:${bg};color:#e6edf3;font-weight:700;font-size:14px;border:none">${initial}</div>`; |
| 51 | } |
| 52 | |
| 53 | function actorLink(actor: string): string { |
| 54 | return `<a href="/${encodeURIComponent(actor)}" style="color:var(--text-primary);font-weight:600">${window.escHtml(actor)}</a>`; |
| 55 | } |
| 56 | |
| 57 | function repoLink(repoId: string): string { |
| 58 | if (!repoId) return ''; |
| 59 | const parts = repoId.split('/'); |
| 60 | const label = parts.length >= 2 ? window.escHtml(parts[1]) : window.escHtml(repoId); |
| 61 | return `<a href="/${encodeURIComponent(repoId)}" style="color:var(--color-accent)">${label}</a>`; |
| 62 | } |
| 63 | |
| 64 | // ── Event metadata ──────────────────────────────────────────────────────────── |
| 65 | |
| 66 | const EVENT_META: Record<string, EventMeta> = { |
| 67 | comment: { icon: '🗨️', sentence: (a, r) => `${actorLink(a)} commented on ${repoLink(r)}` }, |
| 68 | mention: { icon: '💬', sentence: (a, r) => `${actorLink(a)} mentioned you in ${repoLink(r)}` }, |
| 69 | proposal_opened: { icon: '🔀', sentence: (a, r) => `${actorLink(a)} opened a proposal in ${repoLink(r)}` }, |
| 70 | proposal_merged: { icon: '✅', sentence: (a, r) => `${actorLink(a)} merged a proposal in ${repoLink(r)}` }, |
| 71 | issue_opened: { icon: '🐛', sentence: (a, r) => `${actorLink(a)} opened an issue in ${repoLink(r)}` }, |
| 72 | issue_closed: { icon: '✔️', sentence: (a, r) => `${actorLink(a)} closed an issue in ${repoLink(r)}` }, |
| 73 | new_commit: { icon: '🎵', sentence: (a, r) => `${actorLink(a)} committed to ${repoLink(r)}` }, |
| 74 | new_follower: { icon: '👤', sentence: (a) => `${actorLink(a)} followed you` }, |
| 75 | }; |
| 76 | |
| 77 | // ── Card renderer ───────────────────────────────────────────────────────────── |
| 78 | |
| 79 | function eventCard(item: FeedItem): string { |
| 80 | const meta = EVENT_META[item.event_type] ?? { icon: '•', sentence: (a: string) => actorLink(a) }; |
| 81 | const icon = meta.icon; |
| 82 | const sentence = meta.sentence(item.actor, item.repo_id ?? ''); |
| 83 | const timestamp = window.fmtRelative(item.created_at); |
| 84 | const isUnread = !item.is_read; |
| 85 | |
| 86 | const unreadStyle = isUnread |
| 87 | ? 'border-left:3px solid var(--color-accent);padding-left:calc(var(--space-3) - 3px);' |
| 88 | : 'border-left:3px solid transparent;padding-left:calc(var(--space-3) - 3px);opacity:0.75;'; |
| 89 | |
| 90 | const markReadBtn = isUnread |
| 91 | ? `<button |
| 92 | class="mark-read-btn" |
| 93 | data-notif-id="${window.escHtml(item.notif_id)}" |
| 94 | data-action="mark-read" |
| 95 | title="Mark as read" |
| 96 | style="background:none;border:1px solid var(--border-color);border-radius:50%;width:22px;height:22px;cursor:pointer;color:var(--text-muted);font-size:12px;line-height:1;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;margin-left:var(--space-2)" |
| 97 | >✓</button>` |
| 98 | : ''; |
| 99 | |
| 100 | return ` |
| 101 | <div class="comment-item" data-notif-id="${window.escHtml(item.notif_id)}" style="${unreadStyle}"> |
| 102 | ${actorAvatar(item.actor)} |
| 103 | <div class="comment-body" style="flex:1;min-width:0"> |
| 104 | <div class="comment-meta" style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap"> |
| 105 | <span style="font-size:16px;line-height:1">${icon}</span> |
| 106 | <span>${sentence}</span> |
| 107 | <span style="margin-left:auto;white-space:nowrap;display:flex;align-items:center;gap:var(--space-2)"> |
| 108 | ${window.escHtml(timestamp)} |
| 109 | ${markReadBtn} |
| 110 | </span> |
| 111 | </div> |
| 112 | ${isUnread ? '<div class="unread-dot" style="width:6px;height:6px;border-radius:50%;background:var(--color-accent);display:inline-block;margin-top:var(--space-1)"></div>' : ''} |
| 113 | </div> |
| 114 | </div>`; |
| 115 | } |
| 116 | |
| 117 | // ── Mark-read helpers ───────────────────────────────────────────────────────── |
| 118 | |
| 119 | function decrementNavBadge(): void { |
| 120 | const badge = document.getElementById('nav-notif-badge'); |
| 121 | if (!badge) return; |
| 122 | const current = parseInt(badge.textContent ?? '', 10); |
| 123 | if (isNaN(current) || current <= 1) { |
| 124 | badge.style.display = 'none'; |
| 125 | } else { |
| 126 | badge.textContent = String(current - 1); |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | async function markOneRead(btn: HTMLElement): Promise<void> { |
| 131 | const notifId = btn.dataset.notifId; |
| 132 | if (!notifId) return; |
| 133 | try { |
| 134 | await window.apiFetch('/notifications/' + encodeURIComponent(notifId) + '/read', { method: 'POST' }); |
| 135 | const card = document.querySelector<HTMLElement>(`.comment-item[data-notif-id="${CSS.escape(notifId)}"]`); |
| 136 | if (card) { |
| 137 | card.style.borderLeft = '3px solid transparent'; |
| 138 | card.style.opacity = '0.75'; |
| 139 | card.querySelector('.unread-dot')?.remove(); |
| 140 | } |
| 141 | btn.remove(); |
| 142 | decrementNavBadge(); |
| 143 | } catch (e) { |
| 144 | if ((e as Error).message !== 'auth') btn.style.color = 'var(--color-danger)'; |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | async function markAllRead(): Promise<void> { |
| 149 | const markAllBtn = document.getElementById('mark-all-read-btn') as HTMLButtonElement | null; |
| 150 | if (markAllBtn) markAllBtn.disabled = true; |
| 151 | try { |
| 152 | await window.apiFetch('/notifications/read-all', { method: 'POST' }); |
| 153 | document.querySelectorAll<HTMLElement>('.comment-item').forEach(card => { |
| 154 | card.style.borderLeft = '3px solid transparent'; |
| 155 | card.style.opacity = '0.75'; |
| 156 | card.querySelector('.unread-dot')?.remove(); |
| 157 | card.querySelector('.mark-read-btn')?.remove(); |
| 158 | }); |
| 159 | const badge = document.getElementById('nav-notif-badge'); |
| 160 | if (badge) badge.style.display = 'none'; |
| 161 | markAllBtn?.remove(); |
| 162 | } catch (e) { |
| 163 | if (markAllBtn) markAllBtn.disabled = false; |
| 164 | if ((e as Error).message !== 'auth') { |
| 165 | const err = document.getElementById('feed-error'); |
| 166 | if (err) err.textContent = 'Could not mark all as read: ' + (e as Error).message; |
| 167 | } |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | // ── Event delegation ────────────────────────────────────────────────────────── |
| 172 | |
| 173 | function bindActions(): void { |
| 174 | document.addEventListener('click', (e) => { |
| 175 | const el = (e.target as HTMLElement).closest<HTMLElement>('[data-action]'); |
| 176 | if (!el) return; |
| 177 | if (el.dataset.action === 'mark-read') { |
| 178 | void markOneRead(el); |
| 179 | } else if (el.dataset.action === 'mark-all-read') { |
| 180 | void markAllRead(); |
| 181 | } |
| 182 | }); |
| 183 | } |
| 184 | |
| 185 | // ── Main load ───────────────────────────────────────────────────────────────── |
| 186 | |
| 187 | async function load(): Promise<void> { |
| 188 | const contentEl = document.getElementById('content'); |
| 189 | if (!contentEl) return; |
| 190 | |
| 191 | try { |
| 192 | const items = ((await window.apiFetch('/feed?limit=50')) ?? []) as FeedItem[]; |
| 193 | |
| 194 | if (items.length === 0) { |
| 195 | contentEl.innerHTML = ` |
| 196 | <div class="empty-state"> |
| 197 | <div class="empty-icon">🎶</div> |
| 198 | <p class="empty-title">Your feed is empty</p> |
| 199 | <p class="empty-desc">Follow musicians and watch repos to see their activity here.</p> |
| 200 | <a href="/explore" class="btn btn-primary">Explore repos</a> |
| 201 | </div>`; |
| 202 | return; |
| 203 | } |
| 204 | |
| 205 | const hasUnread = items.some(item => !item.is_read); |
| 206 | const markAllHtml = hasUnread |
| 207 | ? `<button |
| 208 | id="mark-all-read-btn" |
| 209 | data-action="mark-all-read" |
| 210 | class="btn btn-secondary" |
| 211 | style="font-size:12px;padding:4px 10px" |
| 212 | >✓ Mark all as read</button>` |
| 213 | : ''; |
| 214 | |
| 215 | contentEl.innerHTML = ` |
| 216 | <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4)"> |
| 217 | <h1 style="margin:0">Activity Feed</h1> |
| 218 | ${markAllHtml} |
| 219 | </div> |
| 220 | <p id="feed-error" style="color:var(--color-danger);font-size:13px"></p> |
| 221 | <div class="card" style="padding:0"> |
| 222 | ${items.map(eventCard).join('')} |
| 223 | </div>`; |
| 224 | } catch (e) { |
| 225 | if ((e as Error).message !== 'auth' && contentEl) { |
| 226 | contentEl.innerHTML = '<p class="error">✕ ' + window.escHtml((e as Error).message) + '</p>'; |
| 227 | } |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | // ── Entry point ─────────────────────────────────────────────────────────────── |
| 232 | |
| 233 | export function initFeed(): void { |
| 234 | bindActions(); |
| 235 | void load(); |
| 236 | } |
File History
1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2
feat: add repair-commit wire endpoint (API parity with repa…
Opus 4.8
minor
⚠
1 day ago