diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 74c75fd..993332f 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -6157,7 +6157,7 @@ dependencies = [
[[package]]
name = "wrystr"
-version = "0.9.4"
+version = "0.9.7"
dependencies = [
"keyring",
"rusqlite",
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
index 10694a8..38c610f 100644
--- a/src-tauri/capabilities/default.json
+++ b/src-tauri/capabilities/default.json
@@ -11,7 +11,8 @@
{
"identifier": "http:default",
"allow": [
- { "url": "https://nostr.build/**" }
+ { "url": "https://nostr.build/**" },
+ { "url": "https://relay.vertexlab.io/**" }
]
},
"dialog:default",
diff --git a/src/components/profile/ProfileView.tsx b/src/components/profile/ProfileView.tsx
index ba6dc3b..b81be2f 100644
--- a/src/components/profile/ProfileView.tsx
+++ b/src/components/profile/ProfileView.tsx
@@ -4,6 +4,7 @@ import { useUIStore } from "../../stores/ui";
import { useUserStore } from "../../stores/user";
import { useMuteStore } from "../../stores/mute";
import { useProfile } from "../../hooks/useProfile";
+import { useReputation } from "../../hooks/useReputation";
import { fetchUserNotesNIP65, fetchAuthorArticles, getNDK } from "../../lib/nostr";
import { shortenPubkey } from "../../lib/utils";
import { NoteCard } from "../feed/NoteCard";
@@ -13,6 +14,34 @@ import { ImageLightbox } from "../shared/ImageLightbox";
import { EditProfileForm } from "./EditProfileForm";
import { ProfileMediaGallery } from "./ProfileMediaGallery";
+function TopFollowerAvatar({ pubkey }: { pubkey: string }) {
+ const profile = useProfile(pubkey);
+ const { openProfile } = useUIStore();
+ const name = profile?.displayName || profile?.name || pubkey.slice(0, 8) + "…";
+
+ return (
+
+ );
+}
+
export function ProfileView() {
const { selectedPubkey, goBack, openDM } = useUIStore();
const { pubkey: ownPubkey, profile: ownProfile, loggedIn, follows, follow, unfollow } = useUserStore();
@@ -39,6 +68,7 @@ export function ProfileView() {
const isFollowing = follows.includes(pubkey);
const { mutedPubkeys, mute, unmute } = useMuteStore();
const isMuted = mutedPubkeys.includes(pubkey);
+ const reputation = useReputation(pubkey);
const handleFollowToggle = async () => {
setFollowPending(true);
@@ -207,6 +237,28 @@ export function ProfileView() {
{shortenPubkey(pubkey)}
+
+ {/* Web of Trust — powered by Vertex */}
+ {reputation.data && reputation.data.topFollowers.length > 0 && (
+
+
Followed by people you trust
+
+ {reputation.data.topFollowers.slice(0, 5).map((f) => (
+
+ ))}
+ {reputation.data.topFollowers.length > 5 && (
+
+ +{reputation.data.topFollowers.length - 5} more
+
+ )}
+
+
+ )}
+ {reputation.loading && (
+
+ )}
)}
diff --git a/src/hooks/useReputation.ts b/src/hooks/useReputation.ts
new file mode 100644
index 0000000..d2c82e3
--- /dev/null
+++ b/src/hooks/useReputation.ts
@@ -0,0 +1,44 @@
+import { useEffect, useState } from "react";
+import { verifyReputation } from "../lib/nostr";
+import type { ReputationResult } from "../lib/nostr";
+import { useUserStore } from "../stores/user";
+
+const cache = new Map();
+const pending = new Map>();
+
+export function useReputation(pubkey: string): { data: ReputationResult | null; loading: boolean } {
+ const [data, setData] = useState(() => cache.get(pubkey) ?? null);
+ const [loading, setLoading] = useState(!cache.has(pubkey));
+ const loggedIn = useUserStore((s) => s.loggedIn);
+
+ useEffect(() => {
+ if (!loggedIn) {
+ setLoading(false);
+ return;
+ }
+
+ if (cache.has(pubkey)) {
+ setData(cache.get(pubkey)!);
+ setLoading(false);
+ return;
+ }
+
+ // Deduplicate concurrent requests
+ if (!pending.has(pubkey)) {
+ const request = verifyReputation(pubkey).then((result) => {
+ cache.set(pubkey, result);
+ pending.delete(pubkey);
+ return result;
+ });
+ pending.set(pubkey, request);
+ }
+
+ setLoading(true);
+ pending.get(pubkey)!.then((result) => {
+ setData(result);
+ setLoading(false);
+ });
+ }, [pubkey, loggedIn]);
+
+ return { data, loading };
+}
diff --git a/src/lib/nostr/index.ts b/src/lib/nostr/index.ts
index 0d4d067..f381ff3 100644
--- a/src/lib/nostr/index.ts
+++ b/src/lib/nostr/index.ts
@@ -12,3 +12,5 @@ export type { AdvancedSearchResults } from "./search";
export { fetchUserRelayList, publishRelayList, fetchRelayRecommendations } from "./relays";
export type { UserRelayList } from "./relays";
export { fetchTrendingCandidates, fetchTrendingHashtags } from "./trending";
+export { verifyReputation } from "./vertex";
+export type { ReputationResult, ReputationEntry } from "./vertex";
diff --git a/src/lib/nostr/vertex.ts b/src/lib/nostr/vertex.ts
new file mode 100644
index 0000000..2e6c5aa
--- /dev/null
+++ b/src/lib/nostr/vertex.ts
@@ -0,0 +1,77 @@
+import { fetch } from "@tauri-apps/plugin-http";
+import { NDKEvent } from "@nostr-dev-kit/ndk";
+import { getNDK } from "./core";
+import { debug } from "../debug";
+
+const VERTEX_API = "https://relay.vertexlab.io/api/v1/dvms";
+const VERTEX_TIMEOUT = 10000;
+
+export interface ReputationEntry {
+ pubkey: string;
+ rank: number;
+ follows?: number;
+ followers?: number;
+}
+
+export interface ReputationResult {
+ topFollowers: ReputationEntry[];
+ totalNodes: number;
+}
+
+/**
+ * Call Vertex Verify Reputation DVM (kind 5312).
+ * Returns personalized reputation data for a target pubkey.
+ */
+export async function verifyReputation(targetPubkey: string, limit = 7): Promise {
+ const ndk = getNDK();
+ if (!ndk.signer) return null;
+
+ try {
+ // Build and sign the DVM request event
+ const event = new NDKEvent(ndk);
+ event.kind = 5312;
+ event.content = "";
+ event.tags = [
+ ["param", "target", targetPubkey],
+ ["param", "limit", String(limit)],
+ ];
+ await event.sign();
+
+ const raw = event.rawEvent();
+ debug.log("vertex:verifyReputation", targetPubkey.slice(0, 8), "requesting...");
+
+ const resp = await fetch(VERTEX_API, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(raw),
+ connectTimeout: VERTEX_TIMEOUT,
+ });
+
+ if (!resp.ok) {
+ debug.warn("vertex:verifyReputation HTTP", resp.status);
+ return null;
+ }
+
+ const responseEvent = await resp.json();
+
+ // Parse the kind 6312 response
+ if (responseEvent.kind === 7000) {
+ debug.warn("vertex:verifyReputation DVM error:", responseEvent.content);
+ return null;
+ }
+
+ const totalNodes = parseInt(
+ responseEvent.tags?.find((t: string[]) => t[0] === "nodes")?.[1] ?? "0",
+ 10,
+ );
+
+ const topFollowers: ReputationEntry[] = JSON.parse(responseEvent.content || "[]");
+
+ debug.log("vertex:verifyReputation", targetPubkey.slice(0, 8), "→", topFollowers.length, "followers, nodes:", totalNodes);
+
+ return { topFollowers, totalNodes };
+ } catch (err) {
+ debug.warn("vertex:verifyReputation failed:", err);
+ return null;
+ }
+}