diff --git a/src/App.tsx b/src/App.tsx index 7ad7c820..141ca07c 100644 --- a/src/App.tsx +++ b/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 { - - - - - - + + + + + + + + diff --git a/src/components/MuteButton/index.tsx b/src/components/MuteButton/index.tsx new file mode 100644 index 00000000..b433b475 --- /dev/null +++ b/src/components/MuteButton/index.tsx @@ -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 ? ( + + ) : ( + + ) +} diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index a83035e4..31f78e33 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -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(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 (
@@ -169,8 +180,7 @@ export default function NoteList({ pullingContent="" >
- {newEvents.filter((event) => listMode !== 'posts' || !isReplyNoteEvent(event)).length > - 0 && ( + {newEvents.filter(eventFilter).length > 0 && (
+ + + navigator.clipboard.writeText('nostr:' + pubkeyToNpub(pubkey))} + > + + {t('copy embedded code')} + + {mutePubkeys.includes(pubkey) ? ( + unmutePubkey(pubkey)} + className="text-destructive focus:text-destructive" + > + + {t('unmute user')} + + ) : ( + mutePubkey(pubkey)} + className="text-destructive focus:text-destructive" + > + + {t('mute user')} + + )} + + + ) +} diff --git a/src/constants.ts b/src/constants.ts index b6e7305d..b42fbda9 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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' } diff --git a/src/hooks/useFetchFollowings.tsx b/src/hooks/useFetchFollowings.tsx index a3fc0be7..cbdc72eb 100644 --- a/src/hooks/useFetchFollowings.tsx +++ b/src/hooks/useFetchFollowings.tsx @@ -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) } diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx index 7c5fec1e..ba69e1eb 100644 --- a/src/hooks/useFetchProfile.tsx +++ b/src/hooks/useFetchProfile.tsx @@ -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(null) const [profile, setProfile] = useState(null) - const pubkey = useMemo(() => (id ? userIdToPubkey(id) : undefined), [id]) + const [pubkey, setPubkey] = useState(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) diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 99532dab..3480c63e 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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' } } diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index d191ebcf..387f51bb 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -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': '取消屏蔽用户' } } diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index a7198ce9..2a334cd3 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -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, diff --git a/src/lib/event.ts b/src/lib/event.ts index 60e859d4..1e386181 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -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] +} diff --git a/src/lib/link.ts b/src/lib/link.ts index 130710b7..398e6f04 100644 --- a/src/lib/link.ts +++ b/src/lib/link.ts @@ -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}` diff --git a/src/lib/tag.ts b/src/lib/tag.ts index 0c5d80e7..81339d8f 100644 --- a/src/lib/tag.ts +++ b/src/lib/tag.ts @@ -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 +} diff --git a/src/pages/secondary/MuteListPage/index.tsx b/src/pages/secondary/MuteListPage/index.tsx new file mode 100644 index 00000000..bb652ce0 --- /dev/null +++ b/src/pages/secondary/MuteListPage/index.tsx @@ -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([]) + const bottomRef = useRef(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 + } + + return ( + +
+ {visibleMutePubkeys.map((pubkey, index) => ( + + ))} + {mutePubkeys.length > visibleMutePubkeys.length &&
} +
+ + ) +} + +function UserItem({ pubkey }: { pubkey: string }) { + const { profile } = useFetchProfile(pubkey) + const { nip05, about } = profile || {} + + return ( +
+ +
+ + +
{about}
+
+ +
+ ) +} diff --git a/src/pages/secondary/ProfilePage/index.tsx b/src/pages/secondary/ProfilePage/index.tsx index ec1aeea8..ee321d09 100644 --- a/src/pages/secondary/ProfilePage/index.tsx +++ b/src/pages/secondary/ProfilePage/index.tsx @@ -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 ) : ( )} +
{username}
@@ -127,11 +132,22 @@ export default function ProfilePage({ id, index }: { id?: string; index?: number {relayList.originalRelays.length}
{t('Relays')}
+ {isSelf && ( + + {mutePubkeys.length} +
{t('Muted')}
+
+ )}
{!isFetchingRelayInfo && ( - + )} ) diff --git a/src/providers/FollowListProvider.tsx b/src/providers/FollowListProvider.tsx index 8625d1d8..00500e69 100644 --- a/src/providers/FollowListProvider.tsx +++ b/src/providers/FollowListProvider.tsx @@ -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(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) } diff --git a/src/providers/MuteListProvider.tsx b/src/providers/MuteListProvider.tsx new file mode 100644 index 00000000..fb3e3015 --- /dev/null +++ b/src/providers/MuteListProvider.tsx @@ -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 + unmutePubkey: (pubkey: string) => Promise +} + +const MuteListContext = createContext(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(undefined) + const [tags, setTags] = useState([]) + 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 ( + + {children} + + ) +} diff --git a/src/providers/NostrProvider/bunker.signer.ts b/src/providers/NostrProvider/bunker.signer.ts index 08859502..baa25529 100644 --- a/src/providers/NostrProvider/bunker.signer.ts +++ b/src/providers/NostrProvider/bunker.signer.ts @@ -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) } diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 67bb39c2..abe07063 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -37,6 +37,8 @@ type TNostrContext = { publish: (draftEvent: TDraftEvent, additionalRelayUrls?: string[]) => Promise signHttpAuth: (url: string, method: string) => Promise signEvent: (draftEvent: TDraftEvent) => Promise + nip04Encrypt: (pubkey: string, plainText: string) => Promise + nip04Decrypt: (pubkey: string, cipherText: string) => Promise checkLogin: (cb?: () => T) => Promise getRelayList: (pubkey: string) => Promise 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 (cb?: () => T): Promise => { if (signer) { return cb && cb() @@ -349,6 +359,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { removeAccount, publish, signHttpAuth, + nip04Encrypt, + nip04Decrypt, checkLogin, signEvent, getRelayList, diff --git a/src/providers/NostrProvider/nip-07.signer.ts b/src/providers/NostrProvider/nip-07.signer.ts index 34506465..c8c5d1ac 100644 --- a/src/providers/NostrProvider/nip-07.signer.ts +++ b/src/providers/NostrProvider/nip-07.signer.ts @@ -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) + } } diff --git a/src/providers/NostrProvider/nsec.signer.ts b/src/providers/NostrProvider/nsec.signer.ts index 519c3e4e..54cd1449 100644 --- a/src/providers/NostrProvider/nsec.signer.ts +++ b/src/providers/NostrProvider/nsec.signer.ts @@ -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) + } } diff --git a/src/routes.tsx b/src/routes.tsx index 208ad105..ced334aa 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -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: }, { path: '/settings', element: }, { path: '/profile-editor', element: }, - { path: '/relays/:url', element: } + { path: '/relays/:url', element: }, + { path: '/mutes', element: } ] export const routes = ROUTES.map(({ path, element }) => ({ diff --git a/src/services/client.service.ts b/src/services/client.service.ts index cbd9dabb..9d4befdb 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -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) { diff --git a/src/services/storage.service.ts b/src/services/storage.service.ts index a168b59b..032b07ac 100644 --- a/src/services/storage.service.ts +++ b/src/services/storage.service.ts @@ -28,6 +28,7 @@ class StorageService { private currentAccount: TAccount | null = null private accountRelayListEventMap: Record = {} // pubkey -> relayListEvent private accountFollowListEventMap: Record = {} // pubkey -> followListEvent + private accountMuteListEventMap: Record = {} // pubkey -> muteListEvent private accountProfileEventMap: Record = {} // 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] } diff --git a/src/types.ts b/src/types.ts index bde67dfb..5ea9006c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -62,11 +62,17 @@ export type TDraftEvent = Pick Promise signEvent: (draftEvent: TDraftEvent) => Promise + nip04?: { + encrypt?: (pubkey: string, plainText: string) => Promise + decrypt?: (pubkey: string, cipherText: string) => Promise + } } export interface ISigner { getPublicKey: () => Promise signEvent: (draftEvent: TDraftEvent) => Promise + nip04Encrypt: (pubkey: string, plainText: string) => Promise + nip04Decrypt: (pubkey: string, cipherText: string) => Promise } export type TSignerType = 'nsec' | 'nip-07' | 'bunker' | 'browser-nsec' | 'ncryptsec'