From 4ce272ce5a4917a0ba825c0375df4344864098a4 Mon Sep 17 00:00:00 2001 From: Jure <44338+hoornet@users.noreply.github.com> Date: Mon, 13 Apr 2026 18:11:45 +0200 Subject: [PATCH] Add opt-in WoT feed filter (hop-1 + hop-2 social graph) --- src/components/feed/Feed.tsx | 13 +++++ src/components/shared/SettingsView.tsx | 69 ++++++++++++++++++++++++++ src/lib/nostr/wot.ts | 22 ++++++++ src/stores/wot.ts | 33 ++++++++++++ 4 files changed, 137 insertions(+) create mode 100644 src/lib/nostr/wot.ts create mode 100644 src/stores/wot.ts diff --git a/src/components/feed/Feed.tsx b/src/components/feed/Feed.tsx index d484635..b1e1f56 100644 --- a/src/components/feed/Feed.tsx +++ b/src/components/feed/Feed.tsx @@ -3,6 +3,7 @@ import { useFeedStore } from "../../stores/feed"; import { useUserStore } from "../../stores/user"; import { useMuteStore } from "../../stores/mute"; import { useUIStore } from "../../stores/ui"; +import { useWoTStore } from "../../stores/wot"; import { fetchFollowFeed, getNDK, ensureConnected } from "../../lib/nostr"; import { diagWrapFetch, logDiag } from "../../lib/feedDiagnostics"; import { detectScript, getEventLanguageTag, FILTER_SCRIPTS } from "../../lib/language"; @@ -44,6 +45,11 @@ export function Feed() { const openHashtag = useUIStore((s) => s.openHashtag); const feedLanguageFilter = useUIStore((s) => s.feedLanguageFilter); const setFeedLanguageFilter = useUIStore((s) => s.setFeedLanguageFilter); + const wotEnabled = useWoTStore((s) => s.enabled); + const wotSet = useWoTStore((s) => s.wotSet); + const wotLoading = useWoTStore((s) => s.loading); + const buildWoT = useWoTStore((s) => s.buildWoT); + const myPubkey = useUserStore((s) => s.pubkey); const [followNotes, setFollowNotes] = useState([]); const [followLoading, setFollowLoading] = useState(false); const [, setTick] = useState(0); @@ -70,6 +76,12 @@ export function Feed() { } }, [tab, follows]); + useEffect(() => { + if (wotEnabled && myPubkey && follows.length > 0 && wotSet.size === 0 && !wotLoading) { + buildWoT(myPubkey, follows); + } + }, [wotEnabled, myPubkey, follows]); + const loadFollowFeed = useCallback(async () => { setFollowLoading(true); try { @@ -91,6 +103,7 @@ export function Feed() { const filteredNotes = activeNotes.filter((event) => { if (mutedPubkeys.includes(event.pubkey)) return false; if (contentMatchesMutedKeyword(event.content)) return false; + if (tab === "global" && wotEnabled && wotSet.size > 0 && !wotSet.has(event.pubkey)) return false; const c = event.content.trim(); if (!c || c.startsWith("{") || c.startsWith("[")) return false; // Filter out notes that look like base64 blobs or relay protocol messages diff --git a/src/components/shared/SettingsView.tsx b/src/components/shared/SettingsView.tsx index 4b72d1f..696f3d3 100644 --- a/src/components/shared/SettingsView.tsx +++ b/src/components/shared/SettingsView.tsx @@ -3,6 +3,7 @@ import { save } from "@tauri-apps/plugin-dialog"; import { writeTextFile } from "@tauri-apps/plugin-fs"; import { useUserStore } from "../../stores/user"; import { useUIStore } from "../../stores/ui"; +import { useWoTStore } from "../../stores/wot"; import { themes } from "../../lib/themes"; import { useMuteStore } from "../../stores/mute"; import { useBookmarkStore } from "../../stores/bookmark"; @@ -136,6 +137,73 @@ function MutedKeywordsSection() { ); } +function WoTSection() { + const { enabled, wotSet, loading, setEnabled, buildWoT } = useWoTStore(); + const { pubkey, follows } = useUserStore(); + const noFollows = follows.length === 0; + + const toggle = () => { + const next = !enabled; + setEnabled(next); + if (next && pubkey && follows.length > 0 && wotSet.size === 0 && !loading) { + buildWoT(pubkey, follows); + } + }; + + const handleRebuild = () => { + if (pubkey && follows.length > 0 && !loading) { + buildWoT(pubkey, follows); + } + }; + + return ( +
+

+ Web of Trust filter +

+

+ Only show global feed notes from people you follow or people they follow. +

+ + {noFollows && ( +

Follow some people first.

+ )} + {enabled && !noFollows && ( +
+ {loading ? ( +

Building…

+ ) : wotSet.size > 0 ? ( +
+

Trusted accounts: {wotSet.size}

+ +
+ ) : null} +
+ )} +
+ ); +} + function IdentitySection() { const { npub, loggedIn } = useUserStore(); const [copied, setCopied] = useState(false); @@ -453,6 +521,7 @@ export function SettingsView() { + ); diff --git a/src/lib/nostr/wot.ts b/src/lib/nostr/wot.ts new file mode 100644 index 0000000..3298ee6 --- /dev/null +++ b/src/lib/nostr/wot.ts @@ -0,0 +1,22 @@ +import { getNDK, fetchWithTimeout, FEED_TIMEOUT } from "./core"; + +export async function buildWoTSet(myPubkey: string, directFollows: string[]): Promise> { + const trusted = new Set(); + trusted.add(myPubkey); // own notes always pass + + // Hop 1 — direct follows + for (const pk of directFollows) trusted.add(pk); + + if (directFollows.length === 0) return trusted; + + // Hop 2 — fetch all their contact lists in one batch + const ndk = getNDK(); + const events = await fetchWithTimeout(ndk, { kinds: [3], authors: directFollows }, FEED_TIMEOUT); + for (const event of events) { + for (const tag of event.tags) { + if (tag[0] === "p" && tag[1]) trusted.add(tag[1]); + } + } + + return trusted; +} diff --git a/src/stores/wot.ts b/src/stores/wot.ts new file mode 100644 index 0000000..3c41d44 --- /dev/null +++ b/src/stores/wot.ts @@ -0,0 +1,33 @@ +import { create } from "zustand"; +import { buildWoTSet } from "../lib/nostr/wot"; + +interface WoTState { + enabled: boolean; + wotSet: Set; + loading: boolean; + lastBuilt: number | null; + setEnabled: (v: boolean) => void; + buildWoT: (myPubkey: string, follows: string[]) => Promise; +} + +const WOT_ENABLED_KEY = "vega_wot_enabled"; + +export const useWoTStore = create((set) => ({ + enabled: localStorage.getItem(WOT_ENABLED_KEY) === "true", + wotSet: new Set(), + loading: false, + lastBuilt: null, + setEnabled: (v) => { + localStorage.setItem(WOT_ENABLED_KEY, String(v)); + set({ enabled: v }); + }, + buildWoT: async (myPubkey, follows) => { + set({ loading: true }); + try { + const wotSet = await buildWoTSet(myPubkey, follows); + set({ wotSet, lastBuilt: Date.now() }); + } finally { + set({ loading: false }); + } + }, +}));