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. 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/` - Translation files located in `src/i18n/locales/`
- Using `react-i18next` for internationalization - 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 - 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 { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { useState } from 'react' import { useState } from 'react'
import HideUntrustedContentButton from '../HideUntrustedContentButton'
import QuoteList from '../QuoteList' import QuoteList from '../QuoteList'
import ReactionList from '../ReactionList' import ReactionList from '../ReactionList'
import ReplyNoteList from '../ReplyNoteList' import ReplyNoteList from '../ReplyNoteList'
import TrustScoreFilter from '../TrustScoreFilter'
import { Tabs, TTabValue } from './Tabs' import { Tabs, TTabValue } from './Tabs'
export default function ExternalContentInteractions({ export default function ExternalContentInteractions({
@ -37,7 +37,7 @@ export default function ExternalContentInteractions({
</ScrollArea> </ScrollArea>
<Separator orientation="vertical" className="h-6" /> <Separator orientation="vertical" className="h-6" />
<div className="size-10 flex items-center justify-center"> <div className="size-10 flex items-center justify-center">
<HideUntrustedContentButton type="interactions" /> <TrustScoreFilter />
</div> </div>
</div> </div>
<Separator /> <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 NoteList, { TNoteListRef } from '@/components/NoteList'
import Tabs from '@/components/Tabs' import Tabs from '@/components/Tabs'
import TrustScoreFilter from '@/components/TrustScoreFilter'
import UserAggregationList, { TUserAggregationListRef } from '@/components/UserAggregationList' import UserAggregationList, { TUserAggregationListRef } from '@/components/UserAggregationList'
import { isTouchDevice } from '@/lib/utils' import { isTouchDevice } from '@/lib/utils'
import { useKindFilter } from '@/providers/KindFilterProvider' import { useKindFilter } from '@/providers/KindFilterProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { TFeedSubRequest, TNoteListMode } from '@/types' import { TFeedSubRequest, TNoteListMode } from '@/types'
import { useMemo, useRef, useState } from 'react' import { useMemo, useRef, useState } from 'react'
@ -25,7 +25,6 @@ export default function NormalFeed({
disable24hMode?: boolean disable24hMode?: boolean
onRefresh?: () => void onRefresh?: () => void
}) { }) {
const { hideUntrustedNotes } = useUserTrust()
const { showKinds } = useKindFilter() const { showKinds } = useKindFilter()
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds) const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
const [listMode, setListMode] = useState<TNoteListMode>(() => storage.getNoteListMode()) const [listMode, setListMode] = useState<TNoteListMode>(() => storage.getNoteListMode())
@ -79,6 +78,7 @@ export default function NormalFeed({
}} }}
/> />
)} )}
<TrustScoreFilter />
{showKindsFilter && ( {showKindsFilter && (
<KindFilter <KindFilter
showKinds={temporaryShowKinds} showKinds={temporaryShowKinds}
@ -103,7 +103,6 @@ export default function NormalFeed({
showKinds={temporaryShowKinds} showKinds={temporaryShowKinds}
subRequests={subRequests} subRequests={subRequests}
hideReplies={listMode === 'posts'} hideReplies={listMode === 'posts'}
hideUntrustedNotes={hideUntrustedNotes}
areAlgoRelays={areAlgoRelays} areAlgoRelays={areAlgoRelays}
showRelayCloseReason={showRelayCloseReason} showRelayCloseReason={showRelayCloseReason}
/> />

View file

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

View file

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

View file

@ -1,11 +1,12 @@
import { ExtendedKind } from '@/constants' 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 { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider' import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react' import { useEffect, useState } from 'react'
import { HighlightNotification } from './HighlightNotification' import { HighlightNotification } from './HighlightNotification'
import { MentionNotification } from './MentionNotification' import { MentionNotification } from './MentionNotification'
import { PollResponseNotification } from './PollResponseNotification' import { PollResponseNotification } from './PollResponseNotification'
@ -23,22 +24,51 @@ export function NotificationItem({
const { pubkey } = useNostr() const { pubkey } = useNostr()
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const { hideUntrustedNotifications, isUserTrusted } = useUserTrust() const { minTrustScore, meetsMinTrustScore } = useUserTrust()
const canShow = useMemo(() => { const [canShow, setCanShow] = useState(false)
return notificationFilter(notification, {
pubkey, useEffect(() => {
mutePubkeySet, const checkCanShow = async () => {
hideContentMentioningMutedUsers, // Check muted users
hideUntrustedNotifications, if (mutePubkeySet.has(notification.pubkey)) {
isUserTrusted 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, notification,
pubkey,
mutePubkeySet, mutePubkeySet,
hideContentMentioningMutedUsers, hideContentMentioningMutedUsers,
hideUntrustedNotifications, minTrustScore,
isUserTrusted meetsMinTrustScore
]) ])
if (!canShow) return null if (!canShow) return null
if (notification.kind === kinds.Reaction) { if (notification.kind === kinds.Reaction) {

View file

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

View file

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

View file

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

View file

@ -1,63 +1,19 @@
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useAllDescendantThreads } from '@/hooks/useThread' import { useFilteredAllReplies } from '@/hooks'
import { getEventKey, getKeyFromTag, getParentTag, isMentioningMutedUsers } from '@/lib/event' import { getEventKey, getKeyFromTag, getParentTag } from '@/lib/event'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { generateBech32IdFromETag } from '@/lib/tag' import { generateBech32IdFromETag } from '@/lib/tag'
import { cn } from '@/lib/utils' 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 { ChevronDown, ChevronUp } from 'lucide-react'
import { NostrEvent } from 'nostr-tools' import { useCallback, useRef, useState } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import ReplyNote from '../ReplyNote' import ReplyNote from '../ReplyNote'
export default function SubReplies({ parentKey }: { parentKey: string }) { export default function SubReplies({ parentKey }: { parentKey: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const allThreads = useAllDescendantThreads(parentKey)
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const [isExpanded, setIsExpanded] = useState(false) const [isExpanded, setIsExpanded] = useState(false)
const replies = useMemo(() => { const { replies } = useFilteredAllReplies(parentKey)
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 [highlightReplyKey, setHighlightReplyKey] = useState<string | undefined>(undefined) const [highlightReplyKey, setHighlightReplyKey] = useState<string | undefined>(undefined)
const replyRefs = useRef<Record<string, HTMLDivElement | null>>({}) const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})

View file

@ -1,10 +1,7 @@
import { useFilteredReplies } from '@/hooks'
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll' import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'
import { useStuff } from '@/hooks/useStuff' import { useStuff } from '@/hooks/useStuff'
import { useAllDescendantThreads } from '@/hooks/useThread' import { getEventKey } from '@/lib/event'
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import threadService from '@/services/thread.service' import threadService from '@/services/thread.service'
import { Event as NEvent } from 'nostr-tools' import { Event as NEvent } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
@ -18,47 +15,9 @@ const SHOW_COUNT = 10
export default function ReplyNoteList({ stuff }: { stuff: NEvent | string }) { export default function ReplyNoteList({ stuff }: { stuff: NEvent | string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const { stuffKey } = useStuff(stuff) const { stuffKey } = useStuff(stuff)
const allThreads = useAllDescendantThreads(stuffKey)
const [initialLoading, setInitialLoading] = useState(false) const [initialLoading, setInitialLoading] = useState(false)
const { replies } = useFilteredReplies(stuffKey)
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
])
// Initial subscription // Initial subscription
useEffect(() => { useEffect(() => {

View file

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

View file

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

View file

@ -1,55 +1,19 @@
import { useFilteredAllReplies } from '@/hooks'
import { useStuff } from '@/hooks/useStuff' import { useStuff } from '@/hooks/useStuff'
import { useAllDescendantThreads } from '@/hooks/useThread'
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { MessageCircle } from 'lucide-react' import { MessageCircle } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import PostEditor from '../PostEditor' import PostEditor from '../PostEditor'
import { formatCount } from './utils' import { formatCount } from './utils'
export default function ReplyButton({ stuff }: { stuff: Event | string }) { export default function ReplyButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, checkLogin } = useNostr() const { checkLogin } = useNostr()
const { event, stuffKey } = useStuff(stuff) const { stuffKey } = useStuff(stuff)
const allThreads = useAllDescendantThreads(stuffKey) const { replies, hasReplied } = useFilteredAllReplies(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 [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
return ( return (
@ -68,7 +32,7 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
title={t('Reply')} title={t('Reply')}
> >
<MessageCircle /> <MessageCircle />
{!!replyCount && <div className="text-sm">{formatCount(replyCount)}</div>} {!!replies.length && <div className="text-sm">{formatCount(replies.length)}</div>}
</button> </button>
<PostEditor parentStuff={stuff} open={open} setOpen={setOpen} /> <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 stuffStatsService from '@/services/stuff-stats.service'
import { Loader, PencilLine, Repeat } from 'lucide-react' import { Loader, PencilLine, Repeat } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import PostEditor from '../PostEditor' import PostEditor from '../PostEditor'
import { formatCount } from './utils' import { formatCount } from './utils'
@ -25,24 +25,38 @@ import { formatCount } from './utils'
export default function RepostButton({ stuff }: { stuff: Event | string }) { export default function RepostButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const { meetsMinTrustScore } = useUserTrust()
const { publish, checkLogin, pubkey } = useNostr() const { publish, checkLogin, pubkey } = useNostr()
const { event, stuffKey } = useStuff(stuff) const { event, stuffKey } = useStuff(stuff)
const noteStats = useStuffStatsById(stuffKey) const noteStats = useStuffStatsById(stuffKey)
const [reposting, setReposting] = useState(false) const [reposting, setReposting] = useState(false)
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false) const [isPostDialogOpen, setIsPostDialogOpen] = useState(false)
const [isDrawerOpen, setIsDrawerOpen] = useState(false) const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const { repostCount, hasReposted } = useMemo(() => { const [repostCount, setRepostCount] = useState(0)
// external content const hasReposted = useMemo(() => {
if (!event) return { repostCount: 0, hasReposted: false } return pubkey ? noteStats?.repostPubkeySet?.has(pubkey) : false
}, [noteStats, pubkey])
return { useEffect(() => {
repostCount: hideUntrustedInteractions const filterReposts = async () => {
? noteStats?.reposts?.filter((repost) => isUserTrusted(repost.pubkey)).length if (!event) {
: noteStats?.reposts?.length, setRepostCount(0)
hasReposted: pubkey ? noteStats?.repostPubkeySet?.has(pubkey) : false 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 canRepost = !hasReposted && !reposting && !!event
const repost = async () => { 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 { toast } from 'sonner'
import { LoadingBar } from '../LoadingBar' import { LoadingBar } from '../LoadingBar'
import NewNotesButton from '../NewNotesButton' import NewNotesButton from '../NewNotesButton'
import TrustScoreBadge from '../TrustScoreBadge'
const LIMIT = 500 const LIMIT = 500
const SHOW_COUNT = 20 const SHOW_COUNT = 20
@ -66,14 +67,16 @@ const UserAggregationList = forwardRef<
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey: currentPubkey, startLogin } = useNostr() const { pubkey: currentPubkey, startLogin } = useNostr()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { hideUntrustedNotes, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { pinnedPubkeySet } = usePinnedUsers() const { pinnedPubkeySet } = usePinnedUsers()
const { meetsMinTrustScore } = useUserTrust()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const { isEventDeleted } = useDeletedEvent() const { isEventDeleted } = useDeletedEvent()
const [since, setSince] = useState(() => dayjs().subtract(1, 'day').unix()) const [since, setSince] = useState(() => dayjs().subtract(1, 'day').unix())
const [events, setEvents] = useState<Event[]>([]) const [events, setEvents] = useState<Event[]>([])
const [filteredEvents, setFilteredEvents] = useState<Event[]>([])
const [newEvents, setNewEvents] = useState<Event[]>([]) const [newEvents, setNewEvents] = useState<Event[]>([])
const [filteredNewEvents, setFilteredNewEvents] = useState<Event[]>([])
const [newEventPubkeys, setNewEventPubkeys] = useState<Set<string>>(new Set()) const [newEventPubkeys, setNewEventPubkeys] = useState<Set<string>>(new Set())
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined) const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -233,31 +236,40 @@ const UserAggregationList = forwardRef<
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
}, [loading]) }, [loading])
const shouldHideEvent = useCallback( const filterEvents = useCallback(
(evt: Event) => { async (events: Event[]) => {
if (evt.pubkey === currentPubkey) return true const results = await Promise.allSettled(
if (isEventDeleted(evt)) return true events.map(async (evt) => {
if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return true if (evt.pubkey === currentPubkey) return null
if (filterMutedNotes && mutePubkeySet.has(evt.pubkey)) return true if (evt.created_at < since) return null
if ( if (isEventDeleted(evt)) return null
filterMutedNotes && if (filterMutedNotes && mutePubkeySet.has(evt.pubkey)) return null
hideContentMentioningMutedUsers && if (
isMentioningMutedUsers(evt, mutePubkeySet) filterMutedNotes &&
) { hideContentMentioningMutedUsers &&
return true 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, mutePubkeySet,
isEventDeleted, isEventDeleted,
currentPubkey, currentPubkey,
filterMutedNotes, filterMutedNotes,
isUserTrusted,
hideContentMentioningMutedUsers, hideContentMentioningMutedUsers,
isMentioningMutedUsers isMentioningMutedUsers,
meetsMinTrustScore
] ]
) )
@ -265,13 +277,17 @@ const UserAggregationList = forwardRef<
return dayjs().diff(dayjs.unix(since), 'day') return dayjs().diff(dayjs.unix(since), 'day')
}, [since]) }, [since])
const filteredEvents = useMemo(() => { useEffect(() => {
return events.filter((evt) => evt.created_at >= since && !shouldHideEvent(evt)) filterEvents(events).then((filtered) => {
}, [events, since, shouldHideEvent]) setFilteredEvents(filtered)
})
}, [events, filterEvents])
const filteredNewEvents = useMemo(() => { useEffect(() => {
return newEvents.filter((evt) => evt.created_at >= since && !shouldHideEvent(evt)) filterEvents(newEvents).then((filtered) => {
}, [newEvents, since, shouldHideEvent]) setFilteredNewEvents(filtered)
})
}, [newEvents, filterEvents])
const aggregations = useMemo(() => { const aggregations = useMemo(() => {
const aggs = userAggregationService.aggregateByUser(filteredEvents) const aggs = userAggregationService.aggregateByUser(filteredEvents)
@ -528,25 +544,28 @@ function UserAggregationItem({
)} )}
<div className="flex-1 min-w-0 flex flex-col"> <div className="flex-1 min-w-0 flex flex-col">
{supportTouch ? ( <div className="flex items-center gap-2">
<SimpleUsername {supportTouch ? (
userId={aggregation.pubkey} <SimpleUsername
className={cn( userId={aggregation.pubkey}
'font-semibold text-base truncate max-w-fit', className={cn(
!hasNewEvents && 'text-muted-foreground' 'font-semibold text-base truncate max-w-fit',
)} !hasNewEvents && 'text-muted-foreground'
skeletonClassName="h-4" )}
/> skeletonClassName="h-4"
) : ( />
<Username ) : (
userId={aggregation.pubkey} <Username
className={cn( userId={aggregation.pubkey}
'font-semibold text-base truncate max-w-fit', className={cn(
!hasNewEvents && 'text-muted-foreground' 'font-semibold text-base truncate max-w-fit',
)} !hasNewEvents && 'text-muted-foreground'
skeletonClassName="h-4" )}
/> skeletonClassName="h-4"
)} />
)}
<TrustScoreBadge pubkey={aggregation.pubkey} />
</div>
<FormattedTimestamp <FormattedTimestamp
timestamp={aggregation.lastEventTime} timestamp={aggregation.lastEventTime}
className="text-sm text-muted-foreground" className="text-sm text-muted-foreground"

View file

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

View file

@ -5,6 +5,7 @@ export * from './useFetchProfile'
export * from './useFetchRelayInfo' export * from './useFetchRelayInfo'
export * from './useFetchRelayInfos' export * from './useFetchRelayInfos'
export * from './useFetchRelayList' export * from './useFetchRelayList'
export * from './useFilteredReplies'
export * from './useInfiniteScroll' export * from './useInfiniteScroll'
export * from './useSearchProfiles' export * from './useSearchProfiles'
export * from './useTranslatedEvent' 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': 'أدخل كلمة المرور', 'Enter Password': 'أدخل كلمة المرور',
Password: 'كلمة المرور', Password: 'كلمة المرور',
Confirm: 'تأكيد', 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': 'تحميل صور الملف الشخصي تلقائيًا' 'Auto-load profile pictures': 'تحميل صور الملف الشخصي تلقائيًا'
} }
} }

View file

@ -659,6 +659,20 @@ export default {
'Enter Password': 'Passwort eingeben', 'Enter Password': 'Passwort eingeben',
Password: 'Passwort', Password: 'Passwort',
Confirm: 'Bestätigen', 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' 'Auto-load profile pictures': 'Profilbilder automatisch laden'
} }
} }

View file

@ -643,6 +643,19 @@ export default {
'Enter Password': 'Enter Password', 'Enter Password': 'Enter Password',
Password: 'Password', Password: 'Password',
Confirm: 'Confirm', 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' 'Auto-load profile pictures': 'Auto-load profile pictures'
} }
} }

View file

@ -653,6 +653,20 @@ export default {
'Enter Password': 'Ingresar contraseña', 'Enter Password': 'Ingresar contraseña',
Password: 'Contraseña', Password: 'Contraseña',
Confirm: 'Confirmar', 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' 'Auto-load profile pictures': 'Cargar imágenes de perfil automáticamente'
} }
} }

View file

@ -648,6 +648,21 @@ export default {
'Enter Password': 'رمز عبور را وارد کنید', 'Enter Password': 'رمز عبور را وارد کنید',
Password: 'رمز عبور', Password: 'رمز عبور',
Confirm: 'تأیید', 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': 'بارگذاری خودکار تصاویر پروفایل' 'Auto-load profile pictures': 'بارگذاری خودکار تصاویر پروفایل'
} }
} }

View file

@ -656,6 +656,21 @@ export default {
'Enter Password': 'Entrer le mot de passe', 'Enter Password': 'Entrer le mot de passe',
Password: 'Mot de passe', Password: 'Mot de passe',
Confirm: 'Confirmer', 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' 'Auto-load profile pictures': 'Charger les images de profil automatiquement'
} }
} }

View file

@ -649,6 +649,20 @@ export default {
'Enter Password': 'पासवर्ड दर्ज करें', 'Enter Password': 'पासवर्ड दर्ज करें',
Password: 'पासवर्ड', Password: 'पासवर्ड',
Confirm: 'पुष्टि करें', 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': 'प्रोफ़ाइल चित्र स्वतः लोड करें' 'Auto-load profile pictures': 'प्रोफ़ाइल चित्र स्वतः लोड करें'
} }
} }

View file

@ -641,6 +641,21 @@ export default {
'Enter Password': 'Jelszó megadása', 'Enter Password': 'Jelszó megadása',
Password: 'Jelszó', Password: 'Jelszó',
Confirm: 'Megerősítés', 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' 'Auto-load profile pictures': 'Profilképek automatikus betöltése'
} }
} }

View file

@ -653,6 +653,20 @@ export default {
'Enter Password': 'Inserisci password', 'Enter Password': 'Inserisci password',
Password: 'Password', Password: 'Password',
Confirm: 'Conferma', 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' 'Auto-load profile pictures': 'Caricamento automatico immagini di profilo'
} }
} }

View file

@ -647,6 +647,21 @@ export default {
'Enter Password': 'パスワードを入力', 'Enter Password': 'パスワードを入力',
Password: 'パスワード', Password: 'パスワード',
Confirm: '確認', 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': 'プロフィール画像を自動読み込み' 'Auto-load profile pictures': 'プロフィール画像を自動読み込み'
} }
} }

View file

@ -644,6 +644,18 @@ export default {
'Enter Password': '비밀번호 입력', 'Enter Password': '비밀번호 입력',
Password: '비밀번호', Password: '비밀번호',
Confirm: '확인', 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': '프로필 사진 자동 로드' 'Auto-load profile pictures': '프로필 사진 자동 로드'
} }
} }

View file

@ -654,6 +654,20 @@ export default {
'Enter Password': 'Wprowadź hasło', 'Enter Password': 'Wprowadź hasło',
Password: 'Hasło', Password: 'Hasło',
Confirm: 'Potwierdź', 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' 'Auto-load profile pictures': 'Automatyczne ładowanie zdjęć profilowych'
} }
} }

View file

@ -649,6 +649,21 @@ export default {
'Enter Password': 'Digite a senha', 'Enter Password': 'Digite a senha',
Password: 'Senha', Password: 'Senha',
Confirm: 'Confirmar', 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' 'Auto-load profile pictures': 'Carregar fotos de perfil automaticamente'
} }
} }

View file

@ -652,6 +652,21 @@ export default {
'Enter Password': 'Introduza a palavra-passe', 'Enter Password': 'Introduza a palavra-passe',
Password: 'Palavra-passe', Password: 'Palavra-passe',
Confirm: 'Confirmar', 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' 'Auto-load profile pictures': 'Carregar fotos de perfil automaticamente'
} }
} }

View file

@ -653,6 +653,20 @@ export default {
'Enter Password': 'Введите пароль', 'Enter Password': 'Введите пароль',
Password: 'Пароль', Password: 'Пароль',
Confirm: 'Подтвердить', 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': 'Автозагрузка аватаров' 'Auto-load profile pictures': 'Автозагрузка аватаров'
} }
} }

View file

@ -638,6 +638,21 @@ export default {
'Enter Password': 'ป้อนรหัสผ่าน', 'Enter Password': 'ป้อนรหัสผ่าน',
Password: 'รหัสผ่าน', Password: 'รหัสผ่าน',
Confirm: 'ยืนยัน', 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': 'โหลดรูปโปรไฟล์อัตโนมัติ' 'Auto-load profile pictures': 'โหลดรูปโปรไฟล์อัตโนมัติ'
} }
} }

View file

@ -624,6 +624,18 @@ export default {
'Enter Password': '輸入密碼', 'Enter Password': '輸入密碼',
Password: '密碼', Password: '密碼',
Confirm: '確認', 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': '自動載入大頭照' 'Auto-load profile pictures': '自動載入大頭照'
} }
} }

View file

@ -629,6 +629,18 @@ export default {
'Enter Password': '输入密码', 'Enter Password': '输入密码',
Password: '密码', Password: '密码',
Confirm: '确认', 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': '自动加载头像' 'Auto-load profile pictures': '自动加载头像'
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -45,9 +45,6 @@ class LocalStorageService {
private accountFeedInfoMap: Record<string, TFeedInfo | undefined> = {} private accountFeedInfoMap: Record<string, TFeedInfo | undefined> = {}
private mediaUploadService: string = DEFAULT_NIP_96_SERVICE private mediaUploadService: string = DEFAULT_NIP_96_SERVICE
private autoplay: boolean = true private autoplay: boolean = true
private hideUntrustedInteractions: boolean = false
private hideUntrustedNotifications: boolean = false
private hideUntrustedNotes: boolean = false
private translationServiceConfigMap: Record<string, TTranslationServiceConfig> = {} private translationServiceConfigMap: Record<string, TTranslationServiceConfig> = {}
private mediaUploadServiceConfigMap: Record<string, TMediaUploadServiceConfig> = {} private mediaUploadServiceConfigMap: Record<string, TMediaUploadServiceConfig> = {}
private dismissedTooManyRelaysAlert: boolean = false private dismissedTooManyRelaysAlert: boolean = false
@ -66,6 +63,7 @@ class LocalStorageService {
private quickReaction: boolean = false private quickReaction: boolean = false
private quickReactionEmoji: string | TEmoji = '+' private quickReactionEmoji: string | TEmoji = '+'
private nsfwDisplayPolicy: TNsfwDisplayPolicy = NSFW_DISPLAY_POLICY.HIDE_CONTENT private nsfwDisplayPolicy: TNsfwDisplayPolicy = NSFW_DISPLAY_POLICY.HIDE_CONTENT
private minTrustScore: number = 40
constructor() { constructor() {
if (!LocalStorageService.instance) { if (!LocalStorageService.instance) {
@ -134,25 +132,6 @@ class LocalStorageService {
this.autoplay = window.localStorage.getItem(StorageKey.AUTOPLAY) !== 'false' 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( const translationServiceConfigMapStr = window.localStorage.getItem(
StorageKey.TRANSLATION_SERVICE_CONFIG_MAP StorageKey.TRANSLATION_SERVICE_CONFIG_MAP
) )
@ -274,6 +253,28 @@ class LocalStorageService {
this.quickReactionEmoji = quickReactionEmojiStr 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 // Clean up deprecated data
window.localStorage.removeItem(StorageKey.PINNED_PUBKEYS) window.localStorage.removeItem(StorageKey.PINNED_PUBKEYS)
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP) window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
@ -425,39 +426,6 @@ class LocalStorageService {
window.localStorage.setItem(StorageKey.AUTOPLAY, autoplay.toString()) 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) { getTranslationServiceConfig(pubkey?: string | null) {
return this.translationServiceConfigMap[pubkey ?? '_'] ?? { service: 'jumble' } return this.translationServiceConfigMap[pubkey ?? '_'] ?? { service: 'jumble' }
} }
@ -633,6 +601,17 @@ class LocalStorageService {
this.nsfwDisplayPolicy = policy this.nsfwDisplayPolicy = policy
window.localStorage.setItem(StorageKey.NSFW_DISPLAY_POLICY, 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() const instance = new LocalStorageService()