+ {/if}
+ {/snippet}
+ {@render children()}
+
diff --git a/web/src/lib/components/profile/VouchButton.svelte b/web/src/lib/components/profile/VouchButton.svelte
index 3813f838..0e1d9254 100644
--- a/web/src/lib/components/profile/VouchButton.svelte
+++ b/web/src/lib/components/profile/VouchButton.svelte
@@ -46,7 +46,10 @@
let reason = $state("");
let popoverEl = $state();
- const popoverId = $derived(`vouch-popover-${profileDid.replace(/[^a-zA-Z0-9]/g, "-")}`);
+ // 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");
@@ -71,10 +74,20 @@
? look.vouch
: current?.kind === "denounce"
? look.denounce
- : ({ icon: ShieldQuestionMark, text: "Vouch", variant: "default", color: "" } as const);
+ : ({
+ 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 };
+ : {
+ icon: base.icon,
+ text: base.text,
+ variant: "ghost" as const,
+ colorClass: base.color
+ };
});
const onToggle = (event: ToggleEvent) => {
@@ -116,100 +129,100 @@
{trigger.text}
-
-
-
Vouch for {label}
-
- {#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.
-
- Read more
-
- {/if}
-
-
-
- {#if networkVouches.length > 0}
-
-
From your network
-
-
- {/if}
-
-
- {#each choices as option (option.value)}
- {@const Icon = option.icon}
- {@const active = choice === option.value}
-
{/if}
diff --git a/web/src/lib/components/profile/pages.ts b/web/src/lib/components/profile/pages.ts
index d8f09f3b..c61005c7 100644
--- a/web/src/lib/components/profile/pages.ts
+++ b/web/src/lib/components/profile/pages.ts
@@ -13,15 +13,30 @@ import {
type LinkSource
} from "$lib/api/enrich";
import { fetchPage } from "$lib/api/pagination";
-import { IdentityCache, INVALID_HANDLE } from "$lib/api/identity";
-import type { RecordView, RepoRecord } from "$lib/api/records";
+import { IdentityCache, INVALID_HANDLE, type MiniDoc } from "$lib/api/identity";
+import {
+ getProfile,
+ getProfiles,
+ type RecordView,
+ type RepoRecord,
+ type ProfileRecord
+} from "$lib/api/records";
+import { getViewerVouch } from "$lib/api/graph";
import type { SearchPage } from "$lib/api/search";
import { didFromUri, rkeyFromUri } from "$lib/api/uri";
import type { VouchRecord } from "$lib/api/graph";
import type * as ShTangledFeedStar from "$lib/api/lexicons/types/sh/tangled/feed/star";
import type * as ShTangledString from "$lib/api/lexicons/types/sh/tangled/string";
import type * as ShTangledGraphFollow from "$lib/api/lexicons/types/sh/tangled/graph/follow";
-import type { RepoCardData, StringCardData, PersonData, VouchData, StarData } from "./types";
+import type {
+ RepoCardData,
+ StringCardData,
+ PersonData,
+ VouchData,
+ StarData,
+ ProfileCardData,
+ DirectVouch
+} from "./types";
import { repoKey } from "./types";
export const PROFILE_PAGE_LIMIT = 30;
@@ -356,6 +371,69 @@ export const fetchVouchesPage = async (
return { items };
};
+const FOLLOWERS_SOURCE: LinkSource = "sh.tangled.graph.follow:subject";
+const FOLLOWING_SOURCE: LinkSource = "sh.tangled.graph.follow:.repo";
+
+export interface ProfileCardOptions {
+ // a did or handle; resolveMiniDoc accepts either
+ identifier: string;
+ viewerDid?: string;
+}
+
+// the data behind the profile hovercard, fetched on demand when a card first
+// opens. mirrors the profile route load but folded into one resolveMiniDoc call
+// (handle, follower/following counts and the viewer's follow, in one round trip)
+// plus the profile record for bio/pronouns. network vouches are viewer-scoped and
+// need the authed agent, so they're fetched separately by the caller.
+export const fetchProfileCard = async (
+ ctx: BobbinContext,
+ { identifier, viewerDid }: ProfileCardOptions
+): Promise => {
+ const enriched = await enrich(ctx, {
+ xrpc: "blue.microcosm.identity.resolveMiniDoc",
+ params: { identifier },
+ enrich: [
+ { source: FOLLOWERS_SOURCE, type: TYPE_COUNT, targets: ["did"] },
+ { source: FOLLOWING_SOURCE, type: TYPE_COUNT, targets: ["did"] },
+ ...(viewerDid
+ ? [{ source: FOLLOWERS_SOURCE, type: TYPE_VIEWER, targets: ["did"] }]
+ : [])
+ ],
+ ...(viewerDid ? { viewer: viewerDid } : {})
+ });
+ const doc = enriched.output;
+ const did = doc.did;
+ const isSelf = viewerDid === did;
+
+ const [profile, viewerVouch] = await Promise.all([
+ getProfile(ctx, did)
+ .then((view) => view.value)
+ .catch(() => null),
+ viewerDid && !isSelf
+ ? getViewerVouch(ctx, viewerDid, did)
+ .then((v): DirectVouch | null =>
+ v ? { kind: v.kind, reason: v.reason } : null
+ )
+ .catch(() => null)
+ : Promise.resolve(null)
+ ]);
+
+ const viewerFollowUri = isSelf ? null : viewerUriOf(enriched.data, did, FOLLOWERS_SOURCE);
+ const viewerFollowRkey = viewerFollowUri ? rkeyFromUri(viewerFollowUri) : viewerFollowUri;
+
+ return {
+ did,
+ handle: doc.handle,
+ description: profile?.description,
+ pronouns: profile?.pronouns,
+ followers: countOf(enriched.data, did, FOLLOWERS_SOURCE),
+ following: countOf(enriched.data, did, FOLLOWING_SOURCE),
+ viewerFollowRkey,
+ isSelf,
+ viewerVouch
+ };
+};
+
export interface PinnedOptions {
keys: readonly string[];
handle: string;
diff --git a/web/src/lib/components/profile/types.ts b/web/src/lib/components/profile/types.ts
index c40267d8..0239b249 100644
--- a/web/src/lib/components/profile/types.ts
+++ b/web/src/lib/components/profile/types.ts
@@ -78,6 +78,20 @@ export interface NetworkVouch {
kind: "vouch" | "denounce";
}
+// the on-demand payload behind the profile hovercard: enough to render a compact
+// card without loading the full profile route
+export interface ProfileCardData {
+ did: string;
+ handle: string;
+ description?: string;
+ pronouns?: string;
+ followers: number;
+ following: number;
+ viewerFollowRkey?: string | null;
+ isSelf: boolean;
+ viewerVouch: DirectVouch | null;
+}
+
export type StarData =
| { kind: "repo"; uri: string; createdAt: string; repo: RepoCardData }
| { kind: "string"; uri: string; createdAt: string; ownerHandle: string; rkey: string };