feat: hide replies from untrusted users

This commit is contained in:
codytseng
2025-06-01 22:00:57 +08:00
parent 4785efd43c
commit 1bd8ee03b1
8 changed files with 159 additions and 24 deletions

View File

@@ -15,6 +15,7 @@ import { NostrProvider } from './providers/NostrProvider'
import { NoteStatsProvider } from './providers/NoteStatsProvider'
import { ReplyProvider } from './providers/ReplyProvider'
import { ScreenSizeProvider } from './providers/ScreenSizeProvider'
import { UserTrustProvider } from './providers/UserTrustProvider'
import { ZapProvider } from './providers/ZapProvider'
export default function App(): JSX.Element {
@@ -27,18 +28,20 @@ export default function App(): JSX.Element {
<FavoriteRelaysProvider>
<FollowListProvider>
<MuteListProvider>
<BookmarksProvider>
<FeedProvider>
<ReplyProvider>
<NoteStatsProvider>
<MediaUploadServiceProvider>
<PageManager />
<Toaster />
</MediaUploadServiceProvider>
</NoteStatsProvider>
</ReplyProvider>
</FeedProvider>
</BookmarksProvider>
<UserTrustProvider>
<BookmarksProvider>
<FeedProvider>
<ReplyProvider>
<NoteStatsProvider>
<MediaUploadServiceProvider>
<PageManager />
<Toaster />
</MediaUploadServiceProvider>
</NoteStatsProvider>
</ReplyProvider>
</FeedProvider>
</BookmarksProvider>
</UserTrustProvider>
</MuteListProvider>
</FollowListProvider>
</FavoriteRelaysProvider>

View File

@@ -5,6 +5,7 @@ import { useMuteList } from '@/providers/MuteListProvider'
import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Collapsible from '../Collapsible'
import Content from '../Content'
import { FormattedTimestamp } from '../FormattedTimestamp'
import NoteOptions from '../NoteOptions'
@@ -12,7 +13,6 @@ import NoteStats from '../NoteStats'
import ParentNotePreview from '../ParentNotePreview'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import Collapsible from '../Collapsible'
export default function ReplyNote({
event,

View File

@@ -9,6 +9,7 @@ import {
import { generateEventIdFromETag, tagNameEquals } from '@/lib/tag'
import { useSecondaryPage } from '@/PageManager'
import { useReply } from '@/providers/ReplyProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service'
import { Filter, Event as NEvent, kinds } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -31,6 +32,7 @@ export default function ReplyNoteList({
}) {
const { t } = useTranslation()
const { currentIndex } = useSecondaryPage()
const { isUserTrusted } = useUserTrust()
const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined)
const { repliesMap, addReplies } = useReply()
const replies = useMemo(() => {
@@ -248,6 +250,10 @@ export default function ReplyNoteList({
{replies.length > 0 && (loading || until) && <Separator className="mt-2" />}
<div className={className}>
{replies.slice(0, showCount).map((reply) => {
if (!isUserTrusted(reply.pubkey)) {
return null
}
const parentEventTag = getParentEventTag(reply)
const parentEventOriginalId = parentEventTag?.[1]
const parentEventId = parentEventTag ? generateEventIdFromETag(parentEventTag) : undefined

View File

@@ -14,6 +14,7 @@ export const StorageKey = {
ACCOUNT_FEED_INFO_MAP: 'accountFeedInfoMap',
MEDIA_UPLOAD_SERVICE: 'mediaUploadService',
AUTOPLAY: 'autoplay',
HIDE_UNTRUSTED_REPLIES: 'hideUntrustedReplies',
ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated
ACCOUNT_FOLLOW_LIST_EVENT_MAP: 'accountFollowListEventMap', // deprecated
ACCOUNT_MUTE_LIST_EVENT_MAP: 'accountMuteListEventMap', // deprecated

View File

@@ -6,6 +6,7 @@ import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { cn } from '@/lib/utils'
import { useAutoplay } from '@/providers/AutoplayProvider'
import { useTheme } from '@/providers/ThemeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { SelectValue } from '@radix-ui/react-select'
import { forwardRef, HTMLProps, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -15,6 +16,8 @@ const GeneralSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage)
const { themeSetting, setThemeSetting } = useTheme()
const { autoplay, setAutoplay } = useAutoplay()
const { enabled: hideUntrustedRepliesEnabled, updateEnabled: updateHideUntrustedRepliesEnabled } =
useUserTrust()
const handleLanguageChange = (value: TLanguage) => {
i18n.changeLanguage(value)
@@ -63,6 +66,19 @@ const GeneralSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
</Label>
<Switch id="autoplay" checked={autoplay} onCheckedChange={setAutoplay} />
</SettingItem>
<SettingItem>
<Label htmlFor="hide-untrusted-replies" className="text-base font-normal">
{t('Hide replies from untrusted users')}
<div className="text-muted-foreground">
{t('Only show replies from your followed users and the users they follow')}
</div>
</Label>
<Switch
id="hide-untrusted-replies"
checked={hideUntrustedRepliesEnabled}
onCheckedChange={updateHideUntrustedRepliesEnabled}
/>
</SettingItem>
</div>
</SecondaryPageLayout>
)

View File

@@ -0,0 +1,59 @@
import client from '@/services/client.service'
import { createContext, useContext, useEffect, useState } from 'react'
import { useNostr } from './NostrProvider'
import storage from '@/services/local-storage.service'
type TUserTrustContext = {
enabled: boolean
updateEnabled: (enabled: boolean) => void
isUserTrusted: (pubkey: string) => boolean
}
const UserTrustContext = createContext<TUserTrustContext | undefined>(undefined)
export const useUserTrust = () => {
const context = useContext(UserTrustContext)
if (!context) {
throw new Error('useUserTrust must be used within a UserTrustProvider')
}
return context
}
const wotSet = new Set<string>()
export function UserTrustProvider({ children }: { children: React.ReactNode }) {
const { pubkey: currentPubkey } = useNostr()
const [enabled, setEnabled] = useState(storage.getHideUntrustedReplies())
useEffect(() => {
if (!currentPubkey) return
const initWoT = async () => {
const followings = await client.fetchFollowings(currentPubkey)
await Promise.allSettled(
followings.map(async (pubkey) => {
wotSet.add(pubkey)
const _followings = await client.fetchFollowings(pubkey)
_followings.forEach((following) => wotSet.add(following))
})
)
}
initWoT()
}, [currentPubkey])
const updateEnabled = (enabled: boolean) => {
setEnabled(enabled)
storage.setHideUntrustedReplies(enabled)
}
const isUserTrusted = (pubkey: string) => {
if (!currentPubkey || !enabled) return true
return wotSet.has(pubkey)
}
return (
<UserTrustContext.Provider value={{ enabled, updateEnabled, isUserTrusted }}>
{children}
</UserTrustContext.Provider>
)
}

View File

@@ -56,6 +56,13 @@ class ClientService extends EventTarget {
maxBatchSize: 500
}
)
private fetchFollowListEventFromBigRelaysDataloader = new DataLoader<string, NEvent | undefined>(
this.followListEventBatchLoadFn.bind(this),
{
batchScheduleFn: (callback) => setTimeout(callback, 50),
maxBatchSize: 500
}
)
private relayListEventDataLoader = new DataLoader<string, NEvent | undefined>(
this.relayListEventBatchLoadFn.bind(this),
{
@@ -659,7 +666,6 @@ class ClientService extends EventTarget {
const profileFromBigRelays = await this.fetchProfileEventFromBigRelaysDataloader.load(pubkey)
if (profileFromBigRelays) {
this.addUsernameToIndex(profileFromBigRelays)
await indexedDb.putReplaceableEvent(profileFromBigRelays)
return profileFromBigRelays
}
@@ -755,12 +761,8 @@ class ClientService extends EventTarget {
await this.relayListEventBatchLoadFn([pubkey])
}
async fetchFollowListEvent(pubkey: string, storeToIndexedDb = false) {
const event = await this.followListCache.fetch(pubkey)
if (storeToIndexedDb && event) {
await indexedDb.putReplaceableEvent(event)
}
return event
async fetchFollowListEvent(pubkey: string) {
return await this.followListCache.fetch(pubkey)
}
async fetchBookmarkListEvent(pubkey: string): Promise<NEvent | undefined> {
@@ -778,8 +780,8 @@ class ClientService extends EventTarget {
return events.sort((a, b) => b.created_at - a.created_at)[0]
}
async fetchFollowings(pubkey: string, storeToIndexedDb = false) {
const followListEvent = await this.fetchFollowListEvent(pubkey, storeToIndexedDb)
async fetchFollowings(pubkey: string) {
const followListEvent = await this.fetchFollowListEvent(pubkey)
return followListEvent ? extractPubkeysFromEventTags(followListEvent.tags) : []
}
@@ -826,11 +828,13 @@ class ClientService extends EventTarget {
updateFollowListCache(event: NEvent) {
this.followListCache.set(event.pubkey, Promise.resolve(event))
indexedDb.putReplaceableEvent(event)
}
updateRelayListCache(event: NEvent) {
this.relayListEventDataLoader.clear(event.pubkey)
this.relayListEventDataLoader.prime(event.pubkey, Promise.resolve(event))
indexedDb.putReplaceableEvent(event)
}
async searchNpubsFromCache(query: string, limit: number = 100) {
@@ -845,7 +849,7 @@ class ClientService extends EventTarget {
}
async initUserIndexFromFollowings(pubkey: string, signal: AbortSignal) {
const followings = await this.fetchFollowings(pubkey, true)
const followings = await this.fetchFollowings(pubkey)
for (let i = 0; i * 20 < followings.length; i++) {
if (signal.aborted) return
await Promise.all(
@@ -1016,6 +1020,30 @@ class ClientService extends EventTarget {
return profileEvents
}
private async followListEventBatchLoadFn(pubkeys: readonly string[]) {
const events = await this.query(BIG_RELAY_URLS, {
authors: Array.from(new Set(pubkeys)),
kinds: [kinds.Contacts],
limit: pubkeys.length
})
const eventsMap = new Map<string, NEvent>()
for (const event of events) {
const pubkey = event.pubkey
const existing = eventsMap.get(pubkey)
if (!existing || existing.created_at < event.created_at) {
eventsMap.set(pubkey, event)
}
}
const followListEvents = pubkeys.map((pubkey) => {
return eventsMap.get(pubkey)
})
followListEvents.forEach(
(followListEvent) => followListEvent && indexedDb.putReplaceableEvent(followListEvent)
)
return followListEvents
}
private async relayListEventBatchLoadFn(pubkeys: readonly string[]) {
const events = await this.query(BIG_RELAY_URLS, {
authors: pubkeys as string[],
@@ -1047,9 +1075,19 @@ class ClientService extends EventTarget {
if (storedFollowListEvent) {
return storedFollowListEvent
}
const followListEventFromBigRelays =
await this.fetchFollowListEventFromBigRelaysDataloader.load(pubkey)
if (followListEventFromBigRelays) {
return followListEventFromBigRelays
}
const relayList = await this.fetchRelayList(pubkey)
const followListEvents = await this.query(relayList.write.concat(BIG_RELAY_URLS), {
const relays = relayList.write.filter((url) => !BIG_RELAY_URLS.includes(url))
if (!relays.length) {
return undefined
}
const followListEvents = await this.query(relays, {
authors: [pubkey],
kinds: [kinds.Contacts]
})

View File

@@ -25,6 +25,7 @@ class LocalStorageService {
private accountFeedInfoMap: Record<string, TFeedInfo | undefined> = {}
private mediaUploadService: string = DEFAULT_NIP_96_SERVICE
private autoplay: boolean = true
private hideUntrustedReplies: boolean = true
constructor() {
if (!LocalStorageService.instance) {
@@ -92,6 +93,9 @@ class LocalStorageService {
this.autoplay = window.localStorage.getItem(StorageKey.AUTOPLAY) !== 'false'
this.hideUntrustedReplies =
window.localStorage.getItem(StorageKey.HIDE_UNTRUSTED_REPLIES) !== 'false'
// Clean up deprecated data
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
@@ -247,6 +251,14 @@ class LocalStorageService {
this.autoplay = autoplay
window.localStorage.setItem(StorageKey.AUTOPLAY, autoplay.toString())
}
getHideUntrustedReplies() {
return this.hideUntrustedReplies
}
setHideUntrustedReplies(hide: boolean) {
this.hideUntrustedReplies = hide
window.localStorage.setItem(StorageKey.HIDE_UNTRUSTED_REPLIES, hide.toString())
}
}
const instance = new LocalStorageService()