<script lang="ts">
import type { Snippet } from "svelte";
import { prefersReducedMotion } from "svelte/motion";
import { resolve } from "$app/paths";
import Hovercard from "#lib/components/ui/Hovercard.svelte";
import VouchAvatar from "#lib/components/profile/VouchAvatar.svelte";
import FollowButton from "#lib/components/profile/FollowButton.svelte";
import VouchButton from "#lib/components/profile/VouchButton.svelte";
import NetworkVouches from "#lib/components/profile/NetworkVouches.svelte";
import FollowerFollowing from "#lib/components/profile/FollowerFollowing.svelte";
import ModerationNotice from "#lib/components/moderation/ModerationNotice.svelte";
import { createAuthorGate } from "#lib/components/moderation/gate.svelte.ts";
import { getAuth } from "#lib/auth.svelte.ts";
import { createBobbinClient } from "#lib/api/client.ts";
import { IdentityCache, profileIdentifier } from "#lib/api/identity.ts";
import { fetchProfileCard, resolveVouches } from "#lib/components/profile/pages.ts";
import { networkVouchesFor } from "#lib/components/profile/vouchBadge.svelte.ts";
import { directVouch } from "#lib/components/profile/vouchRelationship.ts";
import { revealItem } from "#lib/motion/reveal.ts";
import { RAIL_DURATION, railEasing } from "#lib/motion/spring.ts";
import type {
DirectVouch,
ProfileCardData,
NetworkVouch
} from "#lib/components/profile/types.ts";
interface Props {
identifier: string;
disabled?: boolean;
openDelay?: number;
class?: string;
preview?: { card: ProfileCardData; networkVouches?: NetworkVouch[] };
children: Snippet;
}
let {
identifier,
disabled = false,
openDelay = 600,
class: className,
preview,
children
}: Props = $props();
const auth = getAuth();
// vouch button only renders when signed in
const signedIn = $derived(Boolean(auth?.currentDid));
type Status = "idle" | "loading" | "loaded" | "error";
let status = $state<Status>("idle");
let card = $state<ProfileCardData | null>(null);
let networkVouches = $state<NetworkVouch[]>([]);
let viewerVouch = $state<DirectVouch | null>(null);
let requested = false;
let hovercard = $state<Hovercard>();
export const toggle = () => hovercard?.toggle() ?? false;
const gate = createAuthorGate(
() => card?.moderation,
() => card?.did
);
const load = async () => {
if (requested) return;
if (preview) {
requested = true;
card = preview.card;
networkVouches = preview.networkVouches ?? [];
status = "loaded";
return;
}
if (!auth) return;
requested = true;
status = "loading";
const bobbinUrl = auth.bobbinUrl;
const viewerDid = auth.currentDid ?? undefined;
try {
const ctx = createBobbinClient({ serviceUrl: bobbinUrl });
card = await fetchProfileCard(ctx, { identifier, viewerDid });
status = "loaded";
} catch {
status = "error";
requested = false; // let a later hover retry
return;
}
const agent = auth.agent;
if (!agent || card.isSelf || !viewerDid) return;
const cache = new IdentityCache(createBobbinClient({ serviceUrl: bobbinUrl }));
const subjectDid = card.did;
networkVouchesFor(auth, subjectDid)
.then(async (items) => {
viewerVouch = directVouch({ viewer: viewerDid, actor: subjectDid, vouches: items });
networkVouches = (await resolveVouches([...items], subjectDid, cache))
.filter((v) => v.direction === "incoming" && v.did !== viewerDid)
.map((v) => ({ did: v.did, handle: v.handle, kind: v.kind }));
})
.catch(() => {
networkVouches = [];
});
};
</script>
<Hovercard
bind:this={hovercard}
{disabled}
{openDelay}
class={className}
cardClass="w-80 p-sm"
onIntent={load}
>
{#snippet content()}
{#if status === "loaded" && gate.masked}
<ModerationNotice {gate} />
{:else if status === "loaded" && card}
{@const cardPath = profileIdentifier(card.handle, card.did)}
<div class="flex flex-col gap-xs">
<div class="flex items-center gap-xs">
<a href={resolve(`/${cardPath}` as "/")} class="block size-12 shrink-0">
<VouchAvatar did={card.did} handle={card.handle} size="full" pixels={48} />
</a>
<div class="min-w-0">
<a
href={resolve(`/${cardPath}` as "/")}
title={card.handle}
class="block truncate font-medium text-foreground-default hover:underline"
>
{card.handle}
</a>
{#if card.pronouns}
<p class="typography-paragraph-small text-foreground-muted">
{card.pronouns}
</p>
{/if}
</div>
</div>
{#if card.description}
<p
class="mt-3xs line-clamp-3 px-3xs typography-paragraph-small wrap-break-word"
>
{card.description}
</p>
{/if}
<FollowerFollowing
actor={cardPath}
followers={card.followers}
following={card.following}
class="my-2xs! px-2xs"
/>
{#if !card.isSelf}
<div class="flex items-start gap-xs">
<div class="flex-1">
<FollowButton
profileDid={card.did}
initialRkey={card.viewerFollowRkey}
/>
</div>
{#if signedIn}
<div class="flex-1">
<VouchButton
profileDid={card.did}
profileLabel={card.handle}
initialVouch={viewerVouch}
{networkVouches}
/>
</div>
{/if}
</div>
{/if}
{#if networkVouches.length > 0}
<div
in:revealItem={{
duration: prefersReducedMotion.current ? 0 : RAIL_DURATION,
easing: railEasing
}}
>
<NetworkVouches
subject={cardPath}
vouches={networkVouches}
popover={false}
/>
</div>
{/if}
</div>
{:else if status === "error"}
<p class="py-md text-center typography-paragraph-small text-foreground-muted">
Couldn't load profile.
</p>
{:else}
<div class="flex animate-pulse flex-col gap-xs" aria-label="Loading profile">
<div class="flex items-center gap-xs">
<div class="size-12 shrink-0 rounded-full bg-background-subtle"></div>
<div class="h-4 w-32 rounded-md bg-background-subtle"></div>
</div>
<div class="my-2xs h-4 w-2/3 rounded-md bg-background-subtle"></div>
<div class="h-8 rounded-md bg-background-subtle"></div>
</div>
{/if}
{/snippet}
{@render children()}
</Hovercard>