feat: support choosing between public and private mute

This commit is contained in:
codytseng
2025-06-04 22:09:27 +08:00
parent 30a32ca94f
commit ec1692c066
19 changed files with 473 additions and 120 deletions

View File

@@ -1,29 +1,43 @@
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useToast } from '@/hooks'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { Loader } from 'lucide-react'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { BellOff, Loader } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function MuteButton({ pubkey }: { pubkey: string }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { toast } = useToast()
const { pubkey: accountPubkey, checkLogin } = useNostr()
const { mutePubkeys, mutePubkey, unmutePubkey } = useMuteList()
const { mutePubkeys, changing, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } =
useMuteList()
const [updating, setUpdating] = useState(false)
const isMuted = useMemo(() => mutePubkeys.includes(pubkey), [mutePubkeys, pubkey])
if (!accountPubkey || (pubkey && pubkey === accountPubkey)) return null
const handleMute = async (e: React.MouseEvent) => {
const handleMute = async (e: React.MouseEvent, isPrivate = true) => {
e.stopPropagation()
checkLogin(async () => {
if (isMuted) return
setUpdating(true)
try {
await mutePubkey(pubkey)
if (isPrivate) {
await mutePubkeyPrivately(pubkey)
} else {
await mutePubkeyPublicly(pubkey)
}
} catch (error) {
toast({
title: t('Mute failed'),
@@ -56,23 +70,76 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
})
}
return isMuted ? (
<Button
className="w-20 min-w-20 rounded-full"
variant="secondary"
onClick={handleUnmute}
disabled={updating}
>
{updating ? <Loader className="animate-spin" /> : t('Unmute')}
</Button>
) : (
if (isMuted) {
return (
<Button
className="w-20 min-w-20 rounded-full"
variant="secondary"
onClick={handleUnmute}
disabled={updating || changing}
>
{updating ? <Loader className="animate-spin" /> : t('Unmute')}
</Button>
)
}
const trigger = (
<Button
variant="destructive"
className="w-20 min-w-20 rounded-full"
onClick={handleMute}
disabled={updating}
disabled={updating || changing}
>
{updating ? <Loader className="animate-spin" /> : t('Mute')}
</Button>
)
if (isSmallScreen) {
return (
<Drawer>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent>
<div className="py-2">
<Button
className="w-full p-6 justify-start text-destructive text-lg gap-4 [&_svg]:size-5 focus:text-destructive"
variant="ghost"
onClick={(e) => handleMute(e, true)}
disabled={updating || changing}
>
{updating ? <Loader className="animate-spin" /> : t('Mute user privately')}
</Button>
<Button
className="w-full p-6 justify-start text-destructive text-lg gap-4 [&_svg]:size-5 focus:text-destructive"
variant="ghost"
onClick={(e) => handleMute(e, false)}
disabled={updating || changing}
>
{updating ? <Loader className="animate-spin" /> : t('Mute user publicly')}
</Button>
</div>
</DrawerContent>
</Drawer>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={(e) => handleMute(e, true)}
className="text-destructive focus:text-destructive"
>
<BellOff />
{t('Mute user privately')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => handleMute(e, false)}
className="text-destructive focus:text-destructive"
>
<BellOff />
{t('Mute user publicly')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -25,7 +25,7 @@ export default function NoteOptions({ event, className }: { event: Event; classN
const { pubkey } = useNostr()
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const { mutePubkey, unmutePubkey, mutePubkeys } = useMuteList()
const { mutePubkeyPublicly, mutePubkeyPrivately, unmutePubkey, mutePubkeys } = useMuteList()
const isMuted = useMemo(() => mutePubkeys.includes(event.pubkey), [mutePubkeys, event])
const trigger = (
@@ -97,23 +97,45 @@ export default function NoteOptions({ event, className }: { event: Event; classN
<Code />
{t('View raw event')}
</Button>
{pubkey && (
<Button
onClick={() => {
setIsDrawerOpen(false)
if (isMuted) {
{pubkey &&
(isMuted ? (
<Button
onClick={() => {
setIsDrawerOpen(false)
unmutePubkey(event.pubkey)
} else {
mutePubkey(event.pubkey)
}
}}
className="w-full p-6 justify-start text-destructive text-lg gap-4 [&_svg]:size-5 focus:text-destructive"
variant="ghost"
>
{isMuted ? <Bell /> : <BellOff />}
{isMuted ? t('Unmute user') : t('Mute user')}
</Button>
)}
}}
className="w-full p-6 justify-start text-destructive text-lg gap-4 [&_svg]:size-5 focus:text-destructive"
variant="ghost"
>
<Bell />
{t('Unmute user')}
</Button>
) : (
<>
<Button
onClick={() => {
setIsDrawerOpen(false)
mutePubkeyPrivately(event.pubkey)
}}
className="w-full p-6 justify-start text-destructive text-lg gap-4 [&_svg]:size-5 focus:text-destructive"
variant="ghost"
>
<BellOff />
{t('Mute user privately')}
</Button>
<Button
onClick={() => {
setIsDrawerOpen(false)
mutePubkeyPublicly(event.pubkey)
}}
className="w-full p-6 justify-start text-destructive text-lg gap-4 [&_svg]:size-5 focus:text-destructive"
variant="ghost"
>
<BellOff />
{t('Mute user publicly')}
</Button>
</>
))}
</div>
</DrawerContent>
</Drawer>
@@ -155,13 +177,32 @@ export default function NoteOptions({ event, className }: { event: Event; classN
{pubkey && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => (isMuted ? unmutePubkey(event.pubkey) : mutePubkey(event.pubkey))}
className="text-destructive focus:text-destructive"
>
{isMuted ? <Bell /> : <BellOff />}
{isMuted ? t('Unmute user') : t('Mute user')}
</DropdownMenuItem>
{isMuted ? (
<DropdownMenuItem
onClick={() => unmutePubkey(event.pubkey)}
className="text-destructive focus:text-destructive"
>
<Bell />
{t('Unmute user')}
</DropdownMenuItem>
) : (
<>
<DropdownMenuItem
onClick={() => mutePubkeyPrivately(event.pubkey)}
className="text-destructive focus:text-destructive"
>
<BellOff />
{t('Mute user privately')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => mutePubkeyPublicly(event.pubkey)}
className="text-destructive focus:text-destructive"
>
<BellOff />
{t('Mute user publicly')}
</DropdownMenuItem>
</>
)}
</>
)}
</DropdownMenuContent>

View File

@@ -14,10 +14,12 @@ import { useTranslation } from 'react-i18next'
export default function ProfileOptions({ pubkey }: { pubkey: string }) {
const { t } = useTranslation()
const { pubkey: accountPubkey } = useNostr()
const { mutePubkeys, mutePubkey, unmutePubkey } = useMuteList()
const { mutePubkeys, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } = useMuteList()
if (pubkey === accountPubkey) return null
const isMuted = mutePubkeys.includes(pubkey)
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -32,7 +34,7 @@ export default function ProfileOptions({ pubkey }: { pubkey: string }) {
<Copy />
{t('Copy user ID')}
</DropdownMenuItem>
{mutePubkeys.includes(pubkey) ? (
{isMuted ? (
<DropdownMenuItem
onClick={() => unmutePubkey(pubkey)}
className="text-destructive focus:text-destructive"
@@ -41,13 +43,22 @@ export default function ProfileOptions({ pubkey }: { pubkey: string }) {
{t('Unmute user')}
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => mutePubkey(pubkey)}
className="text-destructive focus:text-destructive"
>
<BellOff />
{t('Mute user')}
</DropdownMenuItem>
<>
<DropdownMenuItem
onClick={() => mutePubkeyPrivately(pubkey)}
className="text-destructive focus:text-destructive"
>
<BellOff />
{t('Mute user privately')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => mutePubkeyPublicly(pubkey)}
className="text-destructive focus:text-destructive"
>
<BellOff />
{t('Mute user publicly')}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -148,7 +148,6 @@ export default {
Mute: 'كتم',
Muted: 'تم كتمه',
Unmute: 'إلغاء الكتم',
'Mute user': 'كتم المستخدم',
'Unmute user': 'إلغاء كتم المستخدم',
'Append n relays': 'إضافة {{n}} ريلايات',
Append: 'إضافة',
@@ -237,6 +236,8 @@ export default {
'Hide content from untrusted users': 'إخفاء المحتوى من المستخدمين غير الموثوقين',
'Only show content from your followed users and the users they follow':
'فقط عرض المحتوى من المستخدمين الذين تتابعهم والمستخدمين الذين يتابعونهم',
'Followed by': 'متابع من قبل'
'Followed by': 'متابع من قبل',
'Mute user privately': 'كتم المستخدم بشكل خاص',
'Mute user publicly': 'كتم المستخدم علنياً'
}
}

View File

@@ -151,7 +151,6 @@ export default {
Mute: 'Stummschalten',
Muted: 'Stummgeschaltet',
Unmute: 'Stummschaltung aufheben',
'Mute user': 'Benutzer stummschalten',
'Unmute user': 'Benutzer-Stummschaltung aufheben',
'Append n relays': 'Füge {{n}} Relays hinzu',
Append: 'Hinzufügen',
@@ -244,6 +243,8 @@ export default {
'Inhalte von nicht vertrauenswürdigen Benutzern ausblenden',
'Only show content from your followed users and the users they follow':
'Nur Inhalte von Benutzern anzeigen, denen du folgst und die sie folgen',
'Followed by': 'Gefolgt von'
'Followed by': 'Gefolgt von',
'Mute user privately': 'Benutzer privat stummschalten',
'Mute user publicly': 'Benutzer öffentlich stummschalten'
}
}

View File

@@ -149,7 +149,6 @@ export default {
Mute: 'Mute',
Muted: 'Muted',
Unmute: 'Unmute',
'Mute user': 'Mute user',
'Unmute user': 'Unmute user',
'Append n relays': 'Append {{n}} relays',
Append: 'Append',
@@ -237,6 +236,8 @@ export default {
'Hide content from untrusted users': 'Hide content from untrusted users',
'Only show content from your followed users and the users they follow':
'Only show content from your followed users and the users they follow',
'Followed by': 'Followed by'
'Followed by': 'Followed by',
'Mute user privately': 'Mute user privately',
'Mute user publicly': 'Mute user publicly'
}
}

View File

@@ -152,7 +152,6 @@ export default {
Mute: 'Silenciar',
Muted: 'Silenciado',
Unmute: 'Activar sonido',
'Mute user': 'Silenciar usuario',
'Unmute user': 'Activar sonido del usuario',
'Append n relays': 'Agregar {{n}} relés',
Append: 'Agregar',
@@ -242,6 +241,8 @@ export default {
'Hide content from untrusted users': 'Ocultar contenido de usuarios no confiables',
'Only show content from your followed users and the users they follow':
'Solo mostrar contenido de tus usuarios seguidos y los usuarios que ellos siguen',
'Followed by': 'Seguidos por'
'Followed by': 'Seguidos por',
'Mute user privately': 'Silenciar usuario en privado',
'Mute user publicly': 'Silenciar usuario públicamente'
}
}

View File

@@ -151,7 +151,6 @@ export default {
Mute: 'Couper le son',
Muted: 'En sourdine',
Unmute: 'Activer le son',
'Mute user': "Mettre l'utilisateur en sourdine",
'Unmute user': "Désactiver la sourdine de l'utilisateur",
'Append n relays': 'Ajouter {{n}} relais',
Append: 'Ajouter',
@@ -242,6 +241,8 @@ export default {
'Hide content from untrusted users': 'Hider le contenu des utilisateurs non fiables',
'Only show content from your followed users and the users they follow':
'Afficher uniquement le contenu de vos utilisateurs suivis et des utilisateurs quils suivent',
'Followed by': 'Suivi par'
'Followed by': 'Suivi par',
'Mute user privately': 'Mettre lutilisateur en sourdine en privé',
'Mute user publicly': 'Mettre lutilisateur en sourdine publiquement'
}
}

View File

@@ -151,7 +151,6 @@ export default {
Mute: 'Zittisci',
Muted: 'Zittiti',
Unmute: 'Ridai voce',
'Mute user': 'Zittisci utente',
'Unmute user': 'Ridai voce a questo utente',
'Append n relays': 'Aggiungi {{n}} relays',
Append: 'Aggiungi',
@@ -241,6 +240,8 @@ export default {
'Hide content from untrusted users': 'Nascondi contenuti da utenti non fidati',
'Only show content from your followed users and the users they follow':
'Mostra solo contenuti dai tuoi utenti seguiti e dagli utenti che seguono',
'Followed by': 'Seguito da'
'Followed by': 'Seguito da',
'Mute user privately': 'Zittisci utente privatamente',
'Mute user publicly': 'Zittisci utente pubblicamente'
}
}

View File

@@ -150,7 +150,6 @@ export default {
Mute: 'ミュート',
Muted: 'ミュート済み',
Unmute: 'ミュート解除',
'Mute user': 'ユーザーをミュート',
'Unmute user': 'ユーザーのミュート解除',
'Append n relays': '{{n}} 個のリレイを追加',
Append: '追加',
@@ -238,6 +237,8 @@ export default {
'Hide content from untrusted users': '信頼できないユーザーのコンテンツを非表示',
'Only show content from your followed users and the users they follow':
'フォローしているユーザーとそのユーザーがフォローしているユーザーのコンテンツのみを表示',
'Followed by': 'フォロワー'
'Followed by': 'フォロワー',
'Mute user privately': 'ユーザーを非公開でミュート',
'Mute user publicly': 'ユーザーを公開でミュート'
}
}

View File

@@ -148,7 +148,6 @@ export default {
Mute: 'Zablokuj',
Muted: 'Zablokowani',
Unmute: 'Przywróć',
'Mute user': 'Ucisz użytkownika ',
'Unmute user': 'Przywróć użytkownika ',
'Append n relays': 'Dodaj {{n}} transmiterów',
Append: 'Dodaj',
@@ -240,6 +239,8 @@ export default {
'Hide content from untrusted users': 'Ukryj treści od nieznanych użytkowników',
'Only show content from your followed users and the users they follow':
'Pokaż tylko treści od użytkowników, których obserwujesz i ich obserwowanych',
'Followed by': 'Obserwowany przez'
'Followed by': 'Obserwowany przez',
'Mute user privately': 'Zablokuj użytkownika prywatnie',
'Mute user publicly': 'Zablokuj użytkownika publicznie'
}
}

View File

@@ -150,7 +150,6 @@ export default {
Mute: 'Silenciar',
Muted: 'Silenciado',
Unmute: 'Desativar silêncio',
'Mute user': 'Silenciar usuário',
'Unmute user': 'Desativar silêncio do usuário',
'Append n relays': 'Adicionar {{n}} relés',
Append: 'Adicionar',
@@ -240,6 +239,8 @@ export default {
'Hide content from untrusted users': 'Ocultar conteúdo de usuários não confiáveis',
'Only show content from your followed users and the users they follow':
'Mostrar apenas conteúdo dos usuários que você segue e dos usuários que eles seguem',
'Followed by': 'Seguido por'
'Followed by': 'Seguido por',
'Mute user privately': 'Silenciar usuário privadamente',
'Mute user publicly': 'Silenciar usuário publicamente'
}
}

View File

@@ -151,7 +151,6 @@ export default {
Mute: 'Silenciar',
Muted: 'Silenciado',
Unmute: 'Ativar som',
'Mute user': 'Silenciar usuário',
'Unmute user': 'Ativar som do usuário',
'Append n relays': 'Adicionar {{n}} relés',
Append: 'Adicionar',
@@ -241,6 +240,8 @@ export default {
'Hide content from untrusted users': 'Esconder conteúdo de usuários não confiáveis',
'Only show content from your followed users and the users they follow':
'Mostrar apenas conteúdo dos usuários que você segue e dos usuários que eles seguem',
'Followed by': 'Seguido por'
'Followed by': 'Seguido por',
'Mute user privately': 'Silenciar usuário privadamente',
'Mute user publicly': 'Silenciar usuário publicamente'
}
}

View File

@@ -241,6 +241,8 @@ export default {
'Hide content from untrusted users': 'Скрыть контент от недоверенных пользователей',
'Only show content from your followed users and the users they follow':
'Показывать только контент от пользователей, на которых вы подписаны, и от пользователей, на которых они подписаны',
'Followed by': 'Подписан на'
'Followed by': 'Подписан на',
'Mute user privately': 'Заглушить пользователя приватно',
'Mute user publicly': 'Заглушить пользователя публично'
}
}

View File

@@ -150,7 +150,6 @@ export default {
Mute: '屏蔽',
Muted: '已屏蔽',
Unmute: '取消屏蔽',
'Mute user': '屏蔽用户',
'Unmute user': '取消屏蔽用户',
'Append n relays': '追加 {{n}} 个服务器',
Append: '追加',
@@ -238,6 +237,8 @@ export default {
'Hide content from untrusted users': '隐藏不受信任用户的内容',
'Only show content from your followed users and the users they follow':
'仅显示您关注的用户及其关注的用户的内容',
'Followed by': '关注者'
'Followed by': '关注者',
'Mute user privately': '悄悄屏蔽',
'Mute user publicly': '公开屏蔽'
}
}

View File

@@ -1,19 +1,22 @@
import MuteButton from '@/components/MuteButton'
import Nip05 from '@/components/Nip05'
import { Button } from '@/components/ui/button'
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
import { useFetchProfile } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { forwardRef, useEffect, useRef, useState } from 'react'
import { Loader, Lock, Unlock } from 'lucide-react'
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import NotFoundPage from '../NotFoundPage'
const MuteListPage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation()
const { profile } = useNostr()
const { mutePubkeys } = useMuteList()
const { profile, pubkey } = useNostr()
const { getMutePubkeys } = useMuteList()
const mutePubkeys = useMemo(() => getMutePubkeys(), [pubkey])
const [visibleMutePubkeys, setVisibleMutePubkeys] = useState<string[]>([])
const bottomRef = useRef<HTMLDivElement>(null)
@@ -73,17 +76,59 @@ MuteListPage.displayName = 'MuteListPage'
export default MuteListPage
function UserItem({ pubkey }: { pubkey: string }) {
const { changing, getMuteType, switchToPrivateMute, switchToPublicMute } = useMuteList()
const { profile } = useFetchProfile(pubkey)
const muteType = useMemo(() => getMuteType(pubkey), [pubkey, getMuteType])
const [switching, setSwitching] = useState(false)
return (
<div className="flex gap-2 items-start">
<UserAvatar userId={pubkey} className="shrink-0" />
<div className="w-full overflow-hidden">
<Username userId={pubkey} className="font-semibold truncate" skeletonClassName="h-4" />
<Username
userId={pubkey}
className="font-semibold truncate max-w-full w-fit"
skeletonClassName="h-4"
/>
<Nip05 pubkey={pubkey} />
<div className="truncate text-muted-foreground text-sm">{profile?.about}</div>
</div>
<MuteButton pubkey={pubkey} />
<div className="flex gap-2 items-center">
{switching ? (
<Button disabled variant="ghost" size="icon">
<Loader className="animate-spin" />
</Button>
) : muteType === 'private' ? (
<Button
variant="ghost"
size="icon"
onClick={() => {
if (switching) return
setSwitching(true)
switchToPublicMute(pubkey).finally(() => setSwitching(false))
}}
disabled={changing}
>
<Lock className="text-green-400" />
</Button>
) : muteType === 'public' ? (
<Button
variant="ghost"
size="icon"
onClick={() => {
if (switching) return
setSwitching(true)
switchToPrivateMute(pubkey).finally(() => setSwitching(false))
}}
disabled={changing}
>
<Unlock className="text-muted-foreground" />
</Button>
) : null}
<MuteButton pubkey={pubkey} />
</div>
</div>
)
}

View File

@@ -1,14 +1,23 @@
import { createMuteListDraftEvent } from '@/lib/draft-event'
import { extractPubkeysFromEventTags, isSameTag } from '@/lib/tag'
import { extractPubkeysFromEventTags } from '@/lib/tag'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import dayjs from 'dayjs'
import { Event } from 'nostr-tools'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { z } from 'zod'
import { useNostr } from './NostrProvider'
type TMuteListContext = {
mutePubkeys: string[]
mutePubkey: (pubkey: string) => Promise<void>
changing: boolean
getMutePubkeys: () => string[]
getMuteType: (pubkey: string) => 'public' | 'private' | null
mutePubkeyPublicly: (pubkey: string) => Promise<void>
mutePubkeyPrivately: (pubkey: string) => Promise<void>
unmutePubkey: (pubkey: string) => Promise<void>
switchToPublicMute: (pubkey: string) => Promise<void>
switchToPrivateMute: (pubkey: string) => Promise<void>
}
const MuteListContext = createContext<TMuteListContext | undefined>(undefined)
@@ -31,59 +40,209 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
nip04Encrypt
} = useNostr()
const [tags, setTags] = useState<string[][]>([])
const mutePubkeys = useMemo(() => extractPubkeysFromEventTags(tags), [tags])
const [privateTags, setPrivateTags] = useState<string[][]>([])
const publicMutePubkeySet = useMemo(() => new Set(extractPubkeysFromEventTags(tags)), [tags])
const privateMutePubkeySet = useMemo(
() => new Set(extractPubkeysFromEventTags(privateTags)),
[privateTags]
)
const mutePubkeys = useMemo(() => {
return Array.from(
new Set([...Array.from(privateMutePubkeySet), ...Array.from(publicMutePubkeySet)])
)
}, [publicMutePubkeySet, privateMutePubkeySet])
const [changing, setChanging] = useState(false)
const getPrivateTags = async (muteListEvent: Event) => {
if (!muteListEvent.content) return []
const storedDecryptedTags = await indexedDb.getMuteDecryptedTags(muteListEvent.id)
if (storedDecryptedTags) {
return storedDecryptedTags
} else {
try {
const plainText = await nip04Decrypt(muteListEvent.pubkey, muteListEvent.content)
const privateTags = z.array(z.array(z.string())).parse(JSON.parse(plainText))
await indexedDb.putMuteDecryptedTags(muteListEvent.id, privateTags)
return privateTags
} catch (error) {
console.error('Failed to decrypt mute list content', error)
return []
}
}
}
useEffect(() => {
const updateMuteTags = async () => {
if (!muteListEvent) return
const tags = [...muteListEvent.tags]
if (muteListEvent.content) {
const storedDecryptedTags = await indexedDb.getMuteDecryptedTags(muteListEvent.id)
if (storedDecryptedTags) {
tags.push(...storedDecryptedTags)
} else {
try {
const plainText = await nip04Decrypt(muteListEvent.pubkey, muteListEvent.content)
const contentTags = z.array(z.array(z.string())).parse(JSON.parse(plainText))
await indexedDb.putMuteDecryptedTags(muteListEvent.id, contentTags)
tags.push(...contentTags.filter((tag) => tags.every((t) => !isSameTag(t, tag))))
} catch (error) {
console.error('Failed to decrypt mute list content', error)
}
}
if (!muteListEvent) {
setTags([])
setPrivateTags([])
return
}
setTags(tags)
const privateTags = await getPrivateTags(muteListEvent).catch(() => {
return []
})
setPrivateTags(privateTags)
setTags(muteListEvent.tags)
}
updateMuteTags()
}, [muteListEvent])
const mutePubkey = async (pubkey: string) => {
if (!accountPubkey) return
const getMutePubkeys = () => {
return mutePubkeys
}
const newTags = tags.concat([['p', pubkey]])
const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newTags))
const newMuteListDraftEvent = createMuteListDraftEvent(muteListEvent?.tags ?? [], cipherText)
const newMuteListEvent = await publish(newMuteListDraftEvent)
await updateMuteListEvent(newMuteListEvent, newTags)
const getMuteType = useCallback(
(pubkey: string): 'public' | 'private' | null => {
if (publicMutePubkeySet.has(pubkey)) return 'public'
if (privateMutePubkeySet.has(pubkey)) return 'private'
return null
},
[publicMutePubkeySet, privateMutePubkeySet]
)
const publishNewMuteListEvent = async (tags: string[][], content?: string) => {
if (dayjs().unix() === muteListEvent?.created_at) {
await new Promise((resolve) => setTimeout(resolve, 1000))
}
const newMuteListDraftEvent = createMuteListDraftEvent(tags, content)
return await publish(newMuteListDraftEvent)
}
const mutePubkeyPublicly = async (pubkey: string) => {
if (!accountPubkey || changing) return
setChanging(true)
try {
const muteListEvent = await client.fetchMuteListEvent(accountPubkey)
if (
muteListEvent &&
muteListEvent.tags.some(([tagName, tagValue]) => tagName === 'p' && tagValue === pubkey)
) {
return
}
const newTags = (muteListEvent?.tags ?? []).concat([['p', pubkey]])
const newMuteListEvent = await publishNewMuteListEvent(newTags, muteListEvent?.content)
const privateTags = await getPrivateTags(newMuteListEvent)
await updateMuteListEvent(newMuteListEvent, privateTags)
} finally {
setChanging(false)
}
}
const mutePubkeyPrivately = async (pubkey: string) => {
if (!accountPubkey || changing) return
setChanging(true)
try {
const muteListEvent = await client.fetchMuteListEvent(accountPubkey)
const privateTags = muteListEvent ? await getPrivateTags(muteListEvent) : []
if (privateTags.some(([tagName, tagValue]) => tagName === 'p' && tagValue === pubkey)) {
return
}
const newPrivateTags = privateTags.concat([['p', pubkey]])
const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags))
const newMuteListEvent = await publishNewMuteListEvent(muteListEvent?.tags ?? [], cipherText)
await updateMuteListEvent(newMuteListEvent, newPrivateTags)
} finally {
setChanging(false)
}
}
const unmutePubkey = async (pubkey: string) => {
if (!accountPubkey || !muteListEvent) return
if (!accountPubkey || changing) return
const newTags = tags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey)
const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newTags))
const newMuteListDraftEvent = createMuteListDraftEvent(
muteListEvent.tags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey),
cipherText
)
const newMuteListEvent = await publish(newMuteListDraftEvent)
await updateMuteListEvent(newMuteListEvent, newTags)
setChanging(true)
try {
const muteListEvent = await client.fetchMuteListEvent(accountPubkey)
if (!muteListEvent) return
const privateTags = await getPrivateTags(muteListEvent)
const newPrivateTags = privateTags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey)
let cipherText = muteListEvent.content
if (newPrivateTags.length !== privateTags.length) {
cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags))
}
const newMuteListEvent = await publishNewMuteListEvent(
muteListEvent.tags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey),
cipherText
)
await updateMuteListEvent(newMuteListEvent, newPrivateTags)
} finally {
setChanging(false)
}
}
const switchToPublicMute = async (pubkey: string) => {
if (!accountPubkey || changing) return
setChanging(true)
try {
const muteListEvent = await client.fetchMuteListEvent(accountPubkey)
if (!muteListEvent) return
const privateTags = await getPrivateTags(muteListEvent)
const newPrivateTags = privateTags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey)
if (newPrivateTags.length === privateTags.length) {
return
}
const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags))
const newMuteListEvent = await publishNewMuteListEvent(
muteListEvent.tags
.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey)
.concat([['p', pubkey]]),
cipherText
)
await updateMuteListEvent(newMuteListEvent, newPrivateTags)
} finally {
setChanging(false)
}
}
const switchToPrivateMute = async (pubkey: string) => {
if (!accountPubkey || changing) return
setChanging(true)
try {
const muteListEvent = await client.fetchMuteListEvent(accountPubkey)
if (!muteListEvent) return
const newTags = muteListEvent.tags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey)
if (newTags.length === muteListEvent.tags.length) {
return
}
const privateTags = await getPrivateTags(muteListEvent)
const newPrivateTags = privateTags
.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey)
.concat([['p', pubkey]])
const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags))
const newMuteListEvent = await publishNewMuteListEvent(newTags, cipherText)
await updateMuteListEvent(newMuteListEvent, newPrivateTags)
} finally {
setChanging(false)
}
}
return (
<MuteListContext.Provider value={{ mutePubkeys, mutePubkey, unmutePubkey }}>
<MuteListContext.Provider
value={{
mutePubkeys,
changing,
getMutePubkeys,
getMuteType,
mutePubkeyPublicly,
mutePubkeyPrivately,
unmutePubkey,
switchToPublicMute,
switchToPrivateMute
}}
>
{children}
</MuteListContext.Provider>
)

