diff --git a/web/src/lib/action.svelte.ts b/web/src/lib/action.svelte.ts new file mode 100644 index 00000000..d92b6faa --- /dev/null +++ b/web/src/lib/action.svelte.ts @@ -0,0 +1,75 @@ +type ActionState = + | { kind: "idle" } + | { kind: "loading" } + | { kind: "ready"; value: T } + | { kind: "failed"; error: string }; +type Operation = (...args: Args) => Promise; + +const defaultError = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause)); + +const create = ( + operation: Operation, + initial: ActionState, + toError: typeof defaultError +) => { + let state = $state>(initial); + let revision = 0; + let args: Args | undefined; + + const run: Operation = async (...nextArgs) => { + const currentRevision = ++revision; + args = nextArgs; + state = { kind: "loading" }; + try { + const value = await operation(...nextArgs); + if (currentRevision !== revision) return; + args = undefined; + state = { kind: "ready", value }; + } catch (cause) { + if (currentRevision !== revision) return; + args = undefined; + state = { kind: "failed", error: toError(cause) }; + } + }; + + return { + get data() { + return state.kind === "ready" ? state.value : undefined; + }, + get error() { + return state.kind === "failed" ? state.error : undefined; + }, + get loading() { + return state.kind === "loading"; + }, + get args() { + return state.kind === "loading" ? args : undefined; + }, + run, + update(update: (current: T) => T): boolean { + if (state.kind !== "ready") return false; + revision++; + state = { kind: "ready", value: update(state.value) }; + return true; + } + }; +}; + +// loads run automatically and rerun when reactive values change: +// const post = createLoad(() => api.getPost(id)); +// +// actions only run when called: +// const save = createAction(api.savePost); +// await save.run(draft); +// +// both expose .data, .error, .loading, and .args. +export const createAction = ( + operation: Operation, + toError: typeof defaultError = defaultError +) => create(operation, { kind: "idle" }, toError); + +export const createLoad = (load: Operation, toError: typeof defaultError = defaultError) => { + const action = create(load, { kind: "loading" }, toError); + $effect(() => void action.run()); + return action; +}; diff --git a/web/src/lib/action.test.ts b/web/src/lib/action.test.ts new file mode 100644 index 00000000..6ab86daa --- /dev/null +++ b/web/src/lib/action.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { createAction } from "./action.svelte"; + +describe("createAction", () => { + it("derives ergonomic projections from one state", async () => { + let resolve!: (value: string) => void; + const action = createAction(() => new Promise((done) => (resolve = done))); + + expect(action.loading).toBe(false); + expect(action.data).toBeUndefined(); + expect(action.error).toBeUndefined(); + + const running = action.run(); + expect(action.loading).toBe(true); + + resolve("done"); + await running; + expect(action.data).toBe("done"); + }); + + it("converts failures", async () => { + const action = createAction( + async () => { + throw new Error("nope"); + }, + (cause) => (cause instanceof Error ? cause.message : "unknown") + ); + + await action.run(); + expect(action.error).toBe("nope"); + }); + + it("ignores an older result after a newer run starts", async () => { + const resolvers: Array<(value: string) => void> = []; + const names: string[] = []; + const action = createAction( + (name: string) => + new Promise((resolve) => { + names.push(name); + resolvers.push(resolve); + }) + ); + const first = action.run("first"); + const second = action.run("second"); + expect(names).toEqual(["first", "second"]); + expect(action.args).toEqual(["second"]); + + resolvers[0]("stale"); + await first; + expect(action.loading).toBe(true); + + resolvers[1]("current"); + await second; + expect(action.data).toBe("current"); + expect(action.args).toBeUndefined(); + }); +}); diff --git a/web/src/lib/auth.svelte.ts b/web/src/lib/auth.svelte.ts index 619220a8..cd4d3430 100644 --- a/web/src/lib/auth.svelte.ts +++ b/web/src/lib/auth.svelte.ts @@ -66,9 +66,24 @@ export interface CurrentUser { handle: string; } +export type AuthState = + | { kind: "logged-out" } + | { kind: "loading"; did: Did | null; profile: AuthProfile | null } + | { kind: "authenticating" } + | { kind: "profile-loading"; agent: OAuthUserAgent; did: Did } + | { kind: "authenticated"; agent: OAuthUserAgent; profile: AuthProfile } + | { + kind: "failed"; + message: string; + agent?: OAuthUserAgent; + did?: Did; + profile?: AuthProfile; + }; + export type { AuthAccount } from "./auth/accounts"; export interface Auth { + readonly state: AuthState; readonly agent: OAuthUserAgent | null; readonly currentDid: Did | null; readonly profile: AuthProfile | null; @@ -170,14 +185,16 @@ export const createAuth = ( initial?: { did: string; handle: string } | null ): Auth => { const seed = initial ?? null; - let agent = $state(null); const bobbinUrlValue = bobbinUrl; - let currentDid = $state((seed?.did as Did | undefined) ?? null); - let profile = $state( - seed ? { did: seed.did as Did, handle: seed.handle } : null + let state = $state( + seed + ? { + kind: "loading", + did: seed.did as Did, + profile: { did: seed.did as Did, handle: seed.handle } + } + : { kind: "logged-out" } ); - let error = $state(null); - let authenticating = $state(false); let accounts = $state([]); // merge atcute's stored sessions with persisted account metadata. @@ -186,36 +203,52 @@ export const createAuth = ( saveAccounts(accounts); }; - const resetLoggedOut = () => { + const currentAgent = (): OAuthUserAgent | null => + "agent" in state ? (state.agent ?? null) : null; + const currentDid = (): Did | null => + "did" in state + ? (state.did ?? null) + : state.kind === "authenticated" + ? state.profile.did + : null; + const currentProfile = (): AuthProfile | null => + "profile" in state ? (state.profile ?? null) : null; + const failureState = (message: string): AuthState => ({ + kind: "failed", + message, + agent: currentAgent() ?? undefined, + did: currentDid() ?? undefined, + profile: currentProfile() ?? undefined + }); + + const resetLoggedOut = (message?: string) => { clearActive(); - agent = null; - currentDid = null; - profile = null; + state = message ? { kind: "failed", message } : { kind: "logged-out" }; syncAccounts(); }; - const hydrateProfile = async (did: Did) => { + const hydrateProfile = async (did: Did, nextAgent: OAuthUserAgent) => { const resolved = await resolveProfile(did, bobbinUrl); - profile = resolved ?? { did, handle: did }; + if (state.kind !== "profile-loading" || state.agent !== nextAgent) return; + const nextProfile = resolved ?? { did, handle: did }; + state = { kind: "authenticated", agent: nextAgent, profile: nextProfile }; const meta = upsertAccount(loadAccounts(), { did, - handle: profile.handle, + handle: nextProfile.handle, addedAt: Math.floor(Date.now() / 1000) }); saveAccounts(meta); accounts = reconcileAccounts(listStoredSessions(), meta); - persistActive(did, profile.handle); + persistActive(did, nextProfile.handle); }; const adoptSession = (session: OAuthSession) => { const nextAgent = new OAuthUserAgent(session); - agent = nextAgent; - error = null; const did = nextAgent.sub as Did; - currentDid = did; + state = { kind: "profile-loading", agent: nextAgent, did }; const known = loadAccounts().find((account) => account.did === did); persistActive(did, known?.handle ?? did); - void hydrateProfile(did); + void hydrateProfile(did, nextAgent); }; // prune dead sessions when re-adoption fails. @@ -227,7 +260,7 @@ export const createAuth = ( } catch (cause) { deleteStoredSession(did); saveAccounts(dropAccount(loadAccounts(), did)); - error = errorMessage(cause); + state = failureState(errorMessage(cause)); return false; } }; @@ -235,29 +268,33 @@ export const createAuth = ( const refresh = async () => { if (!browser) return; configure(); - error = null; + const previousDid = currentDid(); + const previousProfile = currentProfile(); + state = { kind: "loading", did: previousDid, profile: previousProfile }; syncAccounts(); const candidates: Did[] = []; - for (const candidate of [readActiveDid(), currentDid, ...listStoredSessions()]) { + for (const candidate of [readActiveDid(), currentDid(), ...listStoredSessions()]) { if (candidate && !candidates.includes(candidate)) candidates.push(candidate); } + let lastFailure: string | undefined; for (const candidate of candidates) { if (await activate(candidate)) return; + const nextState = state as AuthState; + if (nextState.kind === "failed") lastFailure = nextState.message; } - resetLoggedOut(); + resetLoggedOut(lastFailure); }; const signIn = async (identifier: string, returnTo = "/") => { if (!browser) return; configure(); - error = null; const trimmed = identifier.trim(); if (!trimmed) { - error = "Handle or DID required"; + state = failureState("Handle or DID required"); return; } @@ -282,7 +319,7 @@ export const createAuth = ( window.location.assign(url.toString()); } catch (cause) { - error = errorMessage(cause); + state = failureState(errorMessage(cause)); throw cause; } }; @@ -290,8 +327,7 @@ export const createAuth = ( const completeSignIn = async () => { if (!browser) return "/"; configure(); - error = null; - authenticating = true; + state = { kind: "authenticating" }; try { const params = new SvelteURLSearchParams(location.hash.slice(1)); @@ -302,27 +338,24 @@ export const createAuth = ( return returnToFromState(state); } catch (cause) { - error = errorMessage(cause); + state = failureState(errorMessage(cause)); throw cause; - } finally { - authenticating = false; } }; const switchAccount = async (did: Did) => { if (!browser) return; configure(); - error = null; if (!(await activate(did))) syncAccounts(); }; const removeAccount = async (did: Did) => { if (!browser) return; - error = null; - const wasActive = currentDid === did; + const wasActive = currentDid() === did; try { - if (wasActive && agent) { - await agent.signOut(); + const activeAgent = currentAgent(); + if (wasActive && activeAgent) { + await activeAgent.signOut(); } else { deleteStoredSession(did); } @@ -344,17 +377,18 @@ export const createAuth = ( }; const signOut = async () => { - if (currentDid) { - await removeAccount(currentDid); + const did = currentDid(); + if (did) { + await removeAccount(did); } else { resetLoggedOut(); } }; const signOutAll = async () => { - error = null; try { - if (agent) await agent.signOut(); + const activeAgent = currentAgent(); + if (activeAgent) await activeAgent.signOut(); } catch { // remove local session state below. } @@ -366,32 +400,40 @@ export const createAuth = ( }; return { + get state() { + return state; + }, get agent() { - return agent; + return currentAgent(); }, get currentDid() { - return currentDid; + return currentDid(); }, get profile() { - return profile; + return currentProfile(); }, get bobbinUrl() { return bobbinUrlValue; }, get error() { - return error; + return state.kind === "failed" ? state.message : null; }, get profileLoading() { - return currentDid !== null && profile === null; + return ( + state.kind === "profile-loading" || + (state.kind === "loading" && state.did !== null && state.profile === null) + ); }, get authenticating() { - return authenticating; + return state.kind === "authenticating"; }, get currentUser() { - if (!currentDid) return null; + const did = currentDid(); + if (!did) return null; + const profile = currentProfile(); return { - did: currentDid, - handle: profile?.handle ?? currentDid + did, + handle: profile?.handle ?? did }; }, get accounts() { diff --git a/web/src/lib/components/comment/CommentCard.svelte b/web/src/lib/components/comment/CommentCard.svelte index bf6a9b2c..622708c4 100644 --- a/web/src/lib/components/comment/CommentCard.svelte +++ b/web/src/lib/components/comment/CommentCard.svelte @@ -17,6 +17,7 @@ import CommentBox from "./CommentBox.svelte"; import CommentEditor from "./CommentEditor.svelte"; import type { CommentThread, CommentView, ThreadInput } from "./comments"; + import { createAction } from "$lib/action.svelte"; interface Props { thread: CommentThread; @@ -36,8 +37,6 @@ let replying = $state(false); let editingUri = $state(null); - let deletingUri = $state(null); - let deleteError = $state(null); let reactionsByUri = $derived.by(() => { const map: Record = {}; @@ -75,20 +74,17 @@ ); }; - const handleDelete = async (comment: CommentView) => { + const deleteSelected = createAction(async (comment: CommentView) => { const agent = auth?.agent; - if (!agent || deletingUri) return; + if (!agent) return; + await deleteComment(agent, comment.rkey); + ondeleted?.(comment.uri); + }); + + const handleDelete = (comment: CommentView) => { + if (!auth?.agent || deleteSelected.loading) return; if (!confirm("Delete this comment? This cannot be undone.")) return; - deletingUri = comment.uri; - deleteError = null; - try { - await deleteComment(agent, comment.rkey); - ondeleted?.(comment.uri); - } catch (err) { - deleteError = err instanceof Error ? err.message : "Failed to delete comment"; - } finally { - deletingUri = null; - } + void deleteSelected.run(comment); }; @@ -115,7 +111,7 @@ {#if oncancel} - {/if} diff --git a/web/src/lib/components/profile/FollowButton.svelte b/web/src/lib/components/profile/FollowButton.svelte index ca27cef8..be1cd1cc 100644 --- a/web/src/lib/components/profile/FollowButton.svelte +++ b/web/src/lib/components/profile/FollowButton.svelte @@ -26,7 +26,6 @@ loadedRkey: () => initialRkey }); - let busy = $state(false); const following = $derived(relation.active); const commit = (change: FollowChange) => { @@ -37,9 +36,8 @@ const toggle = async () => { const agent = auth.agent; - if (!agent || busy || !relation.known) return; - busy = true; - relation.resetFailure(); + if (!agent || !relation.known || relation.loading) return; + relation.begin(); try { if (relation.active && relation.rkey) { await deleteFollow(agent, relation.rkey); @@ -64,8 +62,6 @@ } } catch { relation.fail(); - } finally { - busy = false; } }; @@ -81,7 +77,7 @@ variant="default" class="w-full gap-2" insetShadow={true} - loading={busy} + loading={relation.loading} disabled={!relation.known} onclick={toggle} > diff --git a/web/src/lib/components/profile/ProfileEditForm.svelte b/web/src/lib/components/profile/ProfileEditForm.svelte index ffcee257..7f1cf7d0 100644 --- a/web/src/lib/components/profile/ProfileEditForm.svelte +++ b/web/src/lib/components/profile/ProfileEditForm.svelte @@ -8,6 +8,7 @@ import { getAuth } from "$lib/auth.svelte"; import { putProfile } from "$lib/api/profile"; import type { ProfileRecord } from "$lib/api/records"; + import { createAction } from "$lib/action.svelte"; interface Props { profile: ProfileRecord | null; @@ -30,41 +31,33 @@ value: (seed?.links?.[index] as string | undefined) ?? "" })) ); - let busy = $state(false); - let error = $state(null); - const inputClass = "w-full rounded border border-border-default bg-background-default px-2 py-1 outline-none focus:border-border-strong focus:ring-1 focus:ring-border-strong"; - const save = async (event: SubmitEvent) => { - event.preventDefault(); - const agent = auth.agent; - if (!agent || busy) return; - busy = true; - error = null; - const cleanLinks = links.map((link) => link.value.trim()).filter(Boolean); - // put replaces the whole record, so carry unedited fields (avatar, pins, stats). - const record: ProfileRecord = { - ...profile, - $type: "sh.tangled.actor.profile", - bluesky, - description: description.trim() || undefined, - pronouns: pronouns.trim() || undefined, - location: location.trim() || undefined, - links: cleanLinks.length > 0 ? (cleanLinks as ProfileRecord["links"]) : undefined - }; - try { + const save = createAction( + async (event: SubmitEvent) => { + event.preventDefault(); + const agent = auth.agent; + if (!agent) return; + const cleanLinks = links.map((link) => link.value.trim()).filter(Boolean); + // put replaces the whole record, so carry unedited fields (avatar, pins, stats). + const record: ProfileRecord = { + ...profile, + $type: "sh.tangled.actor.profile", + bluesky, + description: description.trim() || undefined, + pronouns: pronouns.trim() || undefined, + location: location.trim() || undefined, + links: cleanLinks.length > 0 ? (cleanLinks as ProfileRecord["links"]) : undefined + }; await putProfile(agent, record); onSaved(record); - } catch { - error = "Could not save profile. Try again."; - } finally { - busy = false; - } - }; + }, + () => "Could not save profile. Try again." + ); - +
@@ -120,7 +111,7 @@ type="button" variant="default" icon={X} - disabled={isPublishing} + disabled={publish.loading} onclick={oncancel} > Cancel @@ -129,17 +120,17 @@ - {#if error} + {#if publish.error}
- +
{/if} diff --git a/web/src/lib/components/ui/Error.svelte b/web/src/lib/components/ui/Error.svelte index 441bd6a6..8775c2d6 100644 --- a/web/src/lib/components/ui/Error.svelte +++ b/web/src/lib/components/ui/Error.svelte @@ -45,8 +45,8 @@ import ChevronRight from "$icon/chevron-right"; interface Props extends Omit, "class" | "children"> { - /** The error message shown next to the icon. */ - label: string; + /** The error message shown next to the icon; renders nothing when absent. */ + label?: string; size?: ErrorVariants["size"]; class?: string; /** Optional collapsible content revealed by a "Show code" toggle. */ @@ -56,25 +56,30 @@ let { label, size = "small", class: className, children, ...rest }: Props = $props(); let open = $state(false); + $effect(() => { + if (!label) open = false; + }); const classes = $derived(error({ size })); - +{/if} diff --git a/web/src/lib/components/ui/MarkdownEditor.svelte b/web/src/lib/components/ui/MarkdownEditor.svelte index d5eaeb06..07296316 100644 --- a/web/src/lib/components/ui/MarkdownEditor.svelte +++ b/web/src/lib/components/ui/MarkdownEditor.svelte @@ -39,8 +39,7 @@ // height and inset — otherwise the whole editor resizes every time you switch tabs. const pane = `rounded border border-border-default px-2.5 py-2 ${textareaMinHeight}`; - let previewHtml = $state(null); - let previewing = $state(false); + let preview = $state | null>(null); let textareaEl = $state(); // focus on mount (and when returning to the write tab) if requested @@ -49,28 +48,16 @@ }); $effect(() => { - if (tab !== "preview") return; + if (tab !== "preview") { + preview = null; + return; + } const source = value; if (!source.trim()) { - previewHtml = null; - previewing = false; + preview = null; return; } - let cancelled = false; - previewing = true; - renderMarkup(source, markup) - .then((html) => { - if (!cancelled) previewHtml = html; - }) - .catch(() => { - if (!cancelled) previewHtml = null; - }) - .finally(() => { - if (!cancelled) previewing = false; - }); - return () => { - cancelled = true; - }; + preview = renderMarkup(source, markup).then((html) => html ?? ""); }); // ctrl/cmd+enter submits the enclosing form, mirroring the old htmx editor const handleKeydown: KeyboardEventHandler = (e) => { @@ -116,14 +103,24 @@ onkeydown={handleKeydown} class={transparent ? "bg-transparent" : undefined} /> - {:else if previewHtml} -
- - {@html previewHtml} -
{:else} -
- {previewing ? "Rendering…" : "Nothing to preview."} -
+ {#await preview} +
Rendering…
+ {:then html} + {#if html} +
+ + {@html html} +
+ {:else} +
+ Nothing to preview. +
+ {/if} + {:catch} +
+ Could not render preview. +
+ {/await} {/if} diff --git a/web/src/lib/optimistic.svelte.ts b/web/src/lib/optimistic.svelte.ts index ba036ff6..012c0725 100644 --- a/web/src/lib/optimistic.svelte.ts +++ b/web/src/lib/optimistic.svelte.ts @@ -64,7 +64,9 @@ export interface OptimisticRelation { readonly rkey: string | null; readonly known: boolean; readonly active: boolean; + readonly loading: boolean; readonly failed: boolean; + begin(): void; created(rkey: string): void; deleted(): void; fail(): void; @@ -74,22 +76,24 @@ export interface OptimisticRelation { export const createOptimisticRelation = ( options: OptimisticRelationOptions ): OptimisticRelation => { - let failed = $state(false); + let status = $state<"idle" | "loading" | "failed">("idle"); let committed = $state(null); const currentKey = $derived(options.key()); const loaded = $derived(options.loadedRkey()); const rkey = $derived(committed?.key === currentKey ? committed.rkey : (loaded ?? null)); + const known = $derived(loaded !== undefined || committed?.key === currentKey); + const active = $derived(rkey !== null); $effect(() => { if (committed === null) return; if (committed.key !== currentKey || loaded === committed.rkey) { committed = null; - failed = false; + status = "idle"; } }); const set = (next: string | null): void => { - failed = false; + status = "idle"; committed = { key: currentKey, rkey: next }; }; @@ -98,21 +102,27 @@ export const createOptimisticRelation = ( return rkey; }, get known() { - return loaded !== undefined || committed?.key === currentKey; + return known; }, get active() { - return rkey !== null; + return active; + }, + get loading() { + return status === "loading"; }, get failed() { - return failed; + return status === "failed"; + }, + begin() { + status = "loading"; }, created: set, deleted: () => set(null), fail() { - failed = true; + status = "failed"; }, resetFailure() { - failed = false; + status = "idle"; } }; }; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index d115b13f..0d9bea87 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -46,7 +46,7 @@ diff --git a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte index 03fa33c6..905809ae 100644 --- a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte +++ b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte @@ -19,6 +19,7 @@ import type { IssueRecord, RecordView } from "$lib/api/records"; import type { ThreadInput } from "$lib/components/comment/comments"; import { getAuth } from "$lib/auth.svelte"; + import { createAction } from "$lib/action.svelte"; let { data } = $props(); @@ -30,8 +31,6 @@ const issuesBase = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/issues`); let editing = $state(false); - let deleting = $state(false); - let deleteError = $state(null); let comments = $derived(data.comments); @@ -75,19 +74,17 @@ editing = false; }; - const handleDelete = async () => { + const removeIssue = createAction(async () => { const agent = auth.agent; - if (!agent || deleting) return; + if (!agent) return; + await deleteIssue(agent, issue.rkey); + await goto(resolve(issuesBase as "/")); + }); + + const handleDelete = () => { + if (!auth.agent || removeIssue.loading) return; if (!confirm("Delete this issue? This cannot be undone.")) return; - deleting = true; - deleteError = null; - try { - await deleteIssue(agent, issue.rkey); - await goto(resolve(issuesBase as "/")); - } catch (err) { - deleteError = err instanceof Error ? err.message : "Failed to delete issue"; - deleting = false; - } + void removeIssue.run(); }; @@ -110,7 +107,7 @@ -