Add opt-in WoT feed filter (hop-1 + hop-2 social graph)

This commit is contained in:
Jure
2026-04-13 18:11:45 +02:00
parent f3b92004f0
commit 4ce272ce5a
4 changed files with 137 additions and 0 deletions
+13
View File
@@ -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<NDKEvent[]>([]);
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
+69
View File
@@ -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 (
<section>
<h2 className="text-text text-[11px] font-medium uppercase tracking-widest mb-2 text-text-dim">
Web of Trust filter
</h2>
<p className="text-text-dim text-[11px] mb-3">
Only show global feed notes from people you follow or people they follow.
</p>
<label className={`flex items-center gap-3 ${noFollows ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}>
<button
onClick={noFollows ? undefined : toggle}
disabled={noFollows}
className={`w-9 h-5 rounded-full transition-colors relative shrink-0 ${
enabled && !noFollows ? "bg-accent" : "bg-border"
}`}
>
<span
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-bg transition-transform ${
enabled && !noFollows ? "translate-x-4" : "translate-x-0"
}`}
/>
</button>
<span className="text-text text-[12px]">WoT feed filter</span>
</label>
{noFollows && (
<p className="text-text-dim text-[10px] mt-1.5 ml-12">Follow some people first.</p>
)}
{enabled && !noFollows && (
<div className="mt-2 ml-12 space-y-1">
{loading ? (
<p className="text-text-dim text-[10px]">Building</p>
) : wotSet.size > 0 ? (
<div className="flex items-center gap-3">
<p className="text-text-dim text-[10px]">Trusted accounts: {wotSet.size}</p>
<button
onClick={handleRebuild}
className="text-[10px] text-text-dim hover:text-accent transition-colors"
>
Rebuild
</button>
</div>
) : null}
</div>
)}
</section>
);
}
function IdentitySection() {
const { npub, loggedIn } = useUserStore();
const [copied, setCopied] = useState(false);
@@ -453,6 +521,7 @@ export function SettingsView() {
<IdentitySection />
<MuteSection />
<MutedKeywordsSection />
<WoTSection />
</div>
</div>
);
+22
View File
@@ -0,0 +1,22 @@
import { getNDK, fetchWithTimeout, FEED_TIMEOUT } from "./core";
export async function buildWoTSet(myPubkey: string, directFollows: string[]): Promise<Set<string>> {
const trusted = new Set<string>();
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;
}
+33
View File
@@ -0,0 +1,33 @@
import { create } from "zustand";
import { buildWoTSet } from "../lib/nostr/wot";
interface WoTState {
enabled: boolean;
wotSet: Set<string>;
loading: boolean;
lastBuilt: number | null;
setEnabled: (v: boolean) => void;
buildWoT: (myPubkey: string, follows: string[]) => Promise<void>;
}
const WOT_ENABLED_KEY = "vega_wot_enabled";
export const useWoTStore = create<WoTState>((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 });
}
},
}));