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