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

@ -149,6 +149,8 @@ And some Providers are placed in `PageManager.tsx` because they need to use the
Jumble is a multi-language application. When you add new text content, please ensure to add translations for all supported languages as much as possible. Append new translations to the end of each translation file without modifying or removing existing keys.
At the trial stage, you can skip translation first. After the feature is completed and confirmed satisfactory, you can add translation content later.
- Translation files located in `src/i18n/locales/`
- Using `react-i18next` for internationalization
- Supported languages: ar, de, en, es, fa, fr, hi, hu, it, ja, ko, pl, pt-BR, pt-PT, ru, th, zh, zh-TW

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, {
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
})
}, [
notification,
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(() => {
useEffect(() => {
const checkHasReplies = async () => {
if (!replies || replies.length === 0) {
return false
setHasReplies(false)
return
}
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
if (!(await meetsMinTrustScore(reply.pubkey))) {
continue
}
}, [replies])
setHasReplies(true)
return
}
setHasReplies(false)
}
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
}
}, [noteStats, event, hideUntrustedInteractions])
const reposts = noteStats?.reposts || []
let count = 0
await Promise.all(
reposts.map(async (repost) => {
if (await meetsMinTrustScore(repost.pubkey)) {
count++
}
})
)
setRepostCount(count)
}
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
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 true
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,6 +544,7 @@ function UserAggregationItem({
)}
<div className="flex-1 min-w-0 flex flex-col">
<div className="flex items-center gap-2">
{supportTouch ? (
<SimpleUsername
userId={aggregation.pubkey}
@ -547,6 +564,8 @@ function UserAggregationItem({
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 filterNotifications = async () => {
const filtered: NostrEvent[] = []
for (const notification of newNotifications) {
await Promise.allSettled(
newNotifications.map(async (notification) => {
if (notification.created_at <= notificationsSeenAt || filtered.length >= 10) {
break
return
}
if (
!notificationFilter(notification, {
!(await notificationFilter(notification, {
pubkey,
mutePubkeySet,
hideContentMentioningMutedUsers,
hideUntrustedNotifications,
isUserTrusted
})
meetsMinTrustScore
}))
) {
continue
return
}
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()