From 0f8a5403cd46d64610ac6b0e40b8c039b7fd51c3 Mon Sep 17 00:00:00 2001 From: codytseng Date: Mon, 13 Jan 2025 16:53:07 +0800 Subject: [PATCH] feat: add mailbox configuration --- .../MailboxSetting/MailboxRelay.tsx | 62 +++++++++++ .../MailboxSetting/NewMailboxRelayInput.tsx | 52 +++++++++ src/components/MailboxSetting/SaveButton.tsx | 52 +++++++++ src/components/MailboxSetting/index.tsx | 82 ++++++++++++++ src/components/MailboxSetting/types.ts | 5 + src/components/NotificationList/index.tsx | 11 +- .../RelaySetsSetting/PullFromRelaysButton.tsx | 19 ++-- .../RelaySetsSetting/PushToRelaysButton.tsx | 8 +- src/components/RelaySetsSetting/index.tsx | 1 + src/components/Sidebar/AccountButton.tsx | 4 +- src/constants.ts | 3 + src/hooks/useFetchRelayList.tsx | 2 +- src/i18n/en.ts | 22 +++- src/i18n/zh.ts | 22 +++- .../secondary/RelaySettingsPage/index.tsx | 17 ++- src/providers/FeedProvider.tsx | 40 +++---- src/providers/FollowListProvider.tsx | 4 +- src/providers/NostrProvider/index.tsx | 103 ++++++++++++++++-- src/services/client.service.ts | 8 +- src/services/storage.service.ts | 70 +++++++++++- 20 files changed, 525 insertions(+), 62 deletions(-) create mode 100644 src/components/MailboxSetting/MailboxRelay.tsx create mode 100644 src/components/MailboxSetting/NewMailboxRelayInput.tsx create mode 100644 src/components/MailboxSetting/SaveButton.tsx create mode 100644 src/components/MailboxSetting/index.tsx create mode 100644 src/components/MailboxSetting/types.ts diff --git a/src/components/MailboxSetting/MailboxRelay.tsx b/src/components/MailboxSetting/MailboxRelay.tsx new file mode 100644 index 0000000..07063d7 --- /dev/null +++ b/src/components/MailboxSetting/MailboxRelay.tsx @@ -0,0 +1,62 @@ +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { CircleX, Server } from 'lucide-react' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { TMailboxRelay, TMailboxRelayScope } from './types' + +export default function MailboxRelay({ + mailboxRelay, + changeMailboxRelayScope, + removeMailboxRelay +}: { + mailboxRelay: TMailboxRelay + changeMailboxRelayScope: (url: string, scope: TMailboxRelayScope) => void + removeMailboxRelay: (url: string) => void +}) { + const { t } = useTranslation() + const relayIcon = useMemo(() => { + const url = new URL(mailboxRelay.url) + return `${url.protocol === 'wss:' ? 'https:' : 'http:'}//${url.host}/favicon.ico` + }, [mailboxRelay.url]) + + return ( +
+
+ + + + + + +
{mailboxRelay.url}
+
+
+ + removeMailboxRelay(mailboxRelay.url)} + className="text-muted-foreground hover:text-destructive clickable" + /> +
+
+ ) +} diff --git a/src/components/MailboxSetting/NewMailboxRelayInput.tsx b/src/components/MailboxSetting/NewMailboxRelayInput.tsx new file mode 100644 index 0000000..64ee56e --- /dev/null +++ b/src/components/MailboxSetting/NewMailboxRelayInput.tsx @@ -0,0 +1,52 @@ +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +export default function NewMailboxRelayInput({ + saveNewMailboxRelay +}: { + saveNewMailboxRelay: (url: string) => string | null +}) { + const { t } = useTranslation() + const [newRelayUrl, setNewRelayUrl] = useState('') + const [newRelayUrlError, setNewRelayUrlError] = useState(null) + + const save = () => { + const error = saveNewMailboxRelay(newRelayUrl) + if (error) { + setNewRelayUrlError(error) + } else { + setNewRelayUrl('') + } + } + + const handleRelayUrlInputKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault() + save() + } + } + + const handleRelayUrlInputChange = (e: React.ChangeEvent) => { + setNewRelayUrl(e.target.value) + setNewRelayUrlError(null) + } + + return ( +
+
+ + +
+ {newRelayUrlError &&
{newRelayUrlError}
} +
+ ) +} diff --git a/src/components/MailboxSetting/SaveButton.tsx b/src/components/MailboxSetting/SaveButton.tsx new file mode 100644 index 0000000..eab77a0 --- /dev/null +++ b/src/components/MailboxSetting/SaveButton.tsx @@ -0,0 +1,52 @@ +import { useToast } from '@/hooks' +import { useNostr } from '@/providers/NostrProvider' +import dayjs from 'dayjs' +import { CloudUpload, Loader } from 'lucide-react' +import { kinds } from 'nostr-tools' +import { useState } from 'react' +import { Button } from '../ui/button' +import { TMailboxRelay } from './types' + +export default function SaveButton({ + mailboxRelays, + hasChange, + setHasChange +}: { + mailboxRelays: TMailboxRelay[] + hasChange: boolean + setHasChange: (hasChange: boolean) => void +}) { + const { toast } = useToast() + const { pubkey, publish, updateRelayList } = useNostr() + const [pushing, setPushing] = useState(false) + + const save = async () => { + setPushing(true) + const event = { + kind: kinds.RelayList, + content: '', + tags: mailboxRelays.map(({ url, scope }) => + scope === 'both' ? ['r', url] : ['r', url, scope] + ), + created_at: dayjs().unix() + } + await publish(event) + updateRelayList({ + write: mailboxRelays.filter(({ scope }) => scope !== 'read').map(({ url }) => url), + read: mailboxRelays.filter(({ scope }) => scope !== 'write').map(({ url }) => url) + }) + toast({ + title: 'Save Successful', + description: 'Successfully saved mailbox relays' + }) + setHasChange(false) + setPushing(false) + } + + return ( + + ) +} diff --git a/src/components/MailboxSetting/index.tsx b/src/components/MailboxSetting/index.tsx new file mode 100644 index 0000000..6145f1a --- /dev/null +++ b/src/components/MailboxSetting/index.tsx @@ -0,0 +1,82 @@ +import { Button } from '@/components/ui/button' +import { normalizeUrl } from '@/lib/url' +import { useNostr } from '@/providers/NostrProvider' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import MailboxRelay from './MailboxRelay' +import NewMailboxRelayInput from './NewMailboxRelayInput' +import SaveButton from './SaveButton' +import { TMailboxRelay, TMailboxRelayScope } from './types' + +export default function MailboxSetting() { + const { t } = useTranslation() + const { pubkey, relayList } = useNostr() + const [relays, setRelays] = useState([]) + const [hasChange, setHasChange] = useState(false) + + useEffect(() => { + if (!relayList) return + + const mailboxRelays: TMailboxRelay[] = relayList.read.map((url) => ({ url, scope: 'read' })) + relayList.write.forEach((url) => { + const item = mailboxRelays.find((r) => r.url === url) + if (item) { + item.scope = 'both' + } else { + mailboxRelays.push({ url, scope: 'write' }) + } + }) + setRelays(mailboxRelays) + }, [relayList]) + + if (!pubkey) { + return + } + + if (!relayList) { + return
{t('loading...')}
+ } + + const changeMailboxRelayScope = (url: string, scope: TMailboxRelayScope) => { + setRelays((prev) => prev.map((r) => (r.url === url ? { ...r, scope } : r))) + setHasChange(true) + } + + const removeMailboxRelay = (url: string) => { + setRelays((prev) => prev.filter((r) => r.url !== url)) + setHasChange(true) + } + + const saveNewMailboxRelay = (url: string) => { + if (url === '') return null + const normalizedUrl = normalizeUrl(url) + if (relays.some((r) => r.url === normalizedUrl)) { + return t('Relay already exists') + } + setRelays([...relays, { url: normalizedUrl, scope: 'both' }]) + setHasChange(true) + return null + } + + return ( +
+
+
{t('read relays description')}
+
{t('write relays description')}
+
{t('read & write relays notice')}
+
+ +
+ {relays.map((relay) => ( + + ))} +
+ +
+ ) +} diff --git a/src/components/MailboxSetting/types.ts b/src/components/MailboxSetting/types.ts new file mode 100644 index 0000000..31a11cf --- /dev/null +++ b/src/components/MailboxSetting/types.ts @@ -0,0 +1,5 @@ +export type TMailboxRelayScope = 'read' | 'write' | 'both' +export type TMailboxRelay = { + url: string + scope: TMailboxRelayScope +} diff --git a/src/components/NotificationList/index.tsx b/src/components/NotificationList/index.tsx index bd03e30..641f3a6 100644 --- a/src/components/NotificationList/index.tsx +++ b/src/components/NotificationList/index.tsx @@ -1,5 +1,6 @@ import { COMMENT_EVENT_KIND, PICTURE_EVENT_KIND } from '@/constants' import { useFetchEvent } from '@/hooks' +import { extractEmbeddedNotesFromContent, extractImagesFromContent } from '@/lib/event' import { toNote } from '@/lib/link' import { tagNameEquals } from '@/lib/tag' import { useSecondaryPage } from '@/PageManager' @@ -11,16 +12,15 @@ import { Event, kinds, nip19, validateEvent } from 'nostr-tools' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import PullToRefresh from 'react-simple-pull-to-refresh' +import { embedded, embeddedNostrNpubRenderer, embeddedNostrProfileRenderer } from '../Embedded' import { FormattedTimestamp } from '../FormattedTimestamp' import UserAvatar from '../UserAvatar' -import { embedded, embeddedNostrNpubRenderer, embeddedNostrProfileRenderer } from '../Embedded' -import { extractEmbeddedNotesFromContent, extractImagesFromContent } from '@/lib/event' const LIMIT = 100 export default function NotificationList() { const { t } = useTranslation() - const { pubkey } = useNostr() + const { pubkey, relayList } = useNostr() const [refreshCount, setRefreshCount] = useState(0) const [timelineKey, setTimelineKey] = useState(undefined) const [refreshing, setRefreshing] = useState(true) @@ -29,14 +29,13 @@ export default function NotificationList() { const bottomRef = useRef(null) useEffect(() => { - if (!pubkey) { + if (!pubkey || !relayList) { setUntil(undefined) return } const init = async () => { setRefreshing(true) - const relayList = await client.fetchRelayList(pubkey) let eventCount = 0 const { closer, timelineKey } = await client.subscribeTimeline( relayList.read.length >= 4 @@ -71,7 +70,7 @@ export default function NotificationList() { return () => { promise.then((closer) => closer?.()) } - }, [pubkey, refreshCount]) + }, [pubkey, refreshCount, relayList]) useEffect(() => { if (refreshing) return diff --git a/src/components/RelaySetsSetting/PullFromRelaysButton.tsx b/src/components/RelaySetsSetting/PullFromRelaysButton.tsx index 2763658..365fbdd 100644 --- a/src/components/RelaySetsSetting/PullFromRelaysButton.tsx +++ b/src/components/RelaySetsSetting/PullFromRelaysButton.tsx @@ -26,9 +26,11 @@ import { TRelaySet } from '@/types' import { CloudDownload } from 'lucide-react' import { kinds } from 'nostr-tools' import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' import RelaySetCard from '../RelaySetCard' export default function PullFromRelaysButton() { + const { t } = useTranslation() const { pubkey } = useNostr() const { isSmallScreen } = useScreenSize() const [open, setOpen] = useState(false) @@ -36,7 +38,7 @@ export default function PullFromRelaysButton() { const trigger = ( ) @@ -47,7 +49,7 @@ export default function PullFromRelaysButton() {
- Select the relay sets you want to pull + {t('Select the relay sets you want to pull')} setOpen(false)} /> @@ -62,7 +64,7 @@ export default function PullFromRelaysButton() { {trigger} - Select the relay sets you want to pull + {t('Select the relay sets you want to pull')} setOpen(false)} /> @@ -72,6 +74,7 @@ export default function PullFromRelaysButton() { } function RemoteRelaySets({ close }: { close?: () => void }) { + const { t } = useTranslation() const { pubkey, relayList } = useNostr() const { mergeRelaySets } = useRelaySets() const [initialed, setInitialed] = useState(false) @@ -117,9 +120,9 @@ function RemoteRelaySets({ close }: { close?: () => void }) { }, [pubkey]) if (!pubkey) return null - if (!initialed) return
Loading...
+ if (!initialed) return
{t('loading...')}
if (!relaySets.length) { - return
No relay sets found
+ return
{t('No relay sets found')}
} return ( @@ -146,7 +149,7 @@ function RemoteRelaySets({ close }: { close?: () => void }) { variant="secondary" onClick={() => setSelectedRelaySetIds(relaySets.map((r) => r.id))} > - All + {t('Select all')}
diff --git a/src/components/RelaySetsSetting/PushToRelaysButton.tsx b/src/components/RelaySetsSetting/PushToRelaysButton.tsx index c83e4ca..c24dbaa 100644 --- a/src/components/RelaySetsSetting/PushToRelaysButton.tsx +++ b/src/components/RelaySetsSetting/PushToRelaysButton.tsx @@ -5,9 +5,11 @@ import { useNostr } from '@/providers/NostrProvider' import { useRelaySets } from '@/providers/RelaySetsProvider' import { CloudUpload, Loader } from 'lucide-react' import { useState } from 'react' +import { useTranslation } from 'react-i18next' import { useRelaySetsSettingComponent } from './provider' export default function PushToRelaysButton() { + const { t } = useTranslation() const { toast } = useToast() const { pubkey, publish } = useNostr() const { relaySets } = useRelaySets() @@ -22,8 +24,8 @@ export default function PushToRelaysButton() { const draftEvents = selectedRelaySets.map((relaySet) => createRelaySetDraftEvent(relaySet)) await Promise.allSettled(draftEvents.map((event) => publish(event))) toast({ - title: 'Push Successful', - description: 'Successfully pushed relay sets to relays' + title: t('Push Successful'), + description: t('Successfully pushed relay sets to relays') }) setPushing(false) } @@ -36,7 +38,7 @@ export default function PushToRelaysButton() { onClick={push} > - Push to relays + {t('Push to relays')} {pushing && } ) diff --git a/src/components/RelaySetsSetting/index.tsx b/src/components/RelaySetsSetting/index.tsx index 5b9b2ea..793ab80 100644 --- a/src/components/RelaySetsSetting/index.tsx +++ b/src/components/RelaySetsSetting/index.tsx @@ -25,6 +25,7 @@ export default function RelaySetsSetting() { const saveRelaySet = () => { if (!newRelaySetName) return addRelaySet(newRelaySetName) + setNewRelaySetName('') } const handleNewRelaySetNameChange = (e: React.ChangeEvent) => { diff --git a/src/components/Sidebar/AccountButton.tsx b/src/components/Sidebar/AccountButton.tsx index b2d63cc..22fe8b6 100644 --- a/src/components/Sidebar/AccountButton.tsx +++ b/src/components/Sidebar/AccountButton.tsx @@ -6,7 +6,6 @@ import { DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import { useFetchProfile } from '@/hooks' import { toProfile } from '@/lib/link' import { formatPubkey, generateImageByPubkey } from '@/lib/pubkey' import { useSecondaryPage } from '@/PageManager' @@ -30,9 +29,8 @@ export default function AccountButton() { function ProfileButton() { const { t } = useTranslation() - const { account } = useNostr() + const { account, profile } = useNostr() const pubkey = account?.pubkey - const { profile } = useFetchProfile(pubkey) const { push } = useSecondaryPage() const [loginDialogOpen, setLoginDialogOpen] = useState(false) const [logoutDialogOpen, setLogoutDialogOpen] = useState(false) diff --git a/src/constants.ts b/src/constants.ts index 9b36036..408e819 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -5,6 +5,9 @@ export const StorageKey = { FEED_TYPE: 'feedType', ACCOUNTS: 'accounts', CURRENT_ACCOUNT: 'currentAccount', + ACCOUNT_RELAY_LIST_MAP: 'accountRelayListMap', + ACCOUNT_FOLLOWINGS_MAP: 'accountFollowingsMap', + ACCOUNT_PROFILE_MAP: 'accountProfileMap', ADD_CLIENT_TAG: 'addClientTag' } diff --git a/src/hooks/useFetchRelayList.tsx b/src/hooks/useFetchRelayList.tsx index 1de44f6..6c6f12e 100644 --- a/src/hooks/useFetchRelayList.tsx +++ b/src/hooks/useFetchRelayList.tsx @@ -1,6 +1,6 @@ +import client from '@/services/client.service' import { TRelayList } from '@/types' import { useEffect, useState } from 'react' -import client from '@/services/client.service' export function useFetchRelayList(pubkey?: string | null) { const [relayList, setRelayList] = useState({ write: [], read: [] }) diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 56bffa9..ae3605e 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -107,6 +107,26 @@ export default { Relays: 'Relays', image: 'image', 'Normal Note': 'Normal Note', - 'Picture Note': 'Picture Note' + 'Picture Note': 'Picture Note', + 'R & W': 'R & W', + Read: 'Read', + Write: 'Write', + 'Push to relays': 'Push to relays', + 'Push Successful': 'Push Successful', + 'Successfully pushed relay sets to relays': 'Successfully pushed relay sets to relays', + 'Pull from relays': 'Pull from relays', + 'Select the relay sets you want to pull': 'Select the relay sets you want to pull', + 'No relay sets found': 'No relay sets found', + 'Pull n relay sets': 'Pull {{n}} relay sets', + Pull: 'Pull', + 'Select all': 'Select all', + 'Relay Sets': 'Relay Sets', + 'Read & Write Relays': 'Read & Write Relays', + 'read relays description': + 'Read relays are used to seek events about you. Other users will publish the events they want you to see to your read relays.', + 'write relays description': + 'Write relays are used to publish your events. Other users will seek your events from your write relays.', + 'read & write relays notice': + 'The number of read and write servers should ideally be kept between 2 and 4.' } } diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index ef00b74..4208976 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -107,6 +107,26 @@ export default { image: '图片', Normal: '普通', 'Normal Note': '普通笔记', - 'Picture Note': '图片笔记' + 'Picture Note': '图片笔记', + 'R & W': '读写', + Read: '只读', + Write: '只写', + 'Push to relays': '保存到服务器', + 'Push Successful': '保存成功', + 'Successfully pushed relay sets to relays': '成功保存到服务器', + 'Pull from relays': '从服务器拉取', + 'Select the relay sets you want to pull': '选择要拉取的服务器组', + 'No relay sets found': '未找到服务器组', + 'Pull n relay sets': '拉取 {{n}} 个服务器组', + Pull: '拉取', + 'Select all': '全选', + 'Relay Sets': '服务器组', + Mailbox: '邮箱', + 'Read & Write Relays': '读写服务器', + 'read relays description': + '读服务器用于寻找与您有关的事件。其他用户会将想要你看到的事件发布到您的读服务器。', + 'write relays description': + '写服务器用于发布您的事件。其他用户会从您的写服务器寻找您发布的事件。', + 'read & write relays notice': '读服务器和写服务器的数量都应尽量保持在 2 到 4 个之间。' } } diff --git a/src/pages/secondary/RelaySettingsPage/index.tsx b/src/pages/secondary/RelaySettingsPage/index.tsx index 3d35eb3..51c80c0 100644 --- a/src/pages/secondary/RelaySettingsPage/index.tsx +++ b/src/pages/secondary/RelaySettingsPage/index.tsx @@ -1,4 +1,6 @@ +import MailboxSetting from '@/components/MailboxSetting' import RelaySetsSetting from '@/components/RelaySetsSetting' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { useTranslation } from 'react-i18next' @@ -7,9 +9,18 @@ export default function RelaySettingsPage({ index }: { index?: number }) { return ( -
- -
+ + + {t('Relay Sets')} + {t('Read & Write Relays')} + + + + + + + +
) } diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx index 04f0f5e..552d1e9 100644 --- a/src/providers/FeedProvider.tsx +++ b/src/providers/FeedProvider.tsx @@ -1,12 +1,11 @@ +import { BIG_RELAY_URLS } from '@/constants' import { isWebsocketUrl, normalizeUrl } from '@/lib/url' -import client from '@/services/client.service' import storage from '@/services/storage.service' import { TFeedType } from '@/types' import { Filter } from 'nostr-tools' import { createContext, useContext, useEffect, useState } from 'react' import { useNostr } from './NostrProvider' import { useRelaySets } from './RelaySetsProvider' -import { BIG_RELAY_URLS } from '@/constants' type TFeedContext = { feedType: TFeedType @@ -29,7 +28,7 @@ export const useFeed = () => { } export function FeedProvider({ children }: { children: React.ReactNode }) { - const { pubkey } = useNostr() + const { pubkey, getRelayList, getFollowings } = useNostr() const { relaySets } = useRelaySets() const [feedType, setFeedType] = useState(storage.getFeedType()) const [relayUrls, setRelayUrls] = useState([]) @@ -46,11 +45,8 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { const searchParams = new URLSearchParams(window.location.search) const temporaryRelayUrls = searchParams .getAll('r') - .map((url) => - !url.startsWith('ws://') && !url.startsWith('wss://') ? `wss://${url}` : url - ) - .filter((url) => isWebsocketUrl(url)) .map((url) => normalizeUrl(url)) + .filter((url) => isWebsocketUrl(url)) if (temporaryRelayUrls.length) { return await switchFeed('temporary', { temporaryRelayUrls }) } @@ -65,10 +61,10 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { }, []) useEffect(() => { - if (feedType !== 'following') return + if (!isReady || feedType !== 'following') return switchFeed('following') - }, [pubkey]) + }, [pubkey, feedType, isReady]) useEffect(() => { if (feedType !== 'relays') return @@ -86,7 +82,9 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { setIsReady(false) if (feedType === 'relays') { const relaySetId = options.activeRelaySetId ?? (relaySets.length > 0 ? relaySets[0].id : null) - if (!relaySetId) return + if (!relaySetId) { + return setIsReady(true) + } const relaySet = relaySets.find((set) => set.id === options.activeRelaySetId) ?? @@ -96,37 +94,35 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { setRelayUrls(relaySet.relayUrls) setActiveRelaySetId(relaySet.id) setFilter({}) - setIsReady(true) storage.setActiveRelaySetId(relaySet.id) storage.setFeedType(feedType) } - return + return setIsReady(true) } if (feedType === 'following') { - if (!pubkey) return + if (!pubkey) { + return setIsReady(true) + } setFeedType(feedType) setActiveRelaySetId(null) - const [relayList, followings] = await Promise.all([ - client.fetchRelayList(pubkey), - client.fetchFollowings(pubkey) - ]) + const [relayList, followings] = await Promise.all([getRelayList(), getFollowings()]) setRelayUrls(relayList.read.concat(BIG_RELAY_URLS).slice(0, 4)) setFilter({ authors: followings.includes(pubkey) ? followings : [...followings, pubkey] }) - setIsReady(true) storage.setFeedType(feedType) - return + return setIsReady(true) } if (feedType === 'temporary') { const urls = options.temporaryRelayUrls ?? temporaryRelayUrls - if (!urls.length) return + if (!urls.length) { + return setIsReady(true) + } setFeedType(feedType) setTemporaryRelayUrls(urls) setRelayUrls(urls) setActiveRelaySetId(null) setFilter({}) - setIsReady(true) - return + return setIsReady(true) } setIsReady(true) } diff --git a/src/providers/FollowListProvider.tsx b/src/providers/FollowListProvider.tsx index 94f5eb7..eed4357 100644 --- a/src/providers/FollowListProvider.tsx +++ b/src/providers/FollowListProvider.tsx @@ -25,7 +25,7 @@ export const useFollowList = () => { } export function FollowListProvider({ children }: { children: React.ReactNode }) { - const { pubkey: accountPubkey, publish } = useNostr() + const { pubkey: accountPubkey, publish, updateFollowings } = useNostr() const [followListEvent, setFollowListEvent] = useState(undefined) const [isFetching, setIsFetching] = useState(true) const followings = useMemo(() => { @@ -65,6 +65,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode }) } const newFollowListEvent = await publish(newFollowListDraftEvent) client.updateFollowListCache(accountPubkey, newFollowListEvent) + updateFollowings([...followings, pubkey]) setFollowListEvent(newFollowListEvent) } @@ -81,6 +82,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode }) } const newFollowListEvent = await publish(newFollowListDraftEvent) client.updateFollowListCache(accountPubkey, newFollowListEvent) + updateFollowings(followings.filter((followPubkey) => followPubkey !== pubkey)) setFollowListEvent(newFollowListEvent) } diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 9226c73..9630013 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -1,9 +1,9 @@ import LoginDialog from '@/components/LoginDialog' -import { useFetchFollowings, useToast } from '@/hooks' -import { useFetchRelayList } from '@/hooks/useFetchRelayList' +import { BIG_RELAY_URLS } from '@/constants' +import { useToast } from '@/hooks' import client from '@/services/client.service' import storage from '@/services/storage.service' -import { ISigner, TAccount, TAccountPointer, TDraftEvent, TRelayList } from '@/types' +import { ISigner, TAccount, TAccountPointer, TDraftEvent, TProfile, TRelayList } from '@/types' import dayjs from 'dayjs' import { Event, kinds } from 'nostr-tools' import { createContext, useContext, useEffect, useState } from 'react' @@ -13,6 +13,7 @@ import { NsecSigner } from './nsec.signer' type TNostrContext = { pubkey: string | null + profile: TProfile | null relayList: TRelayList | null followings: string[] | null account: TAccountPointer | null @@ -29,6 +30,10 @@ type TNostrContext = { signHttpAuth: (url: string, method: string) => Promise signEvent: (draftEvent: TDraftEvent) => Promise checkLogin: (cb?: () => T) => Promise + getRelayList: () => Promise + updateRelayList: (relayList: TRelayList) => void + getFollowings: () => Promise + updateFollowings: (followings: string[]) => void } const NostrContext = createContext(undefined) @@ -46,8 +51,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const [account, setAccount] = useState(null) const [signer, setSigner] = useState(null) const [openLoginDialog, setOpenLoginDialog] = useState(false) - const { relayList, isFetching: isFetchingRelayList } = useFetchRelayList(account?.pubkey) - const { followings, isFetching: isFetchingFollowings } = useFetchFollowings(account?.pubkey) + const [profile, setProfile] = useState(null) + const [relayList, setRelayList] = useState(null) + const [followings, setFollowings] = useState(null) useEffect(() => { const init = async () => { @@ -60,6 +66,40 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { init() }, []) + useEffect(() => { + if (!account) { + setRelayList(null) + return + } + + const storedRelayList = storage.getAccountRelayList(account.pubkey) + if (storedRelayList) { + setRelayList(storedRelayList) + } + const followings = storage.getAccountFollowings(account.pubkey) + if (followings) { + setFollowings(followings) + } + const profile = storage.getAccountProfile(account.pubkey) + if (profile) { + setProfile(profile) + } + client.fetchRelayList(account.pubkey).then((relayList) => { + setRelayList(relayList) + storage.setAccountRelayList(account.pubkey, relayList) + }) + client.fetchFollowings(account.pubkey).then((followings) => { + setFollowings(followings) + storage.setAccountFollowings(account.pubkey, followings) + }) + client.fetchProfile(account.pubkey).then((profile) => { + if (profile) { + setProfile(profile) + storage.setAccountProfile(account.pubkey, profile) + } + }) + }, [account]) + const login = (signer: ISigner, act: TAccount) => { storage.addAccount(act) storage.switchAccount(act) @@ -176,7 +216,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const publish = async (draftEvent: TDraftEvent, additionalRelayUrls: string[] = []) => { const event = await signEvent(draftEvent) - await client.publishEvent(relayList.write.concat(additionalRelayUrls), event) + await client.publishEvent((relayList?.write ?? []).concat(additionalRelayUrls), event) return event } @@ -200,12 +240,53 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { return setOpenLoginDialog(true) } + const getRelayList = async () => { + if (!account) { + return { write: BIG_RELAY_URLS, read: BIG_RELAY_URLS } + } + + const storedRelayList = storage.getAccountRelayList(account.pubkey) + if (storedRelayList) { + return storedRelayList + } + return await client.fetchRelayList(account.pubkey) + } + + const updateRelayList = (relayList: TRelayList) => { + if (!account) { + return + } + setRelayList(relayList) + storage.setAccountRelayList(account.pubkey, relayList) + } + + const getFollowings = async () => { + if (!account) { + return [] + } + + const followings = storage.getAccountFollowings(account.pubkey) + if (followings) { + return followings + } + return await client.fetchFollowings(account.pubkey) + } + + const updateFollowings = (followings: string[]) => { + if (!account) { + return + } + setFollowings(followings) + storage.setAccountFollowings(account.pubkey, followings) + } + return ( {children} diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 0b48dcb..d512acf 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -606,7 +606,9 @@ class ClientService extends EventTarget { return pubkeys.map((pubkey) => { const event = eventsMap.get(pubkey) const relayList = { write: [], read: [] } as TRelayList - if (!event) return relayList + if (!event) { + return { write: BIG_RELAY_URLS, read: BIG_RELAY_URLS } + } event.tags.filter(tagNameEquals('r')).forEach(([, url, type]) => { if (!url || !isWebsocketUrl(url)) return @@ -625,8 +627,8 @@ class ClientService extends EventTarget { } }) return { - write: relayList.write.slice(0, 10), - read: relayList.read.slice(0, 10) + write: relayList.write.length ? relayList.write.slice(0, 10) : BIG_RELAY_URLS, + read: relayList.read.length ? relayList.read.slice(0, 10) : BIG_RELAY_URLS } }) } diff --git a/src/services/storage.service.ts b/src/services/storage.service.ts index 6c5b035..bf7c996 100644 --- a/src/services/storage.service.ts +++ b/src/services/storage.service.ts @@ -1,7 +1,15 @@ import { StorageKey } from '@/constants' import { isSameAccount } from '@/lib/account' import { randomString } from '@/lib/random' -import { TAccount, TAccountPointer, TFeedType, TRelaySet, TThemeSetting } from '@/types' +import { + TAccount, + TAccountPointer, + TFeedType, + TProfile, + TRelayList, + TRelaySet, + TThemeSetting +} from '@/types' const DEFAULT_RELAY_SETS: TRelaySet[] = [ { @@ -25,6 +33,9 @@ class StorageService { private themeSetting: TThemeSetting = 'system' private accounts: TAccount[] = [] private currentAccount: TAccount | null = null + private accountRelayListMap: Record = {} // pubkey -> relayList + private accountFollowingsMap: Record = {} // pubkey -> followings + private accountProfileMap: Record = {} // pubkey -> profile constructor() { if (!StorageService.instance) { @@ -43,6 +54,12 @@ class StorageService { this.currentAccount = currentAccountStr ? JSON.parse(currentAccountStr) : null const feedTypeStr = window.localStorage.getItem(StorageKey.FEED_TYPE) this.feedType = feedTypeStr ? JSON.parse(feedTypeStr) : 'relays' + const accountRelayListMapStr = window.localStorage.getItem(StorageKey.ACCOUNT_RELAY_LIST_MAP) + this.accountRelayListMap = accountRelayListMapStr ? JSON.parse(accountRelayListMapStr) : {} + const accountFollowingsMapStr = window.localStorage.getItem(StorageKey.ACCOUNT_FOLLOWINGS_MAP) + this.accountFollowingsMap = accountFollowingsMapStr ? JSON.parse(accountFollowingsMapStr) : {} + const accountProfileMapStr = window.localStorage.getItem(StorageKey.ACCOUNT_PROFILE_MAP) + this.accountProfileMap = accountProfileMapStr ? JSON.parse(accountProfileMapStr) : {} const relaySetsStr = window.localStorage.getItem(StorageKey.RELAY_SETS) if (!relaySetsStr) { @@ -138,7 +155,22 @@ class StorageService { removeAccount(account: TAccount) { this.accounts = this.accounts.filter((act) => !isSameAccount(act, account)) + delete this.accountFollowingsMap[account.pubkey] + delete this.accountRelayListMap[account.pubkey] + delete this.accountProfileMap[account.pubkey] window.localStorage.setItem(StorageKey.ACCOUNTS, JSON.stringify(this.accounts)) + window.localStorage.setItem( + StorageKey.ACCOUNT_FOLLOWINGS_MAP, + JSON.stringify(this.accountFollowingsMap) + ) + window.localStorage.setItem( + StorageKey.ACCOUNT_RELAY_LIST_MAP, + JSON.stringify(this.accountRelayListMap) + ) + window.localStorage.setItem( + StorageKey.ACCOUNT_PROFILE_MAP, + JSON.stringify(this.accountProfileMap) + ) } switchAccount(account: TAccount | null) { @@ -152,6 +184,42 @@ class StorageService { this.currentAccount = act window.localStorage.setItem(StorageKey.CURRENT_ACCOUNT, JSON.stringify(act)) } + + getAccountRelayList(pubkey: string) { + return this.accountRelayListMap[pubkey] + } + + setAccountRelayList(pubkey: string, relayList: TRelayList) { + this.accountRelayListMap[pubkey] = relayList + window.localStorage.setItem( + StorageKey.ACCOUNT_RELAY_LIST_MAP, + JSON.stringify(this.accountRelayListMap) + ) + } + + getAccountFollowings(pubkey: string) { + return this.accountFollowingsMap[pubkey] + } + + setAccountFollowings(pubkey: string, followings: string[]) { + this.accountFollowingsMap[pubkey] = followings + window.localStorage.setItem( + StorageKey.ACCOUNT_FOLLOWINGS_MAP, + JSON.stringify(this.accountFollowingsMap) + ) + } + + getAccountProfile(pubkey: string) { + return this.accountProfileMap[pubkey] + } + + setAccountProfile(pubkey: string, profile: TProfile) { + this.accountProfileMap[pubkey] = profile + window.localStorage.setItem( + StorageKey.ACCOUNT_PROFILE_MAP, + JSON.stringify(this.accountProfileMap) + ) + } } const instance = new StorageService()