feat: mute
This commit is contained in:
15
src/App.tsx
15
src/App.tsx
@@ -6,6 +6,7 @@ import { ThemeProvider } from '@/providers/ThemeProvider'
|
||||
import { PageManager } from './PageManager'
|
||||
import { FeedProvider } from './providers/FeedProvider'
|
||||
import { FollowListProvider } from './providers/FollowListProvider'
|
||||
import { MuteListProvider } from './providers/MuteListProvider'
|
||||
import { NostrProvider } from './providers/NostrProvider'
|
||||
import { NoteStatsProvider } from './providers/NoteStatsProvider'
|
||||
import { RelaySetsProvider } from './providers/RelaySetsProvider'
|
||||
@@ -18,12 +19,14 @@ export default function App(): JSX.Element {
|
||||
<NostrProvider>
|
||||
<RelaySetsProvider>
|
||||
<FollowListProvider>
|
||||
<FeedProvider>
|
||||
<NoteStatsProvider>
|
||||
<PageManager />
|
||||
<Toaster />
|
||||
</NoteStatsProvider>
|
||||
</FeedProvider>
|
||||
<MuteListProvider>
|
||||
<FeedProvider>
|
||||
<NoteStatsProvider>
|
||||
<PageManager />
|
||||
<Toaster />
|
||||
</NoteStatsProvider>
|
||||
</FeedProvider>
|
||||
</MuteListProvider>
|
||||
</FollowListProvider>
|
||||
</RelaySetsProvider>
|
||||
</NostrProvider>
|
||||
|
||||
78
src/components/MuteButton/index.tsx
Normal file
78
src/components/MuteButton/index.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { PICTURE_EVENT_KIND } from '@/constants'
|
||||
import { useFetchRelayInfos } from '@/hooks'
|
||||
import { isReplyNoteEvent } from '@/lib/event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import client from '@/services/client.service'
|
||||
@@ -23,15 +24,18 @@ type TListMode = 'posts' | 'postsAndReplies' | 'pictures'
|
||||
export default function NoteList({
|
||||
relayUrls,
|
||||
filter = {},
|
||||
className
|
||||
className,
|
||||
filterMutedNotes = true
|
||||
}: {
|
||||
relayUrls: string[]
|
||||
filter?: Filter
|
||||
className?: string
|
||||
filterMutedNotes?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { isLargeScreen } = useScreenSize()
|
||||
const { signEvent, checkLogin } = useNostr()
|
||||
const { mutePubkeys } = useMuteList()
|
||||
const { areAlgoRelays } = useFetchRelayInfos([...relayUrls])
|
||||
const [refreshCount, setRefreshCount] = useState(0)
|
||||
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
||||
@@ -158,6 +162,13 @@ export default function NoteList({
|
||||
setNewEvents([])
|
||||
}
|
||||
|
||||
const eventFilter = (event: Event) => {
|
||||
return (
|
||||
(!filterMutedNotes || !mutePubkeys.includes(event.pubkey)) &&
|
||||
(listMode !== 'posts' || !isReplyNoteEvent(event))
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2 sm:space-y-2', className)}>
|
||||
<ListModeSwitch listMode={listMode} setListMode={setListMode} />
|
||||
@@ -169,8 +180,7 @@ export default function NoteList({
|
||||
pullingContent=""
|
||||
>
|
||||
<div className="space-y-2 sm:space-y-2">
|
||||
{newEvents.filter((event) => listMode !== 'posts' || !isReplyNoteEvent(event)).length >
|
||||
0 && (
|
||||
{newEvents.filter(eventFilter).length > 0 && (
|
||||
<div className="flex justify-center w-full max-sm:mt-2">
|
||||
<Button size="lg" onClick={showNewEvents}>
|
||||
{t('show new notes')}
|
||||
@@ -185,11 +195,9 @@ export default function NoteList({
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
{events
|
||||
.filter((event) => listMode === 'postsAndReplies' || !isReplyNoteEvent(event))
|
||||
.map((event) => (
|
||||
<NoteCard key={event.id} className="w-full" event={event} />
|
||||
))}
|
||||
{events.filter(eventFilter).map((event) => (
|
||||
<NoteCard key={event.id} className="w-full" event={event} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { getSharableEventId } from '@/lib/event'
|
||||
import { Code, Copy, Ellipsis } from 'lucide-react'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { BellOff, Code, Copy, Ellipsis } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -13,7 +15,9 @@ import RawEventDialog from './RawEventDialog'
|
||||
|
||||
export default function NoteOptions({ event }: { event: Event }) {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey } = useNostr()
|
||||
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
|
||||
const { mutePubkey } = useMuteList()
|
||||
|
||||
return (
|
||||
<div className="h-4" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -35,6 +39,15 @@ export default function NoteOptions({ event }: { event: Event }) {
|
||||
<Code />
|
||||
{t('raw event')}
|
||||
</DropdownMenuItem>
|
||||
{pubkey && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => mutePubkey(event.pubkey)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<BellOff />
|
||||
{t('mute author')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<RawEventDialog
|
||||
|
||||
55
src/components/ProfileOptions/index.tsx
Normal file
55
src/components/ProfileOptions/index.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export const StorageKey = {
|
||||
CURRENT_ACCOUNT: 'currentAccount',
|
||||
ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap',
|
||||
ACCOUNT_FOLLOW_LIST_EVENT_MAP: 'accountFollowListEventMap',
|
||||
ACCOUNT_MUTE_LIST_EVENT_MAP: 'accountMuteListEventMap',
|
||||
ACCOUNT_PROFILE_EVENT_MAP: 'accountProfileEventMap',
|
||||
ADD_CLIENT_TAG: 'addClientTag'
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getFollowingsFromFollowListEvent } from '@/lib/event'
|
||||
import { extractPubkeysFromEventTags } from '@/lib/tag'
|
||||
import client from '@/services/client.service'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -18,7 +18,7 @@ export function useFetchFollowings(pubkey?: string | null) {
|
||||
if (!event) return
|
||||
|
||||
setFollowListEvent(event)
|
||||
setFollowings(getFollowingsFromFollowListEvent(event))
|
||||
setFollowings(extractPubkeysFromEventTags(event.tags))
|
||||
} finally {
|
||||
setIsFetching(false)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { getProfileFromProfileEvent } from '@/lib/event'
|
||||
import { userIdToPubkey } from '@/lib/pubkey'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import client from '@/services/client.service'
|
||||
import storage from '@/services/storage.service'
|
||||
import { TProfile } from '@/types'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function useFetchProfile(id?: string) {
|
||||
const { profile: currentAccountProfile } = useNostr()
|
||||
const [isFetching, setIsFetching] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [profile, setProfile] = useState<TProfile | null>(null)
|
||||
const pubkey = useMemo(() => (id ? userIdToPubkey(id) : undefined), [id])
|
||||
const [pubkey, setPubkey] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setProfile(null)
|
||||
setPubkey(null)
|
||||
const fetchProfile = async () => {
|
||||
setIsFetching(true)
|
||||
try {
|
||||
@@ -21,6 +25,16 @@ export function useFetchProfile(id?: string) {
|
||||
return
|
||||
}
|
||||
|
||||
const pubkey = userIdToPubkey(id)
|
||||
setPubkey(pubkey)
|
||||
const storedProfileEvent = storage.getAccountProfileEvent(pubkey)
|
||||
if (storedProfileEvent) {
|
||||
const profile = getProfileFromProfileEvent(storedProfileEvent)
|
||||
setProfile(profile)
|
||||
setIsFetching(false)
|
||||
return
|
||||
}
|
||||
|
||||
const profile = await client.fetchProfile(id)
|
||||
if (profile) {
|
||||
setProfile(profile)
|
||||
|
||||
@@ -49,6 +49,7 @@ export default {
|
||||
Note: 'Note',
|
||||
"username's following": "{{username}}'s following",
|
||||
"username's used relays": "{{username}}'s used relays",
|
||||
"username's muted": "{{username}}'s muted",
|
||||
Login: 'Login',
|
||||
'Follows you': 'Follows you',
|
||||
'Relay Settings': 'Relay Settings',
|
||||
@@ -146,6 +147,12 @@ export default {
|
||||
password: 'password',
|
||||
'Save to': 'Save to',
|
||||
'Enter a name for the new relay set': 'Enter a name for the new relay set',
|
||||
'Save to a new relay set': 'Save to a new relay set'
|
||||
'Save to a new relay set': 'Save to a new relay set',
|
||||
Mute: 'Mute',
|
||||
Muted: 'Muted',
|
||||
Unmute: 'Unmute',
|
||||
'mute author': 'mute author',
|
||||
'mute user': 'mute user',
|
||||
'unmute user': 'unmute user'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ export default {
|
||||
Note: '笔记',
|
||||
"username's following": '{{username}} 的关注',
|
||||
"username's used relays": '{{username}} 使用的服务器',
|
||||
"username's muted": '{{username}} 屏蔽的用户',
|
||||
Login: '登录',
|
||||
'Follows you': '关注了你',
|
||||
'Relay Settings': '服务器设置',
|
||||
@@ -147,6 +148,12 @@ export default {
|
||||
password: '密码',
|
||||
'Save to': '保存到',
|
||||
'Enter a name for the new relay set': '输入新服务器组的名称',
|
||||
'Save to a new relay set': '保存到新服务器组'
|
||||
'Save to a new relay set': '保存到新服务器组',
|
||||
Mute: '屏蔽',
|
||||
Muted: '已屏蔽',
|
||||
Unmute: '取消屏蔽',
|
||||
'mute author': '屏蔽作者',
|
||||
'mute user': '屏蔽用户',
|
||||
'unmute user': '取消屏蔽用户'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +202,15 @@ export function createFollowListDraftEvent(tags: string[][], content?: string):
|
||||
}
|
||||
}
|
||||
|
||||
export function createMuteListDraftEvent(tags: string[][], content?: string): TDraftEvent {
|
||||
return {
|
||||
kind: kinds.Mutelist,
|
||||
content: content ?? '',
|
||||
created_at: dayjs().unix(),
|
||||
tags
|
||||
}
|
||||
}
|
||||
|
||||
export function createProfileDraftEvent(content: string, tags: string[][] = []): TDraftEvent {
|
||||
return {
|
||||
kind: kinds.Metadata,
|
||||
|
||||
@@ -67,18 +67,6 @@ export function getUsingClient(event: Event) {
|
||||
return event.tags.find(tagNameEquals('client'))?.[1]
|
||||
}
|
||||
|
||||
export function getFollowingsFromFollowListEvent(event: Event) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
event.tags
|
||||
.filter(tagNameEquals('p'))
|
||||
.map(([, pubkey]) => pubkey)
|
||||
.filter(Boolean)
|
||||
.reverse()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function getRelayListFromRelayListEvent(event?: Event) {
|
||||
if (!event) {
|
||||
return { write: BIG_RELAY_URLS, read: BIG_RELAY_URLS, originalRelays: [] }
|
||||
@@ -292,3 +280,7 @@ export function extractEmbeddedNotesFromContent(content: string) {
|
||||
|
||||
return { embeddedNotes, contentWithoutEmbeddedNotes: c }
|
||||
}
|
||||
|
||||
export function getLatestEvent(events: Event[]) {
|
||||
return events.sort((a, b) => b.created_at - a.created_at)[0]
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export const toRelaySettings = (tag?: 'mailbox' | 'relay-sets') => {
|
||||
export const toSettings = () => '/settings'
|
||||
export const toProfileEditor = () => '/profile-editor'
|
||||
export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}`
|
||||
export const toMuteList = () => '/mutes'
|
||||
|
||||
export const toNoStrudelProfile = (id: string) => `https://nostrudel.ninja/#/u/${id}`
|
||||
export const toNoStrudelNote = (id: string) => `https://nostrudel.ninja/#/n/${id}`
|
||||
|
||||
@@ -42,3 +42,23 @@ export function extractImageInfoFromTag(tag: string[]): TImageInfo | null {
|
||||
}
|
||||
return image
|
||||
}
|
||||
|
||||
export function extractPubkeysFromEventTags(tags: string[][]) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
tags
|
||||
.filter(tagNameEquals('p'))
|
||||
.map(([, pubkey]) => pubkey)
|
||||
.filter(Boolean)
|
||||
.reverse()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function isSameTag(tag1: string[], tag2: string[]) {
|
||||
if (tag1.length !== tag2.length) return false
|
||||
for (let i = 0; i < tag1.length; i++) {
|
||||
if (tag1[i] !== tag2[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
87
src/pages/secondary/MuteListPage/index.tsx
Normal file
87
src/pages/secondary/MuteListPage/index.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { useFetchRelayList } from '@/hooks/useFetchRelayList'
|
||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||
import {
|
||||
toFollowingList,
|
||||
toMuteList,
|
||||
toOthersRelaySettings,
|
||||
toProfileEditor,
|
||||
toRelaySettings
|
||||
@@ -21,10 +22,12 @@ import { generateImageByPubkey } from '@/lib/pubkey'
|
||||
import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
|
||||
import { useFeed } from '@/providers/FeedProvider'
|
||||
import { useFollowList } from '@/providers/FollowListProvider'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import NotFoundPage from '../NotFoundPage'
|
||||
import ProfileOptions from '@/components/ProfileOptions'
|
||||
|
||||
export default function ProfilePage({ id, index }: { id?: string; index?: number }) {
|
||||
const { t } = useTranslation()
|
||||
@@ -41,6 +44,7 @@ export default function ProfilePage({ id, index }: { id?: string; index?: number
|
||||
)
|
||||
const { pubkey: accountPubkey } = useNostr()
|
||||
const { followings: selfFollowings } = useFollowList()
|
||||
const { mutePubkeys } = useMuteList()
|
||||
const { followings } = useFetchFollowings(profile?.pubkey)
|
||||
const isFollowingYou = useMemo(() => {
|
||||
return (
|
||||
@@ -103,6 +107,7 @@ export default function ProfilePage({ id, index }: { id?: string; index?: number
|
||||
) : (
|
||||
<FollowButton pubkey={pubkey} />
|
||||
)}
|
||||
<ProfileOptions pubkey={pubkey} />
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<div className="text-xl font-semibold">{username}</div>
|
||||
@@ -127,11 +132,22 @@ export default function ProfilePage({ id, index }: { id?: string; index?: number
|
||||
{relayList.originalRelays.length}
|
||||
<div className="text-muted-foreground">{t('Relays')}</div>
|
||||
</SecondaryPageLink>
|
||||
{isSelf && (
|
||||
<SecondaryPageLink to={toMuteList()} className="flex gap-1 hover:underline w-fit">
|
||||
{mutePubkeys.length}
|
||||
<div className="text-muted-foreground">{t('Muted')}</div>
|
||||
</SecondaryPageLink>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isFetchingRelayInfo && (
|
||||
<NoteList filter={{ authors: [pubkey] }} relayUrls={relayUrls} className="mt-2" />
|
||||
<NoteList
|
||||
filter={{ authors: [pubkey] }}
|
||||
relayUrls={relayUrls}
|
||||
className="mt-2"
|
||||
filterMutedNotes={false}
|
||||
/>
|
||||
)}
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createFollowListDraftEvent } from '@/lib/draft-event'
|
||||
import { getFollowingsFromFollowListEvent } from '@/lib/event'
|
||||
import { extractPubkeysFromEventTags } from '@/lib/tag'
|
||||
import client from '@/services/client.service'
|
||||
import storage from '@/services/storage.service'
|
||||
import { Event } from 'nostr-tools'
|
||||
@@ -30,7 +30,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
|
||||
const [followListEvent, setFollowListEvent] = useState<Event | undefined>(undefined)
|
||||
const [isFetching, setIsFetching] = useState(true)
|
||||
const followings = useMemo(
|
||||
() => (followListEvent ? getFollowingsFromFollowListEvent(followListEvent) : []),
|
||||
() => (followListEvent ? extractPubkeysFromEventTags(followListEvent.tags) : []),
|
||||
[followListEvent]
|
||||
)
|
||||
|
||||
@@ -87,7 +87,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
|
||||
const getFollowings = async (pubkey: string) => {
|
||||
const followListEvent = storage.getAccountFollowListEvent(pubkey)
|
||||
if (followListEvent) {
|
||||
return getFollowingsFromFollowListEvent(followListEvent)
|
||||
return extractPubkeysFromEventTags(followListEvent.tags)
|
||||
}
|
||||
return await client.fetchFollowings(pubkey)
|
||||
}
|
||||
|
||||
111
src/providers/MuteListProvider.tsx
Normal file
111
src/providers/MuteListProvider.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -48,6 +48,20 @@ export class BunkerSigner implements ISigner {
|
||||
})
|
||||
}
|
||||
|
||||
async nip04Encrypt(pubkey: string, plainText: string) {
|
||||
if (!this.signer) {
|
||||
throw new Error('Not logged in')
|
||||
}
|
||||
return await this.signer.nip04Encrypt(pubkey, plainText)
|
||||
}
|
||||
|
||||
async nip04Decrypt(pubkey: string, cipherText: string) {
|
||||
if (!this.signer) {
|
||||
throw new Error('Not logged in')
|
||||
}
|
||||
return await this.signer.nip04Decrypt(pubkey, cipherText)
|
||||
}
|
||||
|
||||
getClientSecretKey() {
|
||||
return bytesToHex(this.clientSecretKey)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ type TNostrContext = {
|
||||
publish: (draftEvent: TDraftEvent, additionalRelayUrls?: string[]) => Promise<Event>
|
||||
signHttpAuth: (url: string, method: string) => Promise<string>
|
||||
signEvent: (draftEvent: TDraftEvent) => Promise<Event>
|
||||
nip04Encrypt: (pubkey: string, plainText: string) => Promise<string>
|
||||
nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
|
||||
checkLogin: <T>(cb?: () => T) => Promise<T | void>
|
||||
getRelayList: (pubkey: string) => Promise<TRelayList>
|
||||
updateRelayListEvent: (relayListEvent: Event) => void
|
||||
@@ -299,6 +301,14 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
return 'Nostr ' + btoa(JSON.stringify(event))
|
||||
}
|
||||
|
||||
const nip04Encrypt = async (pubkey: string, plainText: string) => {
|
||||
return signer?.nip04Encrypt(pubkey, plainText) ?? ''
|
||||
}
|
||||
|
||||
const nip04Decrypt = async (pubkey: string, cipherText: string) => {
|
||||
return signer?.nip04Decrypt(pubkey, cipherText) ?? ''
|
||||
}
|
||||
|
||||
const checkLogin = async <T,>(cb?: () => T): Promise<T | void> => {
|
||||
if (signer) {
|
||||
return cb && cb()
|
||||
@@ -349,6 +359,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
removeAccount,
|
||||
publish,
|
||||
signHttpAuth,
|
||||
nip04Encrypt,
|
||||
nip04Decrypt,
|
||||
checkLogin,
|
||||
signEvent,
|
||||
getRelayList,
|
||||
|
||||
@@ -23,4 +23,24 @@ export class Nip07Signer implements ISigner {
|
||||
async signEvent(draftEvent: TDraftEvent) {
|
||||
return await this.signer.signEvent(draftEvent)
|
||||
}
|
||||
|
||||
async nip04Encrypt(pubkey: string, plainText: string) {
|
||||
if (!this.signer) {
|
||||
throw new Error('Not logged in')
|
||||
}
|
||||
if (!this.signer.nip04?.encrypt) {
|
||||
throw new Error('The extension you are using does not support nip04 encryption')
|
||||
}
|
||||
return await this.signer.nip04.encrypt(pubkey, plainText)
|
||||
}
|
||||
|
||||
async nip04Decrypt(pubkey: string, cipherText: string) {
|
||||
if (!this.signer) {
|
||||
throw new Error('Not logged in')
|
||||
}
|
||||
if (!this.signer.nip04?.decrypt) {
|
||||
throw new Error('The extension you are using does not support nip04 decryption')
|
||||
}
|
||||
return await this.signer.nip04.decrypt(pubkey, cipherText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ISigner, TDraftEvent } from '@/types'
|
||||
import { finalizeEvent, getPublicKey as nGetPublicKey, nip19 } from 'nostr-tools'
|
||||
import { finalizeEvent, getPublicKey as nGetPublicKey, nip04, nip19 } from 'nostr-tools'
|
||||
|
||||
export class NsecSigner implements ISigner {
|
||||
private privkey: Uint8Array | null = null
|
||||
@@ -38,4 +38,18 @@ export class NsecSigner implements ISigner {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async nip04Encrypt(pubkey: string, plainText: string) {
|
||||
if (!this.privkey) {
|
||||
throw new Error('Not logged in')
|
||||
}
|
||||
return nip04.encrypt(this.privkey, pubkey, plainText)
|
||||
}
|
||||
|
||||
async nip04Decrypt(pubkey: string, cipherText: string) {
|
||||
if (!this.privkey) {
|
||||
throw new Error('Not logged in')
|
||||
}
|
||||
return nip04.decrypt(this.privkey, pubkey, cipherText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { match } from 'path-to-regexp'
|
||||
import { isValidElement } from 'react'
|
||||
import FollowingListPage from './pages/secondary/FollowingListPage'
|
||||
import HomePage from './pages/secondary/HomePage'
|
||||
import MuteListPage from './pages/secondary/MuteListPage'
|
||||
import NoteListPage from './pages/secondary/NoteListPage'
|
||||
import NotePage from './pages/secondary/NotePage'
|
||||
import OthersRelaySettingsPage from './pages/secondary/OthersRelaySettingsPage'
|
||||
@@ -23,7 +24,8 @@ const ROUTES = [
|
||||
{ path: '/relay-settings', element: <RelaySettingsPage /> },
|
||||
{ path: '/settings', element: <SettingsPage /> },
|
||||
{ path: '/profile-editor', element: <ProfileEditorPage /> },
|
||||
{ path: '/relays/:url', element: <RelayPage /> }
|
||||
{ path: '/relays/:url', element: <RelayPage /> },
|
||||
{ path: '/mutes', element: <MuteListPage /> }
|
||||
]
|
||||
|
||||
export const routes = ROUTES.map(({ path, element }) => ({
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { BIG_RELAY_URLS } from '@/constants'
|
||||
import {
|
||||
getFollowingsFromFollowListEvent,
|
||||
getProfileFromProfileEvent,
|
||||
getRelayListFromRelayListEvent
|
||||
} from '@/lib/event'
|
||||
import { getProfileFromProfileEvent, getRelayListFromRelayListEvent } from '@/lib/event'
|
||||
import { formatPubkey, userIdToPubkey } from '@/lib/pubkey'
|
||||
import { extractPubkeysFromEventTags } from '@/lib/tag'
|
||||
import { TDraftEvent, TProfile, TRelayInfo, TRelayList } from '@/types'
|
||||
import { sha256 } from '@noble/hashes/sha2'
|
||||
import DataLoader from 'dataloader'
|
||||
@@ -437,7 +434,7 @@ class ClientService extends EventTarget {
|
||||
|
||||
async fetchFollowings(pubkey: string) {
|
||||
const followListEvent = await this.fetchFollowListEvent(pubkey)
|
||||
return followListEvent ? getFollowingsFromFollowListEvent(followListEvent) : []
|
||||
return followListEvent ? extractPubkeysFromEventTags(followListEvent.tags) : []
|
||||
}
|
||||
|
||||
updateFollowListCache(pubkey: string, event: NEvent) {
|
||||
|
||||
@@ -28,6 +28,7 @@ class StorageService {
|
||||
private currentAccount: TAccount | null = null
|
||||
private accountRelayListEventMap: Record<string, Event | undefined> = {} // pubkey -> relayListEvent
|
||||
private accountFollowListEventMap: Record<string, Event | undefined> = {} // pubkey -> followListEvent
|
||||
private accountMuteListEventMap: Record<string, Event | undefined> = {} // pubkey -> muteListEvent
|
||||
private accountProfileEventMap: Record<string, Event | undefined> = {} // pubkey -> profileEvent
|
||||
|
||||
constructor() {
|
||||
@@ -60,6 +61,12 @@ class StorageService {
|
||||
this.accountFollowListEventMap = accountFollowListEventMapStr
|
||||
? JSON.parse(accountFollowListEventMapStr)
|
||||
: {}
|
||||
const accountMuteListEventMapStr = window.localStorage.getItem(
|
||||
StorageKey.ACCOUNT_MUTE_LIST_EVENT_MAP
|
||||
)
|
||||
this.accountMuteListEventMap = accountMuteListEventMapStr
|
||||
? JSON.parse(accountMuteListEventMapStr)
|
||||
: {}
|
||||
const accountProfileEventMapStr = window.localStorage.getItem(
|
||||
StorageKey.ACCOUNT_PROFILE_EVENT_MAP
|
||||
)
|
||||
@@ -176,12 +183,17 @@ class StorageService {
|
||||
this.accounts = this.accounts.filter((act) => !isSameAccount(act, account))
|
||||
delete this.accountFollowListEventMap[account.pubkey]
|
||||
delete this.accountRelayListEventMap[account.pubkey]
|
||||
delete this.accountMuteListEventMap[account.pubkey]
|
||||
delete this.accountProfileEventMap[account.pubkey]
|
||||
window.localStorage.setItem(StorageKey.ACCOUNTS, JSON.stringify(this.accounts))
|
||||
window.localStorage.setItem(
|
||||
StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP,
|
||||
JSON.stringify(this.accountFollowListEventMap)
|
||||
)
|
||||
window.localStorage.setItem(
|
||||
StorageKey.ACCOUNT_MUTE_LIST_EVENT_MAP,
|
||||
JSON.stringify(this.accountMuteListEventMap)
|
||||
)
|
||||
window.localStorage.setItem(
|
||||
StorageKey.ACCOUNT_RELAY_LIST_EVENT_MAP,
|
||||
JSON.stringify(this.accountRelayListEventMap)
|
||||
@@ -244,6 +256,26 @@ class StorageService {
|
||||
return true
|
||||
}
|
||||
|
||||
getAccountMuteListEvent(pubkey: string) {
|
||||
return this.accountMuteListEventMap[pubkey]
|
||||
}
|
||||
|
||||
setAccountMuteListEvent(muteListEvent: Event) {
|
||||
const pubkey = muteListEvent.pubkey
|
||||
if (
|
||||
this.accountMuteListEventMap[pubkey] &&
|
||||
this.accountMuteListEventMap[pubkey].created_at > muteListEvent.created_at
|
||||
) {
|
||||
return false
|
||||
}
|
||||
this.accountMuteListEventMap[pubkey] = muteListEvent
|
||||
window.localStorage.setItem(
|
||||
StorageKey.ACCOUNT_MUTE_LIST_EVENT_MAP,
|
||||
JSON.stringify(this.accountMuteListEventMap)
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
getAccountProfileEvent(pubkey: string) {
|
||||
return this.accountProfileEventMap[pubkey]
|
||||
}
|
||||
|
||||
@@ -62,11 +62,17 @@ export type TDraftEvent = Pick<Event, 'content' | 'created_at' | 'kind' | 'tags'
|
||||
export type TNip07 = {
|
||||
getPublicKey: () => Promise<string | null>
|
||||
signEvent: (draftEvent: TDraftEvent) => Promise<Event | null>
|
||||
nip04?: {
|
||||
encrypt?: (pubkey: string, plainText: string) => Promise<string>
|
||||
decrypt?: (pubkey: string, cipherText: string) => Promise<string>
|
||||
}
|
||||
}
|
||||
|
||||
export interface ISigner {
|
||||
getPublicKey: () => Promise<string | null>
|
||||
signEvent: (draftEvent: TDraftEvent) => Promise<Event | null>
|
||||
nip04Encrypt: (pubkey: string, plainText: string) => Promise<string>
|
||||
nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
|
||||
}
|
||||
|
||||
export type TSignerType = 'nsec' | 'nip-07' | 'bunker' | 'browser-nsec' | 'ncryptsec'
|
||||
|
||||
Reference in New Issue
Block a user