feat: trust score filter

This commit is contained in:
codytseng 2025-12-31 18:22:23 +08:00
parent 43f4c34fb3
commit 5f785e5553
48 changed files with 974 additions and 480 deletions

View file

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

View file

@ -1,77 +0,0 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger
} from '@/components/ui/alert-dialog'
import { Button, buttonVariants } from '@/components/ui/button'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { VariantProps } from 'class-variance-authority'
import { Shield, ShieldCheck } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function HideUntrustedContentButton({
type,
size = 'icon'
}: {
type: 'interactions' | 'notifications'
size?: VariantProps<typeof buttonVariants>['size']
}) {
const { t } = useTranslation()
const {
hideUntrustedInteractions,
hideUntrustedNotifications,
updateHideUntrustedInteractions,
updateHideUntrustedNotifications
} = useUserTrust()
const enabled = type === 'interactions' ? hideUntrustedInteractions : hideUntrustedNotifications
const updateEnabled =
type === 'interactions' ? updateHideUntrustedInteractions : updateHideUntrustedNotifications
const typeText = t(type)
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size={size}>
{enabled ? (
<ShieldCheck className="text-green-400" />
) : (
<Shield className="text-muted-foreground" />
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{enabled
? t('Show untrusted {type}', { type: typeText })
: t('Hide untrusted {type}', { type: typeText })}
</AlertDialogTitle>
<AlertDialogDescription>
{enabled
? t('Currently hiding {type} from untrusted users.', { type: typeText })
: t('Currently showing all {type}.', { type: typeText })}{' '}
{t('Trusted users include people you follow and people they follow.')}{' '}
{enabled
? t('Click continue to show all {type}.', { type: typeText })
: t('Click continue to hide {type} from untrusted users.', { type: typeText })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={() => updateEnabled(!enabled)}>
{t('Continue')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View file

@ -1,9 +1,9 @@
import NoteList, { TNoteListRef } from '@/components/NoteList'
import Tabs from '@/components/Tabs'
import TrustScoreFilter from '@/components/TrustScoreFilter'
import UserAggregationList, { TUserAggregationListRef } from '@/components/UserAggregationList'
import { isTouchDevice } from '@/lib/utils'
import { useKindFilter } from '@/providers/KindFilterProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import storage from '@/services/local-storage.service'
import { TFeedSubRequest, TNoteListMode } from '@/types'
import { useMemo, useRef, useState } from 'react'
@ -25,7 +25,6 @@ export default function NormalFeed({
disable24hMode?: boolean
onRefresh?: () => void
}) {
const { hideUntrustedNotes } = useUserTrust()
const { showKinds } = useKindFilter()
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
const [listMode, setListMode] = useState<TNoteListMode>(() => storage.getNoteListMode())
@ -79,6 +78,7 @@ export default function NormalFeed({
}}
/>
)}
<TrustScoreFilter />
{showKindsFilter && (
<KindFilter
showKinds={temporaryShowKinds}
@ -103,7 +103,6 @@ export default function NormalFeed({
showKinds={temporaryShowKinds}
subRequests={subRequests}
hideReplies={listMode === 'posts'}
hideUntrustedNotes={hideUntrustedNotes}
areAlgoRelays={areAlgoRelays}
showRelayCloseReason={showRelayCloseReason}
/>

View file

@ -2,16 +2,17 @@ import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { Event } from 'nostr-tools'
import { useState } from 'react'
import HideUntrustedContentButton from '../HideUntrustedContentButton'
import QuoteList from '../QuoteList'
import ReactionList from '../ReactionList'
import ReplyNoteList from '../ReplyNoteList'
import RepostList from '../RepostList'
import TrustScoreFilter from '../TrustScoreFilter'
import ZapList from '../ZapList'
import { Tabs, TTabValue } from './Tabs'
export default function NoteInteractions({ event }: { event: Event }) {
const [type, setType] = useState<TTabValue>('replies')
let list
switch (type) {
case 'replies':
@ -41,9 +42,7 @@ export default function NoteInteractions({ event }: { event: Event }) {
<ScrollBar orientation="horizontal" className="opacity-0 pointer-events-none" />
</ScrollArea>
<Separator orientation="vertical" className="h-6" />
<div className="size-10 flex items-center justify-center">
<HideUntrustedContentButton type="interactions" />
</div>
<TrustScoreFilter />
</div>
<Separator />
{list}

View file

@ -1,5 +1,6 @@
import NewNotesButton from '@/components/NewNotesButton'
import { Button } from '@/components/ui/button'
import { SPAMMER_PERCENTILE_THRESHOLD } from '@/constants'
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'
import { getEventKey, getKeyFromTag, isMentioningMutedUsers, isReplyNoteEvent } from '@/lib/event'
import { tagNameEquals } from '@/lib/tag'
@ -46,7 +47,6 @@ const NoteList = forwardRef<
showKinds?: number[]
filterMutedNotes?: boolean
hideReplies?: boolean
hideUntrustedNotes?: boolean
hideSpam?: boolean
areAlgoRelays?: boolean
showRelayCloseReason?: boolean
@ -61,7 +61,6 @@ const NoteList = forwardRef<
showKinds,
filterMutedNotes = true,
hideReplies = false,
hideUntrustedNotes = false,
hideSpam = false,
areAlgoRelays = false,
showRelayCloseReason = false,
@ -73,7 +72,7 @@ const NoteList = forwardRef<
) => {
const { t } = useTranslation()
const { startLogin } = useNostr()
const { isUserTrusted, isSpammer } = useUserTrust()
const { isSpammer, meetsMinTrustScore } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const { isEventDeleted } = useDeletedEvent()
@ -106,7 +105,6 @@ const NoteList = forwardRef<
if (pinnedEventHexIdSet.has(evt.id)) return true
if (isEventDeleted(evt)) return true
if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return true
if (filterMutedNotes && mutePubkeySet.has(evt.pubkey)) return true
if (
filterMutedNotes &&
@ -121,7 +119,7 @@ const NoteList = forwardRef<
return false
},
[hideUntrustedNotes, mutePubkeySet, JSON.stringify(pinnedEventIds), isEventDeleted, filterFn]
[mutePubkeySet, JSON.stringify(pinnedEventIds), isEventDeleted, filterFn]
)
useEffect(() => {
@ -195,7 +193,13 @@ const NoteList = forwardRef<
const _filteredNotes = (
await Promise.all(
filteredEvents.map(async (evt, i) => {
if (hideSpam && (await isSpammer(evt.pubkey))) {
// Check trust score filter
if (
!(await meetsMinTrustScore(
evt.pubkey,
hideSpam ? SPAMMER_PERCENTILE_THRESHOLD : undefined
))
) {
return null
}
const key = keys[i]
@ -213,7 +217,7 @@ const NoteList = forwardRef<
setFiltering(true)
processEvents().finally(() => setFiltering(false))
}, [events, shouldHideEvent, hideReplies, isSpammer, hideSpam])
}, [events, shouldHideEvent, hideReplies, hideSpam, meetsMinTrustScore])
useEffect(() => {
const processNewEvents = async () => {
@ -238,6 +242,10 @@ const NoteList = forwardRef<
if (hideSpam && (await isSpammer(evt.pubkey))) {
return null
}
// Check trust score filter
if (!(await meetsMinTrustScore(evt.pubkey))) {
return null
}
return evt
})
)
@ -245,7 +253,7 @@ const NoteList = forwardRef<
setFilteredNewEvents(_filteredNotes)
}
processNewEvents()
}, [newEvents, shouldHideEvent, isSpammer, hideSpam])
}, [newEvents, shouldHideEvent, isSpammer, hideSpam, meetsMinTrustScore])
const scrollToTop = (behavior: ScrollBehavior = 'instant') => {
setTimeout(() => {

View file

@ -1,11 +1,12 @@
import { ExtendedKind } from '@/constants'
import { notificationFilter } from '@/lib/notification'
import { isMentioningMutedUsers } from '@/lib/event'
import { tagNameEquals } from '@/lib/tag'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import { useEffect, useState } from 'react'
import { HighlightNotification } from './HighlightNotification'
import { MentionNotification } from './MentionNotification'
import { PollResponseNotification } from './PollResponseNotification'
@ -23,22 +24,51 @@ export function NotificationItem({
const { pubkey } = useNostr()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const { hideUntrustedNotifications, isUserTrusted } = useUserTrust()
const canShow = useMemo(() => {
return notificationFilter(notification, {
pubkey,
mutePubkeySet,
hideContentMentioningMutedUsers,
hideUntrustedNotifications,
isUserTrusted
})
const { minTrustScore, meetsMinTrustScore } = useUserTrust()
const [canShow, setCanShow] = useState(false)
useEffect(() => {
const checkCanShow = async () => {
// Check muted users
if (mutePubkeySet.has(notification.pubkey)) {
setCanShow(false)
return
}
// Check content mentioning muted users
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(notification, mutePubkeySet)) {
setCanShow(false)
return
}
// Check trust score
if (!(await meetsMinTrustScore(notification.pubkey))) {
setCanShow(false)
return
}
// Check reaction target for kind 7
if (pubkey && notification.kind === kinds.Reaction) {
const targetPubkey = notification.tags.findLast(tagNameEquals('p'))?.[1]
if (targetPubkey !== pubkey) {
setCanShow(false)
return
}
}
setCanShow(true)
}
checkCanShow()
}, [
notification,
pubkey,
mutePubkeySet,
hideContentMentioningMutedUsers,
hideUntrustedNotifications,
isUserTrusted
minTrustScore,
meetsMinTrustScore
])
if (!canShow) return null
if (notification.kind === kinds.Reaction) {

View file

@ -1,17 +1,18 @@
import { useSecondaryPage } from '@/PageManager'
import { useStuff } from '@/hooks/useStuff'
import { useStuffStatsById } from '@/hooks/useStuffStatsById'
import { toProfile } from '@/lib/link'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { TEmoji } from '@/types'
import { Event } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Emoji from '../Emoji'
import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import { useStuff } from '@/hooks/useStuff'
const SHOW_COUNT = 20
@ -19,14 +20,39 @@ export default function ReactionList({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { isSmallScreen } = useScreenSize()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { minTrustScore, meetsMinTrustScore } = useUserTrust()
const { stuffKey } = useStuff(stuff)
const noteStats = useStuffStatsById(stuffKey)
const filteredLikes = useMemo(() => {
return (noteStats?.likes ?? [])
.filter((like) => !hideUntrustedInteractions || isUserTrusted(like.pubkey))
.sort((a, b) => b.created_at - a.created_at)
}, [noteStats, stuffKey, hideUntrustedInteractions, isUserTrusted])
const [filteredLikes, setFilteredLikes] = useState<
Array<{
id: string
pubkey: string
emoji: string | TEmoji
created_at: number
}>
>([])
useEffect(() => {
const filterLikes = async () => {
const likes = noteStats?.likes ?? []
const filtered: {
id: string
pubkey: string
created_at: number
emoji: string | TEmoji
}[] = []
await Promise.all(
likes.map(async (like) => {
if (await meetsMinTrustScore(like.pubkey)) {
filtered.push(like)
}
})
)
filtered.sort((a, b) => b.created_at - a.created_at)
setFilteredLikes(filtered)
}
filterLikes()
}, [noteStats, stuffKey, minTrustScore, meetsMinTrustScore])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement | null>(null)

View file

@ -29,7 +29,7 @@ export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string })
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { pubkey, checkLogin } = useNostr()
const { hideUntrustedNotes, isUserTrusted, isSpammer } = useUserTrust()
const { isSpammer } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const [showEditor, setShowEditor] = useState(false)
const [myReview, setMyReview] = useState<NostrEvent | null>(null)
@ -103,7 +103,7 @@ export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string })
setInitialized(true)
}
init()
}, [relayUrl, pubkey, mutePubkeySet, hideUntrustedNotes, isUserTrusted])
}, [relayUrl, pubkey, mutePubkeySet])
const handleReviewed = (evt: NostrEvent) => {
setMyReview(evt)

View file

@ -10,7 +10,7 @@ import { useMuteList } from '@/providers/MuteListProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ClientTag from '../ClientTag'
import Collapsible from '../Collapsible'
@ -42,11 +42,13 @@ export default function ReplyNote({
const { isSmallScreen } = useScreenSize()
const { push } = useSecondaryPage()
const { mutePubkeySet } = useMuteList()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { minTrustScore, meetsMinTrustScore } = useUserTrust()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const eventKey = useMemo(() => getEventKey(event), [event])
const replies = useThread(eventKey)
const [showMuted, setShowMuted] = useState(false)
const [hasReplies, setHasReplies] = useState(false)
const show = useMemo(() => {
if (showMuted) {
return true
@ -59,24 +61,32 @@ export default function ReplyNote({
}
return true
}, [showMuted, mutePubkeySet, event, hideContentMentioningMutedUsers])
const hasReplies = useMemo(() => {
if (!replies || replies.length === 0) {
return false
useEffect(() => {
const checkHasReplies = async () => {
if (!replies || replies.length === 0) {
setHasReplies(false)
return
}
for (const reply of replies) {
if (mutePubkeySet.has(reply.pubkey)) {
continue
}
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(reply, mutePubkeySet)) {
continue
}
if (!(await meetsMinTrustScore(reply.pubkey))) {
continue
}
setHasReplies(true)
return
}
setHasReplies(false)
}
for (const reply of replies) {
if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) {
continue
}
if (mutePubkeySet.has(reply.pubkey)) {
continue
}
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(reply, mutePubkeySet)) {
continue
}
return true
}
}, [replies])
checkHasReplies()
}, [replies, minTrustScore, meetsMinTrustScore, mutePubkeySet, hideContentMentioningMutedUsers])
return (
<div

View file

@ -1,63 +1,19 @@
import { useSecondaryPage } from '@/PageManager'
import { useAllDescendantThreads } from '@/hooks/useThread'
import { getEventKey, getKeyFromTag, getParentTag, isMentioningMutedUsers } from '@/lib/event'
import { useFilteredAllReplies } from '@/hooks'
import { getEventKey, getKeyFromTag, getParentTag } from '@/lib/event'
import { toNote } from '@/lib/link'
import { generateBech32IdFromETag } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { NostrEvent } from 'nostr-tools'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ReplyNote from '../ReplyNote'
export default function SubReplies({ parentKey }: { parentKey: string }) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const allThreads = useAllDescendantThreads(parentKey)
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const [isExpanded, setIsExpanded] = useState(false)
const replies = useMemo(() => {
const replyKeySet = new Set<string>()
const replyEvents: NostrEvent[] = []
let parentKeys = [parentKey]
while (parentKeys.length > 0) {
const events = parentKeys.flatMap((key) => allThreads.get(key) ?? [])
events.forEach((evt) => {
const key = getEventKey(evt)
if (replyKeySet.has(key)) return
if (mutePubkeySet.has(evt.pubkey)) return
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) return
if (hideUntrustedInteractions && !isUserTrusted(evt.pubkey)) {
const replyKey = getEventKey(evt)
const repliesForThisReply = allThreads.get(replyKey)
// If the reply is not trusted and there are no trusted replies for this reply, skip rendering
if (
!repliesForThisReply ||
repliesForThisReply.every((evt) => !isUserTrusted(evt.pubkey))
) {
return
}
}
replyKeySet.add(key)
replyEvents.push(evt)
})
parentKeys = events.map((evt) => getEventKey(evt))
}
return replyEvents.sort((a, b) => a.created_at - b.created_at)
}, [
parentKey,
allThreads,
mutePubkeySet,
hideContentMentioningMutedUsers,
hideUntrustedInteractions
])
const { replies } = useFilteredAllReplies(parentKey)
const [highlightReplyKey, setHighlightReplyKey] = useState<string | undefined>(undefined)
const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})

View file

@ -1,10 +1,7 @@
import { useFilteredReplies } from '@/hooks'
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'
import { useStuff } from '@/hooks/useStuff'
import { useAllDescendantThreads } from '@/hooks/useThread'
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { getEventKey } from '@/lib/event'
import threadService from '@/services/thread.service'
import { Event as NEvent } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useState } from 'react'
@ -18,47 +15,9 @@ const SHOW_COUNT = 10
export default function ReplyNoteList({ stuff }: { stuff: NEvent | string }) {
const { t } = useTranslation()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const { stuffKey } = useStuff(stuff)
const allThreads = useAllDescendantThreads(stuffKey)
const [initialLoading, setInitialLoading] = useState(false)
const replies = useMemo(() => {
const replyKeySet = new Set<string>()
const thread = allThreads.get(stuffKey) || []
const replyEvents = thread.filter((evt) => {
const key = getEventKey(evt)
if (replyKeySet.has(key)) return false
if (mutePubkeySet.has(evt.pubkey)) return false
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) {
return false
}
if (hideUntrustedInteractions && !isUserTrusted(evt.pubkey)) {
const replyKey = getEventKey(evt)
const repliesForThisReply = allThreads.get(replyKey)
// If the reply is not trusted and there are no trusted replies for this reply, skip rendering
if (
!repliesForThisReply ||
repliesForThisReply.every((evt) => !isUserTrusted(evt.pubkey))
) {
return false
}
}
replyKeySet.add(key)
return true
})
return replyEvents.sort((a, b) => b.created_at - a.created_at)
}, [
stuffKey,
allThreads,
mutePubkeySet,
hideContentMentioningMutedUsers,
hideUntrustedInteractions,
isUserTrusted
])
const { replies } = useFilteredReplies(stuffKey)
// Initial subscription
useEffect(() => {

View file

@ -6,7 +6,7 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { Repeat } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05'
@ -19,13 +19,33 @@ export default function RepostList({ event }: { event: Event }) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { isSmallScreen } = useScreenSize()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { minTrustScore, meetsMinTrustScore } = useUserTrust()
const noteStats = useStuffStatsById(getEventKey(event))
const filteredReposts = useMemo(() => {
return (noteStats?.reposts ?? [])
.filter((repost) => !hideUntrustedInteractions || isUserTrusted(repost.pubkey))
.sort((a, b) => b.created_at - a.created_at)
}, [noteStats, event.id, hideUntrustedInteractions, isUserTrusted])
const [filteredReposts, setFilteredReposts] = useState<
Array<{ id: string; pubkey: string; created_at: number }>
>([])
useEffect(() => {
const filterReposts = async () => {
const reposts = noteStats?.reposts ?? []
const filtered = (
await Promise.all(
reposts.map(async (repost) => {
if (await meetsMinTrustScore(repost.pubkey)) {
return repost
}
})
)
).filter(Boolean) as {
id: string
pubkey: string
created_at: number
}[]
filtered.sort((a, b) => b.created_at - a.created_at)
setFilteredReposts(filtered)
}
filterReposts()
}, [noteStats, event.id, minTrustScore, meetsMinTrustScore])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement | null>(null)

View file

@ -27,23 +27,38 @@ export default function LikeButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { pubkey, publish, checkLogin } = useNostr()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { meetsMinTrustScore } = useUserTrust()
const { quickReaction, quickReactionEmoji } = useUserPreferences()
const { event, externalContent, stuffKey } = useStuff(stuff)
const [liking, setLiking] = useState(false)
const [isEmojiReactionsOpen, setIsEmojiReactionsOpen] = useState(false)
const [isPickerOpen, setIsPickerOpen] = useState(false)
const [likeCount, setLikeCount] = useState(0)
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null)
const isLongPressRef = useRef(false)
const noteStats = useStuffStatsById(stuffKey)
const { myLastEmoji, likeCount } = useMemo(() => {
const myLastEmoji = useMemo(() => {
const stats = noteStats || {}
const myLike = stats.likes?.find((like) => like.pubkey === pubkey)
const likes = hideUntrustedInteractions
? stats.likes?.filter((like) => isUserTrusted(like.pubkey))
: stats.likes
return { myLastEmoji: myLike?.emoji, likeCount: likes?.length }
}, [noteStats, pubkey, hideUntrustedInteractions])
return myLike?.emoji
}, [noteStats, pubkey])
useEffect(() => {
const filterLikes = async () => {
const stats = noteStats || {}
const likes = stats.likes || []
let count = 0
await Promise.all(
likes.map(async (like) => {
if (await meetsMinTrustScore(like.pubkey)) {
count++
}
})
)
setLikeCount(count)
}
filterLikes()
}, [noteStats, meetsMinTrustScore])
useEffect(() => {
setTimeout(() => setIsPickerOpen(false), 100)

View file

@ -1,55 +1,19 @@
import { useFilteredAllReplies } from '@/hooks'
import { useStuff } from '@/hooks/useStuff'
import { useAllDescendantThreads } from '@/hooks/useThread'
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { MessageCircle } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import PostEditor from '../PostEditor'
import { formatCount } from './utils'
export default function ReplyButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation()
const { pubkey, checkLogin } = useNostr()
const { event, stuffKey } = useStuff(stuff)
const allThreads = useAllDescendantThreads(stuffKey)
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const { replyCount, hasReplied } = useMemo(() => {
const hasReplied = pubkey
? allThreads.get(stuffKey)?.some((evt) => evt.pubkey === pubkey)
: false
let replyCount = 0
const replies = [...(allThreads.get(stuffKey) ?? [])]
while (replies.length > 0) {
const reply = replies.pop()
if (!reply) break
const replyKey = getEventKey(reply)
const nestedReplies = allThreads.get(replyKey) ?? []
replies.push(...nestedReplies)
if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) {
continue
}
if (mutePubkeySet.has(reply.pubkey)) {
continue
}
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(reply, mutePubkeySet)) {
continue
}
replyCount++
}
return { replyCount, hasReplied }
}, [allThreads, event, stuffKey, hideUntrustedInteractions])
const { checkLogin } = useNostr()
const { stuffKey } = useStuff(stuff)
const { replies, hasReplied } = useFilteredAllReplies(stuffKey)
const [open, setOpen] = useState(false)
return (
@ -68,7 +32,7 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
title={t('Reply')}
>
<MessageCircle />
{!!replyCount && <div className="text-sm">{formatCount(replyCount)}</div>}
{!!replies.length && <div className="text-sm">{formatCount(replies.length)}</div>}
</button>
<PostEditor parentStuff={stuff} open={open} setOpen={setOpen} />
</>

View file

@ -17,7 +17,7 @@ import { useUserTrust } from '@/providers/UserTrustProvider'
import stuffStatsService from '@/services/stuff-stats.service'
import { Loader, PencilLine, Repeat } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import PostEditor from '../PostEditor'
import { formatCount } from './utils'
@ -25,24 +25,38 @@ import { formatCount } from './utils'
export default function RepostButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { meetsMinTrustScore } = useUserTrust()
const { publish, checkLogin, pubkey } = useNostr()
const { event, stuffKey } = useStuff(stuff)
const noteStats = useStuffStatsById(stuffKey)
const [reposting, setReposting] = useState(false)
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false)
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const { repostCount, hasReposted } = useMemo(() => {
// external content
if (!event) return { repostCount: 0, hasReposted: false }
const [repostCount, setRepostCount] = useState(0)
const hasReposted = useMemo(() => {
return pubkey ? noteStats?.repostPubkeySet?.has(pubkey) : false
}, [noteStats, pubkey])
return {
repostCount: hideUntrustedInteractions
? noteStats?.reposts?.filter((repost) => isUserTrusted(repost.pubkey)).length
: noteStats?.reposts?.length,
hasReposted: pubkey ? noteStats?.repostPubkeySet?.has(pubkey) : false
useEffect(() => {
const filterReposts = async () => {
if (!event) {
setRepostCount(0)
return
}
const reposts = noteStats?.reposts || []
let count = 0
await Promise.all(
reposts.map(async (repost) => {
if (await meetsMinTrustScore(repost.pubkey)) {
count++
}
})
)
setRepostCount(count)
}
}, [noteStats, event, hideUntrustedInteractions])
filterReposts()
}, [noteStats, event, meetsMinTrustScore])
const canRepost = !hasReposted && !reposting && !!event
const repost = async () => {

View file

@ -0,0 +1,171 @@
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent, DrawerHeader, DrawerTrigger } from '@/components/ui/drawer'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Slider } from '@/components/ui/slider'
import { cn } from '@/lib/utils'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { Shield, ShieldCheck } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
const TRUST_LEVELS = [
{ value: 0, label: 'trust-filter.off' },
{ value: 60, label: 'trust-filter.low' },
{ value: 80, label: 'trust-filter.medium' },
{ value: 90, label: 'trust-filter.high' },
{ value: 100, label: 'trust-filter.wot' }
]
function getDescription(score: number, t: (key: string, options?: any) => string) {
if (score === 0) {
return t('trust-filter.show-all-content')
} else if (score === 100) {
return t('trust-filter.only-show-wot')
} else {
return t('trust-filter.hide-bottom-percent', { score })
}
}
export default function TrustScoreFilter() {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { minTrustScore, updateMinTrustScore } = useUserTrust()
const [open, setOpen] = useState(false)
const [temporaryScore, setTemporaryScore] = useState(minTrustScore)
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
setTemporaryScore(minTrustScore)
}, [minTrustScore])
// Debounced update function
const handleScoreChange = (newScore: number) => {
setTemporaryScore(newScore)
// Clear existing timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
}
// Set new timer for debounced update
debounceTimerRef.current = setTimeout(() => {
updateMinTrustScore(newScore)
}, 300) // 300ms debounce delay
}
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
}
}
}, [])
const description = getDescription(temporaryScore, t)
const trigger = (
<Button
variant="ghost"
size="titlebar-icon"
className={cn(
'relative w-fit px-3',
minTrustScore === 0
? 'text-muted-foreground hover:text-foreground'
: 'text-primary hover:text-primary-hover'
)}
onClick={() => {
if (isSmallScreen) {
setOpen(true)
}
}}
>
{minTrustScore < 100 ? <Shield size={16} /> : <ShieldCheck size={16} />}
{minTrustScore > 0 && minTrustScore < 100 && (
<div className="absolute inset-0 w-full h-full flex flex-col items-center justify-center text-[0.55rem] font-mono font-bold">
{minTrustScore}
</div>
)}
</Button>
)
const content = (
<>
{/* Slider */}
<div className="space-y-2">
<div className="flex items-baseline justify-between">
<span className="text-sm text-muted-foreground">
{t('trust-filter.filter-threshold')}
</span>
<span className="text-lg font-semibold text-primary">
{temporaryScore === 0 ? t('trust-filter.off') : `${temporaryScore}%`}
</span>
</div>
<Slider
value={[temporaryScore]}
onValueChange={([value]) => handleScoreChange(value)}
min={0}
max={100}
step={5}
className="w-full"
/>
</div>
{/* Quick Presets */}
<div className="space-y-1.5">
<div className="text-xs text-muted-foreground">{t('trust-filter.quick-presets')}</div>
<div className="flex flex-wrap gap-1.5">
{TRUST_LEVELS.map((level) => (
<button
key={level.value}
onClick={() => handleScoreChange(level.value)}
className={cn(
'text-center py-1.5 px-2 flex-1 rounded text-xs transition-all duration-200',
temporaryScore === level.value
? 'bg-primary text-primary-foreground font-medium shadow-sm'
: 'bg-secondary hover:bg-secondary/80 hover:shadow-sm hover:scale-[1.02]'
)}
>
{t(level.label)}
</button>
))}
</div>
</div>
{/* Description */}
<div className="space-y-1 pt-2 border-t">
<div className="text-sm font-medium text-foreground">{description}</div>
<div className="text-xs text-muted-foreground">
{t('trust-filter.trust-score-description')}
</div>
</div>
</>
)
if (isSmallScreen) {
return (
<>
{trigger}
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild></DrawerTrigger>
<DrawerContent className="px-4 pb-4">
<DrawerHeader className="text-base font-semibold">
{t('trust-filter.title')}
</DrawerHeader>
<div className="space-y-4 pb-4">{content}</div>
</DrawerContent>
</Drawer>
</>
)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent className="p-4 space-y-4 w-96" collisionPadding={16} sideOffset={0}>
{content}
</PopoverContent>
</Popover>
)
}

View file

@ -34,6 +34,7 @@ import PullToRefresh from 'react-simple-pull-to-refresh'
import { toast } from 'sonner'
import { LoadingBar } from '../LoadingBar'
import NewNotesButton from '../NewNotesButton'
import TrustScoreBadge from '../TrustScoreBadge'
const LIMIT = 500
const SHOW_COUNT = 20
@ -66,14 +67,16 @@ const UserAggregationList = forwardRef<
const { t } = useTranslation()
const { pubkey: currentPubkey, startLogin } = useNostr()
const { push } = useSecondaryPage()
const { hideUntrustedNotes, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { pinnedPubkeySet } = usePinnedUsers()
const { meetsMinTrustScore } = useUserTrust()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const { isEventDeleted } = useDeletedEvent()
const [since, setSince] = useState(() => dayjs().subtract(1, 'day').unix())
const [events, setEvents] = useState<Event[]>([])
const [filteredEvents, setFilteredEvents] = useState<Event[]>([])
const [newEvents, setNewEvents] = useState<Event[]>([])
const [filteredNewEvents, setFilteredNewEvents] = useState<Event[]>([])
const [newEventPubkeys, setNewEventPubkeys] = useState<Set<string>>(new Set())
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [loading, setLoading] = useState(true)
@ -233,31 +236,40 @@ const UserAggregationList = forwardRef<
return () => clearTimeout(timeout)
}, [loading])
const shouldHideEvent = useCallback(
(evt: Event) => {
if (evt.pubkey === currentPubkey) return true
if (isEventDeleted(evt)) return true
if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return true
if (filterMutedNotes && mutePubkeySet.has(evt.pubkey)) return true
if (
filterMutedNotes &&
hideContentMentioningMutedUsers &&
isMentioningMutedUsers(evt, mutePubkeySet)
) {
return true
}
const filterEvents = useCallback(
async (events: Event[]) => {
const results = await Promise.allSettled(
events.map(async (evt) => {
if (evt.pubkey === currentPubkey) return null
if (evt.created_at < since) return null
if (isEventDeleted(evt)) return null
if (filterMutedNotes && mutePubkeySet.has(evt.pubkey)) return null
if (
filterMutedNotes &&
hideContentMentioningMutedUsers &&
isMentioningMutedUsers(evt, mutePubkeySet)
) {
return null
}
if (!(await meetsMinTrustScore(evt.pubkey))) {
return null
}
return false
return evt
})
)
return results
.filter((res) => res.status === 'fulfilled' && res.value !== null)
.map((res) => (res as PromiseFulfilledResult<Event>).value)
},
[
hideUntrustedNotes,
mutePubkeySet,
isEventDeleted,
currentPubkey,
filterMutedNotes,
isUserTrusted,
hideContentMentioningMutedUsers,
isMentioningMutedUsers
isMentioningMutedUsers,
meetsMinTrustScore
]
)
@ -265,13 +277,17 @@ const UserAggregationList = forwardRef<
return dayjs().diff(dayjs.unix(since), 'day')
}, [since])
const filteredEvents = useMemo(() => {
return events.filter((evt) => evt.created_at >= since && !shouldHideEvent(evt))
}, [events, since, shouldHideEvent])
useEffect(() => {
filterEvents(events).then((filtered) => {
setFilteredEvents(filtered)
})
}, [events, filterEvents])
const filteredNewEvents = useMemo(() => {
return newEvents.filter((evt) => evt.created_at >= since && !shouldHideEvent(evt))
}, [newEvents, since, shouldHideEvent])
useEffect(() => {
filterEvents(newEvents).then((filtered) => {
setFilteredNewEvents(filtered)
})
}, [newEvents, filterEvents])
const aggregations = useMemo(() => {
const aggs = userAggregationService.aggregateByUser(filteredEvents)
@ -528,25 +544,28 @@ function UserAggregationItem({
)}
<div className="flex-1 min-w-0 flex flex-col">
{supportTouch ? (
<SimpleUsername
userId={aggregation.pubkey}
className={cn(
'font-semibold text-base truncate max-w-fit',
!hasNewEvents && 'text-muted-foreground'
)}
skeletonClassName="h-4"
/>
) : (
<Username
userId={aggregation.pubkey}
className={cn(
'font-semibold text-base truncate max-w-fit',
!hasNewEvents && 'text-muted-foreground'
)}
skeletonClassName="h-4"
/>
)}
<div className="flex items-center gap-2">
{supportTouch ? (
<SimpleUsername
userId={aggregation.pubkey}
className={cn(
'font-semibold text-base truncate max-w-fit',
!hasNewEvents && 'text-muted-foreground'
)}
skeletonClassName="h-4"
/>
) : (
<Username
userId={aggregation.pubkey}
className={cn(
'font-semibold text-base truncate max-w-fit',
!hasNewEvents && 'text-muted-foreground'
)}
skeletonClassName="h-4"
/>
)}
<TrustScoreBadge pubkey={aggregation.pubkey} />
</div>
<FormattedTimestamp
timestamp={aggregation.lastEventTime}
className="text-sm text-muted-foreground"

View file

@ -23,11 +23,8 @@ export const StorageKey = {
LAST_READ_NOTIFICATION_TIME_MAP: 'lastReadNotificationTimeMap',
ACCOUNT_FEED_INFO_MAP: 'accountFeedInfoMap',
AUTOPLAY: 'autoplay',
HIDE_UNTRUSTED_INTERACTIONS: 'hideUntrustedInteractions',
HIDE_UNTRUSTED_NOTIFICATIONS: 'hideUntrustedNotifications',
TRANSLATION_SERVICE_CONFIG_MAP: 'translationServiceConfigMap',
MEDIA_UPLOAD_SERVICE_CONFIG_MAP: 'mediaUploadServiceConfigMap',
HIDE_UNTRUSTED_NOTES: 'hideUntrustedNotes',
DISMISSED_TOO_MANY_RELAYS_ALERT: 'dismissedTooManyRelaysAlert',
SHOW_KINDS: 'showKinds',
SHOW_KINDS_VERSION: 'showKindsVersion',
@ -44,6 +41,10 @@ export const StorageKey = {
QUICK_REACTION: 'quickReaction',
QUICK_REACTION_EMOJI: 'quickReactionEmoji',
NSFW_DISPLAY_POLICY: 'nsfwDisplayPolicy',
MIN_TRUST_SCORE: 'minTrustScore',
HIDE_UNTRUSTED_NOTES: 'hideUntrustedNotes', // deprecated
HIDE_UNTRUSTED_INTERACTIONS: 'hideUntrustedInteractions', // deprecated
HIDE_UNTRUSTED_NOTIFICATIONS: 'hideUntrustedNotifications', // deprecated
DEFAULT_SHOW_NSFW: 'defaultShowNsfw', // deprecated
PINNED_PUBKEYS: 'pinnedPubkeys', // deprecated
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated
@ -466,3 +467,5 @@ export const PRIMARY_COLORS = {
export type TPrimaryColor = keyof typeof PRIMARY_COLORS
export const LONG_PRESS_THRESHOLD = 400
export const SPAMMER_PERCENTILE_THRESHOLD = 60

View file

@ -5,6 +5,7 @@ export * from './useFetchProfile'
export * from './useFetchRelayInfo'
export * from './useFetchRelayInfos'
export * from './useFetchRelayList'
export * from './useFilteredReplies'
export * from './useInfiniteScroll'
export * from './useSearchProfiles'
export * from './useTranslatedEvent'

View file

@ -0,0 +1,160 @@
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { NostrEvent } from 'nostr-tools'
import { useEffect, useState } from 'react'
import { useAllDescendantThreads } from './useThread'
export function useFilteredReplies(stuffKey: string) {
const { pubkey } = useNostr()
const { minTrustScore, meetsMinTrustScore } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const allThreads = useAllDescendantThreads(stuffKey)
const [replies, setReplies] = useState<NostrEvent[]>([])
const [hasReplied, setHasReplied] = useState(false)
useEffect(() => {
const filterReplies = async () => {
const replyKeySet = new Set<string>()
const thread = allThreads.get(stuffKey) || []
const filtered: NostrEvent[] = []
await Promise.all(
thread.map(async (evt) => {
const key = getEventKey(evt)
if (replyKeySet.has(key)) return
replyKeySet.add(key)
if (mutePubkeySet.has(evt.pubkey)) return
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) return
const meetsTrust = await meetsMinTrustScore(evt.pubkey)
if (!meetsTrust) {
const replyKey = getEventKey(evt)
const repliesForThisReply = allThreads.get(replyKey)
// If the reply is not trusted, check if there are any trusted replies for this reply
if (repliesForThisReply && repliesForThisReply.length > 0) {
let hasTrustedReply = false
for (const reply of repliesForThisReply) {
if (await meetsMinTrustScore(reply.pubkey)) {
hasTrustedReply = true
break
}
}
if (!hasTrustedReply) return
} else {
return
}
}
filtered.push(evt)
})
)
filtered.sort((a, b) => b.created_at - a.created_at)
setReplies(filtered)
}
filterReplies()
}, [
stuffKey,
allThreads,
mutePubkeySet,
hideContentMentioningMutedUsers,
minTrustScore,
meetsMinTrustScore
])
useEffect(() => {
let replied = false
for (const reply of replies) {
if (reply.pubkey === pubkey) {
replied = true
break
}
}
setHasReplied(replied)
}, [replies, pubkey])
return { replies, hasReplied }
}
export function useFilteredAllReplies(stuffKey: string) {
const { pubkey } = useNostr()
const allThreads = useAllDescendantThreads(stuffKey)
const { minTrustScore, meetsMinTrustScore } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const [replies, setReplies] = useState<NostrEvent[]>([])
const [hasReplied, setHasReplied] = useState(false)
useEffect(() => {
const filterReplies = async () => {
const replyKeySet = new Set<string>()
const replyEvents: NostrEvent[] = []
let parentKeys = [stuffKey]
while (parentKeys.length > 0) {
const events = parentKeys.flatMap((key) => allThreads.get(key) ?? [])
await Promise.all(
events.map(async (evt) => {
const key = getEventKey(evt)
if (replyKeySet.has(key)) return
replyKeySet.add(key)
if (mutePubkeySet.has(evt.pubkey)) return
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet))
return
const meetsTrust = await meetsMinTrustScore(evt.pubkey)
if (!meetsTrust) {
const replyKey = getEventKey(evt)
const repliesForThisReply = allThreads.get(replyKey)
// If the reply is not trusted, check if there are any trusted replies for this reply
if (repliesForThisReply && repliesForThisReply.length > 0) {
let hasTrustedReply = false
for (const reply of repliesForThisReply) {
if (await meetsMinTrustScore(reply.pubkey)) {
hasTrustedReply = true
break
}
}
if (!hasTrustedReply) return
} else {
return
}
}
replyEvents.push(evt)
})
)
parentKeys = events.map((evt) => getEventKey(evt))
}
setReplies(replyEvents.sort((a, b) => a.created_at - b.created_at))
}
filterReplies()
}, [
stuffKey,
allThreads,
mutePubkeySet,
hideContentMentioningMutedUsers,
minTrustScore,
meetsMinTrustScore
])
useEffect(() => {
let replied = false
for (const reply of replies) {
if (reply.pubkey === pubkey) {
replied = true
break
}
}
setHasReplied(replied)
}, [replies, pubkey])
return { replies, hasReplied }
}

View file

@ -638,6 +638,19 @@ export default {
'Enter Password': 'أدخل كلمة المرور',
Password: 'كلمة المرور',
Confirm: 'تأكيد',
'trust-filter.title': 'مرشح درجة الثقة',
'trust-filter.off': 'إيقاف',
'trust-filter.low': 'منخفض',
'trust-filter.medium': 'متوسط',
'trust-filter.high': 'عالي',
'trust-filter.wot': 'WoT',
'trust-filter.filter-threshold': 'عتبة التصفية',
'trust-filter.quick-presets': 'إعدادات سريعة',
'trust-filter.show-all-content': 'إظهار جميع المحتويات',
'trust-filter.only-show-wot': 'إظهار شبكة الثقة الخاصة بك فقط (المتابَعون + متابَعوهم)',
'trust-filter.hide-bottom-percent':
'تصفية أدنى {{score}}٪ من المستخدمين حسب تصنيف الثقة',
'trust-filter.trust-score-description': 'تصنف درجة الثقة المستخدمين حسب النسبة المئوية للسمعة',
'Auto-load profile pictures': 'تحميل صور الملف الشخصي تلقائيًا'
}
}

View file

@ -659,6 +659,20 @@ export default {
'Enter Password': 'Passwort eingeben',
Password: 'Passwort',
Confirm: 'Bestätigen',
'trust-filter.title': 'Vertrauenswert-Filter',
'trust-filter.off': 'Aus',
'trust-filter.low': 'Niedrig',
'trust-filter.medium': 'Mittel',
'trust-filter.high': 'Hoch',
'trust-filter.wot': 'WoT',
'trust-filter.filter-threshold': 'Filterschwelle',
'trust-filter.quick-presets': 'Schnellvoreinstellungen',
'trust-filter.show-all-content': 'Alle Inhalte anzeigen',
'trust-filter.only-show-wot': 'Nur Ihr Vertrauensnetzwerk anzeigen (Folgende + deren Folgende)',
'trust-filter.hide-bottom-percent':
'Untere {{score}}% der Benutzer nach Vertrauensrang filtern',
'trust-filter.trust-score-description':
'Der Vertrauenswert ordnet Benutzer nach Reputationsperzentil',
'Auto-load profile pictures': 'Profilbilder automatisch laden'
}
}

View file

@ -643,6 +643,19 @@ export default {
'Enter Password': 'Enter Password',
Password: 'Password',
Confirm: 'Confirm',
'trust-filter.title': 'Trust Score Filter',
'trust-filter.off': 'Off',
'trust-filter.low': 'Low',
'trust-filter.medium': 'Medium',
'trust-filter.high': 'High',
'trust-filter.wot': 'WoT',
'trust-filter.filter-threshold': 'Filter Threshold',
'trust-filter.quick-presets': 'Quick presets',
'trust-filter.show-all-content': 'Show all content',
'trust-filter.only-show-wot': 'Only show your Web of Trust (follows + their follows)',
'trust-filter.hide-bottom-percent':
'Filter out bottom {{score}}% of users by trust rank',
'trust-filter.trust-score-description': 'Trust score ranks users by reputation percentile',
'Auto-load profile pictures': 'Auto-load profile pictures'
}
}

