feat: support choosing between public and private mute
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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': 'كتم المستخدم علنياً'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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': 'ユーザーを公開でミュート'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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': 'Заглушить пользователя публично'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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': '公开屏蔽'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user