feat: support hiding indirect notifications

This commit is contained in:
codytseng 2026-01-20 22:47:42 +08:00
parent 331811f683
commit 2cd1ae481b
27 changed files with 196 additions and 38 deletions

View file

@ -1,13 +1,16 @@
import ParentNotePreview from '@/components/ParentNotePreview'
import { NOTIFICATION_LIST_STYLE } from '@/constants'
import { getEmbeddedPubkeys, getParentStuff } from '@/lib/event'
import { ExtendedKind, NOTIFICATION_LIST_STYLE } from '@/constants'
import { getEmbeddedPubkeys, getParentStuff, getParentTag } from '@/lib/event'
import { toExternalContent, toNote } from '@/lib/link'
import { generateBech32IdFromETag, tagNameEquals } from '@/lib/tag'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { useNotificationUserPreference } from '@/providers/NotificationUserPreferenceProvider'
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import client from '@/services/client.service'
import { AtSign, MessageCircle, Quote } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Notification from './Notification'
@ -22,6 +25,7 @@ export function MentionNotification({
const { push } = useSecondaryPage()
const { pubkey } = useNostr()
const { notificationListStyle } = useUserPreferences()
const { hideIndirect } = useNotificationUserPreference()
const isMention = useMemo(() => {
if (!pubkey) return false
const mentions = getEmbeddedPubkeys(notification)
@ -30,6 +34,49 @@ export function MentionNotification({
const { parentEventId, parentExternalContent } = useMemo(() => {
return getParentStuff(notification)
}, [notification])
const [isDirectMention, setIsDirectMention] = useState(false)
useEffect(() => {
const checkIsDirectMention = async () => {
if (!pubkey) return false
if (isMention) return true
if (notification.kind === ExtendedKind.POLL) return true
if (
notification.kind === ExtendedKind.VOICE_COMMENT ||
notification.kind === ExtendedKind.COMMENT
) {
const parentPTag = notification.tags.findLast(tagNameEquals('p'))
const parentPubkey = parentPTag?.[1]
return parentPubkey === pubkey
}
const parentTag = getParentTag(notification)
if (parentTag?.type === 'e') {
const [, , , , parentPubkey] = parentTag.tag
if (parentPubkey) {
return parentPubkey === pubkey
}
const parentEventId = generateBech32IdFromETag(parentTag.tag)
if (!parentEventId) return false
const parentEvent = await client.fetchEvent(parentEventId)
if (parentEvent) {
return parentEvent.pubkey === pubkey
}
return false
}
if (parentTag?.type === 'a') {
const coordinate = parentTag.tag[1]
const [, parentPubkey] = coordinate.split(':')
return parentPubkey === pubkey
}
return false
}
checkIsDirectMention().then(setIsDirectMention)
}, [pubkey, notification, isMention])
if (hideIndirect && !isDirectMention) {
return null
}
return (
<Notification

View file

@ -2,6 +2,7 @@ import Image from '@/components/Image'
import { useFetchEvent } from '@/hooks'
import { generateBech32IdFromATag, generateBech32IdFromETag, tagNameEquals } from '@/lib/tag'
import { useNostr } from '@/providers/NostrProvider'
import { useNotificationUserPreference } from '@/providers/NotificationUserPreferenceProvider'
import { Heart } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
@ -17,6 +18,7 @@ export function ReactionNotification({
}) {
const { t } = useTranslation()
const { pubkey } = useNostr()
const { hideIndirect } = useNotificationUserPreference()
const eventId = useMemo(() => {
const aTag = notification.tags.findLast(tagNameEquals('a'))
if (aTag) {
@ -56,6 +58,9 @@ export function ReactionNotification({
if (!event || !eventId || !reaction) {
return null
}
if (hideIndirect && event.pubkey !== pubkey) {
return null
}
return (
<Notification

View file

@ -1,3 +1,5 @@
import { useNostr } from '@/providers/NostrProvider'
import { useNotificationUserPreference } from '@/providers/NotificationUserPreferenceProvider'
import client from '@/services/client.service'
import { Repeat } from 'lucide-react'
import { Event, validateEvent } from 'nostr-tools'
@ -13,6 +15,8 @@ export function RepostNotification({
isNew?: boolean
}) {
const { t } = useTranslation()
const { pubkey } = useNostr()
const { hideIndirect } = useNotificationUserPreference()
const event = useMemo(() => {
try {
const event = JSON.parse(notification.content) as Event
@ -25,6 +29,9 @@ export function RepostNotification({
}
}, [notification.content])
if (!event) return null
if (hideIndirect && event.pubkey !== pubkey) {
return null
}
return (
<Notification

View file

@ -1,4 +1,4 @@
import { ExtendedKind, NOTIFICATION_LIST_STYLE } from '@/constants'
import { ExtendedKind, NOTIFICATION_LIST_STYLE, SPECIAL_TRUST_SCORE_FILTER_ID } from '@/constants'
import { useInfiniteScroll } from '@/hooks'
import { compareEvents } from '@/lib/event'
import { getDefaultRelayUrls } from '@/lib/relay'
@ -28,6 +28,7 @@ import PullToRefresh from 'react-simple-pull-to-refresh'
import { LoadingBar } from '../LoadingBar'
import { RefreshButton } from '../RefreshButton'
import Tabs from '../Tabs'
import TrustScoreFilter from '../TrustScoreFilter'
import { NotificationItem } from './NotificationItem'
import { NotificationSkeleton } from './NotificationItem/Notification'
@ -280,7 +281,12 @@ const NotificationList = forwardRef((_, ref) => {
setShowCount(SHOW_COUNT)
setNotificationType(type as TNotificationType)
}}
options={!supportTouch ? <RefreshButton onClick={() => refresh()} /> : null}
options={
<>
{!supportTouch ? <RefreshButton onClick={() => refresh()} /> : null}
<TrustScoreFilter filterId={SPECIAL_TRUST_SCORE_FILTER_ID.NOTIFICATIONS} />
</>
}
/>
<div ref={topRef} className="scroll-mt-[calc(6rem+1px)]" />
{supportTouch ? (

View file

@ -45,6 +45,7 @@ export const StorageKey = {
MUTED_WORDS: 'mutedWords',
MIN_TRUST_SCORE: 'minTrustScore',
MIN_TRUST_SCORE_MAP: 'minTrustScoreMap',
HIDE_INDIRECT_NOTIFICATIONS: 'hideIndirectNotifications',
ENABLE_LIVE_FEED: 'enableLiveFeed', // deprecated
HIDE_UNTRUSTED_NOTES: 'hideUntrustedNotes', // deprecated
HIDE_UNTRUSTED_INTERACTIONS: 'hideUntrustedInteractions', // deprecated

View file

@ -665,6 +665,7 @@ export default {
'Zap Details': 'تفاصيل Zap',
'Default trust score filter threshold ({{n}}%)': 'عتبة مرشح درجة الثقة الافتراضية ({{n}}%)',
'No notes found': 'لم يتم العثور على ملاحظات',
'Try again later or check your connection': 'حاول مرة أخرى لاحقًا أو تحقق من اتصالك'
'Try again later or check your connection': 'حاول مرة أخرى لاحقًا أو تحقق من اتصالك',
'Hide indirect': 'إخفاء غير المباشرة'
}
}

View file

@ -688,6 +688,8 @@ export default {
'Default trust score filter threshold ({{n}}%)':
'Standard-Vertrauenswert-Filter-Schwelle ({{n}}%)',
'No notes found': 'Keine Notizen gefunden',
'Try again later or check your connection': 'Versuchen Sie es später erneut oder überprüfen Sie Ihre Verbindung'
'Try again later or check your connection':
'Versuchen Sie es später erneut oder überprüfen Sie Ihre Verbindung',
'Hide indirect': 'Indirekte ausblenden'
}
}

View file

@ -668,8 +668,10 @@ export default {
'Muted words': 'Muted words',
'Add muted word': 'Add muted word',
'Zap Details': 'Zap Details',
'Default trust score filter threshold ({{n}}%)': 'Default trust score filter threshold ({{n}}%)',
'Default trust score filter threshold ({{n}}%)':
'Default trust score filter threshold ({{n}}%)',
'No notes found': 'No notes found',
'Try again later or check your connection': 'Try again later or check your connection'
'Try again later or check your connection': 'Try again later or check your connection',
'Hide indirect': 'Hide indirect'
}
}

View file

@ -682,6 +682,7 @@ export default {
'Default trust score filter threshold ({{n}}%)':
'Umbral predeterminado del filtro de puntuación de confianza ({{n}}%)',
'No notes found': 'No se encontraron notas',
'Try again later or check your connection': 'Inténtalo más tarde o verifica tu conexión'
'Try again later or check your connection': 'Inténtalo más tarde o verifica tu conexión',
'Hide indirect': 'Ocultar indirectas'
}
}

View file

@ -676,6 +676,8 @@ export default {
'Zap Details': 'جزئیات زپ',
'Default trust score filter threshold ({{n}}%)': 'آستانه فیلتر امتیاز اعتماد پیش‌فرض ({{n}}%)',
'No notes found': 'یادداشتی یافت نشد',
'Try again later or check your connection': 'بعداً دوباره امتحان کنید یا اتصال خود را بررسی کنید'
'Try again later or check your connection':
'بعداً دوباره امتحان کنید یا اتصال خود را بررسی کنید',
'Hide indirect': 'پنهان کردن غیرمستقیم'
}
}

View file

@ -686,6 +686,7 @@ export default {
'Default trust score filter threshold ({{n}}%)':
'Seuil par défaut du filtre de score de confiance ({{n}}%)',
'No notes found': 'Aucune note trouvée',
'Try again later or check your connection': 'Réessayez plus tard ou vérifiez votre connexion'
'Try again later or check your connection': 'Réessayez plus tard ou vérifiez votre connexion',
'Hide indirect': 'Masquer indirects'
}
}

View file

@ -677,6 +677,7 @@ export default {
'Zap Details': 'जैप विवरण',
'Default trust score filter threshold ({{n}}%)': 'डिफ़ॉल्ट विश्वास स्कोर फ़िल्टर सीमा ({{n}}%)',
'No notes found': 'कोई नोट्स नहीं मिले',
'Try again later or check your connection': 'बाद में पुनः प्रयास करें या अपना कनेक्शन जाँचें'
'Try again later or check your connection': 'बाद में पुनः प्रयास करें या अपना कनेक्शन जाँचें',
'Hide indirect': 'अप्रत्यक्ष छुपाएं'
}
}

View file

@ -671,6 +671,7 @@ export default {
'Default trust score filter threshold ({{n}}%)':
'Alapértelmezett bizalmi pontszám szűrő küszöbérték ({{n}}%)',
'No notes found': 'Nem található jegyzet',
'Try again later or check your connection': 'Próbáld újra később vagy ellenőrizd a kapcsolatot'
'Try again later or check your connection': 'Próbáld újra később vagy ellenőrizd a kapcsolatot',
'Hide indirect': 'Közvetettek elrejtése'
}
}

View file

@ -682,6 +682,7 @@ export default {
'Default trust score filter threshold ({{n}}%)':
'Soglia predefinita del filtro del punteggio di fiducia ({{n}}%)',
'No notes found': 'Nessuna nota trovata',
'Try again later or check your connection': 'Riprova più tardi o controlla la connessione'
'Try again later or check your connection': 'Riprova più tardi o controlla la connessione',
'Hide indirect': 'Nascondi indirette'
}
}

View file

@ -673,8 +673,11 @@ export default {
'Muted words': 'ミュートワード',
'Add muted word': 'ミュートワードを追加',
'Zap Details': 'Zapの詳細',
'Default trust score filter threshold ({{n}}%)': 'デフォルトの信頼スコアフィルター閾値 ({{n}}%)',
'Default trust score filter threshold ({{n}}%)':
'デフォルトの信頼スコアフィルター閾値 ({{n}}%)',
'No notes found': 'ノートが見つかりません',
'Try again later or check your connection': '後でもう一度お試しいただくか、接続を確認してください'
'Try again later or check your connection':
'後でもう一度お試しいただくか、接続を確認してください',
'Hide indirect': '間接通知を非表示'
}
}

View file

@ -671,6 +671,7 @@ export default {
'Zap Details': '잽 세부 정보',
'Default trust score filter threshold ({{n}}%)': '기본 신뢰 점수 필터 임계값 ({{n}}%)',
'No notes found': '노트를 찾을 수 없습니다',
'Try again later or check your connection': '나중에 다시 시도하거나 연결을 확인하세요'
'Try again later or check your connection': '나중에 다시 시도하거나 연결을 확인하세요',
'Hide indirect': '간접 숨기기'
}
}

View file

@ -680,8 +680,10 @@ export default {
'Muted words': 'Wyciszone słowa',
'Add muted word': 'Dodaj wyciszone słowo',
'Zap Details': 'Szczegóły zapu',
'Default trust score filter threshold ({{n}}%)': 'Domyślny próg filtra wyniku zaufania ({{n}}%)',
'Default trust score filter threshold ({{n}}%)':
'Domyślny próg filtra wyniku zaufania ({{n}}%)',
'No notes found': 'Nie znaleziono notatek',
'Try again later or check your connection': 'Spróbuj ponownie później lub sprawdź połączenie'
'Try again later or check your connection': 'Spróbuj ponownie później lub sprawdź połączenie',
'Hide indirect': 'Ukryj pośrednie'
}
}

View file

@ -679,6 +679,8 @@ export default {
'Default trust score filter threshold ({{n}}%)':
'Limite padrão do filtro de pontuação de confiança ({{n}}%)',
'No notes found': 'Nenhuma nota encontrada',
'Try again later or check your connection': 'Tente novamente mais tarde ou verifique sua conexão'
'Try again later or check your connection':
'Tente novamente mais tarde ou verifique sua conexão',
'Hide indirect': 'Ocultar indiretas'
}
}

View file

@ -682,6 +682,8 @@ export default {
'Default trust score filter threshold ({{n}}%)':
'Limite predefinido do filtro de pontuação de confiança ({{n}}%)',
'No notes found': 'Nenhuma nota encontrada',
'Try again later or check your connection': 'Tente novamente mais tarde ou verifique a sua ligação'
'Try again later or check your connection':
'Tente novamente mais tarde ou verifique a sua ligação',
'Hide indirect': 'Ocultar indiretas'
}
}

View file

@ -682,6 +682,7 @@ export default {
'Default trust score filter threshold ({{n}}%)':
'Порог фильтра рейтинга доверия по умолчанию ({{n}}%)',
'No notes found': 'Заметки не найдены',
'Try again later or check your connection': 'Попробуйте позже или проверьте подключение'
'Try again later or check your connection': 'Попробуйте позже или проверьте подключение',
'Hide indirect': 'Скрыть косвенные'
}
}

View file

@ -667,6 +667,7 @@ export default {
'Default trust score filter threshold ({{n}}%)':
'เกณฑ์ตัวกรองคะแนนความไว้วางใจเริ่มต้น ({{n}}%)',
'No notes found': 'ไม่พบโน้ต',
'Try again later or check your connection': 'ลองใหม่ภายหลังหรือตรวจสอบการเชื่อมต่อของคุณ'
'Try again later or check your connection': 'ลองใหม่ภายหลังหรือตรวจสอบการเชื่อมต่อของคุณ',
'Hide indirect': 'ซ่อนทางอ้อม'
}
}

View file

@ -649,6 +649,7 @@ export default {
'Zap Details': '打閃詳情',
'Default trust score filter threshold ({{n}}%)': '預設信任分數過濾閾值 ({{n}}%)',
'No notes found': '沒有找到筆記',
'Try again later or check your connection': '請稍後重試或檢查網路連接'
'Try again later or check your connection': '請稍後重試或檢查網路連接',
'Hide indirect': '隱藏間接通知'
}
}

View file

@ -654,6 +654,7 @@ export default {
'Zap Details': '打闪详情',
'Default trust score filter threshold ({{n}}%)': '默认信任分数过滤阈值 ({{n}}%)',
'No notes found': '没有找到笔记',
'Try again later or check your connection': '请稍后重试或检查网络连接'
'Try again later or check your connection': '请稍后重试或检查网络连接',
'Hide indirect': '隐藏间接通知'
}
}

View file

@ -127,7 +127,7 @@ function NoteListPageTitlebar({
layoutRef?.current?.scrollToTop('smooth')
}
}}
className={showRelayDetails ? 'bg-accent/50' : ''}
className={showRelayDetails ? 'bg-muted/40' : ''}
>
<Info />
</Button>

View file

@ -1,15 +1,21 @@
import NotificationList from '@/components/NotificationList'
import TrustScoreFilter from '@/components/TrustScoreFilter'
import { SPECIAL_TRUST_SCORE_FILTER_ID } from '@/constants'
import { Button } from '@/components/ui/button'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { cn } from '@/lib/utils'
import { usePrimaryPage } from '@/PageManager'
import {
NotificationUserPreferenceContext,
useNotificationUserPreference
} from '@/providers/NotificationUserPreferenceProvider'
import localStorage from '@/services/local-storage.service'
import { TPageRef } from '@/types'
import { Bell } from 'lucide-react'
import { forwardRef, useEffect, useRef } from 'react'
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
const NotificationListPage = forwardRef<TPageRef>((_, ref) => {
const { current } = usePrimaryPage()
const [hideIndirect, setHideIndirect] = useState(localStorage.getHideIndirectNotifications())
const firstRenderRef = useRef(true)
const notificationListRef = useRef<{ refresh: () => void }>(null)
@ -20,15 +26,30 @@ const NotificationListPage = forwardRef<TPageRef>((_, ref) => {
firstRenderRef.current = false
}, [current])
const updateHideIndirect = useCallback(
(enable: boolean) => {
setHideIndirect(enable)
localStorage.setHideIndirectNotifications(enable)
},
[setHideIndirect]
)
return (
<PrimaryPageLayout
ref={ref}
pageName="notifications"
titlebar={<NotificationListPageTitlebar />}
displayScrollToTopButton
<NotificationUserPreferenceContext.Provider
value={{
hideIndirect,
updateHideIndirect
}}
>
<NotificationList ref={notificationListRef} />
</PrimaryPageLayout>
<PrimaryPageLayout
ref={ref}
pageName="notifications"
titlebar={<NotificationListPageTitlebar />}
displayScrollToTopButton
>
<NotificationList ref={notificationListRef} />
</PrimaryPageLayout>
</NotificationUserPreferenceContext.Provider>
)
})
NotificationListPage.displayName = 'NotificationListPage'
@ -43,7 +64,25 @@ function NotificationListPageTitlebar() {
<Bell />
<div className="text-lg font-semibold">{t('Notifications')}</div>
</div>
<TrustScoreFilter filterId={SPECIAL_TRUST_SCORE_FILTER_ID.NOTIFICATIONS} />
<HideUnrelatedNotificationsToggle />
</div>
)
}
function HideUnrelatedNotificationsToggle() {
const { t } = useTranslation()
const { hideIndirect, updateHideIndirect } = useNotificationUserPreference()
return (
<Button
variant="ghost"
className={cn(
'h-10 px-3 shrink-0 rounded-xl [&_svg]:size-5',
hideIndirect ? 'text-foreground bg-muted/40' : 'text-muted-foreground'
)}
onClick={() => updateHideIndirect(!hideIndirect)}
>
{t('Hide indirect')}
</Button>
)
}

View file

@ -0,0 +1,14 @@
import { createContext, useContext } from 'react'
type TNotificationUserPreferenceContext = {
hideIndirect: boolean
updateHideIndirect: (enable: boolean) => void
}
export const NotificationUserPreferenceContext =
createContext<TNotificationUserPreferenceContext | null>(null)
export function useNotificationUserPreference() {
const ctx = useContext(NotificationUserPreferenceContext)
return ctx ?? { hideIndirect: false, updateHideIndirect: () => {} }
}

View file

@ -68,6 +68,7 @@ class LocalStorageService {
private mutedWords: string[] = []
private minTrustScore: number = 0
private minTrustScoreMap: Record<string, number> = {}
private hideIndirectNotifications: boolean = false
constructor() {
if (!LocalStorageService.instance) {
@ -319,6 +320,9 @@ class LocalStorageService {
}
}
this.hideIndirectNotifications =
window.localStorage.getItem(StorageKey.HIDE_INDIRECT_NOTIFICATIONS) === 'true'
// Clean up deprecated data
window.localStorage.removeItem(StorageKey.PINNED_PUBKEYS)
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
@ -684,6 +688,15 @@ class LocalStorageService {
this.mutedWords = words
window.localStorage.setItem(StorageKey.MUTED_WORDS, JSON.stringify(this.mutedWords))
}
getHideIndirectNotifications() {
return this.hideIndirectNotifications
}
setHideIndirectNotifications(onlyShow: boolean) {
this.hideIndirectNotifications = onlyShow
window.localStorage.setItem(StorageKey.HIDE_INDIRECT_NOTIFICATIONS, onlyShow.toString())
}
}
const instance = new LocalStorageService()