View file

@ -653,6 +653,20 @@ export default {
'Enter Password': 'Ingresar contraseña',
Password: 'Contraseña',
Confirm: 'Confirmar',
'trust-filter.title': 'Filtro de puntuación de confianza',
'trust-filter.off': 'Desactivado',
'trust-filter.low': 'Bajo',
'trust-filter.medium': 'Medio',
'trust-filter.high': 'Alto',
'trust-filter.wot': 'WoT',
'trust-filter.filter-threshold': 'Umbral de filtro',
'trust-filter.quick-presets': 'Ajustes rápidos',
'trust-filter.show-all-content': 'Mostrar todo el contenido',
'trust-filter.only-show-wot': 'Mostrar solo tu red de confianza (seguidos + sus seguidos)',
'trust-filter.hide-bottom-percent':
'Filtrar el {{score}}% inferior de usuarios por clasificación de confianza',
'trust-filter.trust-score-description':
'La puntuación de confianza clasifica a los usuarios por percentil de reputación',
'Auto-load profile pictures': 'Cargar imágenes de perfil automáticamente'
}
}

View file

@ -648,6 +648,21 @@ export default {
'Enter Password': 'رمز عبور را وارد کنید',
Password: 'رمز عبور',
Confirm: 'تأیید',
'trust-filter.title': 'فیلتر امتیاز اعتماد',
'trust-filter.off': 'خاموش',
'trust-filter.low': 'پایین',
'trust-filter.medium': 'متوسط',
'trust-filter.high': 'بالا',
'trust-filter.wot': 'WoT',
'trust-filter.filter-threshold': 'آستانه فیلتر',
'trust-filter.quick-presets': 'تنظیمات سریع',
'trust-filter.show-all-content': 'نمایش همه محتوا',
'trust-filter.only-show-wot':
'فقط شبکه اعتماد شما را نشان دهید (دنبال‌شوندگان + دنبال‌شوندگان آنها)',
'trust-filter.hide-bottom-percent':
'فیلتر کردن {{score}}٪ پایین‌ترین کاربران بر اساس رتبه اعتماد',
'trust-filter.trust-score-description':
'امتیاز اعتماد کاربران را بر اساس صدک شهرت رتبه‌بندی می‌کند',
'Auto-load profile pictures': 'بارگذاری خودکار تصاویر پروفایل'
}
}

