feat: support configurable trust score threshold per context

This commit is contained in:
codytseng 2026-01-14 23:20:28 +08:00
parent 28a1b3096a
commit ca9610b711
46 changed files with 350 additions and 122 deletions

View file

@ -1,5 +1,6 @@
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { SPECIAL_TRUST_SCORE_FILTER_ID } from '@/constants'
import { useState } from 'react' import { useState } from 'react'
import QuoteList from '../QuoteList' import QuoteList from '../QuoteList'
import ReactionList from '../ReactionList' import ReactionList from '../ReactionList'
@ -37,7 +38,7 @@ export default function ExternalContentInteractions({
</ScrollArea> </ScrollArea>
<Separator orientation="vertical" className="h-6" /> <Separator orientation="vertical" className="h-6" />
<div className="size-10 flex items-center justify-center"> <div className="size-10 flex items-center justify-center">
<TrustScoreFilter /> <TrustScoreFilter filterId={SPECIAL_TRUST_SCORE_FILTER_ID.INTERACTIONS} />
</div> </div>
</div> </div>
<Separator /> <Separator />

View file

@ -4,6 +4,7 @@ import TrustScoreFilter from '@/components/TrustScoreFilter'
import UserAggregationList, { TUserAggregationListRef } from '@/components/UserAggregationList' import UserAggregationList, { TUserAggregationListRef } from '@/components/UserAggregationList'
import { isTouchDevice } from '@/lib/utils' import { isTouchDevice } from '@/lib/utils'
import { useKindFilter } from '@/providers/KindFilterProvider' import { useKindFilter } from '@/providers/KindFilterProvider'
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 { useMemo, useRef, useState } from 'react' import { useMemo, useRef, useState } from 'react'
@ -11,6 +12,7 @@ import KindFilter from '../KindFilter'
import { RefreshButton } from '../RefreshButton' import { RefreshButton } from '../RefreshButton'
export default function NormalFeed({ export default function NormalFeed({
trustScoreFilterId,
subRequests, subRequests,
areAlgoRelays = false, areAlgoRelays = false,
isMainFeed = false, isMainFeed = false,
@ -19,6 +21,7 @@ export default function NormalFeed({
onRefresh, onRefresh,
isPubkeyFeed = false isPubkeyFeed = false
}: { }: {
trustScoreFilterId?: string
subRequests: TFeedSubRequest[] subRequests: TFeedSubRequest[]
areAlgoRelays?: boolean areAlgoRelays?: boolean
isMainFeed?: boolean isMainFeed?: boolean
@ -28,6 +31,7 @@ export default function NormalFeed({
isPubkeyFeed?: boolean isPubkeyFeed?: boolean
}) { }) {
const { showKinds } = useKindFilter() const { showKinds } = useKindFilter()
const { getMinTrustScore } = useUserTrust()
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds) const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
const [listMode, setListMode] = useState<TNoteListMode>(() => storage.getNoteListMode()) const [listMode, setListMode] = useState<TNoteListMode>(() => storage.getNoteListMode())
const supportTouch = useMemo(() => isTouchDevice(), []) const supportTouch = useMemo(() => isTouchDevice(), [])
@ -38,6 +42,9 @@ export default function NormalFeed({
return subRequests.every((req) => !req.filter.kinds?.length) return subRequests.every((req) => !req.filter.kinds?.length)
}, [subRequests]) }, [subRequests])
const [trustFilterOpen, setTrustFilterOpen] = useState(false) const [trustFilterOpen, setTrustFilterOpen] = useState(false)
const trustScoreThreshold = useMemo(() => {
return trustScoreFilterId ? getMinTrustScore(trustScoreFilterId) : undefined
}, [trustScoreFilterId, getMinTrustScore])
const handleListModeChange = (mode: TNoteListMode) => { const handleListModeChange = (mode: TNoteListMode) => {
setListMode(mode) setListMode(mode)
@ -85,7 +92,12 @@ export default function NormalFeed({
}} }}
/> />
)} )}
{!isPubkeyFeed && <TrustScoreFilter onOpenChange={handleTrustFilterOpenChange} />} {trustScoreFilterId && (
<TrustScoreFilter
filterId={trustScoreFilterId}
onOpenChange={handleTrustFilterOpenChange}
/>
)}
{showKindsFilter && ( {showKindsFilter && (
<KindFilter <KindFilter
showKinds={temporaryShowKinds} showKinds={temporaryShowKinds}
@ -105,6 +117,7 @@ export default function NormalFeed({
areAlgoRelays={areAlgoRelays} areAlgoRelays={areAlgoRelays}
showRelayCloseReason={showRelayCloseReason} showRelayCloseReason={showRelayCloseReason}
isPubkeyFeed={isPubkeyFeed} isPubkeyFeed={isPubkeyFeed}
trustScoreThreshold={trustScoreThreshold}
/> />
) : ( ) : (
<NoteList <NoteList
@ -115,6 +128,7 @@ export default function NormalFeed({
areAlgoRelays={areAlgoRelays} areAlgoRelays={areAlgoRelays}
showRelayCloseReason={showRelayCloseReason} showRelayCloseReason={showRelayCloseReason}
isPubkeyFeed={isPubkeyFeed} isPubkeyFeed={isPubkeyFeed}
trustScoreThreshold={trustScoreThreshold}
/> />
)} )}
</> </>

View file

@ -1,5 +1,6 @@
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { SPECIAL_TRUST_SCORE_FILTER_ID } from '@/constants'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useState } from 'react' import { useState } from 'react'
import QuoteList from '../QuoteList' import QuoteList from '../QuoteList'
@ -42,7 +43,7 @@ export default function NoteInteractions({ event }: { event: Event }) {
<ScrollBar orientation="horizontal" className="opacity-0 pointer-events-none" /> <ScrollBar orientation="horizontal" className="opacity-0 pointer-events-none" />
</ScrollArea> </ScrollArea>
<Separator orientation="vertical" className="h-6" /> <Separator orientation="vertical" className="h-6" />
<TrustScoreFilter /> <TrustScoreFilter filterId={SPECIAL_TRUST_SCORE_FILTER_ID.INTERACTIONS} />
</div> </div>
<Separator /> <Separator />
{list} {list}

View file

@ -51,13 +51,13 @@ const NoteList = forwardRef<
filterMutedNotes?: boolean filterMutedNotes?: boolean
hideReplies?: boolean hideReplies?: boolean
hideSpam?: boolean hideSpam?: boolean
trustScoreThreshold?: number
areAlgoRelays?: boolean areAlgoRelays?: boolean
showRelayCloseReason?: boolean showRelayCloseReason?: boolean
pinnedEventIds?: string[] pinnedEventIds?: string[]
filterFn?: (event: Event) => boolean filterFn?: (event: Event) => boolean
showNewNotesDirectly?: boolean showNewNotesDirectly?: boolean
isPubkeyFeed?: boolean isPubkeyFeed?: boolean
disableTrustFilter?: boolean
} }
>( >(
( (
@ -67,13 +67,13 @@ const NoteList = forwardRef<
filterMutedNotes = true, filterMutedNotes = true,
hideReplies = false, hideReplies = false,
hideSpam = false, hideSpam = false,
trustScoreThreshold,
areAlgoRelays = false, areAlgoRelays = false,
showRelayCloseReason = false, showRelayCloseReason = false,
pinnedEventIds, pinnedEventIds,
filterFn, filterFn,
showNewNotesDirectly = false, showNewNotesDirectly = false,
isPubkeyFeed = false, isPubkeyFeed = false
disableTrustFilter = false
}, },
ref ref
) => { ) => {
@ -106,20 +106,23 @@ const NoteList = forwardRef<
const showNewNotesDirectlyRef = useRef(showNewNotesDirectly) const showNewNotesDirectlyRef = useRef(showNewNotesDirectly)
showNewNotesDirectlyRef.current = showNewNotesDirectly showNewNotesDirectlyRef.current = showNewNotesDirectly
const shouldHideEvent = useCallback( const pinnedEventHexIdSet = useMemo(() => {
(evt: Event) => { const set = new Set<string>()
const pinnedEventHexIdSet = new Set()
pinnedEventIds?.forEach((id) => { pinnedEventIds?.forEach((id) => {
try { try {
const { type, data } = decode(id) const { type, data } = decode(id)
if (type === 'nevent') { if (type === 'nevent') {
pinnedEventHexIdSet.add(data.id) set.add(data.id)
} }
} catch { } catch {
// ignore // ignore
} }
}) })
return set
}, [pinnedEventIds?.join(',')])
const shouldHideEvent = useCallback(
(evt: Event) => {
if (pinnedEventHexIdSet.has(evt.id)) return true if (pinnedEventHexIdSet.has(evt.id)) return true
if (isEventDeleted(evt)) return true if (isEventDeleted(evt)) return true
if (filterMutedNotes && mutePubkeySet.has(evt.pubkey)) return true if (filterMutedNotes && mutePubkeySet.has(evt.pubkey)) return true
@ -144,7 +147,7 @@ const NoteList = forwardRef<
return false return false
}, },
[mutePubkeySet, JSON.stringify(pinnedEventIds), isEventDeleted, filterFn, mutedWords] [mutePubkeySet, isEventDeleted, filterFn, mutedWords, pinnedEventHexIdSet]
) )
useEffect(() => { useEffect(() => {
@ -222,7 +225,10 @@ const NoteList = forwardRef<
} }
}) })
if (disableTrustFilter) { const _trustScoreThreshold = hideSpam
? SPAMMER_PERCENTILE_THRESHOLD
: (trustScoreThreshold ?? 0)
if (!_trustScoreThreshold || _trustScoreThreshold <= 0) {
setFilteredNotes( setFilteredNotes(
filteredEvents.map((evt, i) => { filteredEvents.map((evt, i) => {
const key = keys[i] const key = keys[i]
@ -236,12 +242,7 @@ const NoteList = forwardRef<
await Promise.all( await Promise.all(
filteredEvents.map(async (evt, i) => { filteredEvents.map(async (evt, i) => {
// Check trust score filter // Check trust score filter
if ( if (!(await meetsMinTrustScore(evt.pubkey, _trustScoreThreshold))) {
!(await meetsMinTrustScore(
evt.pubkey,
hideSpam ? SPAMMER_PERCENTILE_THRESHOLD : undefined
))
) {
return null return null
} }
const key = keys[i] const key = keys[i]
@ -266,7 +267,7 @@ const NoteList = forwardRef<
hideReplies, hideReplies,
hideSpam, hideSpam,
meetsMinTrustScore, meetsMinTrustScore,
disableTrustFilter trustScoreThreshold
]) ])
useEffect(() => { useEffect(() => {
@ -286,6 +287,14 @@ const NoteList = forwardRef<
filteredEvents.push(event) filteredEvents.push(event)
}) })
const _trustScoreThreshold = hideSpam
? SPAMMER_PERCENTILE_THRESHOLD
: (trustScoreThreshold ?? 0)
if (!_trustScoreThreshold || _trustScoreThreshold <= 0) {
setFilteredNewEvents(filteredEvents)
return
}
const _filteredNotes = ( const _filteredNotes = (
await Promise.all( await Promise.all(
filteredEvents.map(async (evt) => { filteredEvents.map(async (evt) => {
@ -293,7 +302,7 @@ const NoteList = forwardRef<
return null return null
} }
// Check trust score filter // Check trust score filter
if (!(await meetsMinTrustScore(evt.pubkey))) { if (!(await meetsMinTrustScore(evt.pubkey, _trustScoreThreshold))) {
return null return null
} }
return evt return evt
@ -303,7 +312,7 @@ const NoteList = forwardRef<
setFilteredNewEvents(_filteredNotes) setFilteredNewEvents(_filteredNotes)
} }
processNewEvents() processNewEvents()
}, [newEvents, shouldHideEvent, isSpammer, hideSpam, meetsMinTrustScore]) }, [newEvents, shouldHideEvent, isSpammer, hideSpam, meetsMinTrustScore, trustScoreThreshold])
const scrollToTop = (behavior: ScrollBehavior = 'instant') => { const scrollToTop = (behavior: ScrollBehavior = 'instant') => {
setTimeout(() => { setTimeout(() => {

View file

@ -1,4 +1,4 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind, SPECIAL_TRUST_SCORE_FILTER_ID } from '@/constants'
import { isMentioningMutedUsers } from '@/lib/event' import { isMentioningMutedUsers } from '@/lib/event'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
@ -24,7 +24,7 @@ export function NotificationItem({
const { pubkey } = useNostr() const { pubkey } = useNostr()
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const { minTrustScore, meetsMinTrustScore } = useUserTrust() const { getMinTrustScore, meetsMinTrustScore } = useUserTrust()
const [canShow, setCanShow] = useState(false) const [canShow, setCanShow] = useState(false)
useEffect(() => { useEffect(() => {
@ -42,10 +42,13 @@ export function NotificationItem({
} }
// Check trust score // Check trust score
if (notification.kind !== kinds.Zap && !(await meetsMinTrustScore(notification.pubkey))) { if (notification.kind !== kinds.Zap) {
const threshold = getMinTrustScore(SPECIAL_TRUST_SCORE_FILTER_ID.NOTIFICATIONS)
if (!(await meetsMinTrustScore(notification.pubkey, threshold))) {
setCanShow(false) setCanShow(false)
return return
} }
}
// Check reaction target for kind 7 // Check reaction target for kind 7
if (pubkey && notification.kind === kinds.Reaction) { if (pubkey && notification.kind === kinds.Reaction) {
@ -65,7 +68,7 @@ export function NotificationItem({
pubkey, pubkey,
mutePubkeySet, mutePubkeySet,
hideContentMentioningMutedUsers, hideContentMentioningMutedUsers,
minTrustScore, getMinTrustScore,
meetsMinTrustScore meetsMinTrustScore
]) ])

View file

@ -177,7 +177,6 @@ export default function ProfileFeed({
filterMutedNotes={false} filterMutedNotes={false}
pinnedEventIds={listMode === 'you' || !!search ? [] : pinnedEventIds} pinnedEventIds={listMode === 'you' || !!search ? [] : pinnedEventIds}
showNewNotesDirectly={myPubkey === pubkey} showNewNotesDirectly={myPubkey === pubkey}
disableTrustFilter
/> />
</> </>
) )

View file

@ -1,4 +1,5 @@
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { SPECIAL_TRUST_SCORE_FILTER_ID } from '@/constants'
import { useStuff } from '@/hooks/useStuff' import { useStuff } from '@/hooks/useStuff'
import { useStuffStatsById } from '@/hooks/useStuffStatsById' import { useStuffStatsById } from '@/hooks/useStuffStatsById'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
@ -20,7 +21,7 @@ export default function ReactionList({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { minTrustScore, meetsMinTrustScore } = useUserTrust() const { getMinTrustScore, meetsMinTrustScore } = useUserTrust()
const { stuffKey } = useStuff(stuff) const { stuffKey } = useStuff(stuff)
const noteStats = useStuffStatsById(stuffKey) const noteStats = useStuffStatsById(stuffKey)
const [filteredLikes, setFilteredLikes] = useState< const [filteredLikes, setFilteredLikes] = useState<
@ -41,18 +42,23 @@ export default function ReactionList({ stuff }: { stuff: Event | string }) {
created_at: number created_at: number
emoji: string | TEmoji emoji: string | TEmoji
}[] = [] }[] = []
const threshold = getMinTrustScore(SPECIAL_TRUST_SCORE_FILTER_ID.INTERACTIONS)
if (threshold) {
await Promise.all( await Promise.all(
likes.map(async (like) => { likes.map(async (like) => {
if (await meetsMinTrustScore(like.pubkey)) { if (await meetsMinTrustScore(like.pubkey, threshold)) {
filtered.push(like) filtered.push(like)
} }
}) })
) )
} else {
filtered.push(...likes)
}
filtered.sort((a, b) => b.created_at - a.created_at) filtered.sort((a, b) => b.created_at - a.created_at)
setFilteredLikes(filtered) setFilteredLikes(filtered)
} }
filterLikes() filterLikes()
}, [noteStats, stuffKey, minTrustScore, meetsMinTrustScore]) }, [noteStats, stuffKey, getMinTrustScore, meetsMinTrustScore])
const [showCount, setShowCount] = useState(SHOW_COUNT) const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement | null>(null) const bottomRef = useRef<HTMLDivElement | null>(null)

View file

@ -52,6 +52,7 @@ export default function Relay({ url, className }: { url?: string; className?: st
</div> </div>
)} )}
<NormalFeed <NormalFeed
trustScoreFilterId={`relay-${normalizedUrl}`}
subRequests={[ subRequests={[
{ urls: [normalizedUrl], filter: debouncedInput ? { search: debouncedInput } : {} } { urls: [normalizedUrl], filter: debouncedInput ? { search: debouncedInput } : {} }
]} ]}

View file

@ -1,6 +1,7 @@
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { SPECIAL_TRUST_SCORE_FILTER_ID } from '@/constants'
import { useThread } from '@/hooks/useThread' import { useThread } from '@/hooks/useThread'
import { getEventKey, isMentioningMutedUsers } from '@/lib/event' import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
@ -42,7 +43,7 @@ export default function ReplyNote({
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { minTrustScore, meetsMinTrustScore } = useUserTrust() const { getMinTrustScore, meetsMinTrustScore } = useUserTrust()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const eventKey = useMemo(() => getEventKey(event), [event]) const eventKey = useMemo(() => getEventKey(event), [event])
const replies = useThread(eventKey) const replies = useThread(eventKey)
@ -69,6 +70,7 @@ export default function ReplyNote({
return return
} }
const trustScoreThreshold = getMinTrustScore(SPECIAL_TRUST_SCORE_FILTER_ID.INTERACTIONS)
for (const reply of replies) { for (const reply of replies) {
if (mutePubkeySet.has(reply.pubkey)) { if (mutePubkeySet.has(reply.pubkey)) {
continue continue
@ -76,7 +78,7 @@ export default function ReplyNote({
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(reply, mutePubkeySet)) { if (hideContentMentioningMutedUsers && isMentioningMutedUsers(reply, mutePubkeySet)) {
continue continue
} }
if (!(await meetsMinTrustScore(reply.pubkey))) { if (trustScoreThreshold && !(await meetsMinTrustScore(reply.pubkey, trustScoreThreshold))) {
continue continue
} }
setHasReplies(true) setHasReplies(true)
@ -86,7 +88,13 @@ export default function ReplyNote({
} }
checkHasReplies() checkHasReplies()
}, [replies, minTrustScore, meetsMinTrustScore, mutePubkeySet, hideContentMentioningMutedUsers]) }, [
replies,
getMinTrustScore,
meetsMinTrustScore,
mutePubkeySet,
hideContentMentioningMutedUsers
])
return ( return (
<div <div

View file

@ -1,4 +1,5 @@
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { SPECIAL_TRUST_SCORE_FILTER_ID } from '@/constants'
import { useStuffStatsById } from '@/hooks/useStuffStatsById' import { useStuffStatsById } from '@/hooks/useStuffStatsById'
import { getEventKey } from '@/lib/event' import { getEventKey } from '@/lib/event'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
@ -19,7 +20,7 @@ export default function RepostList({ event }: { event: Event }) {
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { minTrustScore, meetsMinTrustScore } = useUserTrust() const { getMinTrustScore, meetsMinTrustScore } = useUserTrust()
const noteStats = useStuffStatsById(getEventKey(event)) const noteStats = useStuffStatsById(getEventKey(event))
const [filteredReposts, setFilteredReposts] = useState< const [filteredReposts, setFilteredReposts] = useState<
Array<{ id: string; pubkey: string; created_at: number }> Array<{ id: string; pubkey: string; created_at: number }>
@ -28,10 +29,15 @@ export default function RepostList({ event }: { event: Event }) {
useEffect(() => { useEffect(() => {
const filterReposts = async () => { const filterReposts = async () => {
const reposts = noteStats?.reposts ?? [] const reposts = noteStats?.reposts ?? []
const trustScoreThreshold = getMinTrustScore(SPECIAL_TRUST_SCORE_FILTER_ID.INTERACTIONS)
if (!trustScoreThreshold) {
setFilteredReposts([...reposts].sort((a, b) => b.created_at - a.created_at))
return
}
const filtered = ( const filtered = (
await Promise.all( await Promise.all(
reposts.map(async (repost) => { reposts.map(async (repost) => {
if (await meetsMinTrustScore(repost.pubkey)) { if (await meetsMinTrustScore(repost.pubkey, trustScoreThreshold)) {
return repost return repost
} }
}) })
@ -45,7 +51,7 @@ export default function RepostList({ event }: { event: Event }) {
setFilteredReposts(filtered) setFilteredReposts(filtered)
} }
filterReposts() filterReposts()
}, [noteStats, event.id, minTrustScore, meetsMinTrustScore]) }, [noteStats, event.id, getMinTrustScore, meetsMinTrustScore])
const [showCount, setShowCount] = useState(SHOW_COUNT) const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement | null>(null) const bottomRef = useRef<HTMLDivElement | null>(null)

View file

@ -1,4 +1,4 @@
import { SEARCHABLE_RELAY_URLS } from '@/constants' import { SEARCHABLE_RELAY_URLS, SPECIAL_TRUST_SCORE_FILTER_ID } from '@/constants'
import { getDefaultRelayUrls } from '@/lib/relay' import { getDefaultRelayUrls } from '@/lib/relay'
import { TSearchParams } from '@/types' import { TSearchParams } from '@/types'
import NormalFeed from '../NormalFeed' import NormalFeed from '../NormalFeed'
@ -20,6 +20,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
if (searchParams.type === 'notes') { if (searchParams.type === 'notes') {
return ( return (
<NormalFeed <NormalFeed
trustScoreFilterId={SPECIAL_TRUST_SCORE_FILTER_ID.SEARCH}
subRequests={[{ urls: SEARCHABLE_RELAY_URLS, filter: { search: searchParams.search } }]} subRequests={[{ urls: SEARCHABLE_RELAY_URLS, filter: { search: searchParams.search } }]}
showRelayCloseReason showRelayCloseReason
/> />
@ -28,13 +29,20 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
if (searchParams.type === 'hashtag') { if (searchParams.type === 'hashtag') {
return ( return (
<NormalFeed <NormalFeed
trustScoreFilterId={SPECIAL_TRUST_SCORE_FILTER_ID.HASHTAG}
subRequests={[{ urls: getDefaultRelayUrls(), filter: { '#t': [searchParams.search] } }]} subRequests={[{ urls: getDefaultRelayUrls(), filter: { '#t': [searchParams.search] } }]}
showRelayCloseReason showRelayCloseReason
/> />
) )
} }
if (searchParams.type === 'nak') { if (searchParams.type === 'nak') {
return <NormalFeed subRequests={[searchParams.request]} showRelayCloseReason /> return (
<NormalFeed
trustScoreFilterId={SPECIAL_TRUST_SCORE_FILTER_ID.NAK}
subRequests={[searchParams.request]}
showRelayCloseReason
/>
)
} }
return <Relay url={searchParams.search} /> return <Relay url={searchParams.search} />
} }

View file

@ -1,6 +1,6 @@
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer' import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { LONG_PRESS_THRESHOLD } from '@/constants' import { LONG_PRESS_THRESHOLD, SPECIAL_TRUST_SCORE_FILTER_ID } from '@/constants'
import { useStuff } from '@/hooks/useStuff' import { useStuff } from '@/hooks/useStuff'
import { useStuffStatsById } from '@/hooks/useStuffStatsById' import { useStuffStatsById } from '@/hooks/useStuffStatsById'
import { import {
@ -28,7 +28,7 @@ export default function LikeButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { pubkey, publish, checkLogin } = useNostr() const { pubkey, publish, checkLogin } = useNostr()
const { meetsMinTrustScore } = useUserTrust() const { getMinTrustScore, meetsMinTrustScore } = useUserTrust()
const { quickReaction, quickReactionEmoji } = useUserPreferences() const { quickReaction, quickReactionEmoji } = useUserPreferences()
const { event, externalContent, stuffKey } = useStuff(stuff) const { event, externalContent, stuffKey } = useStuff(stuff)
const [liking, setLiking] = useState(false) const [liking, setLiking] = useState(false)
@ -49,9 +49,15 @@ export default function LikeButton({ stuff }: { stuff: Event | string }) {
const stats = noteStats || {} const stats = noteStats || {}
const likes = stats.likes || [] const likes = stats.likes || []
let count = 0 let count = 0
const trustScoreThreshold = getMinTrustScore(SPECIAL_TRUST_SCORE_FILTER_ID.INTERACTIONS)
if (!trustScoreThreshold) {
setLikeCount(likes.length)
return
}
await Promise.all( await Promise.all(
likes.map(async (like) => { likes.map(async (like) => {
if (await meetsMinTrustScore(like.pubkey)) { if (await meetsMinTrustScore(like.pubkey, trustScoreThreshold)) {
count++ count++
} }
}) })
@ -59,7 +65,7 @@ export default function LikeButton({ stuff }: { stuff: Event | string }) {
setLikeCount(count) setLikeCount(count)
} }
filterLikes() filterLikes()
}, [noteStats, meetsMinTrustScore]) }, [noteStats, meetsMinTrustScore, getMinTrustScore])
useEffect(() => { useEffect(() => {
setTimeout(() => setIsPickerOpen(false), 100) setTimeout(() => setIsPickerOpen(false), 100)

View file

@ -21,11 +21,12 @@ import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import PostEditor from '../PostEditor' import PostEditor from '../PostEditor'
import { formatCount } from './utils' import { formatCount } from './utils'
import { SPECIAL_TRUST_SCORE_FILTER_ID } from '@/constants'
export default function RepostButton({ stuff }: { stuff: Event | string }) { export default function RepostButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { meetsMinTrustScore } = useUserTrust() const { getMinTrustScore, meetsMinTrustScore } = useUserTrust()
const { publish, checkLogin, pubkey } = useNostr() const { publish, checkLogin, pubkey } = useNostr()
const { event, stuffKey } = useStuff(stuff) const { event, stuffKey } = useStuff(stuff)
const noteStats = useStuffStatsById(stuffKey) const noteStats = useStuffStatsById(stuffKey)
@ -46,9 +47,15 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
const reposts = noteStats?.reposts || [] const reposts = noteStats?.reposts || []
let count = 0 let count = 0
const trustScoreThreshold = getMinTrustScore(SPECIAL_TRUST_SCORE_FILTER_ID.INTERACTIONS)
if (!trustScoreThreshold) {
setRepostCount(reposts.length)
return
}
await Promise.all( await Promise.all(
reposts.map(async (repost) => { reposts.map(async (repost) => {
if (await meetsMinTrustScore(repost.pubkey)) { if (await meetsMinTrustScore(repost.pubkey, trustScoreThreshold)) {
count++ count++
} }
}) })
@ -56,7 +63,7 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
setRepostCount(count) setRepostCount(count)
} }
filterReposts() filterReposts()
}, [noteStats, event, meetsMinTrustScore]) }, [noteStats, event, meetsMinTrustScore, getMinTrustScore])
const canRepost = !hasReposted && !reposting && !!event const canRepost = !hasReposted && !reposting && !!event
const repost = async () => { const repost = async () => {

View file

@ -1,4 +1,4 @@
import { TRENDING_NOTES_RELAY_URLS } from '@/constants' import { SPECIAL_TRUST_SCORE_FILTER_ID, TRENDING_NOTES_RELAY_URLS } from '@/constants'
import { simplifyUrl } from '@/lib/url' import { simplifyUrl } from '@/lib/url'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import NormalFeed from '../NormalFeed' import NormalFeed from '../NormalFeed'
@ -19,6 +19,7 @@ export default function TrendingNotes() {
</div> </div>
</div> </div>
<NormalFeed <NormalFeed
trustScoreFilterId={SPECIAL_TRUST_SCORE_FILTER_ID.TRENDING}
subRequests={[{ urls: TRENDING_NOTES_RELAY_URLS, filter: {} }]} subRequests={[{ urls: TRENDING_NOTES_RELAY_URLS, filter: {} }]}
showRelayCloseReason showRelayCloseReason
/> />

View file

@ -28,20 +28,22 @@ function getDescription(score: number, t: (key: string, options?: any) => string
} }
export default function TrustScoreFilter({ export default function TrustScoreFilter({
filterId,
onOpenChange onOpenChange
}: { }: {
filterId: string
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { minTrustScore, updateMinTrustScore } = useUserTrust() const { getMinTrustScore, updateMinTrustScore } = useUserTrust()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [temporaryScore, setTemporaryScore] = useState(minTrustScore) const [temporaryScore, setTemporaryScore] = useState(0)
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null) const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => { useEffect(() => {
setTemporaryScore(minTrustScore) setTemporaryScore(getMinTrustScore(filterId))
}, [minTrustScore]) }, [getMinTrustScore, filterId])
// Debounced update function // Debounced update function
const handleScoreChange = (newScore: number) => { const handleScoreChange = (newScore: number) => {
@ -54,7 +56,7 @@ export default function TrustScoreFilter({
// Set new timer for debounced update // Set new timer for debounced update
debounceTimerRef.current = setTimeout(() => { debounceTimerRef.current = setTimeout(() => {
updateMinTrustScore(newScore) updateMinTrustScore(filterId, newScore)
}, 300) // 300ms debounce delay }, 300) // 300ms debounce delay
} }
@ -81,7 +83,7 @@ export default function TrustScoreFilter({
size="titlebar-icon" size="titlebar-icon"
className={cn( className={cn(
'relative', 'relative',
minTrustScore === 0 temporaryScore === 0
? 'text-muted-foreground hover:text-foreground' ? 'text-muted-foreground hover:text-foreground'
: 'text-primary hover:text-primary-hover' : 'text-primary hover:text-primary-hover'
)} )}
@ -89,10 +91,10 @@ export default function TrustScoreFilter({
setOpen(true) setOpen(true)
}} }}
> >
{minTrustScore < 100 ? <Shield size={16} /> : <ShieldCheck size={16} />} {temporaryScore < 100 ? <Shield size={16} /> : <ShieldCheck size={16} />}
{minTrustScore > 0 && minTrustScore < 100 && ( {temporaryScore > 0 && temporaryScore < 100 && (
<div className="absolute inset-0 w-full h-full flex flex-col items-center justify-center text-[0.5rem] font-mono font-bold"> <div className="absolute inset-0 w-full h-full flex flex-col items-center justify-center text-[0.5rem] font-mono font-bold">
{minTrustScore} {temporaryScore}
</div> </div>
)} )}
</Button> </Button>

View file

@ -55,6 +55,7 @@ const UserAggregationList = forwardRef<
areAlgoRelays?: boolean areAlgoRelays?: boolean
showRelayCloseReason?: boolean showRelayCloseReason?: boolean
isPubkeyFeed?: boolean isPubkeyFeed?: boolean
trustScoreThreshold?: number
} }
>( >(
( (
@ -64,7 +65,8 @@ const UserAggregationList = forwardRef<
filterMutedNotes = true, filterMutedNotes = true,
areAlgoRelays = false, areAlgoRelays = false,
showRelayCloseReason = false, showRelayCloseReason = false,
isPubkeyFeed = false isPubkeyFeed = false,
trustScoreThreshold
}, },
ref ref
) => { ) => {
@ -282,7 +284,10 @@ const UserAggregationList = forwardRef<
) { ) {
return null return null
} }
if (!(await meetsMinTrustScore(evt.pubkey))) { if (
trustScoreThreshold &&
!(await meetsMinTrustScore(evt.pubkey, trustScoreThreshold))
) {
return null return null
} }
@ -300,7 +305,8 @@ const UserAggregationList = forwardRef<
filterMutedNotes, filterMutedNotes,
hideContentMentioningMutedUsers, hideContentMentioningMutedUsers,
isMentioningMutedUsers, isMentioningMutedUsers,
meetsMinTrustScore meetsMinTrustScore,
trustScoreThreshold
] ]
) )

View file

@ -41,9 +41,10 @@ export const StorageKey = {
QUICK_REACTION: 'quickReaction', QUICK_REACTION: 'quickReaction',
QUICK_REACTION_EMOJI: 'quickReactionEmoji', QUICK_REACTION_EMOJI: 'quickReactionEmoji',
NSFW_DISPLAY_POLICY: 'nsfwDisplayPolicy', NSFW_DISPLAY_POLICY: 'nsfwDisplayPolicy',
MIN_TRUST_SCORE: 'minTrustScore',
DEFAULT_RELAY_URLS: 'defaultRelayUrls', DEFAULT_RELAY_URLS: 'defaultRelayUrls',
MUTED_WORDS: 'mutedWords', MUTED_WORDS: 'mutedWords',
MIN_TRUST_SCORE: 'minTrustScore',
MIN_TRUST_SCORE_MAP: 'minTrustScoreMap',
ENABLE_LIVE_FEED: 'enableLiveFeed', // deprecated ENABLE_LIVE_FEED: 'enableLiveFeed', // deprecated
HIDE_UNTRUSTED_NOTES: 'hideUntrustedNotes', // deprecated HIDE_UNTRUSTED_NOTES: 'hideUntrustedNotes', // deprecated
HIDE_UNTRUSTED_INTERACTIONS: 'hideUntrustedInteractions', // deprecated HIDE_UNTRUSTED_INTERACTIONS: 'hideUntrustedInteractions', // deprecated
@ -472,3 +473,13 @@ export type TPrimaryColor = keyof typeof PRIMARY_COLORS
export const LONG_PRESS_THRESHOLD = 400 export const LONG_PRESS_THRESHOLD = 400
export const SPAMMER_PERCENTILE_THRESHOLD = 60 export const SPAMMER_PERCENTILE_THRESHOLD = 60
export const SPECIAL_TRUST_SCORE_FILTER_ID = {
DEFAULT: 'default',
INTERACTIONS: 'interactions',
NOTIFICATIONS: 'notifications',
SEARCH: 'search',
HASHTAG: 'hashtag',
NAK: 'nak',
TRENDING: 'trending'
}

View file

@ -6,10 +6,11 @@ import { useUserTrust } from '@/providers/UserTrustProvider'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useAllDescendantThreads } from './useThread' import { useAllDescendantThreads } from './useThread'
import { SPECIAL_TRUST_SCORE_FILTER_ID } from '@/constants'
export function useFilteredReplies(stuffKey: string) { export function useFilteredReplies(stuffKey: string) {
const { pubkey } = useNostr() const { pubkey } = useNostr()
const { minTrustScore, meetsMinTrustScore } = useUserTrust() const { getMinTrustScore, meetsMinTrustScore } = useUserTrust()
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const allThreads = useAllDescendantThreads(stuffKey) const allThreads = useAllDescendantThreads(stuffKey)
@ -22,6 +23,7 @@ export function useFilteredReplies(stuffKey: string) {
const thread = allThreads.get(stuffKey) || [] const thread = allThreads.get(stuffKey) || []
const filtered: NostrEvent[] = [] const filtered: NostrEvent[] = []
const trustScoreThreshold = getMinTrustScore(SPECIAL_TRUST_SCORE_FILTER_ID.INTERACTIONS)
await Promise.all( await Promise.all(
thread.map(async (evt) => { thread.map(async (evt) => {
const key = getEventKey(evt) const key = getEventKey(evt)
@ -31,7 +33,7 @@ export function useFilteredReplies(stuffKey: string) {
if (mutePubkeySet.has(evt.pubkey)) return if (mutePubkeySet.has(evt.pubkey)) return
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) return if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) return
const meetsTrust = await meetsMinTrustScore(evt.pubkey) const meetsTrust = await meetsMinTrustScore(evt.pubkey, trustScoreThreshold)
if (!meetsTrust) { if (!meetsTrust) {
const replyKey = getEventKey(evt) const replyKey = getEventKey(evt)
const repliesForThisReply = allThreads.get(replyKey) const repliesForThisReply = allThreads.get(replyKey)
@ -39,7 +41,7 @@ export function useFilteredReplies(stuffKey: string) {
if (repliesForThisReply && repliesForThisReply.length > 0) { if (repliesForThisReply && repliesForThisReply.length > 0) {
let hasTrustedReply = false let hasTrustedReply = false
for (const reply of repliesForThisReply) { for (const reply of repliesForThisReply) {
if (await meetsMinTrustScore(reply.pubkey)) { if (await meetsMinTrustScore(reply.pubkey, trustScoreThreshold)) {
hasTrustedReply = true hasTrustedReply = true
break break
} }
@ -63,7 +65,7 @@ export function useFilteredReplies(stuffKey: string) {
allThreads, allThreads,
mutePubkeySet, mutePubkeySet,
hideContentMentioningMutedUsers, hideContentMentioningMutedUsers,
minTrustScore, getMinTrustScore,
meetsMinTrustScore meetsMinTrustScore
]) ])
@ -84,7 +86,7 @@ export function useFilteredReplies(stuffKey: string) {
export function useFilteredAllReplies(stuffKey: string) { export function useFilteredAllReplies(stuffKey: string) {
const { pubkey } = useNostr() const { pubkey } = useNostr()
const allThreads = useAllDescendantThreads(stuffKey) const allThreads = useAllDescendantThreads(stuffKey)
const { minTrustScore, meetsMinTrustScore } = useUserTrust() const { getMinTrustScore, meetsMinTrustScore } = useUserTrust()
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const [replies, setReplies] = useState<NostrEvent[]>([]) const [replies, setReplies] = useState<NostrEvent[]>([])
@ -94,6 +96,7 @@ export function useFilteredAllReplies(stuffKey: string) {
const filterReplies = async () => { const filterReplies = async () => {
const replyKeySet = new Set<string>() const replyKeySet = new Set<string>()
const replyEvents: NostrEvent[] = [] const replyEvents: NostrEvent[] = []
const trustScoreThreshold = getMinTrustScore(SPECIAL_TRUST_SCORE_FILTER_ID.INTERACTIONS)
let parentKeys = [stuffKey] let parentKeys = [stuffKey]
while (parentKeys.length > 0) { while (parentKeys.length > 0) {
@ -108,7 +111,7 @@ export function useFilteredAllReplies(stuffKey: string) {
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet))
return return
const meetsTrust = await meetsMinTrustScore(evt.pubkey) const meetsTrust = await meetsMinTrustScore(evt.pubkey, trustScoreThreshold)
if (!meetsTrust) { if (!meetsTrust) {
const replyKey = getEventKey(evt) const replyKey = getEventKey(evt)
const repliesForThisReply = allThreads.get(replyKey) const repliesForThisReply = allThreads.get(replyKey)
@ -116,7 +119,7 @@ export function useFilteredAllReplies(stuffKey: string) {
if (repliesForThisReply && repliesForThisReply.length > 0) { if (repliesForThisReply && repliesForThisReply.length > 0) {
let hasTrustedReply = false let hasTrustedReply = false
for (const reply of repliesForThisReply) { for (const reply of repliesForThisReply) {
if (await meetsMinTrustScore(reply.pubkey)) { if (await meetsMinTrustScore(reply.pubkey, trustScoreThreshold)) {
hasTrustedReply = true hasTrustedReply = true
break break
} }
@ -141,7 +144,7 @@ export function useFilteredAllReplies(stuffKey: string) {
allThreads, allThreads,
mutePubkeySet, mutePubkeySet,
hideContentMentioningMutedUsers, hideContentMentioningMutedUsers,
minTrustScore, getMinTrustScore,
meetsMinTrustScore meetsMinTrustScore
]) ])

View file

@ -663,6 +663,7 @@ export default {
'Invalid relay URL': 'عنوان URL للمرحل غير صالح', 'Invalid relay URL': 'عنوان URL للمرحل غير صالح',
'Muted words': 'الكلمات المحظورة', 'Muted words': 'الكلمات المحظورة',
'Add muted word': 'إضافة كلمة محظورة', 'Add muted word': 'إضافة كلمة محظورة',
'Zap Details': 'تفاصيل Zap' 'Zap Details': 'تفاصيل Zap',
'Default trust score filter threshold ({{n}}%)': 'عتبة مرشح درجة الثقة الافتراضية ({{n}}%)'
} }
} }

View file

@ -685,6 +685,8 @@ export default {
'Invalid relay URL': 'Ungültige Relay-URL', 'Invalid relay URL': 'Ungültige Relay-URL',
'Muted words': 'Stummgeschaltete Wörter', 'Muted words': 'Stummgeschaltete Wörter',
'Add muted word': 'Stummgeschaltetes Wort hinzufügen', 'Add muted word': 'Stummgeschaltetes Wort hinzufügen',
'Zap Details': 'Zap-Details' 'Zap Details': 'Zap-Details',
'Default trust score filter threshold ({{n}}%)':
'Standard-Vertrauenswert-Filter-Schwelle ({{n}}%)'
} }
} }

View file

@ -668,6 +668,7 @@ export default {
'Invalid relay URL': 'Invalid relay URL', 'Invalid relay URL': 'Invalid relay URL',
'Muted words': 'Muted words', 'Muted words': 'Muted words',
'Add muted word': 'Add muted word', 'Add muted word': 'Add muted word',
'Zap Details': 'Zap Details' 'Zap Details': 'Zap Details',
'Default trust score filter threshold ({{n}}%)': 'Default trust score filter threshold ({{n}}%)'
} }
} }

View file

@ -679,6 +679,8 @@ export default {
'Invalid relay URL': 'URL de relé no válida', 'Invalid relay URL': 'URL de relé no válida',
'Muted words': 'Palabras silenciadas', 'Muted words': 'Palabras silenciadas',
'Add muted word': 'Agregar palabra silenciada', 'Add muted word': 'Agregar palabra silenciada',
'Zap Details': 'Detalles del Zap' 'Zap Details': 'Detalles del Zap',
'Default trust score filter threshold ({{n}}%)':
'Umbral predeterminado del filtro de puntuación de confianza ({{n}}%)'
} }
} }

View file

@ -674,6 +674,7 @@ export default {
'Invalid relay URL': 'آدرس URL رله نامعتبر است', 'Invalid relay URL': 'آدرس URL رله نامعتبر است',
'Muted words': 'کلمات بی‌صدا شده', 'Muted words': 'کلمات بی‌صدا شده',
'Add muted word': 'افزودن کلمه بی‌صدا', 'Add muted word': 'افزودن کلمه بی‌صدا',
'Zap Details': 'جزئیات زپ' 'Zap Details': 'جزئیات زپ',
'Default trust score filter threshold ({{n}}%)': 'آستانه فیلتر امتیاز اعتماد پیش‌فرض ({{n}}%)'
} }
} }

View file

@ -683,6 +683,8 @@ export default {
'Invalid relay URL': 'URL de relais non valide', 'Invalid relay URL': 'URL de relais non valide',
'Muted words': 'Mots masqués', 'Muted words': 'Mots masqués',
'Add muted word': 'Ajouter un mot masqué', 'Add muted word': 'Ajouter un mot masqué',
'Zap Details': 'Détails du Zap' 'Zap Details': 'Détails du Zap',
'Default trust score filter threshold ({{n}}%)':
'Seuil par défaut du filtre de score de confiance ({{n}}%)'
} }
} }

View file

@ -675,6 +675,7 @@ export default {
'Invalid relay URL': 'अमान्य रिले URL', 'Invalid relay URL': 'अमान्य रिले URL',
'Muted words': 'म्यूट किए गए शब्द', 'Muted words': 'म्यूट किए गए शब्द',
'Add muted word': 'म्यूट शब्द जोड़ें', 'Add muted word': 'म्यूट शब्द जोड़ें',
'Zap Details': 'जैप विवरण' 'Zap Details': 'जैप विवरण',
'Default trust score filter threshold ({{n}}%)': 'डिफ़ॉल्ट विश्वास स्कोर फ़िल्टर सीमा ({{n}}%)'
} }
} }

View file

@ -668,6 +668,8 @@ export default {
'Invalid relay URL': 'Érvénytelen továbbító URL', 'Invalid relay URL': 'Érvénytelen továbbító URL',
'Muted words': 'Némított szavak', 'Muted words': 'Némított szavak',
'Add muted word': 'Némított szó hozzáadása', 'Add muted word': 'Némított szó hozzáadása',
'Zap Details': 'Zap Részletek' 'Zap Details': 'Zap Részletek',
'Default trust score filter threshold ({{n}}%)':
'Alapértelmezett bizalmi pontszám szűrő küszöbérték ({{n}}%)'
} }
} }

View file

@ -679,6 +679,8 @@ export default {
'Invalid relay URL': 'URL relay non valido', 'Invalid relay URL': 'URL relay non valido',
'Muted words': 'Parole silenziate', 'Muted words': 'Parole silenziate',
'Add muted word': 'Aggiungi parola silenziata', 'Add muted word': 'Aggiungi parola silenziata',
'Zap Details': 'Dettagli Zap' 'Zap Details': 'Dettagli Zap',
'Default trust score filter threshold ({{n}}%)':
'Soglia predefinita del filtro del punteggio di fiducia ({{n}}%)'
} }
} }

View file

@ -673,6 +673,7 @@ export default {
'Invalid relay URL': '無効なリレーURL', 'Invalid relay URL': '無効なリレーURL',
'Muted words': 'ミュートワード', 'Muted words': 'ミュートワード',
'Add muted word': 'ミュートワードを追加', 'Add muted word': 'ミュートワードを追加',
'Zap Details': 'Zapの詳細' 'Zap Details': 'Zapの詳細',
'Default trust score filter threshold ({{n}}%)': 'デフォルトの信頼スコアフィルター閾値 ({{n}}%)'
} }
} }

View file

@ -669,6 +669,7 @@ export default {
'Invalid relay URL': '유효하지 않은 릴레이 URL', 'Invalid relay URL': '유효하지 않은 릴레이 URL',
'Muted words': '차단 단어', 'Muted words': '차단 단어',
'Add muted word': '차단 단어 추가', 'Add muted word': '차단 단어 추가',
'Zap Details': '잽 세부 정보' 'Zap Details': '잽 세부 정보',
'Default trust score filter threshold ({{n}}%)': '기본 신뢰 점수 필터 임계값 ({{n}}%)'
} }
} }

View file

@ -680,6 +680,7 @@ export default {
'Invalid relay URL': 'Nieprawidłowy adres URL przekaźnika', 'Invalid relay URL': 'Nieprawidłowy adres URL przekaźnika',
'Muted words': 'Wyciszone słowa', 'Muted words': 'Wyciszone słowa',
'Add muted word': 'Dodaj wyciszone słowo', 'Add muted word': 'Dodaj wyciszone słowo',
'Zap Details': 'Szczegóły zapu' 'Zap Details': 'Szczegóły zapu',
'Default trust score filter threshold ({{n}}%)': 'Domyślny próg filtra wyniku zaufania ({{n}}%)'
} }
} }

View file

@ -676,6 +676,8 @@ export default {
'Invalid relay URL': 'URL de relé inválida', 'Invalid relay URL': 'URL de relé inválida',
'Muted words': 'Palavras silenciadas', 'Muted words': 'Palavras silenciadas',
'Add muted word': 'Adicionar palavra silenciada', 'Add muted word': 'Adicionar palavra silenciada',
'Zap Details': 'Detalhes do Zap' 'Zap Details': 'Detalhes do Zap',
'Default trust score filter threshold ({{n}}%)':
'Limite padrão do filtro de pontuação de confiança ({{n}}%)'
} }
} }

View file

@ -679,6 +679,8 @@ export default {
'Invalid relay URL': 'URL de relay inválido', 'Invalid relay URL': 'URL de relay inválido',
'Muted words': 'Palavras silenciadas', 'Muted words': 'Palavras silenciadas',
'Add muted word': 'Adicionar palavra silenciada', 'Add muted word': 'Adicionar palavra silenciada',
'Zap Details': 'Detalhes do Zap' 'Zap Details': 'Detalhes do Zap',
'Default trust score filter threshold ({{n}}%)':
'Limite predefinido do filtro de pontuação de confiança ({{n}}%)'
} }
} }

View file

@ -679,6 +679,8 @@ export default {
'Invalid relay URL': 'Неверный URL реле', 'Invalid relay URL': 'Неверный URL реле',
'Muted words': 'Заблокированные слова', 'Muted words': 'Заблокированные слова',
'Add muted word': 'Добавить заблокированное слово', 'Add muted word': 'Добавить заблокированное слово',
'Zap Details': 'Детали запа' 'Zap Details': 'Детали запа',
'Default trust score filter threshold ({{n}}%)':
'Порог фильтра рейтинга доверия по умолчанию ({{n}}%)'
} }
} }

View file

@ -664,6 +664,8 @@ export default {
'Invalid relay URL': 'URL รีเลย์ไม่ถูกต้อง', 'Invalid relay URL': 'URL รีเลย์ไม่ถูกต้อง',
'Muted words': 'คำที่ถูกปิดเสียง', 'Muted words': 'คำที่ถูกปิดเสียง',
'Add muted word': 'เพิ่มคำที่ถูกปิดเสียง', 'Add muted word': 'เพิ่มคำที่ถูกปิดเสียง',
'Zap Details': 'รายละเอียดซาตส์' 'Zap Details': 'รายละเอียดซาตส์',
'Default trust score filter threshold ({{n}}%)':
'เกณฑ์ตัวกรองคะแนนความไว้วางใจเริ่มต้น ({{n}}%)'
} }
} }

View file

@ -647,6 +647,7 @@ export default {
'Invalid relay URL': '無效的中繼地址', 'Invalid relay URL': '無效的中繼地址',
'Muted words': '屏蔽詞', 'Muted words': '屏蔽詞',
'Add muted word': '添加屏蔽詞', 'Add muted word': '添加屏蔽詞',
'Zap Details': '打閃詳情' 'Zap Details': '打閃詳情',
'Default trust score filter threshold ({{n}}%)': '預設信任分數過濾閾值 ({{n}}%)'
} }
} }

View file

@ -652,6 +652,7 @@ export default {
'Invalid relay URL': '无效的中继地址', 'Invalid relay URL': '无效的中继地址',
'Muted words': '屏蔽词', 'Muted words': '屏蔽词',
'Add muted word': '添加屏蔽词', 'Add muted word': '添加屏蔽词',
'Zap Details': '打闪详情' 'Zap Details': '打闪详情',
'Default trust score filter threshold ({{n}}%)': '默认信任分数过滤阈值 ({{n}}%)'
} }
} }

View file

@ -13,7 +13,7 @@ export async function notificationFilter(
pubkey?: string | null pubkey?: string | null
mutePubkeySet: Set<string> mutePubkeySet: Set<string>
hideContentMentioningMutedUsers?: boolean hideContentMentioningMutedUsers?: boolean
meetsMinTrustScore: (pubkey: string, minScore?: number) => Promise<boolean> meetsMinTrustScore: (pubkey: string) => Promise<boolean>
} }
): Promise<boolean> { ): Promise<boolean> {
if ( if (

View file

@ -2,12 +2,20 @@ import NormalFeed from '@/components/NormalFeed'
import { checkAlgoRelay } from '@/lib/relay' import { checkAlgoRelay } from '@/lib/relay'
import { useFeed } from '@/providers/FeedProvider' import { useFeed } from '@/providers/FeedProvider'
import relayInfoService from '@/services/relay-info.service' import relayInfoService from '@/services/relay-info.service'
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
export default function RelaysFeed() { export default function RelaysFeed() {
const { relayUrls } = useFeed() const { relayUrls, feedInfo } = useFeed()
const [isReady, setIsReady] = useState(false) const [isReady, setIsReady] = useState(false)
const [areAlgoRelays, setAreAlgoRelays] = useState(false) const [areAlgoRelays, setAreAlgoRelays] = useState(false)
const trustScoreFilterId = useMemo(() => {
if (feedInfo?.feedType === 'relay' && feedInfo.id) {
return `relay-${feedInfo.id}`
} else if (feedInfo?.feedType === 'relays' && feedInfo.id) {
return `relays-${feedInfo.id}`
}
return 'relays-default'
}, [feedInfo])
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
@ -24,6 +32,7 @@ export default function RelaysFeed() {
return ( return (
<NormalFeed <NormalFeed
trustScoreFilterId={trustScoreFilterId}
subRequests={[{ urls: relayUrls, filter: {} }]} subRequests={[{ urls: relayUrls, filter: {} }]}
areAlgoRelays={areAlgoRelays} areAlgoRelays={areAlgoRelays}
isMainFeed isMainFeed

View file

@ -1,5 +1,6 @@
import NotificationList from '@/components/NotificationList' import NotificationList from '@/components/NotificationList'
import TrustScoreFilter from '@/components/TrustScoreFilter' import TrustScoreFilter from '@/components/TrustScoreFilter'
import { SPECIAL_TRUST_SCORE_FILTER_ID } from '@/constants'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { usePrimaryPage } from '@/PageManager' import { usePrimaryPage } from '@/PageManager'
import { TPageRef } from '@/types' import { TPageRef } from '@/types'
@ -42,7 +43,7 @@ function NotificationListPageTitlebar() {
<Bell /> <Bell />
<div className="text-lg font-semibold">{t('Notifications')}</div> <div className="text-lg font-semibold">{t('Notifications')}</div>
</div> </div>
<TrustScoreFilter /> <TrustScoreFilter filterId={SPECIAL_TRUST_SCORE_FILTER_ID.NOTIFICATIONS} />
</div> </div>
) )
} }

View file

@ -4,6 +4,7 @@ import ProfileList from '@/components/ProfileList'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useFetchEvent } from '@/hooks/useFetchEvent' import { useFetchEvent } from '@/hooks/useFetchEvent'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { getEventKey } from '@/lib/event'
import { getFollowPackInfoFromEvent } from '@/lib/event-metadata' import { getFollowPackInfoFromEvent } from '@/lib/event-metadata'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
@ -96,7 +97,9 @@ const FollowPackPage = forwardRef(({ id, index }: { id?: string; index?: number
{/* Content */} {/* Content */}
{tab === 'users' && <ProfileList pubkeys={pubkeys} />} {tab === 'users' && <ProfileList pubkeys={pubkeys} />}
{tab === 'feed' && pubkeys.length > 0 && <Feed pubkeys={pubkeys} />} {tab === 'feed' && pubkeys.length > 0 && (
<Feed trustScoreFilterId={`follow-pack-${getEventKey(event)}`} pubkeys={pubkeys} />
)}
</div> </div>
</SecondaryPageLayout> </SecondaryPageLayout>
) )
@ -104,7 +107,7 @@ const FollowPackPage = forwardRef(({ id, index }: { id?: string; index?: number
FollowPackPage.displayName = 'FollowPackPage' FollowPackPage.displayName = 'FollowPackPage'
export default FollowPackPage export default FollowPackPage
function Feed({ pubkeys }: { pubkeys: string[] }) { function Feed({ trustScoreFilterId, pubkeys }: { trustScoreFilterId: string; pubkeys: string[] }) {
const { pubkey: myPubkey } = useNostr() const { pubkey: myPubkey } = useNostr()
const [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([]) const [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([])
@ -112,5 +115,5 @@ function Feed({ pubkeys }: { pubkeys: string[] }) {
client.generateSubRequestsForPubkeys(pubkeys, myPubkey).then(setSubRequests) client.generateSubRequestsForPubkeys(pubkeys, myPubkey).then(setSubRequests)
}, [pubkeys, myPubkey]) }, [pubkeys, myPubkey])
return <NormalFeed subRequests={subRequests} /> return <NormalFeed trustScoreFilterId={trustScoreFilterId} subRequests={subRequests} />
} }

View file

@ -0,0 +1,29 @@
import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
import { SPECIAL_TRUST_SCORE_FILTER_ID } from '@/constants'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { useTranslation } from 'react-i18next'
import SettingItem from './SettingItem'
export default function DefaultTrustScoreFilter() {
const { t } = useTranslation()
const { minTrustScore, updateMinTrustScore } = useUserTrust()
return (
<SettingItem className="flex-col items-start gap-2">
<Label className="text-base font-normal">
{t('Default trust score filter threshold ({{n}}%)', { n: minTrustScore })}
</Label>
<Slider
value={[minTrustScore]}
onValueChange={([value]) =>
updateMinTrustScore(SPECIAL_TRUST_SCORE_FILTER_ID.DEFAULT, value)
}
min={0}
max={100}
step={5}
className="w-full"
/>
</SettingItem>
)
}

View file

@ -19,6 +19,7 @@ import { SelectValue } from '@radix-ui/react-select'
import { RotateCcw } from 'lucide-react' import { RotateCcw } from 'lucide-react'
import { forwardRef, useState } from 'react' import { forwardRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import DefaultTrustScoreFilter from './DefaultTrustScoreFilter'
import MutedWords from './MutedWords' import MutedWords from './MutedWords'
import SettingItem from './SettingItem' import SettingItem from './SettingItem'
@ -150,6 +151,7 @@ const GeneralSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
</SelectContent> </SelectContent>
</Select> </Select>
</SettingItem> </SettingItem>
<DefaultTrustScoreFilter />
<SettingItem> <SettingItem>
<Label htmlFor="quick-reaction" className="text-base font-normal"> <Label htmlFor="quick-reaction" className="text-base font-normal">
<div>{t('Quick reaction')}</div> <div>{t('Quick reaction')}</div>

View file

@ -108,7 +108,22 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
</div> </div>
) )
} else if (data) { } else if (data) {
content = <NormalFeed subRequests={subRequests} disable24hMode={data.type !== 'domain'} /> let trustScoreFilterId: string
if (data.type === 'hashtag') {
trustScoreFilterId = 'hashtag'
} else if (data.type === 'domain') {
trustScoreFilterId = `domain-${data.domain}`
} else {
trustScoreFilterId = 'search'
}
content = (
<NormalFeed
trustScoreFilterId={trustScoreFilterId}
subRequests={subRequests}
disable24hMode={data.type !== 'domain'}
/>
)
} }
return ( return (

View file

@ -1,4 +1,4 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind, SPECIAL_TRUST_SCORE_FILTER_ID } from '@/constants'
import { compareEvents } from '@/lib/event' import { compareEvents } from '@/lib/event'
import { notificationFilter } from '@/lib/notification' import { notificationFilter } from '@/lib/notification'
import { getDefaultRelayUrls } from '@/lib/relay' import { getDefaultRelayUrls } from '@/lib/relay'
@ -34,7 +34,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
const active = useMemo(() => current === 'notifications', [current]) const active = useMemo(() => current === 'notifications', [current])
const { pubkey, notificationsSeenAt, updateNotificationsSeenAt } = useNostr() const { pubkey, notificationsSeenAt, updateNotificationsSeenAt } = useNostr()
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { meetsMinTrustScore } = useUserTrust() const { getMinTrustScore, meetsMinTrustScore } = useUserTrust()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const [newNotifications, setNewNotifications] = useState<NostrEvent[]>([]) const [newNotifications, setNewNotifications] = useState<NostrEvent[]>([])
const [readNotificationIdSet, setReadNotificationIdSet] = useState<Set<string>>(new Set()) const [readNotificationIdSet, setReadNotificationIdSet] = useState<Set<string>>(new Set())
@ -47,6 +47,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
} }
const filterNotifications = async () => { const filterNotifications = async () => {
const filtered: NostrEvent[] = [] const filtered: NostrEvent[] = []
const trustScoreThreshold = getMinTrustScore(SPECIAL_TRUST_SCORE_FILTER_ID.NOTIFICATIONS)
await Promise.allSettled( await Promise.allSettled(
newNotifications.map(async (notification) => { newNotifications.map(async (notification) => {
if (notification.created_at <= notificationsSeenAt || filtered.length >= 10) { if (notification.created_at <= notificationsSeenAt || filtered.length >= 10) {
@ -57,7 +58,10 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
pubkey, pubkey,
mutePubkeySet, mutePubkeySet,
hideContentMentioningMutedUsers, hideContentMentioningMutedUsers,
meetsMinTrustScore meetsMinTrustScore: async (pubkey: string) => {
if (trustScoreThreshold === 0) return true
return meetsMinTrustScore(pubkey, trustScoreThreshold)
}
})) }))
) { ) {
return return

View file

@ -1,3 +1,4 @@
import { SPECIAL_TRUST_SCORE_FILTER_ID } from '@/constants'
import client from '@/services/client.service' import client from '@/services/client.service'
import fayan from '@/services/fayan.service' import fayan from '@/services/fayan.service'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
@ -6,10 +7,12 @@ import { useNostr } from './NostrProvider'
type TUserTrustContext = { type TUserTrustContext = {
minTrustScore: number minTrustScore: number
updateMinTrustScore: (score: number) => void minTrustScoreMap: Record<string, number>
getMinTrustScore: (id: string) => number
updateMinTrustScore: (id: string, score: number) => void
isUserTrusted: (pubkey: string) => boolean isUserTrusted: (pubkey: string) => boolean
isSpammer: (pubkey: string) => Promise<boolean> isSpammer: (pubkey: string) => Promise<boolean>
meetsMinTrustScore: (pubkey: string, minScore?: number) => Promise<boolean> meetsMinTrustScore: (pubkey: string, minScore: number) => Promise<boolean>
} }
const UserTrustContext = createContext<TUserTrustContext | undefined>(undefined) const UserTrustContext = createContext<TUserTrustContext | undefined>(undefined)
@ -27,6 +30,9 @@ const wotSet = new Set<string>()
export function UserTrustProvider({ children }: { children: React.ReactNode }) { export function UserTrustProvider({ children }: { children: React.ReactNode }) {
const { pubkey: currentPubkey } = useNostr() const { pubkey: currentPubkey } = useNostr()
const [minTrustScore, setMinTrustScore] = useState(() => storage.getMinTrustScore()) const [minTrustScore, setMinTrustScore] = useState(() => storage.getMinTrustScore())
const [minTrustScoreMap, setMinTrustScoreMap] = useState<Record<string, number>>(() =>
storage.getMinTrustScoreMap()
)
useEffect(() => { useEffect(() => {
if (!currentPubkey) return if (!currentPubkey) return
@ -70,15 +76,31 @@ export function UserTrustProvider({ children }: { children: React.ReactNode }) {
[isUserTrusted] [isUserTrusted]
) )
const updateMinTrustScore = (score: number) => { const getMinTrustScore = useCallback(
(id: string) => {
return id === SPECIAL_TRUST_SCORE_FILTER_ID.DEFAULT
? minTrustScore
: (minTrustScoreMap[id] ?? minTrustScore)
},
[minTrustScore, minTrustScoreMap]
)
const updateMinTrustScore = (id: string, score: number) => {
if (score < 0 || score > 100) return
if (id === SPECIAL_TRUST_SCORE_FILTER_ID.DEFAULT) {
setMinTrustScore(score) setMinTrustScore(score)
storage.setMinTrustScore(score) storage.setMinTrustScore(score)
} else {
const newMap = { ...minTrustScoreMap, [id]: score }
setMinTrustScoreMap(newMap)
storage.setMinTrustScoreMap(newMap)
}
} }
const meetsMinTrustScore = useCallback( const meetsMinTrustScore = useCallback(
async (pubkey: string, minScore?: number) => { async (pubkey: string, minScore: number) => {
const threshold = minScore !== undefined ? minScore : minTrustScore if (minScore === 0) return true
if (threshold === 0) return true
if (pubkey === currentPubkey) return true if (pubkey === currentPubkey) return true
// WoT users always have 100% trust score // WoT users always have 100% trust score
@ -87,15 +109,17 @@ export function UserTrustProvider({ children }: { children: React.ReactNode }) {
// Get percentile from reputation system // Get percentile from reputation system
const percentile = await fayan.fetchUserPercentile(pubkey) const percentile = await fayan.fetchUserPercentile(pubkey)
if (percentile === null) return true // If no data, indicate the trust server is down, so allow the user if (percentile === null) return true // If no data, indicate the trust server is down, so allow the user
return percentile >= threshold return percentile >= minScore
}, },
[currentPubkey, minTrustScore] [currentPubkey]
) )
return ( return (
<UserTrustContext.Provider <UserTrustContext.Provider
value={{ value={{
minTrustScore, minTrustScore,
minTrustScoreMap,
getMinTrustScore,
updateMinTrustScore, updateMinTrustScore,
isUserTrusted, isUserTrusted,
isSpammer, isSpammer,

View file

@ -64,9 +64,10 @@ class LocalStorageService {
private quickReaction: boolean = false private quickReaction: boolean = false
private quickReactionEmoji: string | TEmoji = '+' private quickReactionEmoji: string | TEmoji = '+'
private nsfwDisplayPolicy: TNsfwDisplayPolicy = NSFW_DISPLAY_POLICY.HIDE_CONTENT private nsfwDisplayPolicy: TNsfwDisplayPolicy = NSFW_DISPLAY_POLICY.HIDE_CONTENT
private minTrustScore: number = 40
private defaultRelayUrls: string[] = BIG_RELAY_URLS private defaultRelayUrls: string[] = BIG_RELAY_URLS
private mutedWords: string[] = [] private mutedWords: string[] = []
private minTrustScore: number = 0
private minTrustScoreMap: Record<string, number> = {}
constructor() { constructor() {
if (!LocalStorageService.instance) { if (!LocalStorageService.instance) {
@ -278,6 +279,18 @@ class LocalStorageService {
} }
} }
const minTrustScoreMapStr = window.localStorage.getItem(StorageKey.MIN_TRUST_SCORE_MAP)
if (minTrustScoreMapStr) {
try {
const map = JSON.parse(minTrustScoreMapStr)
if (typeof map === 'object' && map !== null) {
this.minTrustScoreMap = map
}
} catch {
// Invalid JSON, use default
}
}
const defaultRelayUrlsStr = window.localStorage.getItem(StorageKey.DEFAULT_RELAY_URLS) const defaultRelayUrlsStr = window.localStorage.getItem(StorageKey.DEFAULT_RELAY_URLS)
if (defaultRelayUrlsStr) { if (defaultRelayUrlsStr) {
try { try {
@ -645,6 +658,15 @@ class LocalStorageService {
} }
} }
getMinTrustScoreMap() {
return this.minTrustScoreMap
}
setMinTrustScoreMap(map: Record<string, number>) {
this.minTrustScoreMap = map
window.localStorage.setItem(StorageKey.MIN_TRUST_SCORE_MAP, JSON.stringify(map))
}
getDefaultRelayUrls() { getDefaultRelayUrls() {
return this.defaultRelayUrls return this.defaultRelayUrls
} }