diff --git a/web/src/lib/api/graph.ts b/web/src/lib/api/graph.ts index b507413c6..0352f371f 100644 --- a/web/src/lib/api/graph.ts +++ b/web/src/lib/api/graph.ts @@ -4,7 +4,7 @@ import { ClientResponseError, type BobbinContext } from "$lib/api/client"; import { jsonGet } from "$lib/api/_request"; import { httpStatusFor } from "$lib/api/load"; import { didFromUri, rkeyFromUri } from "$lib/api/uri"; -import { createRecord, deleteRecord, putRecord } from "$lib/api/write"; +import { createRecord, deleteRecord, putRecord, type WrittenRecord } from "$lib/api/write"; import type { VouchRecord } from "$lib/api/records"; import type * as ShTangledFeedGetStar from "$lib/api/lexicons/types/sh/tangled/feed/getStar"; import type * as ShTangledGraphFollow from "$lib/api/lexicons/types/sh/tangled/graph/follow"; @@ -17,6 +17,8 @@ export type { VouchRecord }; const FOLLOW_COLLECTION = "sh.tangled.graph.follow" as Nsid; const STAR_COLLECTION = "sh.tangled.feed.star" as Nsid; const VOUCH_COLLECTION = "sh.tangled.graph.vouch" as Nsid; +const SUBSCRIPTION_COLLECTION = "org.tangled.feed.subscription" as Nsid; + export const getFollowRkey = async ( ctx: BobbinContext, actor: string, @@ -38,7 +40,6 @@ export const getFollowRkey = async ( } }; -// 404 means no star exists, other errors propagate export const getStarRkey = async ( ctx: BobbinContext, actor: string, @@ -60,24 +61,35 @@ export const getStarRkey = async ( } }; -export const createFollow = async (agent: OAuthUserAgent, subject: string): Promise => { - const { uri } = await createRecord(agent, FOLLOW_COLLECTION, { +export const createFollow = async ( + agent: OAuthUserAgent, + subject: string +): Promise => { + const written = await createRecord(agent, FOLLOW_COLLECTION, { $type: "sh.tangled.graph.follow", subject: subject as Did, createdAt: new Date().toISOString() }); - return rkeyFromUri(uri); + return { ...written, rkey: rkeyFromUri(written.uri) }; }; -export const deleteFollow = async (agent: OAuthUserAgent, rkey: string): Promise => { +export const deleteFollow = async ( + agent: OAuthUserAgent, + rkey: string +): Promise<{ uri: ResourceUri }> => { + const uri = `at://${agent.sub}/${FOLLOW_COLLECTION}/${rkey}` as ResourceUri; await deleteRecord(agent, FOLLOW_COLLECTION, rkey); + return { uri }; }; export type StarSubject = | { kind: "repo"; did: string; ownerHandle: string; name: string } | { kind: "string"; uri: string }; -export const createStar = async (agent: OAuthUserAgent, subject: StarSubject): Promise => { +export const createStar = async ( + agent: OAuthUserAgent, + subject: StarSubject +): Promise => { const record = subject.kind === "repo" ? { @@ -88,24 +100,28 @@ export const createStar = async (agent: OAuthUserAgent, subject: StarSubject): P $type: "sh.tangled.feed.star#string" as const, uri: subject.uri as ResourceUri }; - const { uri } = await createRecord(agent, STAR_COLLECTION, { + const written = await createRecord(agent, STAR_COLLECTION, { $type: "sh.tangled.feed.star", subject: record, createdAt: new Date().toISOString() }); - return rkeyFromUri(uri); + return { ...written, rkey: rkeyFromUri(written.uri) }; }; -export const deleteStar = async (agent: OAuthUserAgent, rkey: string): Promise => { +export const deleteStar = async ( + agent: OAuthUserAgent, + rkey: string +): Promise<{ uri: ResourceUri }> => { + const uri = `at://${agent.sub}/${STAR_COLLECTION}/${rkey}` as ResourceUri; await deleteRecord(agent, STAR_COLLECTION, rkey); + return { uri }; }; -const SUBSCRIPTION_COLLECTION = "org.tangled.feed.subscription" as Nsid; export const createSubscription = async ( agent: OAuthUserAgent, repoDid: string -): Promise => { - const { uri } = await createRecord(agent, SUBSCRIPTION_COLLECTION, { +): Promise => { + const written = await createRecord(agent, SUBSCRIPTION_COLLECTION, { $type: SUBSCRIPTION_COLLECTION, subject: { $type: "org.tangled.feed.subscription#repo", @@ -113,11 +129,16 @@ export const createSubscription = async ( }, createdAt: new Date().toISOString() }); - return rkeyFromUri(uri); + return { ...written, rkey: rkeyFromUri(written.uri) }; }; -export const deleteSubscription = async (agent: OAuthUserAgent, rkey: string): Promise => { +export const deleteSubscription = async ( + agent: OAuthUserAgent, + rkey: string +): Promise<{ uri: ResourceUri }> => { + const uri = `at://${agent.sub}/${SUBSCRIPTION_COLLECTION}/${rkey}` as ResourceUri; await deleteRecord(agent, SUBSCRIPTION_COLLECTION, rkey); + return { uri }; }; export const createEntitySubscription = async ( @@ -135,7 +156,6 @@ export const createEntitySubscription = async ( return rkeyFromUri(uri); }; -// 404 means no subscription exists, other errors propagate. export const getSubscriptionRkey = async ( ctx: BobbinContext, actor: string, @@ -154,8 +174,6 @@ export const getSubscriptionRkey = async ( } }; -// creating always mints a record and unsubscribe only deletes one, so -// reuse an existing subscription instead of stacking export const ensureEntitySubscription = async ( agent: OAuthUserAgent, ctx: BobbinContext, @@ -178,19 +196,23 @@ export const putVouch = async ( agent: OAuthUserAgent, subject: string, input: VouchInput -): Promise => { - const { uri } = await putRecord(agent, VOUCH_COLLECTION, subject, { +): Promise => { + return putRecord(agent, VOUCH_COLLECTION, subject, { $type: "sh.tangled.graph.vouch", kind: input.kind, ...(input.reason ? { reason: input.reason } : {}), ...(input.evidences?.length ? { evidences: input.evidences } : {}), createdAt: new Date().toISOString() }); - return rkeyFromUri(uri); }; -export const deleteVouch = async (agent: OAuthUserAgent, subject: string): Promise => { +export const deleteVouch = async ( + agent: OAuthUserAgent, + subject: string +): Promise<{ uri: ResourceUri }> => { + const uri = `at://${agent.sub}/${VOUCH_COLLECTION}/${subject}` as ResourceUri; await deleteRecord(agent, VOUCH_COLLECTION, subject); + return { uri }; }; export const getViewerVouch = async ( ctx: BobbinContext, diff --git a/web/src/lib/api/reaction.ts b/web/src/lib/api/reaction.ts index 7fc032d0f..5188baaf1 100644 --- a/web/src/lib/api/reaction.ts +++ b/web/src/lib/api/reaction.ts @@ -1,4 +1,4 @@ -import type { Nsid } from "@atcute/lexicons/syntax"; +import type { Nsid, ResourceUri } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import type { BobbinContext, XrpcRequestInit } from "$lib/api/client"; import { jsonGet } from "$lib/api/_request"; @@ -8,7 +8,7 @@ import type * as ShTangledFeedReaction from "$lib/api/lexicons/types/sh/tangled/ export type ReactionRecord = ShTangledFeedReaction.Main; -const REACTION_COLLECTION = "sh.tangled.feed.reaction" as Nsid; +export const REACTION_COLLECTION = "sh.tangled.feed.reaction" as Nsid; export const putReaction = async ( agent: OAuthUserAgent, @@ -19,8 +19,13 @@ export const putReaction = async ( return { uri, cid, value: record }; }; -export const deleteReaction = async (agent: OAuthUserAgent, rkey: string): Promise => { +export const deleteReaction = async ( + agent: OAuthUserAgent, + rkey: string +): Promise<{ uri: ResourceUri }> => { + const uri = `at://${agent.sub}/${REACTION_COLLECTION}/${rkey}` as ResourceUri; await deleteRecord(agent, REACTION_COLLECTION, rkey); + return { uri }; }; export interface ReactionListPage { diff --git a/web/src/lib/api/strings.test.ts b/web/src/lib/api/strings.test.ts index 39d8fdea6..4d1ce34ce 100644 --- a/web/src/lib/api/strings.test.ts +++ b/web/src/lib/api/strings.test.ts @@ -167,7 +167,7 @@ describe("strings api", () => { it("creates a star on a string AT-URI", async () => { const { graph, writes } = await load(); - const rkey = await graph.createStar(agent, { + const { rkey } = await graph.createStar(agent, { kind: "string", uri: "at://did:plc:bob/sh.tangled.string/3l7xyz" }); diff --git a/web/src/lib/components/profile/FollowButton.svelte b/web/src/lib/components/profile/FollowButton.svelte index 2d25e7ff4..3f0e1fb80 100644 --- a/web/src/lib/components/profile/FollowButton.svelte +++ b/web/src/lib/components/profile/FollowButton.svelte @@ -6,8 +6,11 @@ import UserRoundPlus from "$icon/user-round-plus"; import UserRoundMinus from "$icon/user-round-minus"; import TriangleAlert from "$icon/triangle-alert"; + import { page } from "$app/state"; import { getAuth } from "$lib/auth.svelte"; import { createFollow, deleteFollow } from "$lib/api/graph"; + import { createBobbinClient } from "$lib/api/client"; + import { awaitDeletedRecord, awaitIndexedOrSaved } from "$lib/api/awaitRecord"; import { getProfileCounts } from "$lib/components/profile/counts.svelte"; import type { FollowChange } from "$lib/components/profile/types"; import { createOptimisticRelation } from "$lib/optimistic.svelte"; @@ -49,10 +52,17 @@ const agent = auth.agent; if (!agent || !relation.known || relation.loading) return; relation.begin(); + const bobbinUrl = page.data.publicConfig?.bobbinUrl; + const ctx = bobbinUrl ? createBobbinClient({ serviceUrl: bobbinUrl }) : null; try { if (relation.active && relation.rkey) { - await deleteFollow(agent, relation.rkey); + const { uri } = await deleteFollow(agent, relation.rkey); relation.deleted(); + if (ctx) { + awaitDeletedRecord(ctx, uri).catch((cause) => + console.error("failed to wait for unfollow index", cause) + ); + } commit({ viewerDid: agent.sub, subjectDid: profileDid, @@ -61,13 +71,19 @@ delta: -1 }); } else { - const rkey = await createFollow(agent, profileDid); - relation.created(rkey); + const written = await createFollow(agent, profileDid); + relation.created(written.rkey); + if (ctx) { + // todo: needs a design pass for surfacing index wait failures + awaitIndexedOrSaved(ctx, written).catch((cause) => + console.error("failed to wait for follow index", cause) + ); + } commit({ viewerDid: agent.sub, subjectDid: profileDid, following: true, - rkey, + rkey: written.rkey, delta: 1 }); } diff --git a/web/src/lib/components/profile/VouchButton.svelte b/web/src/lib/components/profile/VouchButton.svelte index 39b062606..8694854fb 100644 --- a/web/src/lib/components/profile/VouchButton.svelte +++ b/web/src/lib/components/profile/VouchButton.svelte @@ -53,6 +53,8 @@ import { getAuth } from "$lib/auth.svelte"; import { createAction } from "$lib/action.svelte"; import { putVouch, deleteVouch, type VouchKind } from "$lib/api/graph"; + import { createBobbinClient } from "$lib/api/client"; + import { awaitDeletedRecord, awaitIndexedOrSaved } from "$lib/api/awaitRecord"; import type { DirectVouch, NetworkVouch } from "$lib/components/profile/types"; interface Props { @@ -90,7 +92,6 @@ const label = $derived(profileLabel ?? "this user"); - // vouch/denounce carries a color const trigger = $derived.by(() => { const look = { vouch: { @@ -142,12 +143,26 @@ const save = createAction(async () => { const agent = auth.agent; if (!agent) return; + const bobbinUrl = page.data.publicConfig?.bobbinUrl; + const ctx = bobbinUrl ? createBobbinClient({ serviceUrl: bobbinUrl }) : null; if (choice === "none") { - if (current) await deleteVouch(agent, profileDid); + if (current) { + const { uri } = await deleteVouch(agent, profileDid); + if (ctx) { + const ok = await awaitDeletedRecord(ctx, uri); + if (!ok) throw new Error("Bobbin did not confirm deletion."); + } + } committed = { did: profileDid, vouch: null }; } else { const trimmed = reason.trim(); - await putVouch(agent, profileDid, { kind: choice, reason: trimmed || undefined }); + const written = await putVouch(agent, profileDid, { + kind: choice, + reason: trimmed || undefined + }); + if (ctx) { + await awaitIndexedOrSaved(ctx, written); + } committed = { did: profileDid, vouch: { kind: choice, reason: trimmed || undefined } }; } open = false; diff --git a/web/src/lib/components/reaction/ReactionPicker.svelte b/web/src/lib/components/reaction/ReactionPicker.svelte index 71e140427..d60b09b30 100644 --- a/web/src/lib/components/reaction/ReactionPicker.svelte +++ b/web/src/lib/components/reaction/ReactionPicker.svelte @@ -1,8 +1,11 @@