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