feat: hide replies from untrusted users
This commit is contained in:
27
src/App.tsx
27
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 {
|
||||
<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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
59
src/providers/UserTrustProvider.tsx
Normal file
59
src/providers/UserTrustProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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]
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user