View File

@@ -62,7 +62,7 @@ type TNostrContext = {
updateRelayListEvent: (relayListEvent: Event) => Promise<void>
updateProfileEvent: (profileEvent: Event) => Promise<void>
updateFollowListEvent: (followListEvent: Event) => Promise<void>
updateMuteListEvent: (muteListEvent: Event, tags: string[][]) => Promise<void>
updateMuteListEvent: (muteListEvent: Event, privateTags: string[][]) => Promise<void>
updateBookmarkListEvent: (bookmarkListEvent: Event) => Promise<void>
updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void>
updateNotificationsSeenAt: () => Promise<void>
@@ -614,11 +614,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
client.updateFollowListCache(newFollowListEvent)
}
const updateMuteListEvent = async (muteListEvent: Event, tags: string[][]) => {
const updateMuteListEvent = async (muteListEvent: Event, privateTags: string[][]) => {
const newMuteListEvent = await indexedDb.putReplaceableEvent(muteListEvent)
if (newMuteListEvent.id !== muteListEvent.id) return
await indexedDb.putMuteDecryptedTags(muteListEvent.id, tags)
await indexedDb.putMuteDecryptedTags(muteListEvent.id, privateTags)
setMuteListEvent(muteListEvent)
}

View File

@@ -765,6 +765,23 @@ class ClientService extends EventTarget {
return await this.followListCache.fetch(pubkey)
}
async fetchMuteListEvent(pubkey: string): Promise<NEvent | undefined> {
const storedEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.Mutelist)
if (storedEvent) {
return storedEvent
}
const relayList = await this.fetchRelayList(pubkey)
const events = await this.query(relayList.write.concat(BIG_RELAY_URLS), {
authors: [pubkey],
kinds: [kinds.Mutelist]
})
const muteList = events.sort((a, b) => b.created_at - a.created_at)[0]
if (muteList) {
await indexedDb.putReplaceableEvent(muteList)
}
return muteList
}
async fetchBookmarkListEvent(pubkey: string): Promise<NEvent | undefined> {
const storedBookmarkListEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.BookmarkList)
if (storedBookmarkListEvent) {