feat: mute

This commit is contained in:
codytseng
2025-01-19 14:40:05 +08:00
parent 34ff0cd314
commit cbae26e492
26 changed files with 564 additions and 45 deletions

View File

@@ -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>

View 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>
)
}

View File

@@ -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>

View File

@@ -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

View 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>
)
}

View File

@@ -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'
} }

View File

@@ -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)
} }

View File

@@ -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)

View File

@@ -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'
} }
} }

View File

@@ -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': '取消屏蔽用户'
} }
} }

View File

@@ -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,

View File

@@ -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]
}

View File

@@ -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}`

View File

@@ -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
}

View 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>
)
}

View File

@@ -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>
) )

View File

@@ -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)
} }

View 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>
)
}

View File

@@ -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)
} }

View File

@@ -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,

View File

@@ -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)
}
} }

View File

@@ -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)
}
} }

View File

@@ -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 }) => ({

View File

@@ -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) {

View File

@@ -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]
} }

View File

@@ -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'