feat: hide relay reviews from spammer

This commit is contained in:
codytseng
2025-12-09 22:35:06 +08:00
parent d90348dd97
commit ac196cd662
10 changed files with 202 additions and 153 deletions

View File

@@ -6,7 +6,6 @@ import { useKindFilter } from '@/providers/KindFilterProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { TFeedSubRequest, TNoteListMode } from '@/types' import { TFeedSubRequest, TNoteListMode } from '@/types'
import { Event } from 'nostr-tools'
import { useMemo, useRef, useState } from 'react' import { useMemo, useRef, useState } from 'react'
import KindFilter from '../KindFilter' import KindFilter from '../KindFilter'
import { RefreshButton } from '../RefreshButton' import { RefreshButton } from '../RefreshButton'
@@ -16,14 +15,12 @@ export default function NormalFeed({
areAlgoRelays = false, areAlgoRelays = false,
isMainFeed = false, isMainFeed = false,
showRelayCloseReason = false, showRelayCloseReason = false,
filterFn,
disable24hMode = false disable24hMode = false
}: { }: {
subRequests: TFeedSubRequest[] subRequests: TFeedSubRequest[]
areAlgoRelays?: boolean areAlgoRelays?: boolean
isMainFeed?: boolean isMainFeed?: boolean
showRelayCloseReason?: boolean showRelayCloseReason?: boolean
filterFn?: (event: Event) => boolean
disable24hMode?: boolean disable24hMode?: boolean
}) { }) {
const { hideUntrustedNotes } = useUserTrust() const { hideUntrustedNotes } = useUserTrust()
@@ -91,7 +88,6 @@ export default function NormalFeed({
ref={userAggregationListRef} ref={userAggregationListRef}
showKinds={temporaryShowKinds} showKinds={temporaryShowKinds}
subRequests={subRequests} subRequests={subRequests}
filterFn={filterFn}
areAlgoRelays={areAlgoRelays} areAlgoRelays={areAlgoRelays}
showRelayCloseReason={showRelayCloseReason} showRelayCloseReason={showRelayCloseReason}
/> />

View File

@@ -46,6 +46,7 @@ const NoteList = forwardRef<
filterMutedNotes?: boolean filterMutedNotes?: boolean
hideReplies?: boolean hideReplies?: boolean
hideUntrustedNotes?: boolean hideUntrustedNotes?: boolean
hideSpam?: boolean
areAlgoRelays?: boolean areAlgoRelays?: boolean
showRelayCloseReason?: boolean showRelayCloseReason?: boolean
pinnedEventIds?: string[] pinnedEventIds?: string[]
@@ -60,6 +61,7 @@ const NoteList = forwardRef<
filterMutedNotes = true, filterMutedNotes = true,
hideReplies = false, hideReplies = false,
hideUntrustedNotes = false, hideUntrustedNotes = false,
hideSpam = false,
areAlgoRelays = false, areAlgoRelays = false,
showRelayCloseReason = false, showRelayCloseReason = false,
pinnedEventIds, pinnedEventIds,
@@ -70,7 +72,7 @@ const NoteList = forwardRef<
) => { ) => {
const { t } = useTranslation() const { t } = useTranslation()
const { startLogin } = useNostr() const { startLogin } = useNostr()
const { isUserTrusted } = useUserTrust() const { isUserTrusted, isSpammer } = useUserTrust()
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const { isEventDeleted } = useDeletedEvent() const { isEventDeleted } = useDeletedEvent()
@@ -79,7 +81,12 @@ const NoteList = forwardRef<
const [newEvents, setNewEvents] = useState<Event[]>([]) const [newEvents, setNewEvents] = useState<Event[]>([])
const [hasMore, setHasMore] = useState<boolean>(true) const [hasMore, setHasMore] = useState<boolean>(true)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [filtering, setFiltering] = useState(false)
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined) const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [filteredNotes, setFilteredNotes] = useState<
{ key: string; event: Event; reposters: string[] }[]
>([])
const [filteredNewEvents, setFilteredNewEvents] = useState<Event[]>([])
const [refreshCount, setRefreshCount] = useState(0) const [refreshCount, setRefreshCount] = useState(0)
const [showCount, setShowCount] = useState(SHOW_COUNT) const [showCount, setShowCount] = useState(SHOW_COUNT)
const supportTouch = useMemo(() => isTouchDevice(), []) const supportTouch = useMemo(() => isTouchDevice(), [])
@@ -120,98 +127,132 @@ const NoteList = forwardRef<
[hideUntrustedNotes, mutePubkeySet, JSON.stringify(pinnedEventIds), isEventDeleted, filterFn] [hideUntrustedNotes, mutePubkeySet, JSON.stringify(pinnedEventIds), isEventDeleted, filterFn]
) )
const filteredNotes = useMemo(() => { useEffect(() => {
// Store processed event keys to avoid duplicates const processEvents = async () => {
const keySet = new Set<string>() // Store processed event keys to avoid duplicates
// Map to track reposters for each event key const keySet = new Set<string>()
const repostersMap = new Map<string, Set<string>>() // Map to track reposters for each event key
// Final list of filtered events const repostersMap = new Map<string, Set<string>>()
const filteredEvents: Event[] = [] // Final list of filtered events
const keys: string[] = [] const filteredEvents: Event[] = []
const keys: string[] = []
events.forEach((evt) => { events.forEach((evt) => {
const key = getEventKey(evt) const key = getEventKey(evt)
if (keySet.has(key)) return if (keySet.has(key)) return
keySet.add(key) keySet.add(key)
if (shouldHideEvent(evt)) return if (shouldHideEvent(evt)) return
if (hideReplies && isReplyNoteEvent(evt)) return if (hideReplies && isReplyNoteEvent(evt)) return
if (evt.kind !== kinds.Repost && evt.kind !== kinds.GenericRepost) { 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)) {
filteredEvents.push(evt) filteredEvents.push(evt)
keys.push(targetEventKey) keys.push(key)
keySet.add(targetEventKey) return
} }
}
})
return filteredEvents.map((evt, i) => { let targetEventKey: string | undefined
const key = keys[i] let eventFromContent: Event | null = null
return { key, event: evt, reposters: Array.from(repostersMap.get(key) ?? []) } const targetTag = evt.tags.find(tagNameEquals('a')) ?? evt.tags.find(tagNameEquals('e'))
}) if (targetTag) {
}, [events, shouldHideEvent, hideReplies]) 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(() => { const slicedNotes = useMemo(() => {
return filteredNotes.slice(0, showCount) return filteredNotes.slice(0, showCount)
}, [filteredNotes, showCount]) }, [filteredNotes, showCount])
const filteredNewEvents = useMemo(() => { useEffect(() => {
const keySet = new Set<string>() const processNewEvents = async () => {
const keySet = new Set<string>()
const filteredEvents: Event[] = []
return newEvents.filter((event: Event) => { newEvents.forEach((event) => {
if (shouldHideEvent(event)) return false if (shouldHideEvent(event)) return
if (hideReplies && isReplyNoteEvent(event)) return false if (hideReplies && isReplyNoteEvent(event)) return
const key = getEventKey(event) const key = getEventKey(event)
if (keySet.has(key)) { if (keySet.has(key)) {
return false return
} }
keySet.add(key) keySet.add(key)
return true filteredEvents.push(event)
}) })
}, [newEvents, shouldHideEvent])
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') => { const scrollToTop = (behavior: ScrollBehavior = 'instant') => {
setTimeout(() => { setTimeout(() => {
@@ -381,7 +422,7 @@ const NoteList = forwardRef<
reposters={reposters} reposters={reposters}
/> />
))} ))}
{hasMore || loading ? ( {hasMore || loading || filtering ? (
<div ref={bottomRef}> <div ref={bottomRef}>
<NoteCardLoadingSkeleton /> <NoteCardLoadingSkeleton />
</div> </div>

View File

@@ -29,7 +29,7 @@ export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string })
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { pubkey, checkLogin } = useNostr() const { pubkey, checkLogin } = useNostr()
const { hideUntrustedNotes, isUserTrusted } = useUserTrust() const { hideUntrustedNotes, isUserTrusted, isSpammer } = useUserTrust()
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const [showEditor, setShowEditor] = useState(false) const [showEditor, setShowEditor] = useState(false)
const [myReview, setMyReview] = useState<NostrEvent | null>(null) const [myReview, setMyReview] = useState<NostrEvent | null>(null)
@@ -69,12 +69,9 @@ export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string })
let myReview: NostrEvent | null = null let myReview: NostrEvent | null = null
events.sort((a, b) => compareEvents(b, a)) events.sort((a, b) => compareEvents(b, a))
for (const evt of events) { for (const evt of events) {
if ( if (mutePubkeySet.has(evt.pubkey) || pubkeySet.has(evt.pubkey)) {
mutePubkeySet.has(evt.pubkey) ||
pubkeySet.has(evt.pubkey) ||
(hideUntrustedNotes && !isUserTrusted(evt.pubkey))
) {
continue continue
} }
const stars = getStarsFromRelayReviewEvent(evt) 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) setMyReview(myReview)
setReviews(reviews) setReviews(filteredReviews)
setInitialized(true) setInitialized(true)
} }
init() init()

View File

@@ -1,7 +1,7 @@
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' 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 { AlertTriangle, ShieldAlert } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -34,9 +34,9 @@ export default function TrustScoreBadge({
const fetchScore = async () => { const fetchScore = async () => {
try { try {
const data = await trustScoreService.fetchTrustScore(pubkey) const percentile = await fayan.fetchUserPercentile(pubkey)
if (data) { if (percentile !== null) {
setPercentile(data.percentile) setPercentile(percentile)
} }
} catch (error) { } catch (error) {
console.error('Failed to fetch trust score:', error) console.error('Failed to fetch trust score:', error)

View File

@@ -48,7 +48,6 @@ const UserAggregationList = forwardRef<
{ {
subRequests: TFeedSubRequest[] subRequests: TFeedSubRequest[]
showKinds?: number[] showKinds?: number[]
filterFn?: (event: Event) => boolean
filterMutedNotes?: boolean filterMutedNotes?: boolean
areAlgoRelays?: boolean areAlgoRelays?: boolean
showRelayCloseReason?: boolean showRelayCloseReason?: boolean
@@ -58,7 +57,6 @@ const UserAggregationList = forwardRef<
{ {
subRequests, subRequests,
showKinds, showKinds,
filterFn,
filterMutedNotes = true, filterMutedNotes = true,
areAlgoRelays = false, areAlgoRelays = false,
showRelayCloseReason = false showRelayCloseReason = false
@@ -242,9 +240,6 @@ const UserAggregationList = forwardRef<
) { ) {
return true return true
} }
if (filterFn && !filterFn(evt)) {
return true
}
return false return false
}, },
@@ -252,7 +247,6 @@ const UserAggregationList = forwardRef<
hideUntrustedNotes, hideUntrustedNotes,
mutePubkeySet, mutePubkeySet,
isEventDeleted, isEventDeleted,
filterFn,
currentPubkey, currentPubkey,
filterMutedNotes, filterMutedNotes,
isUserTrusted, isUserTrusted,

View File

@@ -45,9 +45,9 @@ const ExplorePage = forwardRef<TPageRef>((_, ref) => {
<NoteList <NoteList
showKinds={[ExtendedKind.RELAY_REVIEW]} showKinds={[ExtendedKind.RELAY_REVIEW]}
subRequests={[{ urls: BIG_RELAY_URLS, filter: {} }]} subRequests={[{ urls: BIG_RELAY_URLS, filter: {} }]}
filterMutedNotes
hideUntrustedNotes={hideUntrustedNotes}
filterFn={relayReviewFilterFn} filterFn={relayReviewFilterFn}
filterMutedNotes
hideSpam
/> />
) : ( ) : (
<FollowingFavoriteRelayList /> <FollowingFavoriteRelayList />

View File

@@ -28,6 +28,7 @@ const RelayReviewsPage = forwardRef(({ url, index }: { url?: string; index?: num
filter: { '#d': [normalizedUrl] } filter: { '#d': [normalizedUrl] }
} }
]} ]}
hideSpam
/> />
</SecondaryPageLayout> </SecondaryPageLayout>
) )

View File

@@ -1,4 +1,5 @@
import client from '@/services/client.service' import client from '@/services/client.service'
import fayan from '@/services/fayan.service'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { createContext, useCallback, useContext, useEffect, useState } from 'react' import { createContext, useCallback, useContext, useEffect, useState } from 'react'
import { useNostr } from './NostrProvider' import { useNostr } from './NostrProvider'
@@ -11,6 +12,7 @@ type TUserTrustContext = {
updateHideUntrustedNotifications: (hide: boolean) => void updateHideUntrustedNotifications: (hide: boolean) => void
updateHideUntrustedNotes: (hide: boolean) => void updateHideUntrustedNotes: (hide: boolean) => void
isUserTrusted: (pubkey: string) => boolean isUserTrusted: (pubkey: string) => boolean
isSpammer: (pubkey: string) => Promise<boolean>
} }
const UserTrustContext = createContext<TUserTrustContext | undefined>(undefined) const UserTrustContext = createContext<TUserTrustContext | undefined>(undefined)
@@ -69,6 +71,16 @@ export function UserTrustProvider({ children }: { children: React.ReactNode }) {
[currentPubkey] [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) => { const updateHideUntrustedInteractions = (hide: boolean) => {
setHideUntrustedInteractions(hide) setHideUntrustedInteractions(hide)
storage.setHideUntrustedInteractions(hide) storage.setHideUntrustedInteractions(hide)
@@ -93,7 +105,8 @@ export function UserTrustProvider({ children }: { children: React.ReactNode }) {
updateHideUntrustedInteractions, updateHideUntrustedInteractions,
updateHideUntrustedNotifications, updateHideUntrustedNotifications,
updateHideUntrustedNotes, updateHideUntrustedNotes,
isUserTrusted isUserTrusted,
isSpammer
}} }}
> >
{children} {children}

View File

@@ -0,0 +1,43 @@
import DataLoader from 'dataloader'
class FayanService {
static instance: FayanService
private userPercentileDataLoader = new DataLoader<string, number | null>(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<number | null> {
return await this.userPercentileDataLoader.load(userId)
}
}
const instance = new FayanService()
export default instance

View File

@@ -1,47 +0,0 @@
import DataLoader from 'dataloader'
export interface TrustScoreData {
percentile: number
}
class TrustScoreService {
static instance: TrustScoreService
private trustScoreDataLoader = new DataLoader<string, TrustScoreData | null>(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<TrustScoreData | null> {
return await this.trustScoreDataLoader.load(userId)
}
}
const instance = new TrustScoreService()
export default instance