A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
3.0 kB · 125 lines
TSX
at dev
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode,} from "react";import { useRouter } from "next/navigation";import semver from "semver";import { toast } from "sonner";import { getPlugins, type PluginSummary } from "@/lib/api";
interface PluginUpdatesContextValue { plugins: PluginSummary[]; hasUpdates: boolean; refresh: () => Promise<void>; markSeen: (id: string, version: string) => void;}
const PluginUpdatesContext = createContext<PluginUpdatesContextValue | null>( null,);
const STORAGE_PREFIX = "happyview:plugin-update-seen:";
function storageKey(id: string) { return `${STORAGE_PREFIX}${id}`;}
function readSeen(id: string): string | null { if (typeof window === "undefined") return null; try { return window.localStorage.getItem(storageKey(id)); } catch { return null; }}
function writeSeen(id: string, version: string) { if (typeof window === "undefined") return; try { window.localStorage.setItem(storageKey(id), version); } catch { // ignore }}
function isNewerThanSeen(latest: string, seen: string | null): boolean { if (!seen) return true; const a = semver.coerce(latest); const b = semver.coerce(seen); if (!a || !b) return latest !== seen; return semver.gt(a, b);}
export function PluginUpdateProvider({ children }: { children: ReactNode }) { const router = useRouter(); const [plugins, setPlugins] = useState<PluginSummary[]>([]);
const refresh = useCallback(() => { return getPlugins() .then((res) => setPlugins(res.plugins)) .catch(() => { // ignore — dashboard may not be ready }); }, []);
const markSeen = useCallback((id: string, version: string) => { writeSeen(id, version); }, []);
useEffect(() => { refresh(); const id = setInterval(refresh, 60_000); return () => clearInterval(id); }, [refresh]);
useEffect(() => { for (const p of plugins) { if (!p.update_available || !p.latest_version) continue; const seen = readSeen(p.id); if (!isNewerThanSeen(p.latest_version, seen)) continue; toast(`${p.name} v${p.latest_version} is available`, { action: { label: "Review", onClick: () => router.push( `/dashboard/settings/plugins?update=${encodeURIComponent(p.id)}`, ), }, }); writeSeen(p.id, p.latest_version); } }, [plugins, router]);
const value = useMemo<PluginUpdatesContextValue>( () => ({ plugins, hasUpdates: plugins.some((p) => p.update_available), refresh, markSeen, }), [plugins, refresh, markSeen], );
return ( <PluginUpdatesContext.Provider value={value}> {children} </PluginUpdatesContext.Provider> );}
export function usePluginUpdates() { const ctx = useContext(PluginUpdatesContext); if (!ctx) { throw new Error( "usePluginUpdates must be used within PluginUpdateProvider", ); } return ctx;}