This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

web: introduce createAction/Load to simplify request loading/error handling

Signed-off-by: dawn <dawn@tangled.org>

author
dawn
date (Aug 1, 2026, 6:21 PM +0300) commit de1202b4 parent da9923c0 change-id vpwqmoqw
+642 -595
+75
web/src/lib/action.svelte.ts
··· 1 + type ActionState<T> = 2 + | { kind: "idle" } 3 + | { kind: "loading" } 4 + | { kind: "ready"; value: T } 5 + | { kind: "failed"; error: string }; 6 + type Operation<T, Args extends unknown[] = []> = (...args: Args) => Promise<T>; 7 + 8 + const defaultError = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause)); 9 + 10 + const create = <T, Args extends unknown[] = []>( 11 + operation: Operation<T, Args>, 12 + initial: ActionState<T>, 13 + toError: typeof defaultError 14 + ) => { 15 + let state = $state<ActionState<T>>(initial); 16 + let revision = 0; 17 + let args: Args | undefined; 18 + 19 + const run: Operation<void, Args> = async (...nextArgs) => { 20 + const currentRevision = ++revision; 21 + args = nextArgs; 22 + state = { kind: "loading" }; 23 + try { 24 + const value = await operation(...nextArgs); 25 + if (currentRevision !== revision) return; 26 + args = undefined; 27 + state = { kind: "ready", value }; 28 + } catch (cause) { 29 + if (currentRevision !== revision) return; 30 + args = undefined; 31 + state = { kind: "failed", error: toError(cause) }; 32 + } 33 + }; 34 + 35 + return { 36 + get data() { 37 + return state.kind === "ready" ? state.value : undefined; 38 + }, 39 + get error() { 40 + return state.kind === "failed" ? state.error : undefined; 41 + }, 42 + get loading() { 43 + return state.kind === "loading"; 44 + }, 45 + get args() { 46 + return state.kind === "loading" ? args : undefined; 47 + }, 48 + run, 49 + update(update: (current: T) => T): boolean { 50 + if (state.kind !== "ready") return false; 51 + revision++; 52 + state = { kind: "ready", value: update(state.value) }; 53 + return true; 54 + } 55 + }; 56 + }; 57 + 58 + // loads run automatically and rerun when reactive values change: 59 + // const post = createLoad(() => api.getPost(id)); 60 + // 61 + // actions only run when called: 62 + // const save = createAction(api.savePost); 63 + // await save.run(draft); 64 + // 65 + // both expose .data, .error, .loading, and .args. 66 + export const createAction = <T, Args extends unknown[] = []>( 67 + operation: Operation<T, Args>, 68 + toError: typeof defaultError = defaultError 69 + ) => create(operation, { kind: "idle" }, toError); 70 + 71 + export const createLoad = <T>(load: Operation<T>, toError: typeof defaultError = defaultError) => { 72 + const action = create(load, { kind: "loading" }, toError); 73 + $effect(() => void action.run()); 74 + return action; 75 + };
+57
web/src/lib/action.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { createAction } from "./action.svelte"; 3 + 4 + describe("createAction", () => { 5 + it("derives ergonomic projections from one state", async () => { 6 + let resolve!: (value: string) => void; 7 + const action = createAction(() => new Promise<string>((done) => (resolve = done))); 8 + 9 + expect(action.loading).toBe(false); 10 + expect(action.data).toBeUndefined(); 11 + expect(action.error).toBeUndefined(); 12 + 13 + const running = action.run(); 14 + expect(action.loading).toBe(true); 15 + 16 + resolve("done"); 17 + await running; 18 + expect(action.data).toBe("done"); 19 + }); 20 + 21 + it("converts failures", async () => { 22 + const action = createAction( 23 + async () => { 24 + throw new Error("nope"); 25 + }, 26 + (cause) => (cause instanceof Error ? cause.message : "unknown") 27 + ); 28 + 29 + await action.run(); 30 + expect(action.error).toBe("nope"); 31 + }); 32 + 33 + it("ignores an older result after a newer run starts", async () => { 34 + const resolvers: Array<(value: string) => void> = []; 35 + const names: string[] = []; 36 + const action = createAction( 37 + (name: string) => 38 + new Promise<string>((resolve) => { 39 + names.push(name); 40 + resolvers.push(resolve); 41 + }) 42 + ); 43 + const first = action.run("first"); 44 + const second = action.run("second"); 45 + expect(names).toEqual(["first", "second"]); 46 + expect(action.args).toEqual(["second"]); 47 + 48 + resolvers[0]("stale"); 49 + await first; 50 + expect(action.loading).toBe(true); 51 + 52 + resolvers[1]("current"); 53 + await second; 54 + expect(action.data).toBe("current"); 55 + expect(action.args).toBeUndefined(); 56 + }); 57 + });
+90 -48
web/src/lib/auth.svelte.ts
··· 66 66 handle: string; 67 67 } 68 68 69 + export type AuthState = 70 + | { kind: "logged-out" } 71 + | { kind: "loading"; did: Did | null; profile: AuthProfile | null } 72 + | { kind: "authenticating" } 73 + | { kind: "profile-loading"; agent: OAuthUserAgent; did: Did } 74 + | { kind: "authenticated"; agent: OAuthUserAgent; profile: AuthProfile } 75 + | { 76 + kind: "failed"; 77 + message: string; 78 + agent?: OAuthUserAgent; 79 + did?: Did; 80 + profile?: AuthProfile; 81 + }; 82 + 69 83 export type { AuthAccount } from "./auth/accounts"; 70 84 71 85 export interface Auth { 86 + readonly state: AuthState; 72 87 readonly agent: OAuthUserAgent | null; 73 88 readonly currentDid: Did | null; 74 89 readonly profile: AuthProfile | null; ··· 170 185 initial?: { did: string; handle: string } | null 171 186 ): Auth => { 172 187 const seed = initial ?? null; 173 - let agent = $state<OAuthUserAgent | null>(null); 174 188 const bobbinUrlValue = bobbinUrl; 175 - let currentDid = $state<Did | null>((seed?.did as Did | undefined) ?? null); 176 - let profile = $state<AuthProfile | null>( 177 - seed ? { did: seed.did as Did, handle: seed.handle } : null 189 + let state = $state<AuthState>( 190 + seed 191 + ? { 192 + kind: "loading", 193 + did: seed.did as Did, 194 + profile: { did: seed.did as Did, handle: seed.handle } 195 + } 196 + : { kind: "logged-out" } 178 197 ); 179 - let error = $state<string | null>(null); 180 - let authenticating = $state(false); 181 198 let accounts = $state<AuthAccount[]>([]); 182 199 183 200 // merge atcute's stored sessions with persisted account metadata. ··· 186 203 saveAccounts(accounts); 187 204 }; 188 205 189 - const resetLoggedOut = () => { 206 + const currentAgent = (): OAuthUserAgent | null => 207 + "agent" in state ? (state.agent ?? null) : null; 208 + const currentDid = (): Did | null => 209 + "did" in state 210 + ? (state.did ?? null) 211 + : state.kind === "authenticated" 212 + ? state.profile.did 213 + : null; 214 + const currentProfile = (): AuthProfile | null => 215 + "profile" in state ? (state.profile ?? null) : null; 216 + const failureState = (message: string): AuthState => ({ 217 + kind: "failed", 218 + message, 219 + agent: currentAgent() ?? undefined, 220 + did: currentDid() ?? undefined, 221 + profile: currentProfile() ?? undefined 222 + }); 223 + 224 + const resetLoggedOut = (message?: string) => { 190 225 clearActive(); 191 - agent = null; 192 - currentDid = null; 193 - profile = null; 226 + state = message ? { kind: "failed", message } : { kind: "logged-out" }; 194 227 syncAccounts(); 195 228 }; 196 229 197 - const hydrateProfile = async (did: Did) => { 230 + const hydrateProfile = async (did: Did, nextAgent: OAuthUserAgent) => { 198 231 const resolved = await resolveProfile(did, bobbinUrl); 199 - profile = resolved ?? { did, handle: did }; 232 + if (state.kind !== "profile-loading" || state.agent !== nextAgent) return; 233 + const nextProfile = resolved ?? { did, handle: did }; 234 + state = { kind: "authenticated", agent: nextAgent, profile: nextProfile }; 200 235 const meta = upsertAccount(loadAccounts(), { 201 236 did, 202 - handle: profile.handle, 237 + handle: nextProfile.handle, 203 238 addedAt: Math.floor(Date.now() / 1000) 204 239 }); 205 240 saveAccounts(meta); 206 241 accounts = reconcileAccounts(listStoredSessions(), meta); 207 - persistActive(did, profile.handle); 242 + persistActive(did, nextProfile.handle); 208 243 }; 209 244 210 245 const adoptSession = (session: OAuthSession) => { 211 246 const nextAgent = new OAuthUserAgent(session); 212 - agent = nextAgent; 213 - error = null; 214 247 const did = nextAgent.sub as Did; 215 - currentDid = did; 248 + state = { kind: "profile-loading", agent: nextAgent, did }; 216 249 const known = loadAccounts().find((account) => account.did === did); 217 250 persistActive(did, known?.handle ?? did); 218 - void hydrateProfile(did); 251 + void hydrateProfile(did, nextAgent); 219 252 }; 220 253 221 254 // prune dead sessions when re-adoption fails. ··· 227 260 } catch (cause) { 228 261 deleteStoredSession(did); 229 262 saveAccounts(dropAccount(loadAccounts(), did)); 230 - error = errorMessage(cause); 263 + state = failureState(errorMessage(cause)); 231 264 return false; 232 265 } 233 266 }; ··· 235 268 const refresh = async () => { 236 269 if (!browser) return; 237 270 configure(); 238 - error = null; 271 + const previousDid = currentDid(); 272 + const previousProfile = currentProfile(); 273 + state = { kind: "loading", did: previousDid, profile: previousProfile }; 239 274 syncAccounts(); 240 275 241 276 const candidates: Did[] = []; 242 - for (const candidate of [readActiveDid(), currentDid, ...listStoredSessions()]) { 277 + for (const candidate of [readActiveDid(), currentDid(), ...listStoredSessions()]) { 243 278 if (candidate && !candidates.includes(candidate)) candidates.push(candidate); 244 279 } 245 280 281 + let lastFailure: string | undefined; 246 282 for (const candidate of candidates) { 247 283 if (await activate(candidate)) return; 284 + const nextState = state as AuthState; 285 + if (nextState.kind === "failed") lastFailure = nextState.message; 248 286 } 249 287 250 - resetLoggedOut(); 288 + resetLoggedOut(lastFailure); 251 289 }; 252 290 253 291 const signIn = async (identifier: string, returnTo = "/") => { 254 292 if (!browser) return; 255 293 configure(); 256 - error = null; 257 294 258 295 const trimmed = identifier.trim(); 259 296 if (!trimmed) { 260 - error = "Handle or DID required"; 297 + state = failureState("Handle or DID required"); 261 298 return; 262 299 } 263 300 ··· 282 319 283 320 window.location.assign(url.toString()); 284 321 } catch (cause) { 285 - error = errorMessage(cause); 322 + state = failureState(errorMessage(cause)); 286 323 throw cause; 287 324 } 288 325 }; ··· 290 327 const completeSignIn = async () => { 291 328 if (!browser) return "/"; 292 329 configure(); 293 - error = null; 294 - authenticating = true; 330 + state = { kind: "authenticating" }; 295 331 296 332 try { 297 333 const params = new SvelteURLSearchParams(location.hash.slice(1)); ··· 302 338 303 339 return returnToFromState(state); 304 340 } catch (cause) { 305 - error = errorMessage(cause); 341 + state = failureState(errorMessage(cause)); 306 342 throw cause; 307 - } finally { 308 - authenticating = false; 309 343 } 310 344 }; 311 345 312 346 const switchAccount = async (did: Did) => { 313 347 if (!browser) return; 314 348 configure(); 315 - error = null; 316 349 if (!(await activate(did))) syncAccounts(); 317 350 }; 318 351 319 352 const removeAccount = async (did: Did) => { 320 353 if (!browser) return; 321 - error = null; 322 - const wasActive = currentDid === did; 354 + const wasActive = currentDid() === did; 323 355 try { 324 - if (wasActive && agent) { 325 - await agent.signOut(); 356 + const activeAgent = currentAgent(); 357 + if (wasActive && activeAgent) { 358 + await activeAgent.signOut(); 326 359 } else { 327 360 deleteStoredSession(did); 328 361 } ··· 344 377 }; 345 378 346 379 const signOut = async () => { 347 - if (currentDid) { 348 - await removeAccount(currentDid); 380 + const did = currentDid(); 381 + if (did) { 382 + await removeAccount(did); 349 383 } else { 350 384 resetLoggedOut(); 351 385 } 352 386 }; 353 387 354 388 const signOutAll = async () => { 355 - error = null; 356 389 try { 357 - if (agent) await agent.signOut(); 390 + const activeAgent = currentAgent(); 391 + if (activeAgent) await activeAgent.signOut(); 358 392 } catch { 359 393 // remove local session state below. 360 394 } ··· 366 400 }; 367 401 368 402 return { 403 + get state() { 404 + return state; 405 + }, 369 406 get agent() { 370 - return agent; 407 + return currentAgent(); 371 408 }, 372 409 get currentDid() { 373 - return currentDid; 410 + return currentDid(); 374 411 }, 375 412 get profile() { 376 - return profile; 413 + return currentProfile(); 377 414 }, 378 415 get bobbinUrl() { 379 416 return bobbinUrlValue; 380 417 }, 381 418 get error() { 382 - return error; 419 + return state.kind === "failed" ? state.message : null; 383 420 }, 384 421 get profileLoading() { 385 - return currentDid !== null && profile === null; 422 + return ( 423 + state.kind === "profile-loading" || 424 + (state.kind === "loading" && state.did !== null && state.profile === null) 425 + ); 386 426 }, 387 427 get authenticating() { 388 - return authenticating; 428 + return state.kind === "authenticating"; 389 429 }, 390 430 get currentUser() { 391 - if (!currentDid) return null; 431 + const did = currentDid(); 432 + if (!did) return null; 433 + const profile = currentProfile(); 392 434 return { 393 - did: currentDid, 394 - handle: profile?.handle ?? currentDid 435 + did, 436 + handle: profile?.handle ?? did 395 437 }; 396 438 }, 397 439 get accounts() {
+13 -17
web/src/lib/components/comment/CommentCard.svelte
··· 17 17 import CommentBox from "./CommentBox.svelte"; 18 18 import CommentEditor from "./CommentEditor.svelte"; 19 19 import type { CommentThread, CommentView, ThreadInput } from "./comments"; 20 + import { createAction } from "$lib/action.svelte"; 20 21 21 22 interface Props { 22 23 thread: CommentThread; ··· 36 37 37 38 let replying = $state(false); 38 39 let editingUri = $state<string | null>(null); 39 - let deletingUri = $state<string | null>(null); 40 - let deleteError = $state<string | null>(null); 41 40 42 41 let reactionsByUri = $derived.by(() => { 43 42 const map: Record<string, ReactionGroup[]> = {}; ··· 75 74 ); 76 75 }; 77 76 78 - const handleDelete = async (comment: CommentView) => { 77 + const deleteSelected = createAction(async (comment: CommentView) => { 79 78 const agent = auth?.agent; 80 - if (!agent || deletingUri) return; 79 + if (!agent) return; 80 + await deleteComment(agent, comment.rkey); 81 + ondeleted?.(comment.uri); 82 + }); 83 + 84 + const handleDelete = (comment: CommentView) => { 85 + if (!auth?.agent || deleteSelected.loading) return; 81 86 if (!confirm("Delete this comment? This cannot be undone.")) return; 82 - deletingUri = comment.uri; 83 - deleteError = null; 84 - try { 85 - await deleteComment(agent, comment.rkey); 86 - ondeleted?.(comment.uri); 87 - } catch (err) { 88 - deleteError = err instanceof Error ? err.message : "Failed to delete comment"; 89 - } finally { 90 - deletingUri = null; 91 - } 87 + void deleteSelected.run(comment); 92 88 }; 93 89 </script> 94 90 ··· 115 111 <button 116 112 type="button" 117 113 aria-label="Delete comment" 118 - disabled={deletingUri === comment.uri} 114 + disabled={deleteSelected.loading && deleteSelected.args?.[0].uri === comment.uri} 119 115 class="cursor-pointer text-foreground-danger hover:text-foreground-danger-strong disabled:opacity-50" 120 116 onclick={() => handleDelete(comment)} 121 117 > ··· 211 207 </div> 212 208 {/if} 213 209 214 - {#if deleteError} 210 + {#if deleteSelected.error} 215 211 <div class="border-t border-border-default px-6 py-2"> 216 - <ErrorAlert label={deleteError} /> 212 + <ErrorAlert label={deleteSelected.error} /> 217 213 </div> 218 214 {/if} 219 215
+48 -57
web/src/lib/components/comment/CommentEditor.svelte
··· 11 11 import Spinner from "$lib/components/ui/Spinner.svelte"; 12 12 import { renderMarkup, type MarkupContext } from "$lib/markup"; 13 13 import type { CommentRecord } from "$lib/api/records"; 14 + import { createAction } from "$lib/action.svelte"; 14 15 import type { ThreadInput } from "./comments"; 15 16 16 17 interface Props { ··· 59 60 60 61 let body = $state(untrack(() => initialBody)); 61 62 let tab = $state<"write" | "preview">("write"); 62 - let isPublishing = $state(false); 63 - let error = $state<string | null>(null); 64 63 65 - const canSubmit = $derived(body.trim() !== "" && !isPublishing); 66 - 67 - const handleSubmit = async (e: SubmitEvent) => { 68 - e.preventDefault(); 64 + const publish = createAction(async (event: SubmitEvent) => { 65 + event.preventDefault(); 69 66 const agent = auth.agent; 70 - if (!agent || !canSubmit) return; 71 - if (!subjectCid) { 72 - error = "Cannot comment: the subject record is missing a cid."; 73 - return; 67 + if (!agent || body.trim() === "") return; 68 + if (!subjectCid) throw new Error("Cannot comment: the subject record is missing a cid."); 69 + const createdAtValue = createdAt ?? new Date().toISOString(); 70 + const targetRkey = rkey ?? tidNow(); 71 + const record: CommentRecord = { 72 + $type: "sh.tangled.feed.comment", 73 + subject: { uri: subjectUri, cid: subjectCid } as CommentRecord["subject"], 74 + body: { $type: "sh.tangled.markup.markdown", text: body }, 75 + createdAt: createdAtValue 76 + }; 77 + if (replyToUri && replyToCid) { 78 + record.replyTo = { uri: replyToUri, cid: replyToCid } as CommentRecord["replyTo"]; 74 79 } 75 - isPublishing = true; 76 - error = null; 77 - try { 78 - const createdAtValue = createdAt ?? new Date().toISOString(); 79 - const targetRkey = rkey ?? tidNow(); 80 - const record: CommentRecord = { 81 - $type: "sh.tangled.feed.comment", 82 - subject: { uri: subjectUri, cid: subjectCid } as CommentRecord["subject"], 83 - body: { $type: "sh.tangled.markup.markdown", text: body }, 84 - createdAt: createdAtValue 85 - }; 86 - if (replyToUri && replyToCid) { 87 - record.replyTo = { uri: replyToUri, cid: replyToCid } as CommentRecord["replyTo"]; 88 - } 89 - const saved = await putComment(agent, targetRkey, record); 90 - // render markdown client-side so the optimistic comment matches a real one 91 - const bodyHtml = await renderMarkup(body, markup).catch(() => null); 92 - onsubmitted?.({ 93 - comment: { 94 - uri: saved.uri, 95 - cid: saved.cid, 96 - rkey: targetRkey, 97 - authorDid, 98 - authorHandle, 99 - createdAt: createdAtValue, 100 - body, 101 - bodyHtml 102 - }, 103 - replyTo: replyToUri ?? null 104 - }); 105 - // keep the prefilled body when editing; reset when composing a fresh comment 106 - if (!rkey) { 107 - body = ""; 108 - tab = "write"; 109 - } 110 - } catch (err) { 111 - error = err instanceof Error ? err.message : "Failed to post comment"; 112 - } finally { 113 - isPublishing = false; 80 + const saved = await putComment(agent, targetRkey, record); 81 + // render markdown client-side so the optimistic comment matches a real one 82 + const bodyHtml = await renderMarkup(body, markup).catch(() => null); 83 + onsubmitted?.({ 84 + comment: { 85 + uri: saved.uri, 86 + cid: saved.cid, 87 + rkey: targetRkey, 88 + authorDid, 89 + authorHandle, 90 + createdAt: createdAtValue, 91 + body, 92 + bodyHtml 93 + }, 94 + replyTo: replyToUri ?? null 95 + }); 96 + // keep the prefilled body when editing; reset when composing a fresh comment 97 + if (!rkey) { 98 + body = ""; 99 + tab = "write"; 114 100 } 115 - }; 101 + }); 102 + const canSubmit = $derived(body.trim() !== "" && !publish.loading); 116 103 </script> 117 104 118 - <form onsubmit={handleSubmit} class="flex flex-col gap-2"> 105 + <form onsubmit={publish.run} class="flex flex-col gap-2"> 119 106 <MarkdownEditor 120 107 name="body" 121 108 {rows} ··· 125 112 transparent 126 113 bind:value={body} 127 114 bind:tab 128 - disabled={isPublishing} 115 + disabled={publish.loading} 129 116 /> 130 117 131 - {#if error} 132 - <ErrorAlert label={error} /> 133 - {/if} 118 + <ErrorAlert label={publish.error} /> 134 119 135 120 <div class="flex items-center gap-2"> 136 121 <Button 137 122 type="submit" 138 123 variant="primary" 139 - icon={isPublishing ? Spinner : submitIcon} 124 + icon={publish.loading ? Spinner : submitIcon} 140 125 disabled={!canSubmit} 141 126 > 142 127 {submitLabel} 143 128 </Button> 144 129 {#if oncancel} 145 - <Button type="button" variant="default" icon={X} disabled={isPublishing} onclick={oncancel}> 130 + <Button 131 + type="button" 132 + variant="default" 133 + icon={X} 134 + disabled={publish.loading} 135 + onclick={oncancel} 136 + > 146 137 Cancel 147 138 </Button> 148 139 {/if}
+3 -7
web/src/lib/components/profile/FollowButton.svelte
··· 26 26 loadedRkey: () => initialRkey 27 27 }); 28 28 29 - let busy = $state(false); 30 29 const following = $derived(relation.active); 31 30 32 31 const commit = (change: FollowChange) => { ··· 37 36 38 37 const toggle = async () => { 39 38 const agent = auth.agent; 40 - if (!agent || busy || !relation.known) return; 41 - busy = true; 42 - relation.resetFailure(); 39 + if (!agent || !relation.known || relation.loading) return; 40 + relation.begin(); 43 41 try { 44 42 if (relation.active && relation.rkey) { 45 43 await deleteFollow(agent, relation.rkey); ··· 64 62 } 65 63 } catch { 66 64 relation.fail(); 67 - } finally { 68 - busy = false; 69 65 } 70 66 }; 71 67 </script> ··· 81 77 variant="default" 82 78 class="w-full gap-2" 83 79 insetShadow={true} 84 - loading={busy} 80 + loading={relation.loading} 85 81 disabled={!relation.known} 86 82 onclick={toggle} 87 83 >
+25 -32
web/src/lib/components/profile/ProfileEditForm.svelte
··· 8 8 import { getAuth } from "$lib/auth.svelte"; 9 9 import { putProfile } from "$lib/api/profile"; 10 10 import type { ProfileRecord } from "$lib/api/records"; 11 + import { createAction } from "$lib/action.svelte"; 11 12 12 13 interface Props { 13 14 profile: ProfileRecord | null; ··· 30 31 value: (seed?.links?.[index] as string | undefined) ?? "" 31 32 })) 32 33 ); 33 - let busy = $state(false); 34 - let error = $state<string | null>(null); 35 - 36 34 const inputClass = 37 35 "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"; 38 36 39 - const save = async (event: SubmitEvent) => { 40 - event.preventDefault(); 41 - const agent = auth.agent; 42 - if (!agent || busy) return; 43 - busy = true; 44 - error = null; 45 - const cleanLinks = links.map((link) => link.value.trim()).filter(Boolean); 46 - // put replaces the whole record, so carry unedited fields (avatar, pins, stats). 47 - const record: ProfileRecord = { 48 - ...profile, 49 - $type: "sh.tangled.actor.profile", 50 - bluesky, 51 - description: description.trim() || undefined, 52 - pronouns: pronouns.trim() || undefined, 53 - location: location.trim() || undefined, 54 - links: cleanLinks.length > 0 ? (cleanLinks as ProfileRecord["links"]) : undefined 55 - }; 56 - try { 37 + const save = createAction( 38 + async (event: SubmitEvent) => { 39 + event.preventDefault(); 40 + const agent = auth.agent; 41 + if (!agent) return; 42 + const cleanLinks = links.map((link) => link.value.trim()).filter(Boolean); 43 + // put replaces the whole record, so carry unedited fields (avatar, pins, stats). 44 + const record: ProfileRecord = { 45 + ...profile, 46 + $type: "sh.tangled.actor.profile", 47 + bluesky, 48 + description: description.trim() || undefined, 49 + pronouns: pronouns.trim() || undefined, 50 + location: location.trim() || undefined, 51 + links: cleanLinks.length > 0 ? (cleanLinks as ProfileRecord["links"]) : undefined 52 + }; 57 53 await putProfile(agent, record); 58 54 onSaved(record); 59 - } catch { 60 - error = "Could not save profile. Try again."; 61 - } finally { 62 - busy = false; 63 - } 64 - }; 55 + }, 56 + () => "Could not save profile. Try again." 57 + ); 65 58 </script> 66 59 67 - <form class="my-2 flex max-w-full flex-col gap-4 text-sm" onsubmit={save}> 60 + <form class="my-2 flex max-w-full flex-col gap-4 text-sm" onsubmit={save.run}> 68 61 <div class="flex flex-col gap-1"> 69 62 <label for="profile-bio">Bio</label> 70 63 <textarea ··· 113 106 {/each} 114 107 </div> 115 108 116 - {#if error} 117 - <p class="typography-paragraph-small text-foreground-danger">{error}</p> 109 + {#if save.error} 110 + <p class="typography-paragraph-small text-foreground-danger">{save.error}</p> 118 111 {/if} 119 112 120 113 <div class="flex items-center justify-between gap-2"> 121 - <Button type="submit" variant="default" class="w-full gap-2" loading={busy}> 114 + <Button type="submit" variant="default" class="w-full gap-2" loading={save.loading}> 122 115 <Check class="size-4" aria-hidden="true" /> 123 116 Save 124 117 </Button> 125 - <Button variant="default" class="w-full gap-2" disabled={busy} onclick={onCancel}> 118 + <Button variant="default" class="w-full gap-2" disabled={save.loading} onclick={onCancel}> 126 119 <X class="size-4" aria-hidden="true" /> 127 120 Cancel 128 121 </Button>
+24 -27
web/src/lib/components/reaction/ReactionPicker.svelte
··· 1 1 <script lang="ts"> 2 2 import { now as tidNow } from "@atcute/tid"; 3 3 import SmilePlus from "$icon/smile-plus"; 4 + import { createAction } from "$lib/action.svelte"; 4 5 import { putReaction, deleteReaction } from "$lib/api/reaction"; 5 6 import { getAuth } from "$lib/auth.svelte"; 6 7 import type { ReactionRecord } from "$lib/api/records"; ··· 24 25 const anchorName = `--${popoverId}`; 25 26 26 27 let panel = $state<HTMLElement>(); 27 - let pending = $state<ReactionKind | null>(null); 28 28 29 - const pick = async (kind: ReactionKind) => { 29 + const pick = createAction(async (kind: ReactionKind) => { 30 30 const agent = auth?.agent; 31 - if (!agent || pending) return; 31 + if (!agent) return; 32 32 33 - pending = kind; 34 - try { 35 - const existingRkey = reacted?.get(kind); 36 - if (existingRkey) { 37 - await deleteReaction(agent, existingRkey); 38 - onunreacted?.(kind); 39 - } else { 40 - const rkey = tidNow(); 41 - const record: ReactionRecord = { 42 - $type: "sh.tangled.feed.reaction", 43 - subject: subjectUri as ReactionRecord["subject"], 44 - reaction: kind, 45 - createdAt: new Date().toISOString() 46 - }; 47 - await putReaction(agent, rkey, record); 48 - onreacted?.(kind, rkey); 49 - } 50 - panel?.hidePopover(); 51 - } catch { 52 - // leave the panel open on failure so the user can retry 53 - } finally { 54 - pending = null; 33 + const existingRkey = reacted?.get(kind); 34 + if (existingRkey) { 35 + await deleteReaction(agent, existingRkey); 36 + onunreacted?.(kind); 37 + } else { 38 + const rkey = tidNow(); 39 + const record: ReactionRecord = { 40 + $type: "sh.tangled.feed.reaction", 41 + subject: subjectUri as ReactionRecord["subject"], 42 + reaction: kind, 43 + createdAt: new Date().toISOString() 44 + }; 45 + await putReaction(agent, rkey, record); 46 + onreacted?.(kind, rkey); 55 47 } 48 + panel?.hidePopover(); 49 + }); 50 + 51 + const handlePick = (kind: ReactionKind) => { 52 + if (!pick.loading) void pick.run(kind); 56 53 }; 57 54 </script> 58 55 ··· 80 77 size="sm" 81 78 aria-label={`React with ${kind}`} 82 79 aria-pressed={reacted?.has(kind) ?? false} 83 - disabled={pending !== null} 84 - onclick={() => pick(kind)} 80 + disabled={pick.loading} 81 + onclick={() => handlePick(kind)} 85 82 > 86 83 {kind} 87 84 </Button>
+17 -30
web/src/lib/components/repo/EmptyRepo.svelte
··· 3 3 import { getAuth } from "$lib/auth.svelte"; 4 4 import { createBobbinClient } from "$lib/api/client"; 5 5 import { count } from "$lib/api/count"; 6 + import { createLoad } from "$lib/action.svelte"; 6 7 import type { RepoInfo } from "./types"; 7 8 8 9 interface Props { ··· 26 27 ); 27 28 const remote = $derived(`git@${sshHost}:${repo.repoDid ?? `${repo.ownerHandle}/${repo.name}`}`); 28 29 29 - type KeyStatus = "checking" | "has-key" | "no-key"; 30 - 31 - let keyStatus = $state<KeyStatus>("checking"); 32 - 33 - $effect(() => { 30 + const keyStatus = createLoad(async (): Promise<boolean | undefined> => { 34 31 if (hasSshKey !== undefined) { 35 - keyStatus = hasSshKey ? "has-key" : "no-key"; 36 - return; 32 + return hasSshKey; 37 33 } 38 34 39 - if (!isOwner) return; 35 + const owner = isOwner; 40 36 const did = auth.currentDid; 41 - if (!did) return; 42 - 43 - let cancelled = false; 44 - keyStatus = "checking"; 45 - 46 - (async () => { 47 - try { 48 - const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); 49 - const result = await count(ctx, "sh.tangled.publicKey.countKeys", did); 50 - if (!cancelled) keyStatus = result.count > 0 ? "has-key" : "no-key"; 51 - } catch { 52 - // safe fallback: guide the owner through generating a key. 53 - if (!cancelled) keyStatus = "no-key"; 54 - } 55 - })(); 56 - 57 - return () => { 58 - cancelled = true; 59 - }; 37 + const serviceUrl = auth.bobbinUrl; 38 + if (!owner || !did) return undefined; 39 + try { 40 + const ctx = createBobbinClient({ serviceUrl }); 41 + const result = await count(ctx, "sh.tangled.publicKey.countKeys", did); 42 + return result.count > 0; 43 + } catch { 44 + // safe fallback: guide the owner through generating a key. 45 + return false; 46 + } 60 47 }); 61 48 </script> 62 49 63 50 {#snippet bullet(n: number)} 64 51 <span 65 - class="mr-2 inline-flex size-5 shrink-0 items-center justify-center rounded-full bg-background-inset align-middle font-mono typography-monospace-small" 52 + class="mr-2 inline-flex size-5 shrink-0 items-center justify-center rounded-full bg-background-inset align-middle typography-monospace-small font-mono" 66 53 > 67 54 {n} 68 55 </span> ··· 75 62 {/snippet} 76 63 77 64 {#if isOwner} 78 - {#if keyStatus === "checking"} 65 + {#if keyStatus.loading || keyStatus.data === undefined} 79 66 <div class="py-6" aria-hidden="true"></div> 80 - {:else if keyStatus === "has-key"} 67 + {:else if keyStatus.data} 81 68 <div class="flex w-full place-content-center"> 82 69 <div class="flex w-fit flex-col gap-5 py-6 text-sm"> 83 70 <p>This is an empty repository.</p>
+3 -7
web/src/lib/components/repo/StarButton.svelte
··· 37 37 loaded: () => initialCount 38 38 }); 39 39 40 - let busy = $state(false); 41 40 const starred = $derived(relation.active); 42 41 const failed = $derived(relation.failed || starCount.failed); 43 42 44 43 const toggle = async () => { 45 44 const agent = auth.agent; 46 - if (!agent || busy || !relation.known || !repoDid) return; 47 - busy = true; 48 - relation.resetFailure(); 45 + if (!agent || !relation.known || relation.loading || !repoDid) return; 46 + relation.begin(); 49 47 starCount.resetFailure(); 50 48 try { 51 49 if (relation.active && relation.rkey) { ··· 62 60 } catch { 63 61 relation.fail(); 64 62 starCount.fail(); 65 - } finally { 66 - busy = false; 67 63 } 68 64 }; 69 65 </script> ··· 76 72 size="sm" 77 73 {insetShadow} 78 74 class="flex-1" 79 - loading={busy} 75 + loading={relation.loading} 80 76 disabled={!relation.known} 81 77 onclick={toggle} 82 78 >
+22 -34
web/src/lib/components/repo/issues/IssueForm.svelte
··· 13 13 import Spinner from "$lib/components/ui/Spinner.svelte"; 14 14 import { type MarkupContext } from "$lib/markup"; 15 15 import type { IssueRecord, RecordView } from "$lib/api/records"; 16 + import { createAction } from "$lib/action.svelte"; 16 17 17 18 interface Props { 18 19 repoDid: string; ··· 46 47 47 48 let title = $state(untrack(() => initialTitle)); 48 49 let body = $state(untrack(() => initialBody)); 49 - let isPublishing = $state(false); 50 - let error = $state<string | null>(null); 51 - 52 50 const submitText = $derived(submitLabel ?? (mode === "edit" ? "Save" : "Create issue")); 53 51 const submitIcon = $derived(mode === "edit" ? Pencil : CirclePlus); 54 - const canSubmit = $derived(title.trim() !== "" && !isPublishing); 55 52 56 - const handleSubmit = async (e: SubmitEvent) => { 57 - e.preventDefault(); 53 + const save = createAction(async (event: SubmitEvent) => { 54 + event.preventDefault(); 58 55 const agent = auth.agent; 59 - if (!agent || !canSubmit) return; 60 - isPublishing = true; 61 - error = null; 62 - try { 63 - const targetRkey = rkey ?? tidNow(); 64 - const record: IssueRecord = { 65 - $type: "sh.tangled.repo.issue", 66 - repo: repoDid as IssueRecord["repo"], 67 - title, 68 - body, 69 - createdAt: createdAt ?? new Date().toISOString() 70 - }; 71 - const saved = await putIssue(agent, targetRkey, record); 72 - onsaved(saved); 73 - } catch (err) { 74 - error = err instanceof Error ? err.message : "Failed to save issue"; 75 - } finally { 76 - isPublishing = false; 77 - } 78 - }; 56 + if (!agent || title.trim() === "") return; 57 + const targetRkey = rkey ?? tidNow(); 58 + const record: IssueRecord = { 59 + $type: "sh.tangled.repo.issue", 60 + repo: repoDid as IssueRecord["repo"], 61 + title, 62 + body, 63 + createdAt: createdAt ?? new Date().toISOString() 64 + }; 65 + const saved = await putIssue(agent, targetRkey, record); 66 + onsaved(saved); 67 + }); 68 + const canSubmit = $derived(title.trim() !== "" && !save.loading); 79 69 </script> 80 70 81 - <form onsubmit={handleSubmit} class="flex flex-col gap-4"> 71 + <form onsubmit={save.run} class="flex flex-col gap-4"> 82 72 <div class="flex flex-col gap-1.5"> 83 73 <label for="issue-title" class="text-sm text-foreground-default">Title</label> 84 - <Input id="issue-title" name="title" required bind:value={title} disabled={isPublishing} /> 74 + <Input id="issue-title" name="title" required bind:value={title} disabled={save.loading} /> 85 75 </div> 86 76 87 77 <div class="flex flex-col gap-1.5"> ··· 94 84 placeholder={bodyPlaceholder} 95 85 {markup} 96 86 bind:value={body} 97 - disabled={isPublishing} 87 + disabled={save.loading} 98 88 /> 99 89 </div> 100 90 101 - {#if error} 102 - <ErrorAlert label={error} /> 103 - {/if} 91 + <ErrorAlert label={save.error} /> 104 92 105 93 <div class="flex items-center justify-end gap-2"> 106 - <Button type="button" variant="default" icon={X} disabled={isPublishing} onclick={oncancel}> 94 + <Button type="button" variant="default" icon={X} disabled={save.loading} onclick={oncancel}> 107 95 Cancel 108 96 </Button> 109 97 <Button 110 98 type="submit" 111 99 variant="primary" 112 - icon={isPublishing ? Spinner : submitIcon} 100 + icon={save.loading ? Spinner : submitIcon} 113 101 disabled={!canSubmit} 114 102 > 115 103 {submitText}
+42 -51
web/src/lib/components/settings/tabs/KeysTab.svelte
··· 16 16 import SettingsToolbar from "../SettingsToolbar.svelte"; 17 17 import SettingsList from "../SettingsList.svelte"; 18 18 import SettingsEntry from "../SettingsEntry.svelte"; 19 + import { createAction, createLoad } from "$lib/action.svelte"; 19 20 import Plus from "$icon/plus"; 20 21 import Trash from "$icon/trash-2"; 21 22 import Key from "$icon/key"; ··· 23 24 const auth = getAuth(); 24 25 const user = $derived(auth.currentUser); 25 26 26 - let keys = $state<ListedRecord<PublicKeyRecord>[]>([]); 27 - let fingerprints = $state<Record<string, string>>({}); 28 - let loading = $state(true); 29 - let loadError = $state<string | null>(null); 30 - let deleting = $state<string | null>(null); 31 - 27 + type KeysData = { 28 + keys: ListedRecord<PublicKeyRecord>[]; 29 + fingerprints: Record<string, string>; 30 + }; 32 31 // ssh SHA256 fingerprint of the base64 key blob, matching `ssh-keygen -lf`. 33 32 const sshFingerprint = async (pubkey: string): Promise<string | null> => { 34 33 const parts = pubkey.trim().split(/\s+/); ··· 43 42 } 44 43 }; 45 44 46 - const load = async () => { 45 + const loaded = createLoad(async (): Promise<KeysData> => { 46 + const serviceUrl = auth.bobbinUrl; 47 47 const did = user?.did; 48 - if (!did) return; 49 - loading = true; 50 - loadError = null; 51 - try { 52 - const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); 53 - keys = await listPubKeys(ctx, did); 54 - const entries = await Promise.all( 55 - keys.map(async (k) => [k.rkey, await sshFingerprint(k.value.key)] as const) 56 - ); 57 - fingerprints = Object.fromEntries( 58 - entries.filter(([, fp]) => fp !== null) as [string, string][] 59 - ); 60 - } catch (err) { 61 - loadError = err instanceof Error ? err.message : "Failed to load keys"; 62 - } finally { 63 - loading = false; 64 - } 65 - }; 48 + if (!did) return { keys: [], fingerprints: {} }; 49 + const ctx = createBobbinClient({ serviceUrl }); 50 + const keys = await listPubKeys(ctx, did); 51 + const entries = await Promise.all( 52 + keys.map(async (k) => [k.rkey, await sshFingerprint(k.value.key)] as const) 53 + ); 54 + const fingerprints = Object.fromEntries( 55 + entries.filter(([, fp]) => fp !== null) as [string, string][] 56 + ); 57 + return { keys, fingerprints }; 58 + }); 66 59 67 - $effect(() => { 68 - if (user?.did) void load(); 60 + const removeKey = createAction(async (key: ListedRecord<PublicKeyRecord>) => { 61 + const agent = auth.agent; 62 + if (!agent) return; 63 + await deletePubKey(agent, key.rkey); 64 + // drop just this key from local state instead of refetching everything. 65 + loaded.update((data) => ({ 66 + ...data, 67 + keys: data.keys.filter((candidate) => candidate.rkey !== key.rkey) 68 + })); 69 69 }); 70 70 71 - const remove = async (k: ListedRecord<PublicKeyRecord>) => { 72 - if (!auth.agent) return; 73 - if (!confirm(`Are you sure you want to delete the key ${k.value.name}?`)) return; 74 - deleting = k.rkey; 75 - try { 76 - await deletePubKey(auth.agent, k.rkey); 77 - // drop just this key from local state instead of refetching everything. 78 - keys = keys.filter((x) => x.rkey !== k.rkey); 79 - } catch (err) { 80 - loadError = err instanceof Error ? err.message : "Failed to delete key"; 81 - } finally { 82 - deleting = null; 83 - } 71 + const remove = (key: ListedRecord<PublicKeyRecord>) => { 72 + if (!auth.agent || removeKey.loading) return; 73 + if (!confirm(`Are you sure you want to delete the key ${key.value.name}?`)) return; 74 + void removeKey.run(key); 84 75 }; 85 76 </script> 86 77 ··· 96 87 {/snippet} 97 88 </SettingsToolbar> 98 89 99 - {#if loadError} 100 - <ErrorAlert label={loadError} /> 101 - {/if} 90 + <ErrorAlert label={loaded.error} /> 91 + <ErrorAlert label={removeKey.error} /> 102 92 103 - {#if loading} 93 + {#if loaded.loading} 104 94 <div 105 95 class="flex items-center justify-center gap-2 p-4 typography-paragraph-regular text-foreground-subtle" 106 96 > 107 97 <Spinner /> Loading… 108 98 </div> 109 - {:else if keys.length === 0} 99 + {:else if loaded.data?.keys.length === 0} 110 100 <SettingsEmpty message="No keys added yet" /> 111 - {:else} 101 + {:else if loaded.data} 102 + {@const data = loaded.data} 112 103 <SettingsList> 113 - {#each keys as k (k.rkey)} 104 + {#each data.keys as k (k.rkey)} 114 105 <SettingsEntry icon={Key} title={k.value.name}> 115 106 {#snippet meta()} 116 - {#if fingerprints[k.rkey]} 117 - <span class="font-mono typography-monospace-regular break-all text-foreground-muted"> 118 - {fingerprints[k.rkey]} 107 + {#if data.fingerprints[k.rkey]} 108 + <span class="typography-monospace-regular font-mono break-all text-foreground-muted"> 109 + {data.fingerprints[k.rkey]} 119 110 </span> 120 111 {/if} 121 112 <span class="typography-paragraph-regular text-foreground-muted"> ··· 126 117 <Button 127 118 variant="danger" 128 119 icon={Trash} 129 - loading={deleting === k.rkey} 120 + loading={removeKey.loading && removeKey.args?.[0].rkey === k.rkey} 130 121 onclick={() => remove(k)} 131 122 > 132 123 Delete
+22 -46
web/src/lib/components/settings/tabs/NotificationsTab.svelte
··· 15 15 import ErrorAlert from "$lib/components/ui/Error.svelte"; 16 16 import SettingsHeader from "../SettingsHeader.svelte"; 17 17 import SettingsList from "../SettingsList.svelte"; 18 + import { createAction, createLoad } from "$lib/action.svelte"; 18 19 import Save from "$icon/save"; 19 20 import Star from "$icon/star"; 20 21 import CircleDot from "$icon/circle-dot"; ··· 113 114 } 114 115 ]); 115 116 116 - let loading = $state(true); 117 - let saving = $state(false); 118 - let loadError = $state<string | null>(null); 119 - let saveError = $state<string | null>(null); 120 117 // last-saved values; Save lights up when the toggles drift from these. 121 118 let baseline = $state<NotificationPreferences | null>(null); 122 119 123 120 const dirty = $derived(baseline !== null && prefs.some((p) => p.enabled !== baseline![p.key])); 124 121 125 - const load = async () => { 122 + const loaded = createLoad(async () => { 126 123 const agent = auth.agent; 127 - if (!agent || !apiUrl) return; 128 - loading = true; 129 - loadError = null; 130 - try { 131 - const ctx = createAppviewClient({ apiUrl, agent }); 132 - const values = await getNotificationPreferences(ctx); 133 - for (const pref of prefs) pref.enabled = values[pref.key]; 134 - baseline = values; 135 - } catch (err) { 136 - loadError = 137 - err instanceof Error ? err.message : "Failed to load notification preferences."; 138 - } finally { 139 - loading = false; 140 - } 141 - }; 142 - 143 - $effect(() => { 144 - if (auth.agent && apiUrl) void load(); 124 + const url = apiUrl; 125 + if (!agent || !url) return; 126 + const ctx = createAppviewClient({ apiUrl: url, agent }); 127 + const values = await getNotificationPreferences(ctx); 128 + for (const pref of prefs) pref.enabled = values[pref.key]; 129 + baseline = values; 145 130 }); 146 131 147 - const save = async () => { 132 + const save = createAction(async () => { 148 133 const agent = auth.agent; 149 - if (!agent || !apiUrl || !baseline || !dirty) return; 150 - saving = true; 151 - saveError = null; 134 + const url = apiUrl; 135 + if (!agent || !url || !baseline || !dirty) return; 152 136 // send only the toggles that changed since the last save. 153 137 const patch: Partial<NotificationPreferences> = {}; 154 138 for (const pref of prefs) { 155 139 if (pref.enabled !== baseline[pref.key]) patch[pref.key] = pref.enabled; 156 140 } 157 - try { 158 - const ctx = createAppviewClient({ apiUrl, agent }); 159 - await updateNotificationPreferences(ctx, patch); 160 - baseline = { ...baseline, ...patch }; 161 - } catch (err) { 162 - saveError = err instanceof Error ? err.message : "Failed to save preferences."; 163 - } finally { 164 - saving = false; 165 - } 166 - }; 141 + const ctx = createAppviewClient({ apiUrl: url, agent }); 142 + await updateNotificationPreferences(ctx, patch); 143 + baseline = { ...baseline, ...patch }; 144 + }); 167 145 </script> 168 146 169 147 <SettingsHeader ··· 171 149 description="Choose which notifications you want to receive when activity happens on your repositories and profile." 172 150 > 173 151 {#snippet action()} 174 - <Button variant="primary" icon={Save} disabled={!dirty || saving} onclick={save}>Save</Button> 152 + <Button variant="primary" icon={Save} disabled={!dirty || save.loading} onclick={save.run}> 153 + Save 154 + </Button> 175 155 {/snippet} 176 156 </SettingsHeader> 177 157 178 - {#if loadError} 179 - <ErrorAlert label={loadError} /> 180 - {/if} 181 - {#if saveError} 182 - <ErrorAlert label={saveError} /> 183 - {/if} 158 + <ErrorAlert label={loaded.error} /> 159 + <ErrorAlert label={save.error} /> 184 160 185 - {#if loading} 161 + {#if loaded.loading} 186 162 <div class="flex items-center justify-center gap-2 p-4 text-sm text-foreground-subtle"> 187 163 <Spinner /> Loading… 188 164 </div> ··· 198 174 </span> 199 175 <span class="typography-paragraph-regular text-foreground-muted">{pref.description}</span> 200 176 </div> 201 - <Toggle bind:checked={pref.enabled} disabled={saving} aria-label={pref.label} /> 177 + <Toggle bind:checked={pref.enabled} disabled={save.loading} aria-label={pref.label} /> 202 178 </div> 203 179 {/each} 204 180 </SettingsList>
+7 -17
web/src/lib/components/settings/tabs/ProfileTab.svelte
··· 12 12 import SettingsSaveBar from "../SettingsSaveBar.svelte"; 13 13 import CodeChip from "../CodeChip.svelte"; 14 14 import ThemePicker from "../ThemePicker.svelte"; 15 + import { createLoad } from "$lib/action.svelte"; 15 16 import Pencil from "$icon/pencil"; 16 17 import Key from "$icon/key"; 17 18 import Pause from "$icon/pause"; ··· 20 21 const auth = getAuth(); 21 22 const user = $derived(auth.currentUser); 22 23 23 - let pds = $state<string | null>(null); 24 - 25 - $effect(() => { 24 + const pds = createLoad(async () => { 26 25 const did = user?.did; 27 - if (!did) return; 28 - const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); 29 - let cancelled = false; 30 - resolveMiniDoc(ctx, did) 31 - .then((doc) => { 32 - if (!cancelled) pds = doc.pds ?? null; 33 - }) 34 - .catch(() => { 35 - if (!cancelled) pds = null; 36 - }); 37 - return () => { 38 - cancelled = true; 39 - }; 26 + const serviceUrl = auth.bobbinUrl; 27 + if (!did) return null; 28 + const ctx = createBobbinClient({ serviceUrl }); 29 + return (await resolveMiniDoc(ctx, did)).pds ?? null; 40 30 }); 41 31 42 32 // everything below is mocked — nothing is persisted yet ··· 116 106 <CodeChip class="w-full break-all sm:w-auto">{user?.did ?? "…"}</CodeChip> 117 107 </SettingsRow> 118 108 <SettingsRow title="Personal Data Server (PDS)"> 119 - <CodeChip class="break-all">{pds ?? "…"}</CodeChip> 109 + <CodeChip class="break-all">{pds.data ?? "…"}</CodeChip> 120 110 </SettingsRow> 121 111 </SettingsSection> 122 112
+66 -73
web/src/lib/components/settings/tabs/SitesTab.svelte
··· 16 16 import SettingsEntry from "../SettingsEntry.svelte"; 17 17 import FormRow from "../FormRow.svelte"; 18 18 import DocsButton from "../DocsButton.svelte"; 19 + import { createAction, createLoad } from "$lib/action.svelte"; 19 20 import Globe from "$icon/globe"; 20 21 import Check from "$icon/check"; 21 22 import Unlink from "$icon/unlink"; ··· 25 26 const apiUrl = $derived(page.data.publicConfig?.apiUrl as string | undefined); 26 27 const sitesDomain = $derived((page.data.publicConfig?.sitesDomain as string | undefined) ?? ""); 27 28 28 - let domain = $state<string | null>(null); 29 - let loading = $state(true); 30 - let loadError = $state<string | null>(null); 31 - let actionError = $state<string | null>(null); 32 29 let subdomain = $state(""); 33 - let claiming = $state(false); 34 - let releasing = $state(false); 35 30 36 31 // prefer the xrpc error's message/name over a generic client error string. 37 32 const errMessage = (err: unknown, fallback: string): string => { 38 33 if (err instanceof ClientResponseError) return err.description || err.error || fallback; 39 34 return err instanceof Error ? err.message : fallback; 40 35 }; 36 + const loaded = createLoad( 37 + async (): Promise<string | null> => { 38 + const agent = auth.agent; 39 + const url = apiUrl; 40 + if (!agent || !url) return null; 41 + const ctx = createAppviewClient({ apiUrl: url, agent }); 42 + return getDomainClaim(ctx); 43 + }, 44 + (cause) => errMessage(cause, "Failed to load your sites domain.") 45 + ); 41 46 42 - const load = async () => { 43 - const agent = auth.agent; 44 - if (!agent || !apiUrl) return; 45 - loading = true; 46 - loadError = null; 47 - try { 48 - const ctx = createAppviewClient({ apiUrl, agent }); 49 - domain = await getDomainClaim(ctx); 50 - } catch (err) { 51 - loadError = errMessage(err, "Failed to load your sites domain."); 52 - } finally { 53 - loading = false; 54 - } 55 - }; 47 + const claim = createAction( 48 + async (event: SubmitEvent) => { 49 + event.preventDefault(); 50 + const agent = auth.agent; 51 + const url = apiUrl; 52 + const domain = subdomain.trim(); 53 + if (!agent || !url || !domain) return; 54 + const ctx = createAppviewClient({ apiUrl: url, agent }); 55 + await claimDomain(ctx, domain); 56 + subdomain = ""; 57 + await loaded.run(); 58 + }, 59 + (cause) => errMessage(cause, "Failed to claim domain.") 60 + ); 56 61 57 - $effect(() => { 58 - if (auth.agent && apiUrl) void load(); 59 - }); 62 + const release = createAction( 63 + async () => { 64 + const agent = auth.agent; 65 + const url = apiUrl; 66 + const domain = loaded.data; 67 + if (!agent || !url || !domain) return; 68 + const ctx = createAppviewClient({ apiUrl: url, agent }); 69 + await releaseDomain(ctx, domain); 70 + await loaded.run(); 71 + }, 72 + (cause) => errMessage(cause, "Failed to release domain.") 73 + ); 74 + const actionError = $derived(claim.error ?? release.error); 60 75 61 - const claim = async () => { 62 - const agent = auth.agent; 63 - const sd = subdomain.trim(); 64 - if (!agent || !apiUrl || !sd || claiming) return; 65 - claiming = true; 66 - actionError = null; 67 - try { 68 - const ctx = createAppviewClient({ apiUrl, agent }); 69 - await claimDomain(ctx, sd); 70 - subdomain = ""; 71 - await load(); 72 - } catch (err) { 73 - actionError = errMessage(err, "Failed to claim domain."); 74 - } finally { 75 - claiming = false; 76 - } 77 - }; 78 - 79 - const release = async () => { 80 - const agent = auth.agent; 81 - if (!agent || !apiUrl || !domain || releasing) return; 76 + const releaseDomainClaim = () => { 77 + const domain = loaded.data; 78 + if (!auth.agent || release.loading || !domain) return; 82 79 if (!confirm(`Release ${domain}? This removes all site data served from it.`)) return; 83 - releasing = true; 84 - actionError = null; 85 - try { 86 - const ctx = createAppviewClient({ apiUrl, agent }); 87 - await releaseDomain(ctx, domain); 88 - await load(); 89 - } catch (err) { 90 - actionError = errMessage(err, "Failed to release domain."); 91 - } finally { 92 - releasing = false; 93 - } 80 + void release.run(); 94 81 }; 95 82 </script> 96 83 ··· 106 93 {/snippet} 107 94 </SettingsToolbar> 108 95 109 - {#if loadError} 110 - <ErrorAlert label={loadError} /> 111 - {/if} 96 + <ErrorAlert label={loaded.error} /> 112 97 {#if actionError} 113 98 <ErrorAlert label={actionError} /> 114 99 {/if} 115 100 116 - {#if loading} 101 + {#if loaded.loading} 117 102 <div class="flex items-center justify-center gap-2 p-4 text-sm text-foreground-subtle"> 118 103 <Spinner /> Loading… 119 104 </div> 120 - {:else if domain} 105 + {:else if loaded.data} 121 106 <SettingsList> 122 - <SettingsEntry icon={Globe} title={domain}> 107 + <SettingsEntry icon={Globe} title={loaded.data}> 123 108 {#snippet tags()} 124 109 <Tag color="success" size="sm" icon={Check}>Active</Tag> 125 110 {/snippet} 126 111 {#snippet actions()} 127 - <Button variant="danger" icon={Unlink} disabled={releasing} onclick={release}> 112 + <Button 113 + variant="danger" 114 + icon={Unlink} 115 + loading={release.loading} 116 + onclick={releaseDomainClaim} 117 + > 128 118 Release 129 119 </Button> 130 120 {/snippet} 131 121 </SettingsEntry> 132 122 </SettingsList> 133 - {:else} 134 - <form 135 - class="flex w-full flex-col gap-4" 136 - onsubmit={(e) => { 137 - e.preventDefault(); 138 - void claim(); 139 - }} 140 - > 141 - <FormRow label="Subdomain" description="Lowercase letters, digits, and hyphens; 4–63 characters."> 123 + {:else if loaded.data !== undefined} 124 + <form class="flex w-full flex-col gap-4" onsubmit={claim.run}> 125 + <FormRow 126 + label="Subdomain" 127 + description="Lowercase letters, digits, and hyphens; 4–63 characters." 128 + > 142 129 <div class="flex items-center gap-2"> 143 130 <Input 144 131 bind:value={subdomain} 145 132 placeholder="floo" 146 133 suffix={sitesDomain ? `.${sitesDomain}` : undefined} 147 - disabled={claiming} 134 + disabled={claim.loading} 148 135 class="flex-1" 149 136 /> 150 - <Button type="submit" variant="primary" icon={Plus} disabled={claiming || !subdomain.trim()}> 137 + <Button 138 + type="submit" 139 + variant="primary" 140 + icon={Plus} 141 + loading={claim.loading} 142 + disabled={!subdomain.trim()} 143 + > 151 144 Claim 152 145 </Button> 153 146 </div>
+24 -33
web/src/lib/components/strings/StringForm.svelte
··· 9 9 import { getAuth } from "$lib/auth.svelte"; 10 10 import { putString } from "$lib/api/strings"; 11 11 import type { StringRecord } from "$lib/api/records"; 12 + import { createAction } from "$lib/action.svelte"; 12 13 13 14 interface Props { 14 15 mode?: "create" | "edit"; ··· 37 38 let filename = $state(untrack(() => initialFilename)); 38 39 let description = $state(untrack(() => initialDescription)); 39 40 let content = $state(untrack(() => initialContent)); 40 - let isPublishing = $state(false); 41 - let error = $state<string | null>(null); 42 41 43 42 const lineCount = $derived(content === "" ? 0 : content.split("\n").length); 44 43 const byteCount = $derived(new TextEncoder().encode(content).length); 45 44 46 - const handleSubmit = async (e: SubmitEvent) => { 47 - e.preventDefault(); 45 + const publish = createAction(async (event: SubmitEvent) => { 46 + event.preventDefault(); 48 47 const agent = auth.agent; 49 - if (!agent || isPublishing) return; 50 - isPublishing = true; 51 - error = null; 52 - try { 53 - const targetRkey = rkey ?? tidNow(); 54 - const record: StringRecord = { 55 - $type: "sh.tangled.string", 56 - filename, 57 - description, 58 - contents: content, 59 - createdAt: createdAt ?? new Date().toISOString() 60 - }; 61 - await putString(agent, targetRkey, record); 62 - onsaved(targetRkey); 63 - } catch (err) { 64 - error = err instanceof Error ? err.message : "Failed to publish string"; 65 - } finally { 66 - isPublishing = false; 67 - } 68 - }; 48 + if (!agent) return; 49 + const targetRkey = rkey ?? tidNow(); 50 + const record: StringRecord = { 51 + $type: "sh.tangled.string", 52 + filename, 53 + description, 54 + contents: content, 55 + createdAt: createdAt ?? new Date().toISOString() 56 + }; 57 + await putString(agent, targetRkey, record); 58 + onsaved(targetRkey); 59 + }); 69 60 </script> 70 61 71 62 <div class="rounded-sm border border-border-default bg-background-default"> 72 - <form onsubmit={handleSubmit}> 63 + <form onsubmit={publish.run}> 73 64 <div class="flex flex-col gap-2 p-4"> 74 65 <div class="flex flex-col gap-2 md:flex-row"> 75 66 <input ··· 79 70 placeholder="Filename" 80 71 required 81 72 bind:value={filename} 82 - disabled={isPublishing} 73 + disabled={publish.loading} 83 74 class="rounded-sm border border-border-default bg-background-default p-2 text-sm text-foreground-default placeholder:text-foreground-placeholder focus:ring-1 focus:ring-border-strong focus:outline-none disabled:opacity-50 md:max-w-64" 84 75 /> 85 76 <input ··· 89 80 placeholder="Description ..." 90 81 maxlength={280} 91 82 bind:value={description} 92 - disabled={isPublishing} 83 + disabled={publish.loading} 93 84 class="flex-1 rounded-sm border border-border-default bg-background-default p-2 text-sm text-foreground-default placeholder:text-foreground-placeholder focus:ring-1 focus:ring-border-strong focus:outline-none disabled:opacity-50" 94 85 /> 95 86 </div> ··· 102 93 rows={20} 103 94 spellcheck={false} 104 95 bind:value={content} 105 - disabled={isPublishing} 96 + disabled={publish.loading} 106 97 class="w-full resize-y rounded-sm border border-border-default bg-background-default p-2 font-mono text-sm text-foreground-default placeholder:text-foreground-placeholder focus:ring-1 focus:ring-border-strong focus:outline-none disabled:opacity-50" 107 98 ></textarea> 108 99 </div> ··· 120 111 type="button" 121 112 variant="default" 122 113 icon={X} 123 - disabled={isPublishing} 114 + disabled={publish.loading} 124 115 onclick={oncancel} 125 116 > 126 117 Cancel ··· 129 120 <Button 130 121 type="submit" 131 122 variant="primary" 132 - icon={isPublishing ? Spinner : ArrowUp} 133 - disabled={isPublishing} 123 + icon={publish.loading ? Spinner : ArrowUp} 124 + disabled={publish.loading} 134 125 > 135 126 Publish 136 127 </Button> 137 128 </div> 138 129 </div> 139 130 140 - {#if error} 131 + {#if publish.error} 141 132 <div class="px-4 pb-4"> 142 - <ErrorAlert label={error} /> 133 + <ErrorAlert label={publish.error} /> 143 134 </div> 144 135 {/if} 145 136 </form>
+23 -18
web/src/lib/components/ui/Error.svelte
··· 45 45 import ChevronRight from "$icon/chevron-right"; 46 46 47 47 interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "class" | "children"> { 48 - /** The error message shown next to the icon. */ 49 - label: string; 48 + /** The error message shown next to the icon; renders nothing when absent. */ 49 + label?: string; 50 50 size?: ErrorVariants["size"]; 51 51 class?: string; 52 52 /** Optional collapsible content revealed by a "Show code" toggle. */ ··· 56 56 let { label, size = "small", class: className, children, ...rest }: Props = $props(); 57 57 58 58 let open = $state(false); 59 + $effect(() => { 60 + if (!label) open = false; 61 + }); 59 62 60 63 const classes = $derived(error({ size })); 61 64 </script> 62 65 63 - <div class={classes.root({ class: className })} role="alert" {...rest}> 64 - <div class={classes.header()}> 65 - <OctagonAlert class={classes.icon()} aria-hidden="true" /> 66 - <span class={classes.label()}>{label}</span> 67 - </div> 66 + {#if label} 67 + <div class={classes.root({ class: className })} role="alert" {...rest}> 68 + <div class={classes.header()}> 69 + <OctagonAlert class={classes.icon()} aria-hidden="true" /> 70 + <span class={classes.label()}>{label}</span> 71 + </div> 68 72 69 - {#if children} 70 - <button type="button" class={classes.toggle()} onclick={() => (open = !open)}> 71 - <ChevronRight class="{classes.chevron()} {open ? 'rotate-90' : ''}" aria-hidden="true" /> 72 - <span>Show code</span> 73 - </button> 74 - {#if open} 75 - <div class={classes.body()}> 76 - {@render children()} 77 - </div> 73 + {#if children} 74 + <button type="button" class={classes.toggle()} onclick={() => (open = !open)}> 75 + <ChevronRight class="{classes.chevron()} {open ? 'rotate-90' : ''}" aria-hidden="true" /> 76 + <span>Show code</span> 77 + </button> 78 + {#if open} 79 + <div class={classes.body()}> 80 + {@render children()} 81 + </div> 82 + {/if} 78 83 {/if} 79 - {/if} 80 - </div> 84 + </div> 85 + {/if}
+25 -28
web/src/lib/components/ui/MarkdownEditor.svelte
··· 39 39 // height and inset — otherwise the whole editor resizes every time you switch tabs. 40 40 const pane = `rounded border border-border-default px-2.5 py-2 ${textareaMinHeight}`; 41 41 42 - let previewHtml = $state<string | null>(null); 43 - let previewing = $state(false); 42 + let preview = $state<Promise<string> | null>(null); 44 43 let textareaEl = $state<HTMLTextAreaElement>(); 45 44 46 45 // focus on mount (and when returning to the write tab) if requested ··· 49 48 }); 50 49 51 50 $effect(() => { 52 - if (tab !== "preview") return; 51 + if (tab !== "preview") { 52 + preview = null; 53 + return; 54 + } 53 55 const source = value; 54 56 if (!source.trim()) { 55 - previewHtml = null; 56 - previewing = false; 57 + preview = null; 57 58 return; 58 59 } 59 - let cancelled = false; 60 - previewing = true; 61 - renderMarkup(source, markup) 62 - .then((html) => { 63 - if (!cancelled) previewHtml = html; 64 - }) 65 - .catch(() => { 66 - if (!cancelled) previewHtml = null; 67 - }) 68 - .finally(() => { 69 - if (!cancelled) previewing = false; 70 - }); 71 - return () => { 72 - cancelled = true; 73 - }; 60 + preview = renderMarkup(source, markup).then((html) => html ?? ""); 74 61 }); 75 62 // ctrl/cmd+enter submits the enclosing form, mirroring the old htmx editor 76 63 const handleKeydown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => { ··· 116 103 onkeydown={handleKeydown} 117 104 class={transparent ? "bg-transparent" : undefined} 118 105 /> 119 - {:else if previewHtml} 120 - <div class={`markup ${pane} ${surface}`}> 121 - <!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in $lib/markup --> 122 - {@html previewHtml} 123 - </div> 124 106 {:else} 125 - <div class={`${pane} ${surface} text-sm text-foreground-subtle italic`}> 126 - {previewing ? "Rendering…" : "Nothing to preview."} 127 - </div> 107 + {#await preview} 108 + <div class={`${pane} ${surface} text-sm text-foreground-subtle italic`}>Rendering…</div> 109 + {:then html} 110 + {#if html} 111 + <div class={`markup ${pane} ${surface}`}> 112 + <!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in $lib/markup --> 113 + {@html html} 114 + </div> 115 + {:else} 116 + <div class={`${pane} ${surface} text-sm text-foreground-subtle italic`}> 117 + Nothing to preview. 118 + </div> 119 + {/if} 120 + {:catch} 121 + <div class={`${pane} ${surface} text-sm text-foreground-subtle italic`}> 122 + Could not render preview. 123 + </div> 124 + {/await} 128 125 {/if} 129 126 </div>
+18 -8
web/src/lib/optimistic.svelte.ts
··· 64 64 readonly rkey: string | null; 65 65 readonly known: boolean; 66 66 readonly active: boolean; 67 + readonly loading: boolean; 67 68 readonly failed: boolean; 69 + begin(): void; 68 70 created(rkey: string): void; 69 71 deleted(): void; 70 72 fail(): void; ··· 74 76 export const createOptimisticRelation = ( 75 77 options: OptimisticRelationOptions 76 78 ): OptimisticRelation => { 77 - let failed = $state(false); 79 + let status = $state<"idle" | "loading" | "failed">("idle"); 78 80 let committed = $state<null | { key: string; rkey: string | null }>(null); 79 81 const currentKey = $derived(options.key()); 80 82 const loaded = $derived(options.loadedRkey()); 81 83 const rkey = $derived(committed?.key === currentKey ? committed.rkey : (loaded ?? null)); 84 + const known = $derived(loaded !== undefined || committed?.key === currentKey); 85 + const active = $derived(rkey !== null); 82 86 83 87 $effect(() => { 84 88 if (committed === null) return; 85 89 if (committed.key !== currentKey || loaded === committed.rkey) { 86 90 committed = null; 87 - failed = false; 91 + status = "idle"; 88 92 } 89 93 }); 90 94 91 95 const set = (next: string | null): void => { 92 - failed = false; 96 + status = "idle"; 93 97 committed = { key: currentKey, rkey: next }; 94 98 }; 95 99 ··· 98 102 return rkey; 99 103 }, 100 104 get known() { 101 - return loaded !== undefined || committed?.key === currentKey; 105 + return known; 102 106 }, 103 107 get active() { 104 - return rkey !== null; 108 + return active; 109 + }, 110 + get loading() { 111 + return status === "loading"; 105 112 }, 106 113 get failed() { 107 - return failed; 114 + return status === "failed"; 115 + }, 116 + begin() { 117 + status = "loading"; 108 118 }, 109 119 created: set, 110 120 deleted: () => set(null), 111 121 fail() { 112 - failed = true; 122 + status = "failed"; 113 123 }, 114 124 resetFailure() { 115 - failed = false; 125 + status = "idle"; 116 126 } 117 127 }; 118 128 };
+1 -1
web/src/routes/+layout.svelte
··· 46 46 <Topbar 47 47 user={auth.currentUser} 48 48 variant={topbarVariant} 49 - loading={auth.authenticating || auth.profileLoading} 49 + loading={auth.state.kind === "authenticating" || auth.state.kind === "profile-loading"} 50 50 onSignOut={auth.signOut} 51 51 /> 52 52 </header>
+14 -22
web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte
··· 19 19 import type { IssueRecord, RecordView } from "$lib/api/records"; 20 20 import type { ThreadInput } from "$lib/components/comment/comments"; 21 21 import { getAuth } from "$lib/auth.svelte"; 22 + import { createAction } from "$lib/action.svelte"; 22 23 23 24 let { data } = $props(); 24 25 ··· 30 31 const issuesBase = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/issues`); 31 32 32 33 let editing = $state(false); 33 - let deleting = $state(false); 34 - let deleteError = $state<string | null>(null); 35 34 36 35 let comments = $derived(data.comments); 37 36 ··· 75 74 editing = false; 76 75 }; 77 76 78 - const handleDelete = async () => { 77 + const removeIssue = createAction(async () => { 79 78 const agent = auth.agent; 80 - if (!agent || deleting) return; 79 + if (!agent) return; 80 + await deleteIssue(agent, issue.rkey); 81 + await goto(resolve(issuesBase as "/")); 82 + }); 83 + 84 + const handleDelete = () => { 85 + if (!auth.agent || removeIssue.loading) return; 81 86 if (!confirm("Delete this issue? This cannot be undone.")) return; 82 - deleting = true; 83 - deleteError = null; 84 - try { 85 - await deleteIssue(agent, issue.rkey); 86 - await goto(resolve(issuesBase as "/")); 87 - } catch (err) { 88 - deleteError = err instanceof Error ? err.message : "Failed to delete issue"; 89 - deleting = false; 90 - } 87 + void removeIssue.run(); 91 88 }; 92 89 </script> 93 90 ··· 110 107 <button 111 108 type="button" 112 109 aria-label="Delete issue" 113 - disabled={deleting} 110 + disabled={removeIssue.loading} 114 111 class="cursor-pointer text-foreground-danger hover:text-foreground-danger-strong disabled:opacity-50" 115 112 onclick={handleDelete} 116 113 > ··· 144 141 actions={isAuthor ? issueActions : undefined} 145 142 /> 146 143 <IssueBody body={issue.body} bodyHtml={issue.bodyHtml} /> 147 - <Reactions 148 - reactions={issue.reactions} 149 - subjectUri={issue.uri} 150 - alwaysShow 151 - class="mt-2" 152 - /> 153 - {#if deleteError} 144 + <Reactions reactions={issue.reactions} subjectUri={issue.uri} alwaysShow class="mt-2" /> 145 + {#if removeIssue.error} 154 146 <div class="mt-3"> 155 - <ErrorAlert label={deleteError} /> 147 + <ErrorAlert label={removeIssue.error} /> 156 148 </div> 157 149 {/if} 158 150 {/if}
+13 -20
web/src/routes/login/+page.svelte
··· 3 3 import { resolve } from "$app/paths"; 4 4 import { page } from "$app/state"; 5 5 import { getAuth } from "$lib/auth.svelte"; 6 + import { createAction } from "$lib/action.svelte"; 6 7 import Button from "$lib/components/ui/Button.svelte"; 7 8 import CircleAlert from "$icon/circle-alert"; 8 9 import { onMount } from "svelte"; ··· 13 14 const returnTo = $derived(page.url.searchParams.get("return_url") ?? "/"); 14 15 15 16 let identifier = $state(page.url.searchParams.get("identifier") ?? ""); 16 - let isSubmitting = $state(false); 17 + const login = createAction(async (event: SubmitEvent) => { 18 + event.preventDefault(); 19 + await auth.signIn(identifier, returnTo); 20 + }); 17 21 18 22 onMount(() => { 19 23 if (browser && location.hostname === "localhost") { ··· 22 26 location.replace(url); 23 27 } 24 28 }); 25 - 26 - const submit = async (event: SubmitEvent) => { 27 - event.preventDefault(); 28 - isSubmitting = true; 29 - try { 30 - await auth.signIn(identifier, returnTo); 31 - } finally { 32 - isSubmitting = false; 33 - } 34 - }; 35 29 </script> 36 30 37 31 <svelte:head> ··· 48 42 </p> 49 43 </div> 50 44 51 - <form class="mt-4" onsubmit={submit}> 45 + <form class="mt-4" onsubmit={login.run}> 52 46 <div class="flex flex-col"> 53 47 <label for="identifier" class="py-2 text-sm text-foreground-default">Handle</label> 54 48 <input 55 49 id="identifier" 56 50 bind:value={identifier} 57 - disabled={isSubmitting} 51 + disabled={login.loading} 58 52 type="text" 59 53 autocomplete="username" 60 54 autocapitalize="none" ··· 68 62 Use your <span class="text-foreground-default">AT Protocol</span> handle to log in. If 69 63 you're unsure, this is likely your Tangled 70 64 <code 71 - class="rounded-sm bg-background-inset px-1 font-mono typography-monospace-small text-foreground-default" 65 + class="rounded-sm bg-background-inset px-1 typography-monospace-small font-mono text-foreground-default" 72 66 >(.tngl.sh)</code 73 67 > 74 68 or Bluesky 75 69 <code 76 - class="rounded-sm bg-background-inset px-1 font-mono typography-monospace-small text-foreground-default" 70 + class="rounded-sm bg-background-inset px-1 typography-monospace-small font-mono text-foreground-default" 77 71 >(.bsky.social)</code 78 72 > 79 73 account. ··· 83 77 <Button 84 78 type="submit" 85 79 size="sm" 86 - disabled={isSubmitting} 87 - loading={isSubmitting} 80 + loading={login.loading} 88 81 spinnerClass="size-4" 89 - aria-label={isSubmitting ? "Logging in" : undefined} 82 + aria-label={login.loading ? "Logging in" : undefined} 90 83 class="my-2 mt-6 w-full typography-paragraph-large disabled:opacity-100" 91 84 > 92 85 Login ··· 100 93 on Tangled now! 101 94 </p> 102 95 103 - {#if auth.error} 96 + {#if auth.state.kind === "failed"} 104 97 <div 105 98 class="my-2 flex gap-2 rounded-sm border border-border-danger bg-background-danger-subtle px-3 py-2 text-foreground-danger shadow-xs" 106 99 > 107 100 <span class="py-1" aria-hidden="true"><CircleAlert class="size-4" /></span> 108 101 <div> 109 102 <h5 class="font-medium">Login error</h5> 110 - <p class="text-sm">{auth.error} Please try again.</p> 103 + <p class="text-sm">{auth.state.message} Please try again.</p> 111 104 </div> 112 105 </div> 113 106 {/if}
+2 -2
web/src/routes/oauth/callback/+page.svelte
··· 23 23 <title>Signing in &middot; Tangled</title> 24 24 </svelte:head> 25 25 26 - {#if auth.error} 26 + {#if auth.state.kind === "failed"} 27 27 <section class="mx-auto flex max-w-md flex-col items-center gap-4 px-7 py-16 text-center"> 28 - <p class="typography-paragraph-large text-foreground-danger">{auth.error}</p> 28 + <p class="typography-paragraph-large text-foreground-danger">{auth.state.message}</p> 29 29 <a href={appPath("/login")} class="inline-flex underline">Try again</a> 30 30 </section> 31 31 {:else}
+8 -17
web/src/routes/settings/keys/new/+page.svelte
··· 3 3 import { resolve } from "$app/paths"; 4 4 import { getAuth } from "$lib/auth.svelte"; 5 5 import { createPubKey } from "$lib/api/settings"; 6 + import { createAction } from "$lib/action.svelte"; 6 7 import Button from "$lib/components/ui/Button.svelte"; 7 8 import Input from "$lib/components/ui/Input.svelte"; 8 9 import ErrorAlert from "$lib/components/ui/Error.svelte"; ··· 17 18 18 19 let name = $state(""); 19 20 let key = $state(""); 20 - let submitting = $state(false); 21 - let error = $state<string | null>(null); 22 21 23 22 const valid = $derived(name.trim().length > 0 && key.trim().length > 0); 24 23 25 - const submit = async () => { 24 + const save = createAction(async () => { 26 25 if (!auth.agent || !valid) return; 27 - submitting = true; 28 - error = null; 29 - try { 30 - await createPubKey(auth.agent, name.trim(), key.trim()); 31 - await goto(resolve("/settings/keys")); 32 - } catch (err) { 33 - error = err instanceof Error ? err.message : "Failed to add key"; 34 - submitting = false; 35 - } 36 - }; 26 + await createPubKey(auth.agent, name.trim(), key.trim()); 27 + await goto(resolve("/settings/keys")); 28 + }); 29 + const submit = save.run; 37 30 </script> 38 31 39 32 <DrillDown ··· 42 35 title="Add SSH key" 43 36 description="SSH keys allow you to push to repositories in knots you're a member of." 44 37 > 45 - {#if error} 46 - <ErrorAlert label={error} /> 47 - {/if} 38 + <ErrorAlert label={save.error} /> 48 39 49 40 <SettingsList padding="tight"> 50 41 <FormRow label="Title" for="key-title"> ··· 57 48 58 49 <FormActions> 59 50 <Button href="/settings/keys" icon={X}>Cancel</Button> 60 - <Button variant="primary" icon={Plus} disabled={!valid} loading={submitting} onclick={submit}> 51 + <Button variant="primary" icon={Plus} disabled={!valid} loading={save.loading} onclick={submit}> 61 52 Add 62 53 </Button> 63 54 </FormActions>