View file

@ -656,6 +656,21 @@ export default {
'Enter Password': 'Entrer le mot de passe',
Password: 'Mot de passe',
Confirm: 'Confirmer',
'trust-filter.title': 'Filtre de score de confiance',
'trust-filter.off': 'Désactivé',
'trust-filter.low': 'Faible',
'trust-filter.medium': 'Moyen',
'trust-filter.high': 'Élevé',
'trust-filter.wot': 'WoT',
'trust-filter.filter-threshold': 'Seuil de filtrage',
'trust-filter.quick-presets': 'Préréglages rapides',
'trust-filter.show-all-content': 'Afficher tout le contenu',
'trust-filter.only-show-wot':
'Afficher uniquement votre réseau de confiance (abonnements + leurs abonnements)',
'trust-filter.hide-bottom-percent':
'Filtrer les {{score}}% inférieurs des utilisateurs par classement de confiance',
'trust-filter.trust-score-description':
'Le score de confiance classe les utilisateurs par percentile de réputation',
'Auto-load profile pictures': 'Charger les images de profil automatiquement'
}
}

View file

@ -649,6 +649,20 @@ export default {
'Enter Password': 'पासवर्ड दर्ज करें',
Password: 'पासवर्ड',
Confirm: 'पुष्टि करें',
'trust-filter.title': 'विश्वास स्कोर फ़िल्टर',
'trust-filter.off': 'बंद',
'trust-filter.low': 'कम',
'trust-filter.medium': 'मध्यम',
'trust-filter.high': 'उच्च',
'trust-filter.wot': 'WoT',
'trust-filter.filter-threshold': 'फ़िल्टर सीमा',
'trust-filter.quick-presets': 'त्वरित प्रीसेट',
'trust-filter.show-all-content': 'सभी सामग्री दिखाएं',
'trust-filter.only-show-wot': 'केवल अपना विश्वास नेटवर्क दिखाएं (फ़ॉलो + उनके फ़ॉलो)',
'trust-filter.hide-bottom-percent':
'विश्वास रैंक द्वारा निचले {{score}}% उपयोगकर्ताओं को फ़िल्टर करें',
'trust-filter.trust-score-description':
'विश्वास स्कोर प्रतिष्ठा प्रतिशतक द्वारा उपयोगकर्ताओं को रैंक करता है',
'Auto-load profile pictures': 'प्रोफ़ाइल चित्र स्वतः लोड करें'
}
}

