diff --git a/src/App.tsx b/src/App.tsx index f8eb5964..7d0d7e96 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 { - - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx index 11164acd..e786e174 100644 --- a/src/components/ReplyNote/index.tsx +++ b/src/components/ReplyNote/index.tsx @@ -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, diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 0a326686..16022778 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -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(undefined) const { repliesMap, addReplies } = useReply() const replies = useMemo(() => { @@ -248,6 +250,10 @@ export default function ReplyNoteList({ {replies.length > 0 && (loading || until) && }
{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 diff --git a/src/constants.ts b/src/constants.ts index b0d27e6f..e13bcd85 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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 diff --git a/src/pages/secondary/GeneralSettingsPage/index.tsx b/src/pages/secondary/GeneralSettingsPage/index.tsx index 215aae7b..09cedc00 100644 --- a/src/pages/secondary/GeneralSettingsPage/index.tsx +++ b/src/pages/secondary/GeneralSettingsPage/index.tsx @@ -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(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) => { + + + +
) diff --git a/src/providers/UserTrustProvider.tsx b/src/providers/UserTrustProvider.tsx new file mode 100644 index 00000000..9e379d87 --- /dev/null +++ b/src/providers/UserTrustProvider.tsx @@ -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(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() + +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 ( + + {children} + + ) +} diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 643278d5..64d0c919 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -56,6 +56,13 @@ class ClientService extends EventTarget { maxBatchSize: 500 } ) + private fetchFollowListEventFromBigRelaysDataloader = new DataLoader( + this.followListEventBatchLoadFn.bind(this), + { + batchScheduleFn: (callback) => setTimeout(callback, 50), + maxBatchSize: 500 + } + ) private relayListEventDataLoader = new DataLoader( 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 { @@ -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() + 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] }) diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index 31f0641f..e5d392dd 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -25,6 +25,7 @@ class LocalStorageService { private accountFeedInfoMap: Record = {} 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()