feat: hide untrusted content button

This commit is contained in:
codytseng
2025-06-09 01:08:50 +08:00
parent 5913cc3b88
commit 963051e70d
12 changed files with 177 additions and 57 deletions

View File

@@ -0,0 +1,69 @@
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'
export default function HideUntrustedContentButton({
type,
size = 'icon'
}: {
type: 'interactions' | 'notifications'
size?: VariantProps<typeof buttonVariants>['size']
}) {
const {
hideUntrustedInteractions,
hideUntrustedNotifications,
updateHideUntrustedInteractions,
updateHideUntrustedNotifications
} = useUserTrust()
const enabled = type === 'interactions' ? hideUntrustedInteractions : hideUntrustedNotifications
const updateEnabled =
type === 'interactions' ? updateHideUntrustedInteractions : updateHideUntrustedNotifications
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 ? 'Show' : 'Hide'} untrusted {type}
</AlertDialogTitle>
<AlertDialogDescription>
{enabled
? `Currently hiding ${type} from untrusted users. `
: `Currently showing all ${type}. `}
Trusted users include people you follow and people they follow.
{enabled
? ` Click continue to show all ${type}.`
: ` Click continue to hide ${type} from untrusted users.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => updateEnabled(!enabled)}>Continue</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -1,6 +1,7 @@
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 ReplyNoteList from '../ReplyNoteList' import ReplyNoteList from '../ReplyNoteList'
import { Tabs, TTabValue } from './Tabs' import { Tabs, TTabValue } from './Tabs'
@@ -16,7 +17,10 @@ export default function NoteInteractions({
return ( return (
<> <>
<div className="flex items-center justify-between pr-1">
<Tabs selectedTab={type} onTabChange={setType} /> <Tabs selectedTab={type} onTabChange={setType} />
<HideUntrustedContentButton type="interactions" />
</div>
<Separator /> <Separator />
{type === 'replies' ? ( {type === 'replies' ? (
<ReplyNoteList index={pageIndex} event={event} /> <ReplyNoteList index={pageIndex} event={event} />

View File

@@ -12,11 +12,13 @@ export default function ReplyButton({ event }: { event: Event }) {
const { t } = useTranslation() const { t } = useTranslation()
const { checkLogin } = useNostr() const { checkLogin } = useNostr()
const { repliesMap } = useReply() const { repliesMap } = useReply()
const { isUserTrusted } = useUserTrust() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const replyCount = useMemo( const replyCount = useMemo(() => {
() => repliesMap.get(event.id)?.events.filter((evt) => isUserTrusted(evt.pubkey)).length || 0, if (hideUntrustedInteractions) {
[repliesMap, event.id, isUserTrusted] return repliesMap.get(event.id)?.events.filter((evt) => isUserTrusted(evt.pubkey)).length ?? 0
) }
return repliesMap.get(event.id)?.events.length ?? 0
}, [repliesMap, event.id, isUserTrusted])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
return ( return (

View File

@@ -23,7 +23,7 @@ const NotificationList = forwardRef((_, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { current } = usePrimaryPage() const { current } = usePrimaryPage()
const { pubkey } = useNostr() const { pubkey } = useNostr()
const { isUserTrusted } = useUserTrust() const { hideUntrustedNotifications, isUserTrusted } = useUserTrust()
const { clearNewNotifications, getNotificationsSeenAt } = useNotification() const { clearNewNotifications, getNotificationsSeenAt } = useNotification()
const { updateNoteStatsByEvents } = useNoteStats() const { updateNoteStatsByEvents } = useNoteStats()
const [notificationType, setNotificationType] = useState<TNotificationType>('all') const [notificationType, setNotificationType] = useState<TNotificationType>('all')
@@ -124,9 +124,11 @@ const NotificationList = forwardRef((_, ref) => {
}, [pubkey, refreshCount, filterKinds, current]) }, [pubkey, refreshCount, filterKinds, current])
useEffect(() => { useEffect(() => {
const visibleNotifications = notifications let visibleNotifications = notifications.slice(0, showCount)
.slice(0, showCount) if (hideUntrustedNotifications) {
.filter((event) => isUserTrusted(event.pubkey)) visibleNotifications = visibleNotifications.filter((event) => isUserTrusted(event.pubkey))
}
const index = visibleNotifications.findIndex((event) => event.created_at <= lastReadTime) const index = visibleNotifications.findIndex((event) => event.created_at <= lastReadTime)
if (index === -1) { if (index === -1) {
setNewNotifications(visibleNotifications) setNewNotifications(visibleNotifications)
@@ -135,7 +137,7 @@ const NotificationList = forwardRef((_, ref) => {
setNewNotifications(visibleNotifications.slice(0, index)) setNewNotifications(visibleNotifications.slice(0, index))
setOldNotifications(visibleNotifications.slice(index)) setOldNotifications(visibleNotifications.slice(index))
} }
}, [notifications, lastReadTime, showCount, isUserTrusted]) }, [notifications, lastReadTime, showCount, hideUntrustedNotifications, isUserTrusted])
useEffect(() => { useEffect(() => {
const options = { const options = {

View File

@@ -1,5 +1,6 @@
import { BIG_RELAY_URLS } from '@/constants' import { BIG_RELAY_URLS } from '@/constants'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
@@ -13,6 +14,7 @@ const SHOW_COUNT = 10
export default function QuoteList({ event, className }: { event: Event; className?: string }) { export default function QuoteList({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { startLogin } = useNostr() const { startLogin } = useNostr()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined) const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [events, setEvents] = useState<Event[]>([]) const [events, setEvents] = useState<Event[]>([])
const [showCount, setShowCount] = useState(SHOW_COUNT) const [showCount, setShowCount] = useState(SHOW_COUNT)
@@ -124,9 +126,12 @@ export default function QuoteList({ event, className }: { event: Event; classNam
<div className={className}> <div className={className}>
<div className="min-h-screen"> <div className="min-h-screen">
<div> <div>
{events.slice(0, showCount).map((event) => ( {events.slice(0, showCount).map((event) => {
<NoteCard key={event.id} className="w-full" event={event} /> if (hideUntrustedInteractions && !isUserTrusted(event.pubkey)) {
))} return null
}
return <NoteCard key={event.id} className="w-full" event={event} />
})}
</div> </div>
{hasMore || loading ? ( {hasMore || loading ? (
<div ref={bottomRef}> <div ref={bottomRef}>

View File

@@ -32,7 +32,7 @@ export default function ReplyNoteList({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { currentIndex } = useSecondaryPage() const { currentIndex } = useSecondaryPage()
const { isUserTrusted } = useUserTrust() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined) const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined)
const { repliesMap, addReplies } = useReply() const { repliesMap, addReplies } = useReply()
const replies = useMemo(() => { const replies = useMemo(() => {
@@ -250,7 +250,7 @@ export default function ReplyNoteList({
)} )}
<div className={className}> <div className={className}>
{replies.slice(0, showCount).map((reply) => { {replies.slice(0, showCount).map((reply) => {
if (!isUserTrusted(reply.pubkey)) { if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) {
const repliesForThisReply = repliesMap.get(reply.id) const repliesForThisReply = repliesMap.get(reply.id)
// If the reply is not trusted and there are no trusted replies for this reply, skip rendering // If the reply is not trusted and there are no trusted replies for this reply, skip rendering
if ( if (

View File

@@ -14,7 +14,9 @@ export const StorageKey = {
ACCOUNT_FEED_INFO_MAP: 'accountFeedInfoMap', ACCOUNT_FEED_INFO_MAP: 'accountFeedInfoMap',
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', MEDIA_UPLOAD_SERVICE: 'mediaUploadService',
AUTOPLAY: 'autoplay', AUTOPLAY: 'autoplay',
HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', HIDE_UNTRUSTED_INTERACTIONS: 'hideUntrustedInteractions',
HIDE_UNTRUSTED_NOTIFICATIONS: 'hideUntrustedNotifications',
HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', // deprecated
ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated
ACCOUNT_FOLLOW_LIST_EVENT_MAP: 'accountFollowListEventMap', // deprecated ACCOUNT_FOLLOW_LIST_EVENT_MAP: 'accountFollowListEventMap', // deprecated
ACCOUNT_MUTE_LIST_EVENT_MAP: 'accountMuteListEventMap', // deprecated ACCOUNT_MUTE_LIST_EVENT_MAP: 'accountMuteListEventMap', // deprecated
@@ -83,4 +85,4 @@ export const NIP_96_SERVICE = [
] ]
export const DEFAULT_NIP_96_SERVICE = 'https://nostr.build' export const DEFAULT_NIP_96_SERVICE = 'https://nostr.build'
export const DEFAULT_NOSTRCONNECT_RELAY = ['wss://relay.nsec.app/']; export const DEFAULT_NOSTRCONNECT_RELAY = ['wss://relay.nsec.app/']

View File

@@ -1,3 +1,4 @@
import HideUntrustedContentButton from '@/components/HideUntrustedContentButton'
import NotificationList from '@/components/NotificationList' import NotificationList from '@/components/NotificationList'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { usePrimaryPage } from '@/PageManager' import { usePrimaryPage } from '@/PageManager'
@@ -35,9 +36,12 @@ function NotificationListPageTitlebar() {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<div className="flex gap-2 items-center h-full pl-3"> <div className="flex gap-2 items-center justify-between h-full pl-3">
<div className="flex items-center gap-2">
<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" />
</div>
) )
} }

View File

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

View File

@@ -25,7 +25,7 @@ export const useNotification = () => {
export function NotificationProvider({ children }: { children: React.ReactNode }) { export function NotificationProvider({ children }: { children: React.ReactNode }) {
const { pubkey, notificationsSeenAt, updateNotificationsSeenAt } = useNostr() const { pubkey, notificationsSeenAt, updateNotificationsSeenAt } = useNostr()
const { isUserTrusted } = useUserTrust() const { hideUntrustedNotifications, isUserTrusted } = useUserTrust()
const { mutePubkeys } = useMuteList() const { mutePubkeys } = useMuteList()
const [newNotificationIds, setNewNotificationIds] = useState(new Set<string>()) const [newNotificationIds, setNewNotificationIds] = useState(new Set<string>())
const subCloserRef = useRef<SubCloser | null>(null) const subCloserRef = useRef<SubCloser | null>(null)
@@ -66,7 +66,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
if ( if (
evt.pubkey !== pubkey && evt.pubkey !== pubkey &&
!mutePubkeys.includes(evt.pubkey) && !mutePubkeys.includes(evt.pubkey) &&
isUserTrusted(evt.pubkey) (!hideUntrustedNotifications || isUserTrusted(evt.pubkey))
) { ) {
setNewNotificationIds((prev) => new Set([...prev, evt.id])) setNewNotificationIds((prev) => new Set([...prev, evt.id]))
} }

View File

@@ -1,11 +1,13 @@
import client from '@/services/client.service' import client from '@/services/client.service'
import storage from '@/services/local-storage.service'
import { createContext, useCallback, useContext, useEffect, useState } from 'react' import { createContext, useCallback, useContext, useEffect, useState } from 'react'
import { useNostr } from './NostrProvider' import { useNostr } from './NostrProvider'
import storage from '@/services/local-storage.service'
type TUserTrustContext = { type TUserTrustContext = {
enabled: boolean hideUntrustedInteractions: boolean
updateEnabled: (enabled: boolean) => void hideUntrustedNotifications: boolean
updateHideUntrustedInteractions: (hide: boolean) => void
updateHideUntrustedNotifications: (hide: boolean) => void
isUserTrusted: (pubkey: string) => boolean isUserTrusted: (pubkey: string) => boolean
} }
@@ -23,7 +25,12 @@ 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 [enabled, setEnabled] = useState(storage.getHideUntrustedEvents()) const [hideUntrustedInteractions, setHideUntrustedInteractions] = useState(() =>
storage.getHideUntrustedInteractions()
)
const [hideUntrustedNotifications, setHideUntrustedNotifications] = useState(() =>
storage.getHideUntrustedNotifications()
)
useEffect(() => { useEffect(() => {
if (!currentPubkey) return if (!currentPubkey) return
@@ -43,19 +50,32 @@ export function UserTrustProvider({ children }: { children: React.ReactNode }) {
const isUserTrusted = useCallback( const isUserTrusted = useCallback(
(pubkey: string) => { (pubkey: string) => {
if (!currentPubkey || !enabled) return true if (!currentPubkey) return true
return wotSet.has(pubkey) return wotSet.has(pubkey)
}, },
[currentPubkey, enabled] [currentPubkey]
) )
const updateEnabled = (enabled: boolean) => { const updateHideUntrustedInteractions = (hide: boolean) => {
setEnabled(enabled) setHideUntrustedInteractions(hide)
storage.setHideUntrustedEvents(enabled) storage.setHideUntrustedInteractions(hide)
}
const updateHideUntrustedNotifications = (hide: boolean) => {
setHideUntrustedNotifications(hide)
storage.setHideUntrustedNotifications(hide)
} }
return ( return (
<UserTrustContext.Provider value={{ enabled, updateEnabled, isUserTrusted }}> <UserTrustContext.Provider
value={{
hideUntrustedInteractions,
hideUntrustedNotifications,
updateHideUntrustedInteractions,
updateHideUntrustedNotifications,
isUserTrusted
}}
>
{children} {children}
</UserTrustContext.Provider> </UserTrustContext.Provider>
) )

View File

@@ -25,7 +25,8 @@ 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 hideUntrustedEvents: boolean = true private hideUntrustedInteractions: boolean = false
private hideUntrustedNotifications: boolean = false
constructor() { constructor() {
if (!LocalStorageService.instance) { if (!LocalStorageService.instance) {
@@ -93,8 +94,20 @@ class LocalStorageService {
this.autoplay = window.localStorage.getItem(StorageKey.AUTOPLAY) !== 'false' this.autoplay = window.localStorage.getItem(StorageKey.AUTOPLAY) !== 'false'
this.hideUntrustedEvents = const hideUntrustedEvents =
window.localStorage.getItem(StorageKey.HIDE_UNTRUSTED_EVENTS) !== 'false' 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
)
this.hideUntrustedInteractions = storedHideUntrustedInteractions
? storedHideUntrustedInteractions === 'true'
: hideUntrustedEvents
this.hideUntrustedNotifications = storedHideUntrustedNotifications
? storedHideUntrustedNotifications === 'true'
: hideUntrustedEvents
// Clean up deprecated data // Clean up deprecated data
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP) window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
@@ -252,13 +265,28 @@ class LocalStorageService {
window.localStorage.setItem(StorageKey.AUTOPLAY, autoplay.toString()) window.localStorage.setItem(StorageKey.AUTOPLAY, autoplay.toString())
} }
getHideUntrustedEvents() { getHideUntrustedInteractions() {
return this.hideUntrustedEvents return this.hideUntrustedInteractions
} }
setHideUntrustedEvents(hide: boolean) { setHideUntrustedInteractions(hideUntrustedInteractions: boolean) {
this.hideUntrustedEvents = hide this.hideUntrustedInteractions = hideUntrustedInteractions
window.localStorage.setItem(StorageKey.HIDE_UNTRUSTED_EVENTS, hide.toString()) 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()
)
} }
} }