View file

@ -641,6 +641,21 @@ export default {
'Enter Password': 'Jelszó megadása',
Password: 'Jelszó',
Confirm: 'Megerősítés',
'trust-filter.title': 'Bizalmi pontszám szűrő',
'trust-filter.off': 'Ki',
'trust-filter.low': 'Alacsony',
'trust-filter.medium': 'Közepes',
'trust-filter.high': 'Magas',
'trust-filter.wot': 'WoT',
'trust-filter.filter-threshold': 'Szűrési küszöb',
'trust-filter.quick-presets': 'Gyors beállítások',
'trust-filter.show-all-content': 'Minden tartalom megjelenítése',
'trust-filter.only-show-wot':
'Csak a bizalmi hálózatod megjelenítése (követettek + követetteik)',
'trust-filter.hide-bottom-percent':
'Alsó {{score}}% felhasználók szűrése bizalmi rangsor szerint',
'trust-filter.trust-score-description':
'A bizalmi pontszám a felhasználókat hírnév percentilis szerint rangsorolja',
'Auto-load profile pictures': 'Profilképek automatikus betöltése'
}
}

View file

@ -653,6 +653,20 @@ export default {
'Enter Password': 'Inserisci password',
Password: 'Password',
Confirm: 'Conferma',
'trust-filter.title': 'Filtro punteggio di fiducia',
'trust-filter.off': 'Disattivato',
'trust-filter.low': 'Basso',
'trust-filter.medium': 'Medio',
'trust-filter.high': 'Alto',
'trust-filter.wot': 'WoT',
'trust-filter.filter-threshold': 'Soglia di filtro',
'trust-filter.quick-presets': 'Preimpostazioni rapide',
'trust-filter.show-all-content': 'Mostra tutti i contenuti',
'trust-filter.only-show-wot': 'Mostra solo la tua rete di fiducia (seguiti + i loro seguiti)',
'trust-filter.hide-bottom-percent':
'Filtra il {{score}}% inferiore degli utenti per classifica di fiducia',
'trust-filter.trust-score-description':
'Il punteggio di fiducia classifica gli utenti per percentile di reputazione',
'Auto-load profile pictures': 'Caricamento automatico immagini di profilo'
}
}

