26 changed files with 564 additions and 45 deletions
@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import { useToast } from '@/hooks' |
||||
import { useMuteList } from '@/providers/MuteListProvider' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { 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 { toast } = useToast() |
||||
const { pubkey: accountPubkey, checkLogin } = useNostr() |
||||
const { mutePubkeys, mutePubkey, 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) => { |
||||
e.stopPropagation() |
||||
checkLogin(async () => { |
||||
if (isMuted) return |
||||
|
||||
setUpdating(true) |
||||
try { |
||||
await mutePubkey(pubkey) |
||||
} catch (error) { |
||||
toast({ |
||||
title: t('Mute failed'), |
||||
description: (error as Error).message, |
||||
variant: 'destructive' |
||||
}) |
||||
} finally { |
||||
setUpdating(false) |
||||
} |
||||
}) |
||||
} |
||||
|
||||
const handleUnmute = async (e: React.MouseEvent) => { |
||||
e.stopPropagation() |
||||
checkLogin(async () => { |
||||
if (!isMuted) return |
||||
|
||||
setUpdating(true) |
||||
try { |
||||
await unmutePubkey(pubkey) |
||||
} catch (error) { |
||||
toast({ |
||||
title: t('Unmute failed'), |
||||
description: (error as Error).message, |
||||
variant: 'destructive' |
||||
}) |
||||
} finally { |
||||
setUpdating(false) |
||||
} |
||||
}) |
||||
} |
||||
|
||||
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> |
||||
) : ( |
||||
<Button |
||||
variant="destructive" |
||||
className="w-20 min-w-20 rounded-full" |
||||
onClick={handleMute} |
||||
disabled={updating} |
||||
> |
||||
{updating ? <Loader className="animate-spin" /> : t('Mute')} |
||||
</Button> |
||||
) |
||||
} |
||||
@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import { |
||||
DropdownMenu, |
||||
DropdownMenuContent, |
||||
DropdownMenuItem, |
||||
DropdownMenuTrigger |
||||
} from '@/components/ui/dropdown-menu' |
||||
import { pubkeyToNpub } from '@/lib/pubkey' |
||||
import { useMuteList } from '@/providers/MuteListProvider' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { Bell, BellOff, Copy, Ellipsis } from 'lucide-react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
export default function ProfileOptions({ pubkey }: { pubkey: string }) { |
||||
const { t } = useTranslation() |
||||
const { pubkey: accountPubkey } = useNostr() |
||||
const { mutePubkeys, mutePubkey, unmutePubkey } = useMuteList() |
||||
|
||||
if (pubkey === accountPubkey) return null |
||||
|
||||
return ( |
||||
<DropdownMenu> |
||||
<DropdownMenuTrigger asChild> |
||||
<Button variant="secondary" size="icon" className="rounded-full"> |
||||
<Ellipsis className="text-muted-foreground hover:text-foreground cursor-pointer" /> |
||||
</Button> |
||||
</DropdownMenuTrigger> |
||||
<DropdownMenuContent collisionPadding={8}> |
||||
<DropdownMenuItem |
||||
onClick={() => navigator.clipboard.writeText('nostr:' + pubkeyToNpub(pubkey))} |
||||
> |
||||
<Copy /> |
||||
{t('copy embedded code')} |
||||
</DropdownMenuItem> |
||||
{mutePubkeys.includes(pubkey) ? ( |
||||
<DropdownMenuItem |
||||
onClick={() => unmutePubkey(pubkey)} |
||||
className="text-destructive focus:text-destructive" |
||||
> |
||||
<Bell /> |
||||
{t('unmute user')} |
||||
</DropdownMenuItem> |
||||
) : ( |
||||
<DropdownMenuItem |
||||
onClick={() => mutePubkey(pubkey)} |
||||
className="text-destructive focus:text-destructive" |
||||
> |
||||
<BellOff /> |
||||
{t('mute user')} |
||||
</DropdownMenuItem> |
||||
)} |
||||
</DropdownMenuContent> |
||||
</DropdownMenu> |
||||
) |
||||
} |
||||
@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
|
||||
import MuteButton from '@/components/MuteButton' |
||||
import Nip05 from '@/components/Nip05' |
||||
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 { useEffect, useRef, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import NotFoundPage from '../NotFoundPage' |
||||
|
||||
export default function MuteListPage({ index }: { index?: number }) { |
||||
const { t } = useTranslation() |
||||
const { profile } = useNostr() |
||||
const { mutePubkeys } = useMuteList() |
||||
const [visibleMutePubkeys, setVisibleMutePubkeys] = useState<string[]>([]) |
||||
const bottomRef = useRef<HTMLDivElement>(null) |
||||
|
||||
useEffect(() => { |
||||
setVisibleMutePubkeys(mutePubkeys.slice(0, 10)) |
||||
}, [mutePubkeys]) |
||||
|
||||
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]) |
||||
|
||||
if (!profile) { |
||||
return <NotFoundPage /> |
||||
} |
||||
|
||||
return ( |
||||
<SecondaryPageLayout |
||||
index={index} |
||||
title={t("username's muted", { username: profile.username })} |
||||
displayScrollToTopButton |
||||
> |
||||
<div className="space-y-2 px-4"> |
||||
{visibleMutePubkeys.map((pubkey, index) => ( |
||||
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} /> |
||||
))} |
||||
{mutePubkeys.length > visibleMutePubkeys.length && <div ref={bottomRef} />} |
||||
</div> |
||||
</SecondaryPageLayout> |
||||
) |
||||
} |
||||
|
||||
function UserItem({ pubkey }: { pubkey: string }) { |
||||
const { profile } = useFetchProfile(pubkey) |
||||
const { nip05, about } = profile || {} |
||||
|
||||
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" /> |
||||
<Nip05 nip05={nip05} pubkey={pubkey} /> |
||||
<div className="truncate text-muted-foreground text-sm">{about}</div> |
||||
</div> |
||||
<MuteButton pubkey={pubkey} /> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
|
||||
import { createMuteListDraftEvent } from '@/lib/draft-event' |
||||
import { getLatestEvent } from '@/lib/event' |
||||
import { extractPubkeysFromEventTags, isSameTag } from '@/lib/tag' |
||||
import client from '@/services/client.service' |
||||
import storage from '@/services/storage.service' |
||||
import { Event, kinds } from 'nostr-tools' |
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react' |
||||
import { z } from 'zod' |
||||
import { useNostr } from './NostrProvider' |
||||
|
||||
type TMuteListContext = { |
||||
mutePubkeys: string[] |
||||
mutePubkey: (pubkey: string) => Promise<void> |
||||
unmutePubkey: (pubkey: string) => Promise<void> |
||||
} |
||||
|
||||
const MuteListContext = createContext<TMuteListContext | undefined>(undefined) |
||||
|
||||
export const useMuteList = () => { |
||||
const context = useContext(MuteListContext) |
||||
if (!context) { |
||||
throw new Error('useMuteList must be used within a MuteListProvider') |
||||
} |
||||
return context |
||||
} |
||||
|
||||
export function MuteListProvider({ children }: { children: React.ReactNode }) { |
||||
const { pubkey: accountPubkey, publish, relayList, nip04Decrypt, nip04Encrypt } = useNostr() |
||||
const [muteListEvent, setMuteListEvent] = useState<Event | undefined>(undefined) |
||||
const [tags, setTags] = useState<string[][]>([]) |
||||
const mutePubkeys = useMemo(() => extractPubkeysFromEventTags(tags), [tags]) |
||||
|
||||
useEffect(() => { |
||||
if (!muteListEvent) { |
||||
setTags([]) |
||||
return |
||||
} |
||||
|
||||
const updateTags = async () => { |
||||
const tags = muteListEvent.tags |
||||
if (muteListEvent.content && accountPubkey) { |
||||
try { |
||||
const plainText = await nip04Decrypt(accountPubkey, muteListEvent.content) |
||||
const contentTags = z.array(z.array(z.string())).parse(JSON.parse(plainText)) |
||||
tags.push(...contentTags.filter((tag) => tags.every((t) => !isSameTag(t, tag)))) |
||||
} catch { |
||||
// ignore
|
||||
} |
||||
} |
||||
setTags(tags) |
||||
} |
||||
updateTags() |
||||
}, [muteListEvent]) |
||||
|
||||
useEffect(() => { |
||||
if (!accountPubkey) return |
||||
|
||||
const init = async () => { |
||||
setMuteListEvent(undefined) |
||||
const storedMuteListEvent = storage.getAccountMuteListEvent(accountPubkey) |
||||
if (storedMuteListEvent) { |
||||
setMuteListEvent(storedMuteListEvent) |
||||
} |
||||
const events = await client.fetchEvents(relayList?.write ?? client.getDefaultRelayUrls(), { |
||||
kinds: [kinds.Mutelist], |
||||
authors: [accountPubkey] |
||||
}) |
||||
const muteEvent = getLatestEvent(events) as Event | undefined |
||||
if (muteEvent) { |
||||
setMuteListEvent(muteEvent) |
||||
} |
||||
} |
||||
|
||||
init() |
||||
}, [accountPubkey]) |
||||
|
||||
const updateMuteListEvent = (event: Event) => { |
||||
const isNew = storage.setAccountMuteListEvent(event) |
||||
if (!isNew) return |
||||
setMuteListEvent(event) |
||||
} |
||||
|
||||
const mutePubkey = async (pubkey: string) => { |
||||
if (!accountPubkey) return |
||||
|
||||
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) |
||||
updateMuteListEvent(newMuteListEvent) |
||||
} |
||||
|
||||
const unmutePubkey = async (pubkey: string) => { |
||||
if (!accountPubkey || !muteListEvent) 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) |
||||
updateMuteListEvent(newMuteListEvent) |
||||
} |
||||
|
||||
return ( |
||||
<MuteListContext.Provider value={{ mutePubkeys, mutePubkey, unmutePubkey }}> |
||||
{children} |
||||
</MuteListContext.Provider> |
||||
) |
||||
} |
||||
Loading…
Reference in new issue