From ac196cd662b1e6f5eee1fa8d8f67d080fc66a890 Mon Sep 17 00:00:00 2001 From: codytseng Date: Tue, 9 Dec 2025 22:35:06 +0800 Subject: [PATCH] feat: hide relay reviews from spammer --- src/components/NormalFeed/index.tsx | 4 - src/components/NoteList/index.tsx | 205 +++++++++++------- .../RelayInfo/RelayReviewsPreview.tsx | 22 +- src/components/TrustScoreBadge/index.tsx | 8 +- src/components/UserAggregationList/index.tsx | 6 - src/pages/primary/ExplorePage/index.tsx | 4 +- .../secondary/RelayReviewsPage/index.tsx | 1 + src/providers/UserTrustProvider.tsx | 15 +- src/services/fayan.service.ts | 43 ++++ src/services/trust-score.service.ts | 47 ---- 10 files changed, 202 insertions(+), 153 deletions(-) create mode 100644 src/services/fayan.service.ts delete mode 100644 src/services/trust-score.service.ts diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx index d59ebf1..eef29da 100644 --- a/src/components/NormalFeed/index.tsx +++ b/src/components/NormalFeed/index.tsx @@ -6,7 +6,6 @@ import { useKindFilter } from '@/providers/KindFilterProvider' import { useUserTrust } from '@/providers/UserTrustProvider' import storage from '@/services/local-storage.service' import { TFeedSubRequest, TNoteListMode } from '@/types' -import { Event } from 'nostr-tools' import { useMemo, useRef, useState } from 'react' import KindFilter from '../KindFilter' import { RefreshButton } from '../RefreshButton' @@ -16,14 +15,12 @@ export default function NormalFeed({ areAlgoRelays = false, isMainFeed = false, showRelayCloseReason = false, - filterFn, disable24hMode = false }: { subRequests: TFeedSubRequest[] areAlgoRelays?: boolean isMainFeed?: boolean showRelayCloseReason?: boolean - filterFn?: (event: Event) => boolean disable24hMode?: boolean }) { const { hideUntrustedNotes } = useUserTrust() @@ -91,7 +88,6 @@ export default function NormalFeed({ ref={userAggregationListRef} showKinds={temporaryShowKinds} subRequests={subRequests} - filterFn={filterFn} areAlgoRelays={areAlgoRelays} showRelayCloseReason={showRelayCloseReason} /> diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 2aec965..e1e142c 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -46,6 +46,7 @@ const NoteList = forwardRef< filterMutedNotes?: boolean hideReplies?: boolean hideUntrustedNotes?: boolean + hideSpam?: boolean areAlgoRelays?: boolean showRelayCloseReason?: boolean pinnedEventIds?: string[] @@ -60,6 +61,7 @@ const NoteList = forwardRef< filterMutedNotes = true, hideReplies = false, hideUntrustedNotes = false, + hideSpam = false, areAlgoRelays = false, showRelayCloseReason = false, pinnedEventIds, @@ -70,7 +72,7 @@ const NoteList = forwardRef< ) => { const { t } = useTranslation() const { startLogin } = useNostr() - const { isUserTrusted } = useUserTrust() + const { isUserTrusted, isSpammer } = useUserTrust() const { mutePubkeySet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() const { isEventDeleted } = useDeletedEvent() @@ -79,7 +81,12 @@ const NoteList = forwardRef< const [newEvents, setNewEvents] = useState([]) const [hasMore, setHasMore] = useState(true) const [loading, setLoading] = useState(true) + const [filtering, setFiltering] = useState(false) const [timelineKey, setTimelineKey] = useState(undefined) + const [filteredNotes, setFilteredNotes] = useState< + { key: string; event: Event; reposters: string[] }[] + >([]) + const [filteredNewEvents, setFilteredNewEvents] = useState([]) const [refreshCount, setRefreshCount] = useState(0) const [showCount, setShowCount] = useState(SHOW_COUNT) const supportTouch = useMemo(() => isTouchDevice(), []) @@ -120,98 +127,132 @@ const NoteList = forwardRef< [hideUntrustedNotes, mutePubkeySet, JSON.stringify(pinnedEventIds), isEventDeleted, filterFn] ) - const filteredNotes = useMemo(() => { - // Store processed event keys to avoid duplicates - const keySet = new Set() - // Map to track reposters for each event key - const repostersMap = new Map>() - // Final list of filtered events - const filteredEvents: Event[] = [] - const keys: string[] = [] + useEffect(() => { + const processEvents = async () => { + // Store processed event keys to avoid duplicates + const keySet = new Set() + // Map to track reposters for each event key + const repostersMap = new Map>() + // Final list of filtered events + const filteredEvents: Event[] = [] + const keys: string[] = [] - events.forEach((evt) => { - const key = getEventKey(evt) - if (keySet.has(key)) return - keySet.add(key) + events.forEach((evt) => { + const key = getEventKey(evt) + if (keySet.has(key)) return + keySet.add(key) - if (shouldHideEvent(evt)) return - if (hideReplies && isReplyNoteEvent(evt)) return - if (evt.kind !== kinds.Repost && evt.kind !== kinds.GenericRepost) { - filteredEvents.push(evt) - keys.push(key) - return - } - - let targetEventKey: string | undefined - let eventFromContent: Event | null = null - const targetTag = evt.tags.find(tagNameEquals('a')) ?? evt.tags.find(tagNameEquals('e')) - if (targetTag) { - targetEventKey = getKeyFromTag(targetTag) - } else { - // Attempt to extract the target event from the repost content - if (evt.content) { - try { - eventFromContent = JSON.parse(evt.content) as Event - } catch { - eventFromContent = null - } - } - if (eventFromContent) { - if ( - eventFromContent.kind === kinds.Repost || - eventFromContent.kind === kinds.GenericRepost - ) { - return - } - if (shouldHideEvent(evt)) return - - targetEventKey = getEventKey(eventFromContent) - } - } - - if (targetEventKey) { - // Add to reposters map - const reposters = repostersMap.get(targetEventKey) - if (reposters) { - reposters.add(evt.pubkey) - } else { - repostersMap.set(targetEventKey, new Set([evt.pubkey])) - } - - // If the target event is not already included, add it now - if (!keySet.has(targetEventKey)) { + if (shouldHideEvent(evt)) return + if (hideReplies && isReplyNoteEvent(evt)) return + if (evt.kind !== kinds.Repost && evt.kind !== kinds.GenericRepost) { filteredEvents.push(evt) - keys.push(targetEventKey) - keySet.add(targetEventKey) + keys.push(key) + return } - } - }) - return filteredEvents.map((evt, i) => { - const key = keys[i] - return { key, event: evt, reposters: Array.from(repostersMap.get(key) ?? []) } - }) - }, [events, shouldHideEvent, hideReplies]) + let targetEventKey: string | undefined + let eventFromContent: Event | null = null + const targetTag = evt.tags.find(tagNameEquals('a')) ?? evt.tags.find(tagNameEquals('e')) + if (targetTag) { + targetEventKey = getKeyFromTag(targetTag) + } else { + // Attempt to extract the target event from the repost content + if (evt.content) { + try { + eventFromContent = JSON.parse(evt.content) as Event + } catch { + eventFromContent = null + } + } + if (eventFromContent) { + if ( + eventFromContent.kind === kinds.Repost || + eventFromContent.kind === kinds.GenericRepost + ) { + return + } + if (shouldHideEvent(evt)) return + + targetEventKey = getEventKey(eventFromContent) + } + } + + if (targetEventKey) { + // Add to reposters map + const reposters = repostersMap.get(targetEventKey) + if (reposters) { + reposters.add(evt.pubkey) + } else { + repostersMap.set(targetEventKey, new Set([evt.pubkey])) + } + + // If the target event is not already included, add it now + if (!keySet.has(targetEventKey)) { + filteredEvents.push(evt) + keys.push(targetEventKey) + keySet.add(targetEventKey) + } + } + }) + + const _filteredNotes = ( + await Promise.all( + filteredEvents.map(async (evt, i) => { + if (hideSpam && (await isSpammer(evt.pubkey))) { + return null + } + const key = keys[i] + return { key, event: evt, reposters: Array.from(repostersMap.get(key) ?? []) } + }) + ) + ).filter(Boolean) as { + key: string + event: Event + reposters: string[] + }[] + + setFilteredNotes(_filteredNotes) + } + + setFiltering(true) + processEvents().finally(() => setFiltering(false)) + }, [events, shouldHideEvent, hideReplies, isSpammer, hideSpam]) const slicedNotes = useMemo(() => { return filteredNotes.slice(0, showCount) }, [filteredNotes, showCount]) - const filteredNewEvents = useMemo(() => { - const keySet = new Set() + useEffect(() => { + const processNewEvents = async () => { + const keySet = new Set() + const filteredEvents: Event[] = [] - return newEvents.filter((event: Event) => { - if (shouldHideEvent(event)) return false - if (hideReplies && isReplyNoteEvent(event)) return false + newEvents.forEach((event) => { + if (shouldHideEvent(event)) return + if (hideReplies && isReplyNoteEvent(event)) return - const key = getEventKey(event) - if (keySet.has(key)) { - return false - } - keySet.add(key) - return true - }) - }, [newEvents, shouldHideEvent]) + const key = getEventKey(event) + if (keySet.has(key)) { + return + } + keySet.add(key) + filteredEvents.push(event) + }) + + const _filteredNotes = ( + await Promise.all( + filteredEvents.map(async (evt) => { + if (hideSpam && (await isSpammer(evt.pubkey))) { + return null + } + return evt + }) + ) + ).filter(Boolean) as Event[] + setFilteredNewEvents(_filteredNotes) + } + processNewEvents() + }, [newEvents, shouldHideEvent, isSpammer, hideSpam]) const scrollToTop = (behavior: ScrollBehavior = 'instant') => { setTimeout(() => { @@ -381,7 +422,7 @@ const NoteList = forwardRef< reposters={reposters} /> ))} - {hasMore || loading ? ( + {hasMore || loading || filtering ? (
diff --git a/src/components/RelayInfo/RelayReviewsPreview.tsx b/src/components/RelayInfo/RelayReviewsPreview.tsx index cec94bf..b43f7a0 100644 --- a/src/components/RelayInfo/RelayReviewsPreview.tsx +++ b/src/components/RelayInfo/RelayReviewsPreview.tsx @@ -29,7 +29,7 @@ export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string }) const { t } = useTranslation() const { push } = useSecondaryPage() const { pubkey, checkLogin } = useNostr() - const { hideUntrustedNotes, isUserTrusted } = useUserTrust() + const { hideUntrustedNotes, isUserTrusted, isSpammer } = useUserTrust() const { mutePubkeySet } = useMuteList() const [showEditor, setShowEditor] = useState(false) const [myReview, setMyReview] = useState(null) @@ -69,12 +69,9 @@ export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string }) let myReview: NostrEvent | null = null events.sort((a, b) => compareEvents(b, a)) + for (const evt of events) { - if ( - mutePubkeySet.has(evt.pubkey) || - pubkeySet.has(evt.pubkey) || - (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) - ) { + if (mutePubkeySet.has(evt.pubkey) || pubkeySet.has(evt.pubkey)) { continue } const stars = getStarsFromRelayReviewEvent(evt) @@ -90,8 +87,19 @@ export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string }) } } + const filteredReviews = ( + await Promise.all( + reviews.map(async (evt) => { + if (await isSpammer(evt.pubkey)) { + return null + } + return evt + }) + ) + ).filter(Boolean) as NostrEvent[] + setMyReview(myReview) - setReviews(reviews) + setReviews(filteredReviews) setInitialized(true) } init() diff --git a/src/components/TrustScoreBadge/index.tsx b/src/components/TrustScoreBadge/index.tsx index 88c03cc..e9e24af 100644 --- a/src/components/TrustScoreBadge/index.tsx +++ b/src/components/TrustScoreBadge/index.tsx @@ -1,7 +1,7 @@ import { cn } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' import { useUserTrust } from '@/providers/UserTrustProvider' -import trustScoreService from '@/services/trust-score.service' +import fayan from '@/services/fayan.service' import { AlertTriangle, ShieldAlert } from 'lucide-react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -34,9 +34,9 @@ export default function TrustScoreBadge({ const fetchScore = async () => { try { - const data = await trustScoreService.fetchTrustScore(pubkey) - if (data) { - setPercentile(data.percentile) + const percentile = await fayan.fetchUserPercentile(pubkey) + if (percentile !== null) { + setPercentile(percentile) } } catch (error) { console.error('Failed to fetch trust score:', error) diff --git a/src/components/UserAggregationList/index.tsx b/src/components/UserAggregationList/index.tsx index bf3161a..372e65f 100644 --- a/src/components/UserAggregationList/index.tsx +++ b/src/components/UserAggregationList/index.tsx @@ -48,7 +48,6 @@ const UserAggregationList = forwardRef< { subRequests: TFeedSubRequest[] showKinds?: number[] - filterFn?: (event: Event) => boolean filterMutedNotes?: boolean areAlgoRelays?: boolean showRelayCloseReason?: boolean @@ -58,7 +57,6 @@ const UserAggregationList = forwardRef< { subRequests, showKinds, - filterFn, filterMutedNotes = true, areAlgoRelays = false, showRelayCloseReason = false @@ -242,9 +240,6 @@ const UserAggregationList = forwardRef< ) { return true } - if (filterFn && !filterFn(evt)) { - return true - } return false }, @@ -252,7 +247,6 @@ const UserAggregationList = forwardRef< hideUntrustedNotes, mutePubkeySet, isEventDeleted, - filterFn, currentPubkey, filterMutedNotes, isUserTrusted, diff --git a/src/pages/primary/ExplorePage/index.tsx b/src/pages/primary/ExplorePage/index.tsx index 2638b40..a43dffa 100644 --- a/src/pages/primary/ExplorePage/index.tsx +++ b/src/pages/primary/ExplorePage/index.tsx @@ -45,9 +45,9 @@ const ExplorePage = forwardRef((_, ref) => { ) : ( diff --git a/src/pages/secondary/RelayReviewsPage/index.tsx b/src/pages/secondary/RelayReviewsPage/index.tsx index 10810de..d3ac099 100644 --- a/src/pages/secondary/RelayReviewsPage/index.tsx +++ b/src/pages/secondary/RelayReviewsPage/index.tsx @@ -28,6 +28,7 @@ const RelayReviewsPage = forwardRef(({ url, index }: { url?: string; index?: num filter: { '#d': [normalizedUrl] } } ]} + hideSpam /> ) diff --git a/src/providers/UserTrustProvider.tsx b/src/providers/UserTrustProvider.tsx index e4bc333..e904e2d 100644 --- a/src/providers/UserTrustProvider.tsx +++ b/src/providers/UserTrustProvider.tsx @@ -1,4 +1,5 @@ import client from '@/services/client.service' +import fayan from '@/services/fayan.service' import storage from '@/services/local-storage.service' import { createContext, useCallback, useContext, useEffect, useState } from 'react' import { useNostr } from './NostrProvider' @@ -11,6 +12,7 @@ type TUserTrustContext = { updateHideUntrustedNotifications: (hide: boolean) => void updateHideUntrustedNotes: (hide: boolean) => void isUserTrusted: (pubkey: string) => boolean + isSpammer: (pubkey: string) => Promise } const UserTrustContext = createContext(undefined) @@ -69,6 +71,16 @@ export function UserTrustProvider({ children }: { children: React.ReactNode }) { [currentPubkey] ) + const isSpammer = useCallback( + async (pubkey: string) => { + if (isUserTrusted(pubkey)) return false + const percentile = await fayan.fetchUserPercentile(pubkey) + if (percentile === null) return false + return percentile < 60 + }, + [isUserTrusted] + ) + const updateHideUntrustedInteractions = (hide: boolean) => { setHideUntrustedInteractions(hide) storage.setHideUntrustedInteractions(hide) @@ -93,7 +105,8 @@ export function UserTrustProvider({ children }: { children: React.ReactNode }) { updateHideUntrustedInteractions, updateHideUntrustedNotifications, updateHideUntrustedNotes, - isUserTrusted + isUserTrusted, + isSpammer }} > {children} diff --git a/src/services/fayan.service.ts b/src/services/fayan.service.ts new file mode 100644 index 0000000..e2ee99b --- /dev/null +++ b/src/services/fayan.service.ts @@ -0,0 +1,43 @@ +import DataLoader from 'dataloader' + +class FayanService { + static instance: FayanService + + private userPercentileDataLoader = new DataLoader(async (userIds) => { + return await Promise.all( + userIds.map(async (userId) => { + try { + const res = await fetch(`https://fayan.jumble.social/${userId}`) + if (!res.ok) { + if (res.status === 404) { + return 0 + } + return null + } + const data = await res.json() + if (typeof data.percentile === 'number') { + return data.percentile + } + return null + } catch { + return null + } + }) + ) + }) + + constructor() { + if (!FayanService.instance) { + FayanService.instance = this + } + return FayanService.instance + } + + // null means server error + async fetchUserPercentile(userId: string): Promise { + return await this.userPercentileDataLoader.load(userId) + } +} + +const instance = new FayanService() +export default instance diff --git a/src/services/trust-score.service.ts b/src/services/trust-score.service.ts deleted file mode 100644 index 5abd761..0000000 --- a/src/services/trust-score.service.ts +++ /dev/null @@ -1,47 +0,0 @@ -import DataLoader from 'dataloader' - -export interface TrustScoreData { - percentile: number -} - -class TrustScoreService { - static instance: TrustScoreService - - private trustScoreDataLoader = new DataLoader(async (userIds) => { - return await Promise.all( - userIds.map(async (userId) => { - try { - const res = await fetch(`https://fayan.jumble.social/${userId}`) - if (!res.ok) { - if (res.status === 404) { - return { percentile: 0 } - } - return null - } - const data = await res.json() - if (typeof data.percentile === 'number') { - return { percentile: data.percentile } - } - return null - } catch { - return null - } - }) - ) - }) - - constructor() { - if (!TrustScoreService.instance) { - TrustScoreService.instance = this - } - return TrustScoreService.instance - } - - async fetchTrustScore(userId: string): Promise { - return await this.trustScoreDataLoader.load(userId) - } -} - -const instance = new TrustScoreService() - -export default instance