View file

@ -647,6 +647,21 @@ export default {
'Enter Password': 'パスワードを入力',
Password: 'パスワード',
Confirm: '確認',
'trust-filter.title': '信頼スコアフィルター',
'trust-filter.off': 'オフ',
'trust-filter.low': '低',
'trust-filter.medium': '中',
'trust-filter.high': '高',
'trust-filter.wot': 'WoT',
'trust-filter.filter-threshold': 'フィルター閾値',
'trust-filter.quick-presets': 'クイックプリセット',
'trust-filter.show-all-content': 'すべてのコンテンツを表示',
'trust-filter.only-show-wot':
'あなたの信頼ネットワークのみを表示(フォロー + フォローのフォロー)',
'trust-filter.hide-bottom-percent':
'信頼ランク下位 {{score}}% のユーザーをフィルタリング',
'trust-filter.trust-score-description':
'信頼スコアは評判パーセンタイルでユーザーをランク付けします',
'Auto-load profile pictures': 'プロフィール画像を自動読み込み'
}
}

View file

@ -644,6 +644,18 @@ export default {
'Enter Password': '비밀번호 입력',
Password: '비밀번호',
Confirm: '확인',
'trust-filter.title': '신뢰 점수 필터',
'trust-filter.off': '끄기',
'trust-filter.low': '낮음',
'trust-filter.medium': '중간',
'trust-filter.high': '높음',
'trust-filter.wot': 'WoT',
'trust-filter.filter-threshold': '필터 임계값',
'trust-filter.quick-presets': '빠른 프리셋',
'trust-filter.show-all-content': '모든 콘텐츠 표시',
'trust-filter.only-show-wot': '신뢰 네트워크만 표시 (팔로우 + 팔로우의 팔로우)',
'trust-filter.hide-bottom-percent': '신뢰도 하위 {{score}}% 사용자 필터링',
'trust-filter.trust-score-description': '신뢰 점수는 평판 백분위수로 사용자를 순위 매깁니다',
'Auto-load profile pictures': '프로필 사진 자동 로드'
}
}

