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; + } +}