diff --git a/app/components/Nav.tsx b/app/components/Nav.tsx index 8fd95eb..038a675 100644 --- a/app/components/Nav.tsx +++ b/app/components/Nav.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react' import { Link, NavLink } from 'react-router' import { PencilSquareIcon, UserCircleIcon } from '@heroicons/react/24/solid' -import { SunIcon, MoonIcon, ComputerDesktopIcon, HomeIcon, ClockIcon, UsersIcon, Cog6ToothIcon, QuestionMarkCircleIcon } from '@heroicons/react/24/outline' +import { SunIcon, MoonIcon, ComputerDesktopIcon, HomeIcon, ClockIcon, UsersIcon, RssIcon, Cog6ToothIcon, QuestionMarkCircleIcon } from '@heroicons/react/24/outline' import { useAuth } from '~/lib/auth-context' import { getStoredTheme, persistTheme, applyTheme, type Theme } from '~/lib/theme' import Avatar from '~/components/Avatar' @@ -84,6 +84,23 @@ export default function Nav() { )} + {!loading && session && ( + + `flex items-center gap-1.5 transition-colors ${ + isActive + ? 'text-stone-900 dark:text-stone-100' + : 'text-stone-500 dark:text-stone-400 hover:text-stone-800 dark:hover:text-stone-200' + }` + } + > + + Subscriptions + + )} + {!loading && session && ( (null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [loadingPubs, setLoadingPubs] = useState>({}) + + useEffect(() => { + if (authLoading || !session) return + let cancelled = false + setLoading(true) + + async function load() { + const subs = await listSubscriptions(session!) + if (cancelled) return + + const byAuthor = new Map() + for (const s of subs) { + const list = byAuthor.get(s.authorDid) ?? [] + list.push({ rkey: s.rkey, publicationAtUri: s.publicationAtUri }) + byAuthor.set(s.authorDid, list) + } + + const results = await Promise.allSettled( + [...byAuthor.entries()].map(async ([did, entries]) => { + const pubList = await listPublicPublications(did) + return entries.flatMap(({ rkey, publicationAtUri }) => { + const pub = pubList.find((p) => p.atUri === publicationAtUri) + return pub ? [{ ...pub, rkey }] : [] + }) + }), + ) + + if (cancelled) return + setPubs(results.flatMap((r) => (r.status === 'fulfilled' ? r.value : []))) + } + + load() + .catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load') }) + .finally(() => { if (!cancelled) setLoading(false) }) + + return () => { cancelled = true } + }, [session, authLoading]) + + async function handleUnsubscribe(pub: SubscribedPub) { + if (!session) return + setLoadingPubs((prev) => ({ ...prev, [pub.atUri]: true })) + try { + await unsubscribe(session, pub.rkey) + setPubs((prev) => prev?.filter((p) => p.atUri !== pub.atUri) ?? prev) + } catch { + // non-fatal + } finally { + setLoadingPubs((prev) => ({ ...prev, [pub.atUri]: false })) + } + } + + if (authLoading) { + return ( + + + + ) + } + + if (!session) { + return + } + + return ( + + + Subscriptions + Publications you follow + + + {error && {error}} + + {loading && ( + + + + )} + + {!loading && pubs !== null && pubs.length === 0 && ( + + )} + + {pubs !== null && pubs.length > 0 && ( + + {pubs.map((pub) => { + const isLoading = !!loadingPubs[pub.atUri] + return ( + + + {pub.name} + {new URL(pub.url).host} + {pub.description && ( + {pub.description} + )} + + handleUnsubscribe(pub)} + disabled={isLoading} + className="shrink-0 text-sm px-3 py-1 rounded-full border border-stone-300 dark:border-stone-600 text-stone-600 dark:text-stone-300 hover:border-stone-400 dark:hover:border-stone-500 hover:bg-stone-50 dark:hover:bg-stone-800/50 transition-colors disabled:opacity-50" + > + {isLoading ? 'Unsubscribing…' : 'Subscribed'} + + + ) + })} + + )} + + ) +}
Publications you follow
{error}
{pub.name}
{new URL(pub.url).host}
{pub.description}