feat: hide content mentioning muted users (#524)

Co-authored-by: mleku <me@mleku.dev>
This commit is contained in:
Cody Tseng
2025-09-02 22:18:34 +08:00
committed by GitHub
parent d3578184fb
commit 3c657dfa8c
37 changed files with 289 additions and 83 deletions

View File

@@ -1,5 +1,7 @@
import { ExtendedKind } from '@/constants'
import { isMentioningMutedUsers } from '@/lib/event'
import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
@@ -22,10 +24,18 @@ export default function ContentPreview({
className?: string
}) {
const { t } = useTranslation()
const { mutePubkeys } = useMuteList()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const isMuted = useMemo(
() => (event ? mutePubkeys.includes(event.pubkey) : false),
[mutePubkeys, event]
() => (event ? mutePubkeySet.has(event.pubkey) : false),
[mutePubkeySet, event]
)
const isMentioningMuted = useMemo(
() =>
hideContentMentioningMutedUsers && event
? isMentioningMutedUsers(event, mutePubkeySet)
: false,
[event, mutePubkeySet]
)
if (!event) {
@@ -38,6 +48,14 @@ export default function ContentPreview({
)
}
if (isMentioningMuted) {
return (
<div className={cn('pointer-events-none', className)}>
[{t('This note mentions a user you muted')}]
</div>
)
}
if (
[
kinds.ShortTextNote,

View File

@@ -18,10 +18,10 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { pubkey: accountPubkey, checkLogin } = useNostr()
const { mutePubkeys, changing, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } =
const { mutePubkeySet, changing, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } =
useMuteList()
const [updating, setUpdating] = useState(false)
const isMuted = useMemo(() => mutePubkeys.includes(pubkey), [mutePubkeys, pubkey])
const isMuted = useMemo(() => mutePubkeySet.has(pubkey), [mutePubkeySet, pubkey])
if (!accountPubkey || (pubkey && pubkey === accountPubkey)) return null

View File

@@ -54,7 +54,7 @@ export default function Note({
const usingClient = useMemo(() => getUsingClient(event), [event])
const { defaultShowNsfw } = useContentPolicy()
const [showNsfw, setShowNsfw] = useState(false)
const { mutePubkeys } = useMuteList()
const { mutePubkeySet } = useMuteList()
const [showMuted, setShowMuted] = useState(false)
let content: React.ReactNode
@@ -67,7 +67,7 @@ export default function Note({
].includes(event.kind)
) {
content = <UnknownNote className="mt-2" event={event} />
} else if (mutePubkeys.includes(event.pubkey) && !showMuted) {
} else if (mutePubkeySet.has(event.pubkey) && !showMuted) {
content = <MutedNote show={() => setShowMuted(true)} />
} else if (!defaultShowNsfw && isNsfwEvent(event) && !showNsfw) {
content = <NsfwNote show={() => setShowNsfw(true)} />

View File

@@ -1,8 +1,10 @@
import { isMentioningMutedUsers } from '@/lib/event'
import { tagNameEquals } from '@/lib/tag'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import client from '@/services/client.service'
import { Event, kinds, nip19, verifyEvent } from 'nostr-tools'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import MainNoteCard from './MainNoteCard'
export default function RepostNoteCard({
@@ -14,8 +16,19 @@ export default function RepostNoteCard({
className?: string
filterMutedNotes?: boolean
}) {
const { mutePubkeys } = useMuteList()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const [targetEvent, setTargetEvent] = useState<Event | null>(null)
const shouldHide = useMemo(() => {
if (!targetEvent) return true
if (filterMutedNotes && mutePubkeySet.has(targetEvent.pubkey)) {
return true
}
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(targetEvent, mutePubkeySet)) {
return true
}
return false
}, [targetEvent, filterMutedNotes, hideContentMentioningMutedUsers, mutePubkeySet])
useEffect(() => {
const fetch = async () => {
try {
@@ -56,10 +69,7 @@ export default function RepostNoteCard({
fetch()
}, [event])
if (!targetEvent) return null
if (filterMutedNotes && mutePubkeys.includes(targetEvent.pubkey)) {
return null
}
if (!targetEvent || shouldHide) return null
return <MainNoteCard className={className} reposter={event.pubkey} event={targetEvent} />
}

View File

@@ -1,6 +1,9 @@
import { Skeleton } from '@/components/ui/skeleton'
import { isMentioningMutedUsers } from '@/lib/event'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import MainNoteCard from './MainNoteCard'
import RepostNoteCard from './RepostNoteCard'
@@ -13,10 +16,18 @@ export default function NoteCard({
className?: string
filterMutedNotes?: boolean
}) {
const { mutePubkeys } = useMuteList()
if (filterMutedNotes && mutePubkeys.includes(event.pubkey)) {
return null
}
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const shouldHide = useMemo(() => {
if (filterMutedNotes && mutePubkeySet.has(event.pubkey)) {
return true
}
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(event, mutePubkeySet)) {
return true
}
return false
}, [event, filterMutedNotes, mutePubkeySet])
if (shouldHide) return null
if (event.kind === kinds.Repost) {
return (

View File

@@ -2,9 +2,11 @@ import NewNotesButton from '@/components/NewNotesButton'
import { Button } from '@/components/ui/button'
import {
getReplaceableCoordinateFromEvent,
isMentioningMutedUsers,
isReplaceableEvent,
isReplyNoteEvent
} from '@/lib/event'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
@@ -13,7 +15,15 @@ import client from '@/services/client.service'
import { TFeedSubRequest } from '@/types'
import dayjs from 'dayjs'
import { Event } from 'nostr-tools'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState
} from 'react'
import { useTranslation } from 'react-i18next'
import PullToRefresh from 'react-simple-pull-to-refresh'
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
@@ -44,7 +54,8 @@ const NoteList = forwardRef(
const { t } = useTranslation()
const { startLogin } = useNostr()
const { isUserTrusted } = useUserTrust()
const { mutePubkeys } = useMuteList()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const { isEventDeleted } = useDeletedEvent()
const [events, setEvents] = useState<Event[]>([])
const [newEvents, setNewEvents] = useState<Event[]>([])
@@ -56,13 +67,30 @@ const NoteList = forwardRef(
const bottomRef = useRef<HTMLDivElement | null>(null)
const topRef = useRef<HTMLDivElement | null>(null)
const shouldHideEvent = useCallback(
(evt: Event) => {
if (isEventDeleted(evt)) return true
if (hideReplies && isReplyNoteEvent(evt)) return true
if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return true
if (filterMutedNotes && mutePubkeySet.has(evt.pubkey)) return true
if (
filterMutedNotes &&
hideContentMentioningMutedUsers &&
isMentioningMutedUsers(evt, mutePubkeySet)
) {
return true
}
return false
},
[hideReplies, hideUntrustedNotes, mutePubkeySet, isEventDeleted]
)
const filteredEvents = useMemo(() => {
const idSet = new Set<string>()
return events.slice(0, showCount).filter((evt) => {
if (isEventDeleted(evt)) return false
if (hideReplies && isReplyNoteEvent(evt)) return false
if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return false
if (shouldHideEvent(evt)) return false
const id = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id
if (idSet.has(id)) {
@@ -71,16 +99,13 @@ const NoteList = forwardRef(
idSet.add(id)
return true
})
}, [events, hideReplies, hideUntrustedNotes, showCount, isEventDeleted])
}, [events, showCount, shouldHideEvent])
const filteredNewEvents = useMemo(() => {
const idSet = new Set<string>()
return newEvents.filter((event: Event) => {
if (isEventDeleted(event)) return false
if (hideReplies && isReplyNoteEvent(event)) return false
if (hideUntrustedNotes && !isUserTrusted(event.pubkey)) return false
if (filterMutedNotes && mutePubkeys.includes(event.pubkey)) return false
if (shouldHideEvent(event)) return false
const id = isReplaceableEvent(event.kind)
? getReplaceableCoordinateFromEvent(event)
@@ -91,7 +116,7 @@ const NoteList = forwardRef(
idSet.add(id)
return true
})
}, [newEvents, hideReplies, hideUntrustedNotes, filterMutedNotes, mutePubkeys, isEventDeleted])
}, [events, showCount, shouldHideEvent])
const scrollToTop = (behavior: ScrollBehavior = 'instant') => {
setTimeout(() => {

View File

@@ -47,8 +47,8 @@ export function useMenuActions({
const { t } = useTranslation()
const { pubkey, attemptDelete } = useNostr()
const { relaySets, favoriteRelays } = useFavoriteRelays()
const { mutePubkeyPublicly, mutePubkeyPrivately, unmutePubkey, mutePubkeys } = useMuteList()
const isMuted = useMemo(() => mutePubkeys.includes(event.pubkey), [mutePubkeys, event])
const { mutePubkeyPublicly, mutePubkeyPrivately, unmutePubkey, mutePubkeySet } = useMuteList()
const isMuted = useMemo(() => mutePubkeySet.has(event.pubkey), [mutePubkeySet, event])
const broadcastSubMenu: SubMenuAction[] = useMemo(() => {
const items = []

View File

@@ -1,4 +1,7 @@
import { isMentioningMutedUsers } from '@/lib/event'
import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useReply } from '@/providers/ReplyProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
@@ -14,18 +17,29 @@ export default function ReplyButton({ event }: { event: Event }) {
const { pubkey, checkLogin } = useNostr()
const { repliesMap } = useReply()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const { replyCount, hasReplied } = useMemo(() => {
const hasReplied = pubkey
? repliesMap.get(event.id)?.events.some((evt) => evt.pubkey === pubkey)
: false
if (hideUntrustedInteractions) {
return {
replyCount:
repliesMap.get(event.id)?.events.filter((evt) => isUserTrusted(evt.pubkey)).length ?? 0,
hasReplied
}
return {
replyCount:
repliesMap.get(event.id)?.events.filter((evt) => {
if (hideUntrustedInteractions && !isUserTrusted(evt.pubkey)) {
return false
}
if (mutePubkeySet.has(evt.pubkey)) {
return false
}
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) {
return false
}
return true
}).length ?? 0,
hasReplied
}
return { replyCount: repliesMap.get(event.id)?.events.length ?? 0, hasReplied }
}, [repliesMap, event.id, hideUntrustedInteractions])
const [open, setOpen] = useState(false)

View File

@@ -1,6 +1,9 @@
import { ExtendedKind } from '@/constants'
import { isMentioningMutedUsers } from '@/lib/event'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import { MentionNotification } from './MentionNotification'
import { PollResponseNotification } from './PollResponseNotification'
import { ReactionNotification } from './ReactionNotification'
@@ -14,10 +17,19 @@ export function NotificationItem({
notification: Event
isNew?: boolean
}) {
const { mutePubkeys } = useMuteList()
if (mutePubkeys.includes(notification.pubkey)) {
return null
}
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const shouldHide = useMemo(() => {
if (mutePubkeySet.has(notification.pubkey)) {
return true
}
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(notification, mutePubkeySet)) {
return true
}
return false
}, [])
if (shouldHide) return null
if (notification.kind === kinds.Reaction) {
return <ReactionNotification notification={notification} isNew={isNew} />
}

View File

@@ -23,7 +23,7 @@ export default function Mentions({
}) {
const { t } = useTranslation()
const { pubkey } = useNostr()
const { mutePubkeys } = useMuteList()
const { mutePubkeySet } = useMuteList()
const [potentialMentions, setPotentialMentions] = useState<string[]>([])
const [parentEventPubkey, setParentEventPubkey] = useState<string | undefined>()
const [removedPubkeys, setRemovedPubkeys] = useState<string[]>([])
@@ -43,13 +43,13 @@ export default function Mentions({
pubkeys
.filter((p) => potentialMentions.includes(p))
.concat(
potentialMentions.filter((p) => mutePubkeys.includes(p) && p !== _parentEventPubkey)
potentialMentions.filter((p) => mutePubkeySet.has(p) && p !== _parentEventPubkey)
)
)
)
})
})
}, [content, parentEvent, pubkey])
}, [content, parentEvent, pubkey, mutePubkeySet])
useEffect(() => {
const newMentions = potentialMentions.filter((pubkey) => !removedPubkeys.includes(pubkey))

View File

@@ -108,6 +108,7 @@ export default function ProfileFeed({
subRequests={subRequests}
showKinds={temporaryShowKinds}
hideReplies={listMode === 'posts'}
filterMutedNotes={false}
/>
</>
)

View File

@@ -31,7 +31,7 @@ export default function Profile({ id }: { id?: string }) {
const { push } = useSecondaryPage()
const { profile, isFetching } = useFetchProfile(id)
const { pubkey: accountPubkey } = useNostr()
const { mutePubkeys } = useMuteList()
const { mutePubkeySet } = useMuteList()
const { followings } = useFetchFollowings(profile?.pubkey)
const isFollowingYou = useMemo(() => {
return (
@@ -176,7 +176,7 @@ export default function Profile({ id }: { id?: string }) {
<Relays pubkey={pubkey} />
{isSelf && (
<SecondaryPageLink to={toMuteList()} className="flex gap-1 hover:underline w-fit">
{mutePubkeys.length}
{mutePubkeySet.size}
<div className="text-muted-foreground">{t('Muted')}</div>
</SecondaryPageLink>
)}

View File

@@ -9,17 +9,17 @@ import { pubkeyToNpub } from '@/lib/pubkey'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { Bell, BellOff, Copy, Ellipsis } from 'lucide-react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function ProfileOptions({ pubkey }: { pubkey: string }) {
const { t } = useTranslation()
const { pubkey: accountPubkey } = useNostr()
const { mutePubkeys, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } = useMuteList()
const { mutePubkeySet, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } = useMuteList()
const isMuted = useMemo(() => mutePubkeySet.has(pubkey), [mutePubkeySet, pubkey])
if (pubkey === accountPubkey) return null
const isMuted = mutePubkeys.includes(pubkey)
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>

View File

@@ -1,8 +1,9 @@
import { useSecondaryPage } from '@/PageManager'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { getUsingClient } from '@/lib/event'
import { getUsingClient, isMentioningMutedUsers } from '@/lib/event'
import { toNote } from '@/lib/link'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event } from 'nostr-tools'
@@ -33,12 +34,21 @@ export default function ReplyNote({
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { push } = useSecondaryPage()
const { mutePubkeys } = useMuteList()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const [showMuted, setShowMuted] = useState(false)
const show = useMemo(
() => showMuted || !mutePubkeys.includes(event.pubkey),
[showMuted, mutePubkeys, event]
)
const show = useMemo(() => {
if (showMuted) {
return true
}
if (mutePubkeySet.has(event.pubkey)) {
return false
}
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(event, mutePubkeySet)) {
return false
}
return true
}, [showMuted, mutePubkeySet, event, hideContentMentioningMutedUsers])
const usingClient = useMemo(() => getUsingClient(event), [event])
return (

View File

@@ -5,11 +5,15 @@ import {
getRootATag,
getRootETag,
getRootEventHexId,
isMentioningMutedUsers,
isReplaceableEvent,
isReplyNoteEvent
} from '@/lib/event'
import { toNote } from '@/lib/link'
import { generateBech32IdFromETag, tagNameEquals } from '@/lib/tag'
import { useSecondaryPage } from '@/PageManager'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useReply } from '@/providers/ReplyProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service'
@@ -29,8 +33,10 @@ const SHOW_COUNT = 10
export default function ReplyNoteList({ index, event }: { index?: number; event: NEvent }) {
const { t } = useTranslation()
const { currentIndex } = useSecondaryPage()
const { push, currentIndex } = useSecondaryPage()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined)
const { repliesMap, addReplies } = useReply()
const replies = useMemo(() => {
@@ -44,6 +50,9 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
const events = parentEventKeys.flatMap((id) => repliesMap.get(id)?.events || [])
events.forEach((evt) => {
if (replyIdSet.has(evt.id)) return
if (mutePubkeySet.has(evt.pubkey)) return
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) return
replyIdSet.add(evt.id)
replyEvents.push(evt)
})
@@ -309,7 +318,14 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
<ReplyNote
event={reply}
parentEventId={event.id !== parentEventHexId ? parentEventId : undefined}
onClickParent={() => parentEventHexId && highlightReply(parentEventHexId)}
onClickParent={() => {
if (!parentEventHexId) return
if (replies.every((r) => r.id !== parentEventHexId)) {
push(toNote(parentEventId ?? parentEventHexId))
return
}
highlightReply(parentEventHexId)
}}
highlight={highlightReplyId === reply.id}
/>
</div>