Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265<script module lang="ts"> import { tv } from "tailwind-variants";
export const choiceButton = tv({ base: "flex flex-col items-center gap-2xs rounded-sm 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 { resolve } from "$app/paths"; import { loginWithReturn } from "$lib/auth/guards"; 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 NetworkVouches from "$lib/components/profile/NetworkVouches.svelte"; 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 { profileDid: string; profileLabel?: string; initialVouch?: DirectVouch | null; networkVouches?: NetworkVouch[]; bordered?: boolean; insetShadow?: boolean; hideLabelOnMobile?: boolean; }
let { profileDid, profileLabel, initialVouch = null, networkVouches = [], bordered = true, insetShadow = true, hideLabelOnMobile = false }: Props = $props();
const auth = getAuth(); const signedIn = $derived(Boolean(auth.currentDid)); const isSelf = $derived(auth.currentDid === profileDid); const loginHref = $derived( resolve(loginWithReturn(page.url.pathname + page.url.search) as "/login") );
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 variant={trigger.variant} class="w-full gap-xs {trigger.colorClass}" icon={trigger.icon} {insetShadow} href={signedIn ? undefined : loginHref} onclick={signedIn ? start : undefined} > <span class={hideLabelOnMobile ? "max-sm:hidden" : undefined}>{trigger.text}</span> </Button>
<Modal bind:open 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"> <p class="typography-paragraph-small font-semibold">From your network</p> <NetworkVouches subjectHandle={label} 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" 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"} <Textarea bind:value={reason} rows={2} size="small" maxlength={256} resizeable placeholder="Write a reason..." disabled={save.loading} /> {/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}