View file

@ -654,6 +654,20 @@ export default {
'Enter Password': 'Wprowadź hasło',
Password: 'Hasło',
Confirm: 'Potwierdź',
'trust-filter.title': 'Filtr wyniku zaufania',
'trust-filter.off': 'Wyłączony',
'trust-filter.low': 'Niski',
'trust-filter.medium': 'Średni',
'trust-filter.high': 'Wysoki',
'trust-filter.wot': 'WoT',
'trust-filter.filter-threshold': 'Próg filtrowania',
'trust-filter.quick-presets': 'Szybkie ustawienia',
'trust-filter.show-all-content': 'Pokaż całą zawartość',
'trust-filter.only-show-wot': 'Pokaż tylko swoją sieć zaufania (obserwowani + ich obserwowani)',
'trust-filter.hide-bottom-percent':
'Filtruj dolne {{score}}% użytkowników według rankingu zaufania',
'trust-filter.trust-score-description':
'Wynik zaufania klasyfikuje użytkowników według percentyla reputacji',
'Auto-load profile pictures': 'Automatyczne ładowanie zdjęć profilowych'
}
}

View file

@ -649,6 +649,21 @@ export default {
'Enter Password': 'Digite a senha',
Password: 'Senha',
Confirm: 'Confirmar',
'trust-filter.title': 'Filtro de pontuação de confiança',
'trust-filter.off': 'Desativado',
'trust-filter.low': 'Baixo',
'trust-filter.medium': 'Médio',
'trust-filter.high': 'Alto',
'trust-filter.wot': 'WoT',
'trust-filter.filter-threshold': 'Limite de filtro',
'trust-filter.quick-presets': 'Predefinições rápidas',
'trust-filter.show-all-content': 'Mostrar todo o conteúdo',
'trust-filter.only-show-wot':
'Mostrar apenas sua rede de confiança (seguidos + seguidos deles)',
'trust-filter.hide-bottom-percent':
'Filtrar os {{score}}% inferiores de usuários por classificação de confiança',
'trust-filter.trust-score-description':
'A pontuação de confiança classifica os usuários por percentil de reputação',
'Auto-load profile pictures': 'Carregar fotos de perfil automaticamente'
}
}

View file

@ -652,6 +652,21 @@ export default {
'Enter Password': 'Introduza a palavra-passe',
Password: 'Palavra-passe',
Confirm: 'Confirmar',
'trust-filter.title': 'Filtro de pontuação de confiança',
'trust-filter.off': 'Desativado',
'trust-filter.low': 'Baixo',
'trust-filter.medium': 'Médio',
'trust-filter.high': 'Alto',
'trust-filter.wot': 'WoT',
'trust-filter.filter-threshold': 'Limite de filtro',
'trust-filter.quick-presets': 'Predefinições rápidas',
'trust-filter.show-all-content': 'Mostrar todo o conteúdo',
'trust-filter.only-show-wot':
'Mostrar apenas a sua rede de confiança (seguidos + seguidos deles)',
'trust-filter.hide-bottom-percent':
'Filtrar os {{score}}% inferiores de utilizadores por classificação de confiança',
'trust-filter.trust-score-description':
'A pontuação de confiança classifica os utilizadores por percentil de reputação',
'Auto-load profile pictures': 'Carregar fotos de perfil automaticamente'
}
}

View file

@ -653,6 +653,20 @@ export default {
'Enter Password': 'Введите пароль',
Password: 'Пароль',
Confirm: 'Подтвердить',
'trust-filter.title': 'Фильтр доверия',
'trust-filter.off': 'Выкл',
'trust-filter.low': 'Низкий',
'trust-filter.medium': 'Средний',
'trust-filter.high': 'Высокий',
'trust-filter.wot': 'WoT',
'trust-filter.filter-threshold': 'Порог фильтрации',
'trust-filter.quick-presets': 'Быстрые предустановки',
'trust-filter.show-all-content': 'Показать весь контент',
'trust-filter.only-show-wot': 'Показывать только вашу сеть доверия (подписки + их подписки)',
'trust-filter.hide-bottom-percent':
'Отфильтровать нижние {{score}}% пользователей по рейтингу доверия',
'trust-filter.trust-score-description':
'Оценка доверия ранжирует пользователей по процентилю репутации',
'Auto-load profile pictures': 'Автозагрузка аватаров'
}
}

View file

