Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
Svelte
at icy/tmrrpn
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229<script lang="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 NetworkVouches from "./NetworkVouches.svelte"; import { getAuth } from "$lib/auth.svelte"; import { createAction } from "$lib/action.svelte"; import { putVouch, deleteVouch, type VouchKind } from "$lib/api/graph"; import type { DirectVouch, NetworkVouch } from "./types";
interface Props { profileDid: string; profileLabel?: string; initialVouch?: DirectVouch | null; // indirect vouches from the viewer's network, shown inside the popover networkVouches?: NetworkVouch[]; bordered?: boolean; insetShadow?: boolean; }
let { profileDid, profileLabel, initialVouch = null, networkVouches = [], bordered = true, insetShadow = true }: Props = $props();
const auth = getAuth(); const signedIn = $derived(Boolean(auth.currentDid)); const isSelf = $derived(auth.currentDid === profileDid);
let committed = $state<{ did: string; vouch: DirectVouch | null } | null>(null); const current = $derived(committed?.did === profileDid ? committed.vouch : initialVouch);
// popover form state type Choice = VouchKind | "none"; let choice = $state<Choice>("none"); let reason = $state("");
let popoverEl = $state<HTMLDivElement>(); // unique per instance: the same subject can render more than one vouch button on a // page (e.g. several profile hovercards), and a did-derived id would collide const uid = $props.id(); const popoverId = `vouch-popover-${uid}`;
const label = $derived(profileLabel ?? "this user");
// vouch/denounce carries a color 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 onToggle = (event: ToggleEvent) => { if (event.newState !== "open") return; choice = current?.kind ?? "none"; reason = current?.reason ?? ""; };
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; if (choice === "none") { if (current) await deleteVouch(agent, profileDid); committed = { did: profileDid, vouch: null }; } else { const trimmed = reason.trim(); await putVouch(agent, profileDid, { kind: choice, reason: trimmed || undefined }); committed = { did: profileDid, vouch: { kind: choice, reason: trimmed || undefined } }; } popoverEl?.hidePopover(); });</script>
{#if !isSelf && signedIn} <Button variant={trigger.variant} class="w-full gap-2 {trigger.colorClass}" icon={trigger.icon} {insetShadow} popovertarget={popoverId} popovertargetaction="toggle" > {trigger.text} </Button>
<div bind:this={popoverEl} id={popoverId} popover="auto" ontoggle={onToggle} class="m-auto w-[95%] space-y-4 rounded-lg border border-border-default bg-background-default p-4 text-foreground-default shadow-lg backdrop:bg-black/40 md:w-96" > <div> <p class="typography-paragraph-large font-semibold">Vouch for {label}</p> <p class="typography-paragraph-small text-foreground-subtle"> {#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-1 hover:underline" > Read more <ArrowRight class="size-3" aria-hidden="true" /> </a> {/if} </p> </div>
{#if networkVouches.length > 0} <div class="space-y-1"> <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-2"> {#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={[ "flex flex-col items-center gap-1 rounded-sm border p-2 transition-colors disabled:cursor-not-allowed disabled:opacity-60", active ? option.value === "vouch" ? "border-border-success bg-background-success-subtle text-foreground-success" : option.value === "denounce" ? "border-border-danger bg-background-danger-subtle text-foreground-danger" : "border-border-strong bg-background-subtle text-foreground-default" : "border-border-default hover:bg-background-subtle" ]} > <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} maxlength={256} placeholder="Write a reason..." disabled={save.loading} class="w-full resize-y rounded-sm border border-border-default bg-background-default p-2 typography-paragraph-small outline-none placeholder:text-foreground-placeholder focus-within:border-border-strong focus-within:ring-1 focus-within:ring-border-strong disabled:cursor-not-allowed disabled:opacity-60" ></textarea> {/if}
<ErrorAlert label={save.error} />
<div class="flex gap-2"> <Button variant="default" class="flex-1 gap-2" icon={X} disabled={save.loading} popovertarget={popoverId} popovertargetaction="hide" > Cancel </Button> <Button variant="primary" class="flex-1 gap-2" icon={Check} loading={save.loading} onclick={() => save.run()} > {current ? "Update" : "Save"} </Button> </div> </div>{/if}