Apply WoT filter to reactions, zaps, and all feed tabs

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.
This commit is contained in:
Jure
2026-04-23 19:20:29 +02:00
parent c93a07c48e
commit 6a23f0223c
6 changed files with 109 additions and 56 deletions
+1 -1
View File
@@ -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
+2 -1
View File
@@ -162,7 +162,8 @@ function WoTSection() {
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.
Hide notes, reactions, and zaps from outside your social graph
(people you follow + people they follow).
</p>
<label className={`flex items-center gap-3 ${noFollows ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}>
<button
+27 -19
View File
@@ -2,15 +2,19 @@ import { useEffect, useRef, useState } from "react";
import { fetchReactions } from "../lib/nostr";
import type { GroupedReactions } from "../lib/nostr";
import { useUserStore } from "../stores/user";
import { useWoTStore } from "../stores/wot";
// Cache key embeds WoT state so filtered / unfiltered counts don't collide.
const cache = new Map<string, GroupedReactions>();
// Queue to throttle parallel relay queries — too many at once causes timeouts
const pending = new Map<string, Promise<GroupedReactions>>();
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<GroupedReactions> {
if (pending.has(eventId)) return pending.get(eventId)!;
function throttledFetch(cacheKey: string, eventId: string, pubkey?: string, wotSet?: Set<string>): Promise<GroupedReactions> {
if (pending.has(cacheKey)) return pending.get(cacheKey)!;
const promise = new Promise<GroupedReactions>((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<GroupedReacti
}
});
pending.set(eventId, promise);
pending.set(cacheKey, promise);
return promise;
}
export function useReactions(eventId: string, enabled = true): [GroupedReactions | null, (emoji: string) => void] {
const [data, setData] = useState<GroupedReactions | null>(() => 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<GroupedReactions | null>(() => 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<string, number>, myReactions: Set<string>) {
export function seedReactionsCache(eventId: string, groups: Map<string, number>, myReactions: Set<string>, 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 });
}
+22 -11
View File
@@ -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<string, ZapData>();
const pending = new Map<string, Promise<ZapData>>();
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<ZapData> {
if (pending.has(eventId)) return pending.get(eventId)!;
function throttledFetch(cacheKey: string, eventId: string, wotSet?: Set<string>): Promise<ZapData> {
if (pending.has(cacheKey)) return pending.get(cacheKey)!;
const promise = new Promise<ZapData>((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<ZapData> {
}
});
pending.set(eventId, promise);
pending.set(cacheKey, promise);
return promise;
}
export function useZapCount(eventId: string, enabled = true): ZapData | null {
const [data, setData] = useState<ZapData | null>(() => 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<ZapData | null>(() => 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;
}
+52 -22
View File
@@ -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<NDKEvent>, 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<NDKEvent>,
myPubkey?: string,
wotSet?: Set<string>,
): GroupedReactions {
const groups = new Map<string, number>();
const myReactions = new Set<string>();
let total = 0;
@@ -38,6 +69,7 @@ export function groupReactions(events: Iterable<NDKEvent>, 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<NDKEvent>, myPubkey?: string): G
return { groups, myReactions, total };
}
export async function fetchReactions(eventId: string, myPubkey?: string): Promise<GroupedReactions> {
export async function fetchReactions(eventId: string, myPubkey?: string, wotSet?: Set<string>): Promise<GroupedReactions> {
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<number> {
@@ -62,22 +94,21 @@ export async function fetchReplyCount(eventId: string): Promise<number> {
return events.size;
}
export async function fetchZapCount(eventId: string): Promise<{ count: number; totalSats: number }> {
export async function fetchZapCount(eventId: string, wotSet?: Set<string>): 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<string>;
}
export async function fetchBatchEngagement(eventIds: string[], myPubkey?: string): Promise<Map<string, BatchEngagement>> {
export async function fetchBatchEngagement(eventIds: string[], myPubkey?: string, wotSet?: Set<string>): Promise<Map<string, BatchEngagement>> {
const instance = getNDK();
const result = new Map<string, BatchEngagement>();
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);
}
}
}
+5 -2
View File
@@ -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<FeedState>((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);
}
}