Monorepo for Tangled
Something went wrong. Try again.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198<script lang="ts"> import { page } from "$app/state"; import { resolve } from "$app/paths"; import Eye from "$icon/eye"; import EyeFilled from "$lib/components/icons/EyeFilled.svelte"; import TriangleAlert from "$icon/triangle-alert"; import { getAuth } from "$lib/auth.svelte"; import { loginWithReturn } from "$lib/auth/guards"; import { createSubscription, deleteSubscription, getSubscriptionRkey } from "$lib/api/graph"; import { createBobbinClient } from "$lib/api/client"; import { awaitDeletedRecord, awaitIndexedOrSaved } from "$lib/api/awaitRecord"; import Button from "$lib/components/ui/Button.svelte"; import ButtonGroup from "$lib/components/ui/ButtonGroup.svelte"; import Tooltip from "$lib/components/ui/Tooltip.svelte"; import { CROSSFADE_DURATION, CROSSFADE_EASING } from "$lib/motion/spring"; import { prefersReducedMotion } from "svelte/motion";
interface Props { repoDid: string; }
let { repoDid }: Props = $props();
const auth = getAuth(); const signedIn = $derived(Boolean(auth.currentDid)); const loginHref = $derived( resolve(loginWithReturn(page.url.pathname + page.url.search) as "/login") );
let subscribed = $state(false); let known = $state(false); let toggling = $state(false); let subRkey = $state<string | null>(null); let error = $state<string | null>(null);
$effect(() => { const agent = auth.agent; const did = auth.currentDid; if (!agent || !did || !repoDid) { subscribed = false; subRkey = null; known = true; return; } let cancelled = false; known = false; error = null; const bobbinUrl = (page.data as { publicConfig?: { bobbinUrl?: string } })?.publicConfig ?.bobbinUrl; if (!bobbinUrl) return; const ctx = createBobbinClient({ serviceUrl: bobbinUrl }); getSubscriptionRkey(ctx, did, repoDid) .then((rkey) => { if (cancelled) return; subRkey = rkey; subscribed = !!rkey; known = true; }) .catch(() => { if (!cancelled) error = "Could not load subscription state"; }); return () => { cancelled = true; }; });
$effect(() => { if (!error) return; const timer = setTimeout(() => { error = null; }, 2000); return () => clearTimeout(timer); });
const toggle = async () => { const agent = auth.agent; if (!agent || !repoDid || !known || toggling) return; const wasSubscribed = subscribed; const wasRkey = subRkey; error = null; toggling = true; subscribed = !wasSubscribed; const bobbinUrl = (page.data as { publicConfig?: { bobbinUrl?: string } })?.publicConfig ?.bobbinUrl; const ctx = bobbinUrl ? createBobbinClient({ serviceUrl: bobbinUrl }) : null; try { if (wasSubscribed && wasRkey) { const { uri } = await deleteSubscription(agent, wasRkey); subRkey = null; if (ctx) { awaitDeletedRecord(ctx, uri).catch((cause) => console.error("failed to wait for unsubscribe index", cause) ); } } else { const written = await createSubscription(agent, repoDid); subRkey = written.rkey; if (ctx) { // todo: needs a design pass for surfacing index wait failures awaitIndexedOrSaved(ctx, written).catch((cause) => console.error("failed to wait for subscribe index", cause) ); } } } catch { subscribed = wasSubscribed; subRkey = wasRkey; error = "Failed to update subscription"; } finally { toggling = false; } };
const fade = $derived(prefersReducedMotion.current ? 0 : CROSSFADE_DURATION); const face = $derived(error ? "err" : subscribed ? "on" : "off");</script>
<Tooltip content={error ?? ""} disabled={!error}> <ButtonGroup> <Button variant={error ? "danger" : "default"} size="small" insetShadow={true} href={signedIn ? undefined : loginHref} class="min-w-24" onclick={signedIn ? toggle : undefined} style="--swap: {fade}ms; --swap-ease: {CROSSFADE_EASING}" > <span class="faces grid size-4 shrink-0" data-face={face} aria-hidden="true"> <Eye class="face off size-4" /> <EyeFilled class="face on size-4" /> <TriangleAlert class="face err size-4" /> </span> <span class="labels" data-on={subscribed} aria-hidden="true"> <span class="label off">Watch</span> <span class="label on">Watching</span> </span> <span class="sr-only">{subscribed ? "Watching" : "Watch"}</span> </Button> </ButtonGroup></Tooltip><span role="status" aria-live="polite" class="sr-only">{error ?? ""}</span>
<style> .faces :global(.face) { grid-column: 1; grid-row: 1; opacity: 0; scale: 0.92; filter: blur(1px); transition: opacity var(--swap) var(--swap-ease), scale var(--swap) var(--swap-ease), filter var(--swap) var(--swap-ease); }
.faces[data-face="off"] :global(.off), .faces[data-face="on"] :global(.on), .faces[data-face="err"] :global(.err) { opacity: 1; scale: 1; filter: blur(0); }
/* only the visible word is in flow, so the box hugs it and the gap next to the icon stays honest; the button's min-width holds the widest arrangement so nothing beside it moves. keep that min-width in step if the labels ever change. */ .labels { position: relative; }
.label { display: block; white-space: nowrap; opacity: 0; translate: 0 3px; filter: blur(1px); transition: opacity var(--swap) var(--swap-ease), translate var(--swap) var(--swap-ease), filter var(--swap) var(--swap-ease); }
.labels[data-on="false"] .on, .labels[data-on="true"] .off { position: absolute; top: 0; left: 0; }
.labels[data-on="false"] .off, .labels[data-on="true"] .on { opacity: 1; translate: 0 0; filter: blur(0); }</style>