From ec1692c06609fb3a6b5410ffd6ab54e43973faaa Mon Sep 17 00:00:00 2001 From: codytseng Date: Wed, 4 Jun 2025 22:09:27 +0800 Subject: [PATCH] feat: support choosing between public and private mute --- src/components/MuteButton/index.tsx | 99 +++++++-- src/components/NoteOptions/index.tsx | 89 +++++--- src/components/ProfileOptions/index.tsx | 29 ++- src/i18n/locales/ar.ts | 5 +- src/i18n/locales/de.ts | 5 +- src/i18n/locales/en.ts | 5 +- src/i18n/locales/es.ts | 5 +- src/i18n/locales/fr.ts | 5 +- src/i18n/locales/it.ts | 5 +- src/i18n/locales/ja.ts | 5 +- src/i18n/locales/pl.ts | 5 +- src/i18n/locales/pt-BR.ts | 5 +- src/i18n/locales/pt-PT.ts | 5 +- src/i18n/locales/ru.ts | 4 +- src/i18n/locales/zh.ts | 5 +- src/pages/secondary/MuteListPage/index.tsx | 55 ++++- src/providers/MuteListProvider.tsx | 239 +++++++++++++++++---- src/providers/NostrProvider/index.tsx | 6 +- src/services/client.service.ts | 17 ++ 19 files changed, 473 insertions(+), 120 deletions(-) diff --git a/src/components/MuteButton/index.tsx b/src/components/MuteButton/index.tsx index b433b475..e32c144f 100644 --- a/src/components/MuteButton/index.tsx +++ b/src/components/MuteButton/index.tsx @@ -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 ? ( - - ) : ( + if (isMuted) { + return ( + + ) + } + + const trigger = ( ) + + if (isSmallScreen) { + return ( + + {trigger} + +
+ + +
+
+
+ ) + } + + return ( + + {trigger} + + handleMute(e, true)} + className="text-destructive focus:text-destructive" + > + + {t('Mute user privately')} + + handleMute(e, false)} + className="text-destructive focus:text-destructive" + > + + {t('Mute user publicly')} + + + + ) } diff --git a/src/components/NoteOptions/index.tsx b/src/components/NoteOptions/index.tsx index 166ae126..ffde9d92 100644 --- a/src/components/NoteOptions/index.tsx +++ b/src/components/NoteOptions/index.tsx @@ -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 {t('View raw event')} - {pubkey && ( - - )} + }} + className="w-full p-6 justify-start text-destructive text-lg gap-4 [&_svg]:size-5 focus:text-destructive" + variant="ghost" + > + + {t('Unmute user')} + + ) : ( + <> + + + + ))} @@ -155,13 +177,32 @@ export default function NoteOptions({ event, className }: { event: Event; classN {pubkey && ( <> - (isMuted ? unmutePubkey(event.pubkey) : mutePubkey(event.pubkey))} - className="text-destructive focus:text-destructive" - > - {isMuted ? : } - {isMuted ? t('Unmute user') : t('Mute user')} - + {isMuted ? ( + unmutePubkey(event.pubkey)} + className="text-destructive focus:text-destructive" + > + + {t('Unmute user')} + + ) : ( + <> + mutePubkeyPrivately(event.pubkey)} + className="text-destructive focus:text-destructive" + > + + {t('Mute user privately')} + + mutePubkeyPublicly(event.pubkey)} + className="text-destructive focus:text-destructive" + > + + {t('Mute user publicly')} + + + )} )} diff --git a/src/components/ProfileOptions/index.tsx b/src/components/ProfileOptions/index.tsx index 26bdb302..95462dc5 100644 --- a/src/components/ProfileOptions/index.tsx +++ b/src/components/ProfileOptions/index.tsx @@ -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 ( @@ -32,7 +34,7 @@ export default function ProfileOptions({ pubkey }: { pubkey: string }) { {t('Copy user ID')} - {mutePubkeys.includes(pubkey) ? ( + {isMuted ? ( unmutePubkey(pubkey)} className="text-destructive focus:text-destructive" @@ -41,13 +43,22 @@ export default function ProfileOptions({ pubkey }: { pubkey: string }) { {t('Unmute user')} ) : ( - mutePubkey(pubkey)} - className="text-destructive focus:text-destructive" - > - - {t('Mute user')} - + <> + mutePubkeyPrivately(pubkey)} + className="text-destructive focus:text-destructive" + > + + {t('Mute user privately')} + + mutePubkeyPublicly(pubkey)} + className="text-destructive focus:text-destructive" + > + + {t('Mute user publicly')} + + )} diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts index c1243979..6641c22c 100644 --- a/src/i18n/locales/ar.ts +++ b/src/i18n/locales/ar.ts @@ -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': 'كتم المستخدم علنياً' } } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index c547d339..bd3d1a77 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -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' } } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 499a8dc8..1a0acabd 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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' } } diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 429b5f4b..c57315d0 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -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' } } diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 7bdc715a..349815ad 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -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 qu’ils suivent', - 'Followed by': 'Suivi par' + 'Followed by': 'Suivi par', + 'Mute user privately': 'Mettre l’utilisateur en sourdine en privé', + 'Mute user publicly': 'Mettre l’utilisateur en sourdine publiquement' } } diff --git a/src/i18n/locales/it.ts b/src/i18n/locales/it.ts index 642cbd4f..58318b22 100644 --- a/src/i18n/locales/it.ts +++ b/src/i18n/locales/it.ts @@ -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' } } diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index 0e5a0cd9..a46f9d8c 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -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': 'ユーザーを公開でミュート' } } diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index 088a0a37..48c916e2 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -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' } } diff --git a/src/i18n/locales/pt-BR.ts b/src/i18n/locales/pt-BR.ts index df6481ca..5346a757 100644 --- a/src/i18n/locales/pt-BR.ts +++ b/src/i18n/locales/pt-BR.ts @@ -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' } } diff --git a/src/i18n/locales/pt-PT.ts b/src/i18n/locales/pt-PT.ts index 50f7c1f3..f194ad6b 100644 --- a/src/i18n/locales/pt-PT.ts +++ b/src/i18n/locales/pt-PT.ts @@ -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' } } diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 3f46342c..80f425d3 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -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': 'Заглушить пользователя публично' } } diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 5e755a98..82eb8a34 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -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': '公开屏蔽' } } diff --git a/src/pages/secondary/MuteListPage/index.tsx b/src/pages/secondary/MuteListPage/index.tsx index a447c29a..e8723c8c 100644 --- a/src/pages/secondary/MuteListPage/index.tsx +++ b/src/pages/secondary/MuteListPage/index.tsx @@ -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([]) const bottomRef = useRef(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 (
- +
{profile?.about}
- +
+ {switching ? ( + + ) : muteType === 'private' ? ( + + ) : muteType === 'public' ? ( + + ) : null} + +
) } diff --git a/src/providers/MuteListProvider.tsx b/src/providers/MuteListProvider.tsx index e02b7c84..3e63559f 100644 --- a/src/providers/MuteListProvider.tsx +++ b/src/providers/MuteListProvider.tsx @@ -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 + changing: boolean + getMutePubkeys: () => string[] + getMuteType: (pubkey: string) => 'public' | 'private' | null + mutePubkeyPublicly: (pubkey: string) => Promise + mutePubkeyPrivately: (pubkey: string) => Promise unmutePubkey: (pubkey: string) => Promise + switchToPublicMute: (pubkey: string) => Promise + switchToPrivateMute: (pubkey: string) => Promise } const MuteListContext = createContext(undefined) @@ -31,59 +40,209 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { nip04Encrypt } = useNostr() const [tags, setTags] = useState([]) - const mutePubkeys = useMemo(() => extractPubkeysFromEventTags(tags), [tags]) + const [privateTags, setPrivateTags] = useState([]) + 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 ( - + {children} ) diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 186067f9..86a79c6a 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -62,7 +62,7 @@ type TNostrContext = { updateRelayListEvent: (relayListEvent: Event) => Promise updateProfileEvent: (profileEvent: Event) => Promise updateFollowListEvent: (followListEvent: Event) => Promise - updateMuteListEvent: (muteListEvent: Event, tags: string[][]) => Promise + updateMuteListEvent: (muteListEvent: Event, privateTags: string[][]) => Promise updateBookmarkListEvent: (bookmarkListEvent: Event) => Promise updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise updateNotificationsSeenAt: () => Promise @@ -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) } diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 64d0c919..78e2578a 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -765,6 +765,23 @@ class ClientService extends EventTarget { return await this.followListCache.fetch(pubkey) } + async fetchMuteListEvent(pubkey: string): Promise { + 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 { const storedBookmarkListEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.BookmarkList) if (storedBookmarkListEvent) {