@ -638,6 +638,21 @@ export default {
'Enter Password': 'ป้อนรหัสผ่าน',
Password: 'รหัสผ่าน',
Confirm: 'ยืนยัน',
'trust-filter.title': 'ตัวกรองคะแนนความไว้วางใจ',
'trust-filter.off': 'ปิด',
'trust-filter.low': 'ต่ำ',
'trust-filter.medium': 'ปานกลาง',
'trust-filter.high': 'สูง',
'trust-filter.wot': 'WoT',
'trust-filter.filter-threshold': 'เกณฑ์การกรอง',
'trust-filter.quick-presets': 'ค่าที่ตั้งไว้ล่วงหน้าแบบเร็ว',
'trust-filter.show-all-content': 'แสดงเนื้อหาทั้งหมด',
'trust-filter.only-show-wot':
'แสดงเฉพาะเครือข่ายความไว้วางใจของคุณ (ผู้ติดตาม + ผู้ติดตามของพวกเขา)',
'trust-filter.hide-bottom-percent':
'กรอง {{score}}% ล่างสุดของผู้ใช้ตามอันดับความไว้วางใจ',
'trust-filter.trust-score-description':
'คะแนนความไว้วางใจจัดอันดับผู้ใช้ตามเปอร์เซ็นไทล์ชื่อเสียง',
'Auto-load profile pictures': 'โหลดรูปโปรไฟล์อัตโนมัติ'
}
}

View file

@ -624,6 +624,18 @@ export default {
'Enter Password': '輸入密碼',
Password: '密碼',
Confirm: '確認',
'trust-filter.title': '信任分數過濾器',
'trust-filter.off': '關閉',
'trust-filter.low': '低',
'trust-filter.medium': '中',
'trust-filter.high': '高',
'trust-filter.wot': '信任網路',
'trust-filter.filter-threshold': '過濾閾值',
'trust-filter.quick-presets': '快速預設',
'trust-filter.show-all-content': '顯示所有內容',
'trust-filter.only-show-wot': '僅顯示你的信任網路(關注的人 + 他們關注的人)',
'trust-filter.hide-bottom-percent': '過濾掉信任度排名後 {{score}}% 的使用者',
'trust-filter.trust-score-description': '信任分數按聲譽百分位對使用者進行排名',
'Auto-load profile pictures': '自動載入大頭照'
}
}

View file

@ -629,6 +629,18 @@ export default {
'Enter Password': '输入密码',
Password: '密码',
Confirm: '确认',
'trust-filter.title': '信任分数过滤器',
'trust-filter.off': '关闭',
'trust-filter.low': '低',
'trust-filter.medium': '中',
'trust-filter.high': '高',
'trust-filter.wot': '信任网络',
'trust-filter.filter-threshold': '过滤阈值',
'trust-filter.quick-presets': '快速预设',
'trust-filter.show-all-content': '显示所有内容',
'trust-filter.only-show-wot': '仅显示你的信任网络(关注的人 + 他们关注的人)',
'trust-filter.hide-bottom-percent': '过滤掉信任度排名后 {{score}}% 的用户',
'trust-filter.trust-score-description': '信任分数按声誉百分位对用户进行排名',
'Auto-load profile pictures': '自动加载头像'
}
}

View file

