You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

277 lines
9.9 KiB

import JsonViewDialog from '@/components/JsonViewDialog'
import MuteButton from '@/components/MuteButton'
import Nip05 from '@/components/Nip05'
import { RefreshButton } from '@/components/RefreshButton'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Skeleton } from '@/components/ui/skeleton'
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
import { useFetchProfile } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls'
import { createMuteListDraftEvent } from '@/lib/draft-event'
import { useMuteList } from '@/contexts/mute-list-context'
import indexedDb from '@/services/indexed-db.service'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
import { Code, Eraser, Lock, MoreVertical, Unlock } from 'lucide-react'
import dayjs from 'dayjs'
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import NotFoundPage from '../NotFoundPage'
const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { profile, pubkey, muteListEvent, publish, updateMuteListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { getMutePubkeys } = useMuteList()
const [jsonOpen, setJsonOpen] = useState(false)
const [jsonPayload, setJsonPayload] = useState<unknown>(null)
const mutePubkeys = useMemo(() => getMutePubkeys(), [getMutePubkeys])
const [visibleMutePubkeys, setVisibleMutePubkeys] = useState<string[]>([])
const [listRefreshKey, setListRefreshKey] = useState(0)
const [cleanConfirmOpen, setCleanConfirmOpen] = useState(false)
const [cleaning, setCleaning] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null)
const bumpList = useCallback(() => setListRefreshKey((k) => k + 1), [])
const openMuteListJson = useCallback(async () => {
const derivedPubkeys = getMutePubkeys()
let indexedDbDecryptedPrivateTags: string[][] | null = null
if (muteListEvent?.id) {
try {
indexedDbDecryptedPrivateTags = await indexedDb.getMuteDecryptedTags(muteListEvent.id)
} catch {
indexedDbDecryptedPrivateTags = null
}
}
setJsonPayload({
muteListEvent: muteListEvent ?? null,
derivedMutePubkeys: derivedPubkeys,
indexedDbDecryptedPrivateTags,
note:
'Private mutes live in kind 10000 `content` (NIP-04). Decrypt failures in the console usually mean wrong key, read-only session, or bad/corrupt ciphertext — not necessarily a bad public tag list.'
})
setJsonOpen(true)
}, [getMutePubkeys, muteListEvent])
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(bumpList)
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, bumpList])
useEffect(() => {
setVisibleMutePubkeys(mutePubkeys.slice(0, 10))
}, [mutePubkeys, listRefreshKey])
useEffect(() => {
const options = {
root: null,
rootMargin: '10px',
threshold: 1
}
const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && mutePubkeys.length > visibleMutePubkeys.length) {
setVisibleMutePubkeys((prev) => [
...prev,
...mutePubkeys.slice(prev.length, prev.length + 10)
])
}
}, options)
const currentBottomRef = bottomRef.current
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
return () => {
if (observerInstance && currentBottomRef) {
observerInstance.unobserve(currentBottomRef)
}
}
}, [visibleMutePubkeys, mutePubkeys])
const handleCleanList = useCallback(async () => {
if (!pubkey || cleaning) return
setCleaning(true)
try {
if (dayjs().unix() === muteListEvent?.created_at) {
await new Promise((resolve) => setTimeout(resolve, 1000))
}
const comprehensiveRelays = await buildAccountListRelayUrlsForMerge({
accountPubkey: pubkey,
favoriteRelays: favoriteRelays ?? [],
blockedRelays
})
const draft = createMuteListDraftEvent([], '')
const published = await publish(draft, { specifiedRelayUrls: comprehensiveRelays })
await updateMuteListEvent(published, [])
bumpList()
toast.success(t('List cleaned'))
} catch (e) {
toast.error(t('Failed to clean list') + ': ' + (e instanceof Error ? e.message : String(e)))
} finally {
setCleaning(false)
setCleanConfirmOpen(false)
}
}, [pubkey, cleaning, favoriteRelays, blockedRelays, publish, updateMuteListEvent, bumpList, t])
if (!profile) {
return <NotFoundPage />
}
return (
<SecondaryPageLayout
ref={ref}
index={index}
title={hideTitlebar ? undefined : t("username's muted", { username: profile.username })}
hideBackButton={hideTitlebar}
controls={
hideTitlebar ? undefined : (
<div className="flex items-center gap-0">
<RefreshButton onClick={bumpList} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('More options')}>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => void openMuteListJson()}>
<Code className="size-4 mr-2" />
{t('View JSON')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setCleanConfirmOpen(true)}
>
<Eraser className="size-4 mr-2" />
{t('Clean list')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
displayScrollToTopButton
>
<JsonViewDialog
value={jsonPayload}
isOpen={jsonOpen}
onClose={() => setJsonOpen(false)}
/>
<AlertDialog open={cleanConfirmOpen} onOpenChange={setCleanConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Clean this list?')}</AlertDialogTitle>
<AlertDialogDescription>{t('Clean list confirm')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={cleaning}>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={cleaning}
onClick={(e) => {
e.preventDefault()
void handleCleanList()
}}
>
{cleaning ? t('loading...') : t('Clean list')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div key={listRefreshKey} className="space-y-2 px-4 pt-2">
{visibleMutePubkeys.map((pubkey, index) => (
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
))}
{mutePubkeys.length > visibleMutePubkeys.length && <div ref={bottomRef} />}
</div>
</SecondaryPageLayout>
)
})
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 max-w-full w-fit"
skeletonClassName="h-4"
/>
<Nip05 pubkey={pubkey} />
<div className="truncate text-muted-foreground text-sm">{profile?.about}</div>
</div>
<div className="flex gap-2 items-center">
{switching ? (
<Button disabled variant="ghost" size="icon">
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
</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>
)
}