mirror of
https://github.com/hoornet/vega.git
synced 2026-05-06 20:29:12 -07:00
Add Web of Trust reputation on profiles via Vertex DVM
Query Vertex Verify Reputation API (kind 5312) for each profile and display "Followed by people you trust" with clickable top-follower avatars. Includes in-memory cache, request dedup, and graceful fallback when logged out or API unreachable.
This commit is contained in:
@@ -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 (
|
||||
<button
|
||||
onClick={() => openProfile(pubkey)}
|
||||
className="flex items-center gap-1.5 shrink-0 hover:opacity-80 transition-opacity"
|
||||
title={name}
|
||||
>
|
||||
{profile?.picture ? (
|
||||
<img
|
||||
src={profile.picture}
|
||||
alt=""
|
||||
className="w-5 h-5 rounded-sm object-cover bg-bg-raised"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-5 h-5 rounded-sm bg-bg-raised border border-border flex items-center justify-center text-[8px] text-text-dim">
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-[10px] text-text-dim">{name}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
<div className="text-text-dim text-[10px] font-mono mt-2">{shortenPubkey(pubkey)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Web of Trust — powered by Vertex */}
|
||||
{reputation.data && reputation.data.topFollowers.length > 0 && (
|
||||
<div className="px-4 pb-3">
|
||||
<div className="text-[10px] text-text-dim mb-1.5">Followed by people you trust</div>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
{reputation.data.topFollowers.slice(0, 5).map((f) => (
|
||||
<TopFollowerAvatar key={f.pubkey} pubkey={f.pubkey} />
|
||||
))}
|
||||
{reputation.data.topFollowers.length > 5 && (
|
||||
<span className="text-[10px] text-text-dim">
|
||||
+{reputation.data.topFollowers.length - 5} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{reputation.loading && (
|
||||
<div className="px-4 pb-3">
|
||||
<div className="h-3 w-32 bg-bg-raised animate-pulse rounded-sm" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
44
src/hooks/useReputation.ts
Normal file
44
src/hooks/useReputation.ts
Normal file
@@ -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<string, ReputationResult | null>();
|
||||
const pending = new Map<string, Promise<ReputationResult | null>>();
|
||||
|
||||
export function useReputation(pubkey: string): { data: ReputationResult | null; loading: boolean } {
|
||||
const [data, setData] = useState<ReputationResult | null>(() => 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 };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
77
src/lib/nostr/vertex.ts
Normal file
77
src/lib/nostr/vertex.ts
Normal file
@@ -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<ReputationResult | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user