26 changed files with 564 additions and 45 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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