<script lang="ts">
import Button from "#lib/components/ui/Button.svelte";
import ToggleFaces from "#lib/components/ui/ToggleFaces.svelte";
import Tooltip from "#lib/components/ui/Tooltip.svelte";
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.ts";
import { loginHrefFor } from "#lib/auth/guards.ts";
import { createFollow, deleteFollow } from "#lib/api/graph.ts";
import { createBobbinClient } from "#lib/api/client.ts";
import { awaitDeletedRecord, awaitIndexedOrSaved } from "#lib/api/awaitRecord.ts";
import { getProfileCounts } from "#lib/components/profile/counts.svelte.ts";
import type { FollowChange } from "#lib/components/profile/types.ts";
import { createOptimisticRelation, resetFailureSoon } from "#lib/optimistic.svelte.ts";
interface Props {
profileDid: string;
initialRkey?: string | null;
onCommit?: (change: FollowChange) => void;
}
let { profileDid, initialRkey, onCommit }: Props = $props();
const auth = getAuth();
const profileCounts = getProfileCounts();
const signedIn = $derived(Boolean(auth.currentDid));
const isSelf = $derived(auth.currentDid === profileDid);
const relation = createOptimisticRelation({
key: () => `${auth.currentDid ?? ""}:${profileDid}`,
loadedRkey: () => initialRkey
});
const following = $derived(relation.active);
const failed = $derived(relation.failed);
resetFailureSoon(
() => failed,
() => relation.resetFailure()
);
const commit = (change: FollowChange) => {
profileCounts?.adjust(change.subjectDid, "followers", change.delta);
profileCounts?.adjust(change.viewerDid, "following", change.delta);
onCommit?.(change);
};
const toggle = async () => {
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) {
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,
following: false,
rkey: null,
delta: -1
});
} else {
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: written.rkey,
delta: 1
});
}
} catch {
relation.fail();
}
};
</script>
{#if !isSelf}
{#if !signedIn}
<Button
data-testid="follow"
data-following={following}
href={loginHrefFor(page.url)}
variant="default"
class="w-full gap-xs"
icon={UserRoundPlus}
>
Follow
</Button>
{:else}
<Tooltip content="Something went wrong. Try again." disabled={!failed} class="w-full">
<Button
data-testid="follow"
data-following={following}
variant={failed ? "danger" : "default"}
class="w-full min-w-28 gap-xs"
disabled={!relation.known || relation.loading}
aria-busy={relation.loading}
onclick={toggle}
>
<ToggleFaces
on={following}
{failed}
size="regular"
offLabel="Follow"
onLabel="Unfollow"
>
{#snippet off(cls)}
<UserRoundPlus class={cls} />
{/snippet}
{#snippet onIcon(cls)}
<UserRoundMinus class={cls} />
{/snippet}
{#snippet err(cls)}
<TriangleAlert class={cls} />
{/snippet}
</ToggleFaces>
</Button>
</Tooltip>
{/if}
{/if}