<script module lang="ts">
import { tv } from "tailwind-variants";
export const choiceButton = tv({
base: "flex flex-col items-center gap-2xs rounded-md border p-xs transition-colors disabled:cursor-not-allowed disabled:opacity-60",
variants: {
active: {
true: "",
false: "border-border-default hover:bg-background-subtle"
},
value: {
vouch: "",
denounce: "",
none: ""
}
},
compoundVariants: [
{
active: true,
value: "vouch",
class: "border-border-success bg-background-success-subtle text-foreground-success"
},
{
active: true,
value: "denounce",
class: "border-border-danger bg-background-danger-subtle text-foreground-danger"
},
{
active: true,
value: "none",
class: "border-border-strong bg-background-subtle text-foreground-default"
}
]
});
</script>
<script lang="ts">
import { page } from "$app/state";
import type { Did, Handle } from "@atcute/lexicons/syntax";
import { profileIdentifier } from "#lib/api/identity.ts";
import { loginHrefFor } from "#lib/auth/guards.ts";
import Button from "#lib/components/ui/Button.svelte";
import ShieldCheck from "$icon/shield-check";
import ShieldBan from "$icon/shield-ban";
import ShieldOff from "$icon/shield-off";
import ShieldQuestionMark from "$icon/shield-question-mark";
import ArrowRight from "$icon/arrow-right";
import X from "$icon/x";
import Check from "$icon/check";
import ErrorAlert from "#lib/components/ui/Error.svelte";
import Modal from "#lib/components/ui/Modal.svelte";
import Textarea from "#lib/components/ui/Textarea.svelte";
import Field from "#lib/components/ui/Field.svelte";
import NetworkVouches from "#lib/components/profile/NetworkVouches.svelte";
import { getAuth } from "#lib/auth.svelte.ts";
import { prefersReducedMotion } from "svelte/motion";
import { revealItem } from "#lib/motion/reveal.ts";
import { RAIL_DURATION, railEasing } from "#lib/motion/spring.ts";
import { createAction } from "#lib/action.svelte.ts";
import { putVouch, deleteVouch, type VouchKind } from "#lib/api/graph.ts";
import { createBobbinClient } from "#lib/api/client.ts";
import { awaitDeletedRecord, awaitIndexedOrSaved } from "#lib/api/awaitRecord.ts";
import type { DirectVouch, NetworkVouch } from "#lib/components/profile/types.ts";
interface Props {
profileDid: Did;
profileLabel?: Handle;
initialVouch?: DirectVouch | null;
networkVouches?: NetworkVouch[];
bordered?: boolean;
class?: string;
labelClass?: string;
}
let {
profileDid,
profileLabel,
initialVouch = null,
networkVouches = [],
bordered = true,
class: className,
labelClass
}: Props = $props();
const auth = getAuth();
const signedIn = $derived(Boolean(auth.currentDid));
const isSelf = $derived(auth.currentDid === profileDid);
const loginHref = $derived(loginHrefFor(page.url));
let committed = $state<{ did: string; vouch: DirectVouch | null } | null>(null);
const current = $derived(committed?.did === profileDid ? committed.vouch : initialVouch);
type Choice = VouchKind | "none";
let open = $state(false);
let choice = $state<Choice>("none");
let reason = $state("");
const label = $derived(profileLabel ?? "this user");
const trigger = $derived.by(() => {
const look = {
vouch: {
icon: ShieldCheck,
text: "Vouched",
variant: "success",
color: "text-foreground-success"
},
denounce: {
icon: ShieldOff,
text: "Denounced",
variant: "danger",
color: "text-foreground-danger"
}
} as const;
const base =
current?.kind === "vouch"
? look.vouch
: current?.kind === "denounce"
? look.denounce
: ({
icon: ShieldQuestionMark,
text: "Vouch",
variant: "default",
color: ""
} as const);
return bordered
? { icon: base.icon, text: base.text, variant: base.variant, colorClass: "" }
: {
icon: base.icon,
text: base.text,
variant: "ghost" as const,
colorClass: base.color
};
});
const start = () => {
choice = current?.kind ?? "none";
reason = current?.reason ?? "";
open = true;
};
const choices: { value: Choice; icon: typeof ShieldCheck; text: string }[] = [
{ value: "vouch", icon: ShieldCheck, text: "Vouch" },
{ value: "denounce", icon: ShieldBan, text: "Denounce" },
{ value: "none", icon: ShieldOff, text: "None" }
];
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) {
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();
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;
});
</script>
{#if !isSelf}
<Button
data-testid="vouchTrigger"
data-standing={current?.kind ?? "none"}
variant={trigger.variant}
class="w-full gap-xs {trigger.colorClass} {className ?? ''}"
icon={trigger.icon}
href={signedIn ? undefined : loginHref}
onclick={signedIn ? start : undefined}
>
<span class={["grid justify-items-center", labelClass]}>
{#each ["Vouch", "Vouched", "Denounced"] as text (text)}
<span class={["col-start-1 row-start-1", text !== trigger.text && "invisible"]}>
{text}
</span>
{/each}
</span>
</Button>
<Modal bind:open testid="vouch" title="Vouch for {label}" titleIcon={trigger.icon}>
<p class="text-foreground-muted">
{#if current}
You {current.kind === "vouch" ? "vouched for" : "denounced"}
{label}. You can change your decision below.
{:else}
Vouching builds a web-of-trust across Tangled. Vouch for users you have had positive
interactions with.
<a
href="https://blog.tangled.org/vouching"
target="_blank"
rel="external noopener noreferrer"
class="inline-flex items-center gap-2xs hover:underline"
>
Read more <ArrowRight class="size-3.5" aria-hidden="true" />
</a>
{/if}
</p>
{#if networkVouches.length > 0}
<div
class="space-y-2xs"
in:revealItem={{
duration: prefersReducedMotion.current ? 0 : RAIL_DURATION,
easing: railEasing
}}
>
<p class="typography-paragraph-small font-semibold">From your network</p>
<NetworkVouches
subject={profileIdentifier(profileLabel, profileDid)}
vouches={networkVouches}
popover={false}
/>
</div>
{/if}
<div class="grid grid-cols-3 gap-xs">
{#each choices as option (option.value)}
{@const Icon = option.icon}
{@const active = choice === option.value}
<button
type="button"
data-value={option.value}
onclick={() => (choice = option.value)}
disabled={save.loading}
aria-pressed={active}
class={choiceButton({ active, value: option.value })}
>
<Icon class="size-5" aria-hidden="true" />
<span class="typography-paragraph-small font-medium">{option.text}</span>
</button>
{/each}
</div>
{#if choice !== "none"}
<Field subtitle="{[...reason].length}/256 characters">
{#snippet children({ id })}
<Textarea
{id}
bind:value={reason}
rows={2}
size="small"
maxlength={256}
resizeable
class="[&_textarea]:max-h-40"
placeholder="Write a reason..."
disabled={save.loading}
/>
{/snippet}
</Field>
{/if}
<ErrorAlert label={save.error} />
{#snippet footer()}
<Button
variant="default"
class="flex-1 gap-xs"
icon={X}
disabled={save.loading}
onclick={() => (open = false)}
>
Cancel
</Button>
<Button
variant="primary"
class="flex-1 gap-xs"
icon={Check}
loading={save.loading}
onclick={() => save.run()}
>
{current ? "Update" : "Save"}
</Button>
{/snippet}
</Modal>
{/if}