@ -2,26 +2,24 @@ import { kinds, NostrEvent } from 'nostr-tools'
import { isMentioningMutedUsers } from './event'
import { tagNameEquals } from './tag'
export function notificationFilter(
export async function notificationFilter(
event: NostrEvent,
{
pubkey,
mutePubkeySet,
hideContentMentioningMutedUsers,
hideUntrustedNotifications,
isUserTrusted
meetsMinTrustScore
}: {
pubkey?: string | null
mutePubkeySet: Set<string>
hideContentMentioningMutedUsers?: boolean
hideUntrustedNotifications?: boolean
isUserTrusted: (pubkey: string) => boolean
meetsMinTrustScore: (pubkey: string, minScore?: number) => Promise<boolean>
}
): boolean {
): Promise<boolean> {
if (
mutePubkeySet.has(event.pubkey) ||
(hideContentMentioningMutedUsers && isMentioningMutedUsers(event, mutePubkeySet)) ||
(hideUntrustedNotifications && !isUserTrusted(event.pubkey))
!(await meetsMinTrustScore(event.pubkey))
) {
return false
}

View file

@ -7,7 +7,6 @@ import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { getReplaceableEventIdentifier } from '@/lib/event'
import { isLocalNetworkUrl, isOnionUrl, isWebsocketUrl } from '@/lib/url'
import { useUserTrust } from '@/providers/UserTrustProvider'
import storage from '@/services/local-storage.service'
import { TPageRef } from '@/types'
import { Compass, Plus } from 'lucide-react'
@ -18,7 +17,6 @@ import { useTranslation } from 'react-i18next'
type TExploreTabs = 'following' | 'explore' | 'reviews'
const ExplorePage = forwardRef<TPageRef>((_, ref) => {
const { hideUntrustedNotes } = useUserTrust()
const [tab, setTab] = useState<TExploreTabs>('explore')
const topRef = useRef<HTMLDivElement | null>(null)
@ -52,7 +50,7 @@ const ExplorePage = forwardRef<TPageRef>((_, ref) => {
) : (
<FollowingFavoriteRelayList />
)
}, [tab, relayReviewFilterFn, hideUntrustedNotes])
}, [tab, relayReviewFilterFn])
return (
<PrimaryPageLayout

View file

@ -1,5 +1,5 @@
import HideUntrustedContentButton from '@/components/HideUntrustedContentButton'
import NotificationList from '@/components/NotificationList'
import TrustScoreFilter from '@/components/TrustScoreFilter'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { usePrimaryPage } from '@/PageManager'
import { TPageRef } from '@/types'
@ -42,7 +42,7 @@ function NotificationListPageTitlebar() {
<Bell />
<div className="text-lg font-semibold">{t('Notifications')}</div>
</div>
<HideUntrustedContentButton type="notifications" size="titlebar-icon" />
<TrustScoreFilter />
</div>
)
}

View file

@ -3,7 +3,6 @@ import NoteList from '@/components/NoteList'
import Tabs from '@/components/Tabs'
import { BIG_RELAY_URLS } from '@/constants'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { kinds } from 'nostr-tools'
import { forwardRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -12,7 +11,6 @@ type TTab = 'my-packs' | 'explore'
const EmojiPackSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation()
const { hideUntrustedNotes } = useUserTrust()
const [tab, setTab] = useState<TTab>('my-packs')
return (
@ -33,7 +31,6 @@ const EmojiPackSettingsPage = forwardRef(({ index }: { index?: number }, ref) =>
<NoteList
showKinds={[kinds.Emojisets]}
subRequests={[{ urls: BIG_RELAY_URLS, filter: {} }]}
hideUntrustedNotes={hideUntrustedNotes}
/>
)}
</SecondaryPageLayout>

View file

@ -14,8 +14,7 @@ import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { cn, isSupportCheckConnectionType } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { TMediaAutoLoadPolicy, TProfilePictureAutoLoadPolicy, TNsfwDisplayPolicy } from '@/types'
import { TMediaAutoLoadPolicy, TNsfwDisplayPolicy, TProfilePictureAutoLoadPolicy } from '@/types'
import { SelectValue } from '@radix-ui/react-select'
import { RotateCcw } from 'lucide-react'
import { forwardRef, HTMLProps, useState } from 'react'
@ -36,7 +35,6 @@ const GeneralSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
profilePictureAutoLoadPolicy,
setProfilePictureAutoLoadPolicy
} = useContentPolicy()
const { hideUntrustedNotes, updateHideUntrustedNotes } = useUserTrust()
const { quickReaction, updateQuickReaction, quickReactionEmoji, updateQuickReactionEmoji } =
useUserPreferences()
@ -120,16 +118,6 @@ const GeneralSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
</Label>
<Switch id="autoplay" checked={autoplay} onCheckedChange={setAutoplay} />
</SettingItem>
<SettingItem>
<Label htmlFor="hide-untrusted-notes" className="text-base font-normal">
{t('Hide untrusted notes')}
</Label>
<Switch
id="hide-untrusted-notes"
checked={hideUntrustedNotes}
onCheckedChange={updateHideUntrustedNotes}
/>
</SettingItem>
<SettingItem>
<Label htmlFor="hide-content-mentioning-muted-users" className="text-base font-normal">
{t('Hide content mentioning muted users')}

View file

@ -33,42 +33,47 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
const { current } = usePrimaryPage()
const active = useMemo(() => current === 'notifications', [current])
const { pubkey, notificationsSeenAt, updateNotificationsSeenAt } = useNostr()
const { hideUntrustedNotifications, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { meetsMinTrustScore } = useUserTrust()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const [newNotifications, setNewNotifications] = useState<NostrEvent[]>([])
const [readNotificationIdSet, setReadNotificationIdSet] = useState<Set<string>>(new Set())
const filteredNewNotifications = useMemo(() => {
const [filteredNewNotifications, setFilteredNewNotifications] = useState<NostrEvent[]>([])
useEffect(() => {
if (active || notificationsSeenAt < 0) {
return []
setFilteredNewNotifications([])
return
}
const filtered: NostrEvent[] = []
for (const notification of newNotifications) {
if (notification.created_at <= notificationsSeenAt || filtered.length >= 10) {
break
}
if (
!notificationFilter(notification, {
pubkey,
mutePubkeySet,
hideContentMentioningMutedUsers,
hideUntrustedNotifications,
isUserTrusted
const filterNotifications = async () => {
const filtered: NostrEvent[] = []
await Promise.allSettled(
newNotifications.map(async (notification) => {
if (notification.created_at <= notificationsSeenAt || filtered.length >= 10) {
return
}
if (
!(await notificationFilter(notification, {
pubkey,
mutePubkeySet,
hideContentMentioningMutedUsers,
meetsMinTrustScore
}))
) {
return
}
filtered.push(notification)
})
) {
continue
}
filtered.push(notification)
)
setFilteredNewNotifications(filtered)
}
return filtered
filterNotifications()
}, [
newNotifications,
notificationsSeenAt,
mutePubkeySet,
hideContentMentioningMutedUsers,
hideUntrustedNotifications,
isUserTrusted,
active
meetsMinTrustScore
])
useEffect(() => {

View file

@ -5,14 +5,11 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
import { useNostr } from './NostrProvider'
type TUserTrustContext = {
hideUntrustedInteractions: boolean
hideUntrustedNotifications: boolean
hideUntrustedNotes: boolean
updateHideUntrustedInteractions: (hide: boolean) => void
updateHideUntrustedNotifications: (hide: boolean) => void
updateHideUntrustedNotes: (hide: boolean) => void
minTrustScore: number
updateMinTrustScore: (score: number) => void
isUserTrusted: (pubkey: string) => boolean
isSpammer: (pubkey: string) => Promise<boolean>
meetsMinTrustScore: (pubkey: string, minScore?: number) => Promise<boolean>
}
const UserTrustContext = createContext<TUserTrustContext | undefined>(undefined)
@ -29,15 +26,7 @@ const wotSet = new Set<string>()
export function UserTrustProvider({ children }: { children: React.ReactNode }) {
const { pubkey: currentPubkey } = useNostr()
const [hideUntrustedInteractions, setHideUntrustedInteractions] = useState(() =>
storage.getHideUntrustedInteractions()
)
const [hideUntrustedNotifications, setHideUntrustedNotifications] = useState(() =>
storage.getHideUntrustedNotifications()
)
const [hideUntrustedNotes, setHideUntrustedNotes] = useState(() =>
storage.getHideUntrustedNotes()
)
const [minTrustScore, setMinTrustScore] = useState(() => storage.getMinTrustScore())
useEffect(() => {
if (!currentPubkey) return
@ -81,32 +70,36 @@ export function UserTrustProvider({ children }: { children: React.ReactNode }) {
[isUserTrusted]
)
const updateHideUntrustedInteractions = (hide: boolean) => {
setHideUntrustedInteractions(hide)
storage.setHideUntrustedInteractions(hide)
const updateMinTrustScore = (score: number) => {
setMinTrustScore(score)
storage.setMinTrustScore(score)
}
const updateHideUntrustedNotifications = (hide: boolean) => {
setHideUntrustedNotifications(hide)
storage.setHideUntrustedNotifications(hide)
}
const meetsMinTrustScore = useCallback(
async (pubkey: string, minScore?: number) => {
const threshold = minScore !== undefined ? minScore : minTrustScore
if (threshold === 0) return true
if (pubkey === currentPubkey) return true
const updateHideUntrustedNotes = (hide: boolean) => {
setHideUntrustedNotes(hide)
storage.setHideUntrustedNotes(hide)
}
// WoT users always have 100% trust score
if (wotSet.has(pubkey)) return true
// Get percentile from reputation system
const percentile = await fayan.fetchUserPercentile(pubkey)
if (percentile === null) return true // If no data, indicate the trust server is down, so allow the user
return percentile >= threshold
},
[currentPubkey, minTrustScore]
)
return (
<UserTrustContext.Provider
value={{
hideUntrustedInteractions,
hideUntrustedNotifications,
hideUntrustedNotes,
updateHideUntrustedInteractions,
updateHideUntrustedNotifications,
updateHideUntrustedNotes,
minTrustScore,
updateMinTrustScore,
isUserTrusted,
isSpammer
isSpammer,
meetsMinTrustScore
}}
>
{children}

View file

@ -1,3 +1,4 @@
import { userIdToPubkey } from '@/lib/pubkey'
import DataLoader from 'dataloader'
class FayanService {
@ -22,7 +23,12 @@ class FayanService {
return new Array(pubkeys.length).fill(null)
}
},
{ maxBatchSize: 50 }
{
maxBatchSize: 50,
cacheKeyFn: (userId) => {
return userIdToPubkey(userId)
}
}
)
constructor() {

View file

@ -45,9 +45,6 @@ class LocalStorageService {
private accountFeedInfoMap: Record<string, TFeedInfo | undefined> = {}
private mediaUploadService: string = DEFAULT_NIP_96_SERVICE
private autoplay: boolean = true
private hideUntrustedInteractions: boolean = false
private hideUntrustedNotifications: boolean = false
private hideUntrustedNotes: boolean = false
private translationServiceConfigMap: Record<string, TTranslationServiceConfig> = {}
private mediaUploadServiceConfigMap: Record<string, TMediaUploadServiceConfig> = {}
private dismissedTooManyRelaysAlert: boolean = false
@ -66,6 +63,7 @@ class LocalStorageService {
private quickReaction: boolean = false
private quickReactionEmoji: string | TEmoji = '+'
private nsfwDisplayPolicy: TNsfwDisplayPolicy = NSFW_DISPLAY_POLICY.HIDE_CONTENT
private minTrustScore: number = 40
constructor() {
if (!LocalStorageService.instance) {
@ -134,25 +132,6 @@ class LocalStorageService {
this.autoplay = window.localStorage.getItem(StorageKey.AUTOPLAY) !== 'false'
const hideUntrustedEvents =
window.localStorage.getItem(StorageKey.HIDE_UNTRUSTED_EVENTS) === 'true'
const storedHideUntrustedInteractions = window.localStorage.getItem(
StorageKey.HIDE_UNTRUSTED_INTERACTIONS
)
const storedHideUntrustedNotifications = window.localStorage.getItem(
StorageKey.HIDE_UNTRUSTED_NOTIFICATIONS
)
const storedHideUntrustedNotes = window.localStorage.getItem(StorageKey.HIDE_UNTRUSTED_NOTES)
this.hideUntrustedInteractions = storedHideUntrustedInteractions
? storedHideUntrustedInteractions === 'true'
: hideUntrustedEvents
this.hideUntrustedNotifications = storedHideUntrustedNotifications
? storedHideUntrustedNotifications === 'true'
: hideUntrustedEvents
this.hideUntrustedNotes = storedHideUntrustedNotes
? storedHideUntrustedNotes === 'true'
: hideUntrustedEvents
const translationServiceConfigMapStr = window.localStorage.getItem(
StorageKey.TRANSLATION_SERVICE_CONFIG_MAP
)
@ -274,6 +253,28 @@ class LocalStorageService {
this.quickReactionEmoji = quickReactionEmojiStr
}
const minTrustScoreStr = window.localStorage.getItem(StorageKey.MIN_TRUST_SCORE)
if (minTrustScoreStr) {
const score = parseInt(minTrustScoreStr, 10)
if (!isNaN(score) && score >= 0 && score <= 100) {
this.minTrustScore = score
}
} else {
const storedHideUntrustedInteractions =
window.localStorage.getItem(StorageKey.HIDE_UNTRUSTED_INTERACTIONS) === 'true'
const storedHideUntrustedNotifications =
window.localStorage.getItem(StorageKey.HIDE_UNTRUSTED_NOTIFICATIONS) === 'true'
const storedHideUntrustedNotes =
window.localStorage.getItem(StorageKey.HIDE_UNTRUSTED_NOTES) === 'true'
if (
storedHideUntrustedInteractions ||
storedHideUntrustedNotifications ||
storedHideUntrustedNotes
) {
this.minTrustScore = 100 // set to max if any of the old settings were true
}
}
// Clean up deprecated data
window.localStorage.removeItem(StorageKey.PINNED_PUBKEYS)
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
@ -425,39 +426,6 @@ class LocalStorageService {
window.localStorage.setItem(StorageKey.AUTOPLAY, autoplay.toString())
}
getHideUntrustedInteractions() {
return this.hideUntrustedInteractions
}
setHideUntrustedInteractions(hideUntrustedInteractions: boolean) {
this.hideUntrustedInteractions = hideUntrustedInteractions
window.localStorage.setItem(
StorageKey.HIDE_UNTRUSTED_INTERACTIONS,
hideUntrustedInteractions.toString()
)
}
getHideUntrustedNotifications() {
return this.hideUntrustedNotifications
}
setHideUntrustedNotifications(hideUntrustedNotifications: boolean) {
this.hideUntrustedNotifications = hideUntrustedNotifications
window.localStorage.setItem(
StorageKey.HIDE_UNTRUSTED_NOTIFICATIONS,
hideUntrustedNotifications.toString()
)
}
getHideUntrustedNotes() {
return this.hideUntrustedNotes
}
setHideUntrustedNotes(hideUntrustedNotes: boolean) {
this.hideUntrustedNotes = hideUntrustedNotes
window.localStorage.setItem(StorageKey.HIDE_UNTRUSTED_NOTES, hideUntrustedNotes.toString())
}
getTranslationServiceConfig(pubkey?: string | null) {
return this.translationServiceConfigMap[pubkey ?? '_'] ?? { service: 'jumble' }
}
@ -633,6 +601,17 @@ class LocalStorageService {
this.nsfwDisplayPolicy = policy
window.localStorage.setItem(StorageKey.NSFW_DISPLAY_POLICY, policy)
}
getMinTrustScore() {
return this.minTrustScore
}
setMinTrustScore(score: number) {
if (score >= 0 && score <= 100) {
this.minTrustScore = score
window.localStorage.setItem(StorageKey.MIN_TRUST_SCORE, score.toString())
}
}
}
const instance = new LocalStorageService()