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}

    + )} +
    + +
  • + ) + })} +
+ )} +
+ ) +}