mirror of
https://github.com/hoornet/vega.git
synced 2026-06-08 06:01:57 -07:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user