From 6a23f0223c48516aa5d83eddab759d37d4256a1e Mon Sep 17 00:00:00 2001
From: Jure <44338+hoornet@users.noreply.github.com>
Date: Thu, 23 Apr 2026 19:20:29 +0200
Subject: [PATCH] Apply WoT filter to reactions, zaps, and all feed tabs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
WoT was previously global-feed only. Now it filters notes on every feed
tab (global, following, trending) and also gates reaction pills and zap
totals — so a reaction from someone outside your social graph no longer
shows up in the counts.
- engagement.ts: wotSet param threaded through groupReactions,
fetchReactions, fetchZapCount, fetchBatchEngagement. Zaps are filtered
by the pubkey inside the zap request (the actual zapper), not the
outer event.pubkey (the LNURL wallet). Extracted getZapperPubkey and
getZapAmountSats.
- useReactions / useZapCount: cache key embeds WoT state so filtered
and unfiltered counts don't collide. Hooks subscribe to the WoT store
so toggling re-renders.
- feed store: reads WoT state and passes wotSet to fetchBatchEngagement,
seeds cache with the correct key.
- Feed.tsx: drop the tab === "global" guard.
- SettingsView.tsx: update copy to reflect the wider scope.
---
src/components/feed/Feed.tsx | 2 +-
src/components/shared/SettingsView.tsx | 3 +-
src/hooks/useReactions.ts | 46 +++++++++-------
src/hooks/useZapCount.ts | 33 ++++++++----
src/lib/nostr/engagement.ts | 74 ++++++++++++++++++--------
src/stores/feed.ts | 7 ++-
6 files changed, 109 insertions(+), 56 deletions(-)
diff --git a/src/components/feed/Feed.tsx b/src/components/feed/Feed.tsx
index 35a4cea..6e61b66 100644
--- a/src/components/feed/Feed.tsx
+++ b/src/components/feed/Feed.tsx
@@ -105,7 +105,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;
+ if (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 696f3d3..d6c4547 100644
--- a/src/components/shared/SettingsView.tsx
+++ b/src/components/shared/SettingsView.tsx
@@ -162,7 +162,8 @@ function WoTSection() {
Web of Trust filter
- Only show global feed notes from people you follow or people they follow.
+ Hide notes, reactions, and zaps from outside your social graph
+ (people you follow + people they follow).
();
-
-// Queue to throttle parallel relay queries — too many at once causes timeouts
const pending = new Map>();
let activeCount = 0;
const MAX_CONCURRENT = 4;
const queue: Array<() => void> = [];
+function keyFor(eventId: string, wotActive: boolean): string {
+ return wotActive ? `${eventId}|wot` : eventId;
+}
+
function runNext() {
if (queue.length > 0 && activeCount < MAX_CONCURRENT) {
const next = queue.shift()!;
@@ -18,22 +22,20 @@ function runNext() {
}
}
-function throttledFetch(eventId: string, pubkey?: string): Promise {
- if (pending.has(eventId)) return pending.get(eventId)!;
+function throttledFetch(cacheKey: string, eventId: string, pubkey?: string, wotSet?: Set): Promise {
+ if (pending.has(cacheKey)) return pending.get(cacheKey)!;
const promise = new Promise((resolve) => {
const doFetch = () => {
activeCount++;
- fetchReactions(eventId, pubkey)
- .then((result) => {
- resolve(result);
- })
+ fetchReactions(eventId, pubkey, wotSet)
+ .then(resolve)
.catch(() => {
resolve({ groups: new Map(), myReactions: new Set(), total: 0 });
})
.finally(() => {
activeCount--;
- pending.delete(eventId);
+ pending.delete(cacheKey);
runNext();
});
};
@@ -45,12 +47,18 @@ function throttledFetch(eventId: string, pubkey?: string): Promise void] {
- const [data, setData] = useState(() => cache.get(eventId) ?? null);
+ const wotEnabled = useWoTStore((s) => s.enabled);
+ const wotSet = useWoTStore((s) => s.wotSet);
+ const wotActive = wotEnabled && wotSet.size > 0;
+ const effectiveWotSet = wotActive ? wotSet : undefined;
+ const cacheKey = keyFor(eventId, wotActive);
+
+ const [data, setData] = useState(() => cache.get(cacheKey) ?? null);
const pubkeyRef = useRef(useUserStore.getState().pubkey);
useEffect(() => {
@@ -59,19 +67,19 @@ export function useReactions(eventId: string, enabled = true): [GroupedReactions
useEffect(() => {
if (!enabled) return;
- if (cache.has(eventId)) {
- setData(cache.get(eventId)!);
+ if (cache.has(cacheKey)) {
+ setData(cache.get(cacheKey)!);
return;
}
let cancelled = false;
- throttledFetch(eventId, pubkeyRef.current ?? undefined).then((result) => {
+ throttledFetch(cacheKey, eventId, pubkeyRef.current ?? undefined, effectiveWotSet).then((result) => {
if (!cancelled) {
- cache.set(eventId, result);
+ cache.set(cacheKey, result);
setData(result);
}
});
return () => { cancelled = true; };
- }, [eventId, enabled]);
+ }, [cacheKey, enabled]);
const addReaction = (emoji: string) => {
setData((prev) => {
@@ -81,7 +89,7 @@ export function useReactions(eventId: string, enabled = true): [GroupedReactions
myReactions.add(emoji);
const total = (prev?.total ?? 0) + 1;
const next: GroupedReactions = { groups, myReactions, total };
- cache.set(eventId, next);
+ cache.set(cacheKey, next);
return next;
});
};
@@ -90,7 +98,7 @@ export function useReactions(eventId: string, enabled = true): [GroupedReactions
}
/** Seed the cache from batch engagement data (avoids per-note refetching). */
-export function seedReactionsCache(eventId: string, groups: Map, myReactions: Set) {
+export function seedReactionsCache(eventId: string, groups: Map, myReactions: Set, wotActive = false) {
const total = Array.from(groups.values()).reduce((sum, n) => sum + n, 0);
- cache.set(eventId, { groups, myReactions, total });
+ cache.set(keyFor(eventId, wotActive), { groups, myReactions, total });
}
diff --git a/src/hooks/useZapCount.ts b/src/hooks/useZapCount.ts
index d45e88e..4b0e08b 100644
--- a/src/hooks/useZapCount.ts
+++ b/src/hooks/useZapCount.ts
@@ -1,14 +1,20 @@
import { useEffect, useState } from "react";
import { fetchZapCount } from "../lib/nostr";
+import { useWoTStore } from "../stores/wot";
interface ZapData { count: number; totalSats: number; }
+// Cache key embeds WoT state so filtered / unfiltered totals don't collide.
const cache = new Map();
const pending = new Map>();
let activeCount = 0;
const MAX_CONCURRENT = 4;
const queue: Array<() => void> = [];
+function keyFor(eventId: string, wotActive: boolean): string {
+ return wotActive ? `${eventId}|wot` : eventId;
+}
+
function runNext() {
if (queue.length > 0 && activeCount < MAX_CONCURRENT) {
const next = queue.shift()!;
@@ -16,18 +22,18 @@ function runNext() {
}
}
-function throttledFetch(eventId: string): Promise {
- if (pending.has(eventId)) return pending.get(eventId)!;
+function throttledFetch(cacheKey: string, eventId: string, wotSet?: Set): Promise {
+ if (pending.has(cacheKey)) return pending.get(cacheKey)!;
const promise = new Promise((resolve) => {
const doFetch = () => {
activeCount++;
- fetchZapCount(eventId)
+ fetchZapCount(eventId, wotSet)
.then(resolve)
.catch(() => resolve({ count: 0, totalSats: 0 }))
.finally(() => {
activeCount--;
- pending.delete(eventId);
+ pending.delete(cacheKey);
runNext();
});
};
@@ -39,28 +45,33 @@ function throttledFetch(eventId: string): Promise {
}
});
- pending.set(eventId, promise);
+ pending.set(cacheKey, promise);
return promise;
}
export function useZapCount(eventId: string, enabled = true): ZapData | null {
- const [data, setData] = useState(() => cache.get(eventId) ?? null);
+ const wotEnabled = useWoTStore((s) => s.enabled);
+ const wotSet = useWoTStore((s) => s.wotSet);
+ const wotActive = wotEnabled && wotSet.size > 0;
+ const cacheKey = keyFor(eventId, wotActive);
+
+ const [data, setData] = useState(() => cache.get(cacheKey) ?? null);
useEffect(() => {
if (!enabled) return;
- if (cache.has(eventId)) {
- setData(cache.get(eventId)!);
+ if (cache.has(cacheKey)) {
+ setData(cache.get(cacheKey)!);
return;
}
let cancelled = false;
- throttledFetch(eventId).then((d) => {
+ throttledFetch(cacheKey, eventId, wotActive ? wotSet : undefined).then((d) => {
if (!cancelled) {
- cache.set(eventId, d);
+ cache.set(cacheKey, d);
setData(d);
}
});
return () => { cancelled = true; };
- }, [eventId, enabled]);
+ }, [cacheKey, enabled]);
return data;
}
diff --git a/src/lib/nostr/engagement.ts b/src/lib/nostr/engagement.ts
index bddea29..4542f63 100644
--- a/src/lib/nostr/engagement.ts
+++ b/src/lib/nostr/engagement.ts
@@ -29,8 +29,39 @@ function normalizeEmoji(content: string): string | null {
return content;
}
-/** Group reaction events by emoji. Pass myPubkey to track which emojis the user sent. */
-export function groupReactions(events: Iterable, myPubkey?: string): GroupedReactions {
+// A zap's outer event.pubkey is the wallet/LNURL service; the real zapper is
+// the pubkey inside the embedded zap request. Returns null if malformed.
+function getZapperPubkey(event: NDKEvent): string | null {
+ const desc = event.tags.find((t) => t[0] === "description")?.[1];
+ if (!desc) return null;
+ try {
+ const zapReq = JSON.parse(desc) as { pubkey?: string };
+ return typeof zapReq.pubkey === "string" ? zapReq.pubkey : null;
+ } catch {
+ return null;
+ }
+}
+
+function getZapAmountSats(event: NDKEvent): number {
+ const desc = event.tags.find((t) => t[0] === "description")?.[1];
+ if (!desc) return 0;
+ try {
+ const zapReq = JSON.parse(desc) as { tags?: string[][] };
+ const amountTag = zapReq.tags?.find((t) => t[0] === "amount");
+ if (amountTag?.[1]) return Math.round(parseInt(amountTag[1]) / 1000);
+ } catch { /* malformed */ }
+ return 0;
+}
+
+/**
+ * Group reaction events by emoji. Pass myPubkey to track which emojis the user
+ * sent. Pass wotSet to only count reactions from pubkeys in the set.
+ */
+export function groupReactions(
+ events: Iterable,
+ myPubkey?: string,
+ wotSet?: Set,
+): GroupedReactions {
const groups = new Map();
const myReactions = new Set();
let total = 0;
@@ -38,6 +69,7 @@ export function groupReactions(events: Iterable, myPubkey?: string): G
for (const event of events) {
const emoji = normalizeEmoji(event.content);
if (!emoji) continue;
+ if (wotSet && !wotSet.has(event.pubkey)) continue;
groups.set(emoji, (groups.get(emoji) ?? 0) + 1);
total++;
if (myPubkey && event.pubkey === myPubkey) {
@@ -48,11 +80,11 @@ export function groupReactions(events: Iterable, myPubkey?: string): G
return { groups, myReactions, total };
}
-export async function fetchReactions(eventId: string, myPubkey?: string): Promise {
+export async function fetchReactions(eventId: string, myPubkey?: string, wotSet?: Set): Promise {
const instance = getNDK();
const filter: NDKFilter = { kinds: [NDKKind.Reaction], "#e": [eventId] };
const events = await fetchWithTimeout(instance, filter, SINGLE_TIMEOUT);
- return groupReactions(events, myPubkey);
+ return groupReactions(events, myPubkey, wotSet);
}
export async function fetchReplyCount(eventId: string): Promise {
@@ -62,22 +94,21 @@ export async function fetchReplyCount(eventId: string): Promise {
return events.size;
}
-export async function fetchZapCount(eventId: string): Promise<{ count: number; totalSats: number }> {
+export async function fetchZapCount(eventId: string, wotSet?: Set): Promise<{ count: number; totalSats: number }> {
const instance = getNDK();
const filter: NDKFilter = { kinds: [NDKKind.Zap], "#e": [eventId] };
const events = await fetchWithTimeout(instance, filter, SINGLE_TIMEOUT);
let totalSats = 0;
+ let count = 0;
for (const event of events) {
- const desc = event.tags.find((t) => t[0] === "description")?.[1];
- if (desc) {
- try {
- const zapReq = JSON.parse(desc) as { tags?: string[][] };
- const amountTag = zapReq.tags?.find((t) => t[0] === "amount");
- if (amountTag?.[1]) totalSats += Math.round(parseInt(amountTag[1]) / 1000);
- } catch { /* malformed */ }
+ if (wotSet) {
+ const zapper = getZapperPubkey(event);
+ if (!zapper || !wotSet.has(zapper)) continue;
}
+ totalSats += getZapAmountSats(event);
+ count++;
}
- return { count: events.size, totalSats };
+ return { count, totalSats };
}
export interface BatchEngagement {
@@ -88,7 +119,7 @@ export interface BatchEngagement {
myReactions: Set;
}
-export async function fetchBatchEngagement(eventIds: string[], myPubkey?: string): Promise> {
+export async function fetchBatchEngagement(eventIds: string[], myPubkey?: string, wotSet?: Set): Promise> {
const instance = getNDK();
const result = new Map();
for (const id of eventIds) {
@@ -111,6 +142,7 @@ export async function fetchBatchEngagement(eventIds: string[], myPubkey?: string
);
for (const event of reactions) {
+ if (wotSet && !wotSet.has(event.pubkey)) continue;
const eTag = event.tags.find((t) => t[0] === "e")?.[1];
if (eTag && result.has(eTag)) {
const entry = result.get(eTag)!;
@@ -126,21 +158,19 @@ export async function fetchBatchEngagement(eventIds: string[], myPubkey?: string
}
for (const event of replies) {
+ if (wotSet && !wotSet.has(event.pubkey)) continue;
const eTag = event.tags.find((t) => t[0] === "e")?.[1];
if (eTag && result.has(eTag)) result.get(eTag)!.replies++;
}
for (const event of zaps) {
+ if (wotSet) {
+ const zapper = getZapperPubkey(event);
+ if (!zapper || !wotSet.has(zapper)) continue;
+ }
const eTag = event.tags.find((t) => t[0] === "e")?.[1];
if (eTag && result.has(eTag)) {
- const desc = event.tags.find((t) => t[0] === "description")?.[1];
- if (desc) {
- try {
- const zapReq = JSON.parse(desc) as { tags?: string[][] };
- const amountTag = zapReq.tags?.find((t) => t[0] === "amount");
- if (amountTag?.[1]) result.get(eTag)!.zapSats += Math.round(parseInt(amountTag[1]) / 1000);
- } catch { /* malformed */ }
- }
+ result.get(eTag)!.zapSats += getZapAmountSats(event);
}
}
}
diff --git a/src/stores/feed.ts b/src/stores/feed.ts
index 8856c17..762122d 100644
--- a/src/stores/feed.ts
+++ b/src/stores/feed.ts
@@ -3,6 +3,7 @@ import { NDKEvent, NDKFilter, NDKKind, NDKSubscription, NDKSubscriptionCacheUsag
import { connectToRelays, ensureConnected, resetNDK, fetchGlobalFeed, fetchBatchEngagement, fetchTrendingCandidates, getNDK } from "../lib/nostr";
import { seedReactionsCache } from "../hooks/useReactions";
import { useToastStore } from "./toast";
+import { useWoTStore } from "./wot";
import { dbLoadFeed, dbSaveNotes } from "../lib/db";
import { diagWrapFetch, logDiag, startRelaySnapshots, startDiagFileFlusher, getRelayStates } from "../lib/feedDiagnostics";
import { debug } from "../lib/debug";
@@ -301,12 +302,14 @@ export const useFeedStore = create((set, get) => ({
}
const eventIds = notes.map((n) => n.id).filter(Boolean) as string[];
- const engagement = await fetchBatchEngagement(eventIds);
+ const wotState = useWoTStore.getState();
+ const wotActive = wotState.enabled && wotState.wotSet.size > 0;
+ const engagement = await fetchBatchEngagement(eventIds, undefined, wotActive ? wotState.wotSet : undefined);
// Seed per-note reaction cache so emoji pills render instantly
for (const [id, eng] of engagement) {
if (eng.reactionGroups.size > 0) {
- seedReactionsCache(id, eng.reactionGroups, eng.myReactions);
+ seedReactionsCache(id, eng.reactionGroups, eng.myReactions, wotActive);
}
}