From bfc07545b33ec1ee1c6715d67a09e0d70fbec864 Mon Sep 17 00:00:00 2001 From: codytseng Date: Sun, 3 Nov 2024 22:43:51 +0800 Subject: [PATCH] refactor --- package-lock.json | 6 + package.json | 1 + src/renderer/src/App.tsx | 13 +- src/renderer/src/PageManager.tsx | 78 +++-- .../components/Embedded/EmbeddedMention.tsx | 6 +- .../src/components/ImageGallery/index.tsx | 2 +- .../src/components/NoteList/index.tsx | 134 ++++---- .../src/components/ProfileCard/index.tsx | 5 +- .../components/RelaySettings/RelayGroup.tsx | 207 +++++------- .../src/components/RelaySettings/RelayUrl.tsx | 28 +- .../src/components/RelaySettings/index.tsx | 97 +----- .../src/components/RelaySettings/provider.tsx | 40 +++ .../src/components/ReplyNoteList/index.tsx | 16 +- .../src/components/UserAvatar/index.tsx | 8 +- src/renderer/src/hooks/useFetchEvent.tsx | 2 +- src/renderer/src/hooks/useFetchProfile.tsx | 94 +++--- .../PrimaryPageLayout/RefreshButton.tsx | 12 + .../ReloadTimelineButton.tsx | 14 - .../src/layouts/PrimaryPageLayout/index.tsx | 4 +- .../SecondaryPageLayout/ThemeToggle.tsx | 2 +- .../src/pages/primary/NoteListPage/index.tsx | 2 +- .../src/pages/secondary/ProfilePage/index.tsx | 37 ++- .../src/providers/RelaySettingsProvider.tsx | 123 +++++++ .../ThemeProvider.tsx} | 0 src/renderer/src/services/client.service.ts | 304 +++++++++--------- .../src/services/event-bus.service.ts | 5 - src/renderer/src/services/storage.service.ts | 24 +- src/renderer/src/types.ts | 9 + 28 files changed, 665 insertions(+), 608 deletions(-) create mode 100644 src/renderer/src/components/RelaySettings/provider.tsx create mode 100644 src/renderer/src/layouts/PrimaryPageLayout/RefreshButton.tsx delete mode 100644 src/renderer/src/layouts/PrimaryPageLayout/ReloadTimelineButton.tsx create mode 100644 src/renderer/src/providers/RelaySettingsProvider.tsx rename src/renderer/src/{components/theme-provider.tsx => providers/ThemeProvider.tsx} (100%) diff --git a/package-lock.json b/package-lock.json index 47dc3bb3..933df3e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@radix-ui/react-toast": "^1.2.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "dataloader": "^2.2.2", "dayjs": "^1.11.13", "lru-cache": "^11.0.1", "lucide-react": "^0.453.0", @@ -4361,6 +4362,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dataloader": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.2.tgz", + "integrity": "sha512-8YnDaaf7N3k/q5HnTJVuzSyLETjoZjVmHc4AeKAzOvKHEFQKcn64OKBfzHYtE9zGjctNM7V9I0MfnUVLpi7M5g==" + }, "node_modules/dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", diff --git a/package.json b/package.json index ca7ebfeb..3ae02d77 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@radix-ui/react-toast": "^1.2.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "dataloader": "^2.2.2", "dayjs": "^1.11.13", "lru-cache": "^11.0.1", "lucide-react": "^0.453.0", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d62885b8..be5e29f3 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,13 +1,14 @@ import 'yet-another-react-lightbox/styles.css' import './assets/main.css' -import { ThemeProvider } from '@renderer/components/theme-provider' import { Toaster } from '@renderer/components/ui/toaster' +import { ThemeProvider } from '@renderer/providers/ThemeProvider' import { PageManager } from './PageManager' import NoteListPage from './pages/primary/NoteListPage' import HashtagPage from './pages/secondary/HashtagPage' import NotePage from './pages/secondary/NotePage' import ProfilePage from './pages/secondary/ProfilePage' +import { RelaySettingsProvider } from './providers/RelaySettingsProvider' const routes = [ { pageName: 'note', element: }, @@ -19,10 +20,12 @@ export default function App(): JSX.Element { return (
- - - - + + + + + +
) diff --git a/src/renderer/src/PageManager.tsx b/src/renderer/src/PageManager.tsx index fcc9a2d9..70b606d1 100644 --- a/src/renderer/src/PageManager.tsx +++ b/src/renderer/src/PageManager.tsx @@ -17,6 +17,10 @@ type TPushParams = { props: any } +type TPrimaryPageContext = { + refresh: () => void +} + type TSecondaryPageContext = { push: (params: TPushParams) => void pop: () => void @@ -28,13 +32,24 @@ type TStackItem = { component: React.ReactNode } -const SecondaryPageContext = createContext({ - push: () => {}, - pop: () => {} -}) +const PrimaryPageContext = createContext(undefined) + +const SecondaryPageContext = createContext(undefined) + +export function usePrimaryPage() { + const context = useContext(PrimaryPageContext) + if (!context) { + throw new Error('usePrimaryPage must be used within a PrimaryPageContext.Provider') + } + return context +} export function useSecondaryPage() { - return useContext(SecondaryPageContext) + const context = useContext(SecondaryPageContext) + if (!context) { + throw new Error('usePrimaryPage must be used within a SecondaryPageContext.Provider') + } + return context } export function PageManager({ @@ -46,6 +61,7 @@ export function PageManager({ children: React.ReactNode maxStackSize?: number }) { + const [primaryPageKey, setPrimaryPageKey] = useState(0) const [secondaryStack, setSecondaryStack] = useState([]) const routeMap = routes.reduce((acc, route) => { @@ -63,6 +79,8 @@ export function PageManager({ ) } + const refreshPrimary = () => setPrimaryPageKey((prevKey) => prevKey + 1) + const pushSecondary = ({ pageName, props }: TPushParams) => { if (isCurrentPage(secondaryStack, { pageName, props })) return @@ -81,29 +99,33 @@ export function PageManager({ const popSecondary = () => setSecondaryStack((prevStack) => prevStack.slice(0, -1)) return ( - - - - {children} - - - - {secondaryStack.length ? ( - secondaryStack.map((item, index) => ( -
- {item.component} -
- )) - ) : ( - - )} -
-
-
+ + + + +
+ {children} +
+
+ + + {secondaryStack.length ? ( + secondaryStack.map((item, index) => ( +
+ {item.component} +
+ )) + ) : ( + + )} +
+
+
+
) } diff --git a/src/renderer/src/components/Embedded/EmbeddedMention.tsx b/src/renderer/src/components/Embedded/EmbeddedMention.tsx index 7bc8b87f..e1049bf3 100644 --- a/src/renderer/src/components/Embedded/EmbeddedMention.tsx +++ b/src/renderer/src/components/Embedded/EmbeddedMention.tsx @@ -1,9 +1,5 @@ -import { useFetchProfile } from '@renderer/hooks' import Username from '../Username' export function EmbeddedMention({ userId }: { userId: string }) { - const { pubkey } = useFetchProfile(userId) - if (!pubkey) return null - - return + return } diff --git a/src/renderer/src/components/ImageGallery/index.tsx b/src/renderer/src/components/ImageGallery/index.tsx index 1722ecb6..57df4de3 100644 --- a/src/renderer/src/components/ImageGallery/index.tsx +++ b/src/renderer/src/components/ImageGallery/index.tsx @@ -30,7 +30,7 @@ export default function ImageGallery({ {images.map((src, index) => { return ( handlePhotoClick(e, index)} diff --git a/src/renderer/src/components/NoteList/index.tsx b/src/renderer/src/components/NoteList/index.tsx index d70dd618..e2e6d369 100644 --- a/src/renderer/src/components/NoteList/index.tsx +++ b/src/renderer/src/components/NoteList/index.tsx @@ -1,99 +1,68 @@ +import { Button } from '@renderer/components/ui/button' import { isReplyNoteEvent } from '@renderer/lib/event' import { cn } from '@renderer/lib/utils' +import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider' import client from '@renderer/services/client.service' -import { EVENT_TYPES, eventBus } from '@renderer/services/event-bus.service' import dayjs from 'dayjs' -import { RefreshCcw } from 'lucide-react' import { Event, Filter, kinds } from 'nostr-tools' import { useEffect, useMemo, useRef, useState } from 'react' import NoteCard from '../NoteCard' export default function NoteList({ filter = {}, - className, - isHomeTimeline = false + className }: { filter?: Filter className?: string - isHomeTimeline?: boolean }) { const [events, setEvents] = useState([]) - const [since, setSince] = useState(() => dayjs().unix() + 1) + const [newEvents, setNewEvents] = useState([]) const [until, setUntil] = useState(() => dayjs().unix()) const [hasMore, setHasMore] = useState(true) - const [refreshedAt, setRefreshedAt] = useState(() => dayjs().unix()) - const [refreshing, setRefreshing] = useState(false) + const [initialized, setInitialized] = useState(false) const observer = useRef(null) const bottomRef = useRef(null) - + const { relayUrls } = useRelaySettings() const noteFilter = useMemo(() => { return { kinds: [kinds.ShortTextNote, kinds.Repost], - limit: 50, + limit: 100, ...filter } - }, [filter]) + }, [JSON.stringify(filter)]) useEffect(() => { - if (!isHomeTimeline) return + if (relayUrls.length === 0) return - const handleClearList = () => { - setEvents([]) - setSince(dayjs().unix() + 1) - setUntil(dayjs().unix()) - setHasMore(true) - setRefreshedAt(dayjs().unix()) - setRefreshing(false) - } + setInitialized(false) + setEvents([]) + setNewEvents([]) + setHasMore(true) - eventBus.on(EVENT_TYPES.RELOAD_TIMELINE, handleClearList) + const sub = client.subscribeEvents(relayUrls, noteFilter, { + onEose: (events) => { + const processedEvents = events.filter((e) => !isReplyNoteEvent(e)) + setEvents((pre) => [...pre, ...processedEvents]) + if (events.length > 0) { + setUntil(events[events.length - 1].created_at - 1) + } + setInitialized(true) + }, + onNew: (event) => { + if (!isReplyNoteEvent(event)) { + setNewEvents((oldEvents) => [event, ...oldEvents]) + } + } + }) return () => { - eventBus.remove(EVENT_TYPES.RELOAD_TIMELINE, handleClearList) + sub.close() } - }, []) - - const loadMore = async () => { - const events = await client.fetchEvents([{ ...noteFilter, until }]) - if (events.length === 0) { - setHasMore(false) - return - } - - const sortedEvents = events.sort((a, b) => b.created_at - a.created_at) - const processedEvents = sortedEvents.filter((e) => !isReplyNoteEvent(e)) - if (processedEvents.length > 0) { - setEvents((oldEvents) => [...oldEvents, ...processedEvents]) - } - - setUntil(sortedEvents[sortedEvents.length - 1].created_at - 1) - } - - const refresh = async () => { - const now = dayjs().unix() - setRefreshing(true) - const events = await client.fetchEvents([{ ...noteFilter, until: now, since }]) - - const sortedEvents = events.sort((a, b) => b.created_at - a.created_at) - const processedEvents = sortedEvents.filter((e) => !isReplyNoteEvent(e)) - if (sortedEvents.length >= noteFilter.limit) { - // reset - setEvents(processedEvents) - setUntil(sortedEvents[sortedEvents.length - 1].created_at - 1) - } else if (processedEvents.length > 0) { - // append - setEvents((oldEvents) => [...processedEvents, ...oldEvents]) - } - - if (sortedEvents.length > 0) { - setSince(sortedEvents[0].created_at + 1) - } - - setRefreshedAt(now) - setRefreshing(false) - } + }, [JSON.stringify(relayUrls), JSON.stringify(noteFilter)]) useEffect(() => { + if (!initialized) return + const options = { root: null, rootMargin: '10px', @@ -115,26 +84,39 @@ export default function NoteList({ observer.current.unobserve(bottomRef.current) } } - }, [until]) + }, [initialized]) + + const loadMore = async () => { + const events = await client.fetchEvents({ ...noteFilter, until }) + const sortedEvents = events.sort((a, b) => b.created_at - a.created_at) + if (sortedEvents.length === 0) { + setHasMore(false) + return + } + + const processedEvents = sortedEvents.filter((e) => !isReplyNoteEvent(e)) + if (processedEvents.length > 0) { + setEvents((oldEvents) => [...oldEvents, ...processedEvents]) + } + + setUntil(sortedEvents[sortedEvents.length - 1].created_at - 1) + } + + const showNewEvents = () => { + setEvents((oldEvents) => [...newEvents, ...oldEvents]) + setNewEvents([]) + } return ( <> - {events.length > 0 && ( -
- -
- {refreshing - ? 'refreshing...' - : `last refreshed at ${dayjs(refreshedAt * 1000).format('HH:mm:ss')}`} -
+ {newEvents.length > 0 && ( +
+
)}
{events.map((event, i) => ( - + ))}
diff --git a/src/renderer/src/components/ProfileCard/index.tsx b/src/renderer/src/components/ProfileCard/index.tsx index cba63f5a..3abf14a7 100644 --- a/src/renderer/src/components/ProfileCard/index.tsx +++ b/src/renderer/src/components/ProfileCard/index.tsx @@ -1,12 +1,13 @@ import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar' -import { generateImageByPubkey } from '@renderer/lib/pubkey' import { useFetchProfile } from '@renderer/hooks' +import { generateImageByPubkey } from '@renderer/lib/pubkey' +import { useMemo } from 'react' import Nip05 from '../Nip05' import ProfileAbout from '../ProfileAbout' export default function ProfileCard({ pubkey }: { pubkey: string }) { const { avatar = '', username, nip05, about } = useFetchProfile(pubkey) - const defaultAvatar = generateImageByPubkey(pubkey) + const defaultAvatar = useMemo(() => generateImageByPubkey(pubkey), [pubkey]) return (
diff --git a/src/renderer/src/components/RelaySettings/RelayGroup.tsx b/src/renderer/src/components/RelaySettings/RelayGroup.tsx index 47ef82d6..c9c74880 100644 --- a/src/renderer/src/components/RelaySettings/RelayGroup.tsx +++ b/src/renderer/src/components/RelaySettings/RelayGroup.tsx @@ -6,29 +6,16 @@ import { DropdownMenuTrigger } from '@renderer/components/ui/dropdown-menu' import { Input } from '@renderer/components/ui/input' +import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider' import { Check, ChevronDown, Circle, CircleCheck, EllipsisVertical } from 'lucide-react' import { useState } from 'react' -import { TRelayGroup } from './types' import RelayUrls from './RelayUrl' +import { useRelaySettingsComponent } from './provider' +import { TRelayGroup } from './types' -export default function RelayGroup({ - group, - onSwitch, - onDelete, - onRename, - onRelayUrlsUpdate -}: { - group: TRelayGroup - onSwitch: (groupName: string) => void - onDelete: (groupName: string) => void - onRename: (oldGroupName: string, newGroupName: string) => string | null - onRelayUrlsUpdate: (groupName: string, relayUrls: string[]) => void -}) { +export default function RelayGroup({ group }: { group: TRelayGroup }) { + const { expandedRelayGroup } = useRelaySettingsComponent() const { groupName, isActive, relayUrls } = group - const [expanded, setExpanded] = useState(false) - const [renaming, setRenaming] = useState(false) - - const toggleExpanded = () => setExpanded((prev) => !prev) return (
- onSwitch(groupName)} - hasRelayUrls={relayUrls.length > 0} - /> - 0} - setRenaming={setRenaming} - save={onRename} - onToggle={() => onSwitch(groupName)} - /> + +
- + {relayUrls.length} relays - +
- {expanded && ( - onRelayUrlsUpdate(groupName, urls)} - /> - )} + {expandedRelayGroup === groupName && }
) } -function RelayGroupActiveToggle({ - isActive, - hasRelayUrls, - onToggle -}: { - isActive: boolean - hasRelayUrls: boolean - onToggle: () => void -}) { - return ( - <> - {isActive ? ( - - ) : ( - { - if (hasRelayUrls) { - onToggle() - } - }} - /> - )} - +function RelayGroupActiveToggle({ groupName }: { groupName: string }) { + const { relayGroups, switchRelayGroup } = useRelaySettings() + + const isActive = relayGroups.find((group) => group.groupName === groupName)?.isActive + const hasRelayUrls = relayGroups.find((group) => group.groupName === groupName)?.relayUrls.length + + return isActive ? ( + + ) : ( + { + if (hasRelayUrls) { + switchRelayGroup(groupName) + } + }} + /> ) } -function RelayGroupName({ - groupName, - renaming, - hasRelayUrls, - setRenaming, - save, - onToggle -}: { - groupName: string - renaming: boolean - hasRelayUrls: boolean - setRenaming: (renaming: boolean) => void - save: (oldGroupName: string, newGroupName: string) => string | null - onToggle: () => void -}) { +function RelayGroupName({ groupName }: { groupName: string }) { const [newGroupName, setNewGroupName] = useState(groupName) const [newNameError, setNewNameError] = useState(null) + const { relayGroups, switchRelayGroup, renameRelayGroup } = useRelaySettings() + const { renamingGroup, setRenamingGroup } = useRelaySettingsComponent() + + const hasRelayUrls = relayGroups.find((group) => group.groupName === groupName)?.relayUrls.length const saveNewGroupName = () => { - const errMsg = save(groupName, newGroupName) + const errMsg = renameRelayGroup(groupName, newGroupName) if (errMsg) { setNewNameError(errMsg) return } - setRenaming(false) + setRenamingGroup(null) } const handleRenameInputChange = (e: React.ChangeEvent) => { @@ -140,72 +88,61 @@ function RelayGroupName({ } } - return ( - <> - {renaming ? ( -
- - - {newNameError &&
{newNameError}
} -
- ) : ( -
{ - if (hasRelayUrls) { - onToggle() - } - }} - > - {groupName} -
- )} - + return renamingGroup === groupName ? ( +
+ + + {newNameError &&
{newNameError}
} +
+ ) : ( +
{ + if (hasRelayUrls) { + switchRelayGroup(groupName) + } + }} + > + {groupName} +
) } function RelayUrlsExpandToggle({ - expanded, - onClick, + groupName, children }: { - expanded: boolean - onClick: () => void + groupName: string children: React.ReactNode }) { + const { expandedRelayGroup, setExpandedRelayGroup } = useRelaySettingsComponent() return (
setExpandedRelayGroup((pre) => (pre === groupName ? null : groupName))} >
{children}
) } -function RelayGroupOptions({ - groupName, - isActive, - onDelete, - setRenaming -}: { - groupName: string - isActive: boolean - onDelete: (groupName: string) => void - setRenaming: (renaming: boolean) => void -}) { +function RelayGroupOptions({ groupName }: { groupName: string }) { + const { relayGroups, deleteRelayGroup } = useRelaySettings() + const { setRenamingGroup } = useRelaySettingsComponent() + const isActive = relayGroups.find((group) => group.groupName === groupName)?.isActive + return ( @@ -215,11 +152,11 @@ function RelayGroupOptions({ /> - setRenaming(true)}>Rename + setRenamingGroup(groupName)}>Rename onDelete(groupName)} + onClick={() => deleteRelayGroup(groupName)} > Delete diff --git a/src/renderer/src/components/RelaySettings/RelayUrl.tsx b/src/renderer/src/components/RelaySettings/RelayUrl.tsx index bfbac9e5..2d936d8c 100644 --- a/src/renderer/src/components/RelaySettings/RelayUrl.tsx +++ b/src/renderer/src/components/RelaySettings/RelayUrl.tsx @@ -1,18 +1,15 @@ import { Button } from '@renderer/components/ui/button' import { Input } from '@renderer/components/ui/input' +import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider' import client from '@renderer/services/client.service' import { CircleX } from 'lucide-react' import { useEffect, useState } from 'react' -export default function RelayUrls({ - isActive, - relayUrls: rawRelayUrls, - update -}: { - isActive: boolean - relayUrls: string[] - update: (urls: string[]) => void -}) { +export default function RelayUrls({ groupName }: { groupName: string }) { + const { relayGroups, updateRelayGroupRelayUrls } = useRelaySettings() + const rawRelayUrls = relayGroups.find((group) => group.groupName === groupName)?.relayUrls ?? [] + const isActive = relayGroups.find((group) => group.groupName === groupName)?.isActive ?? false + const [newRelayUrl, setNewRelayUrl] = useState('') const [newRelayUrlError, setNewRelayUrlError] = useState(null) const [relays, setRelays] = useState< @@ -38,7 +35,10 @@ export default function RelayUrls({ const removeRelayUrl = (url: string) => { setRelays((relays) => relays.filter((relay) => relay.url !== url)) - update(relays.map(({ url }) => url).filter((u) => u !== url)) + updateRelayGroupRelayUrls( + groupName, + relays.map(({ url }) => url).filter((u) => u !== url) + ) } const saveNewRelayUrl = () => { @@ -51,7 +51,7 @@ export default function RelayUrls({ } setRelays((pre) => [...pre, { url: normalizedUrl, isConnected: false }]) const newRelayUrls = [...relays.map(({ url }) => url), normalizedUrl] - update(newRelayUrls) + updateRelayGroupRelayUrls(groupName, newRelayUrls) setNewRelayUrl('') } @@ -113,11 +113,11 @@ function RelayUrl({
{!isActive ? ( -
+
) : isConnected ? ( -
+
) : ( -
+
)}
{url}
diff --git a/src/renderer/src/components/RelaySettings/index.tsx b/src/renderer/src/components/RelaySettings/index.tsx index 168862fe..09f20e5d 100644 --- a/src/renderer/src/components/RelaySettings/index.tsx +++ b/src/renderer/src/components/RelaySettings/index.tsx @@ -1,91 +1,29 @@ import { Button } from '@renderer/components/ui/button' import { Input } from '@renderer/components/ui/input' import { Separator } from '@renderer/components/ui/separator' -import storage from '@renderer/services/storage.service' +import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider' import { useEffect, useRef, useState } from 'react' +import { RelaySettingsComponentProvider } from './provider' import RelayGroup from './RelayGroup' -import { TRelayGroup } from './types' export default function RelaySettings() { - const [groups, setGroups] = useState([]) + const { relayGroups, addRelayGroup } = useRelaySettings() const [newGroupName, setNewGroupName] = useState('') const [newNameError, setNewNameError] = useState(null) const dummyRef = useRef(null) useEffect(() => { - const init = async () => { - const storedGroups = await storage.getRelayGroups() - setGroups(storedGroups) - } - if (dummyRef.current) { dummyRef.current.focus() } - init() }, []) - const updateGroups = async (newGroups: TRelayGroup[]) => { - setGroups(newGroups) - await storage.setRelayGroups(newGroups) - } - - const switchRelayGroup = (groupName: string) => { - updateGroups( - groups.map((group) => ({ - ...group, - isActive: group.groupName === groupName - })) - ) - } - - const deleteRelayGroup = (groupName: string) => { - updateGroups(groups.filter((group) => group.groupName !== groupName || group.isActive)) - } - - const updateRelayGroupRelayUrls = (groupName: string, relayUrls: string[]) => { - updateGroups( - groups.map((group) => ({ - ...group, - relayUrls: group.groupName === groupName ? relayUrls : group.relayUrls - })) - ) - } - - const renameRelayGroup = (oldGroupName: string, newGroupName: string) => { - if (newGroupName === '') { - return null - } - if (oldGroupName === newGroupName) { - return null - } - if (groups.some((group) => group.groupName === newGroupName)) { - return 'already exists' - } - updateGroups( - groups.map((group) => ({ - ...group, - groupName: group.groupName === oldGroupName ? newGroupName : group.groupName - })) - ) - return null - } - - const addRelayGroup = () => { - if (newGroupName === '') { - return - } - if (groups.some((group) => group.groupName === newGroupName)) { - return setNewNameError('already exists') + const saveRelayGroup = () => { + const errMsg = addRelayGroup(newGroupName) + if (errMsg) { + return setNewNameError(errMsg) } setNewGroupName('') - updateGroups([ - ...groups, - { - groupName: newGroupName, - relayUrls: [], - isActive: false - } - ]) } const handleNewGroupNameChange = (e: React.ChangeEvent) => { @@ -96,27 +34,20 @@ export default function RelaySettings() { const handleNewGroupNameKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter') { event.preventDefault() - addRelayGroup() + saveRelayGroup() } } return ( -
+
Relay Settings
- {groups.map((group, index) => ( - + {relayGroups.map((group, index) => ( + ))}
- {groups.length < 5 && ( + {relayGroups.length < 5 && ( <>
@@ -130,7 +61,7 @@ export default function RelaySettings() { value={newGroupName} onChange={handleNewGroupNameChange} onKeyDown={handleNewGroupNameKeyDown} - onBlur={addRelayGroup} + onBlur={saveRelayGroup} />
@@ -138,6 +69,6 @@ export default function RelaySettings() {
)} -
+ ) } diff --git a/src/renderer/src/components/RelaySettings/provider.tsx b/src/renderer/src/components/RelaySettings/provider.tsx new file mode 100644 index 00000000..81346dfe --- /dev/null +++ b/src/renderer/src/components/RelaySettings/provider.tsx @@ -0,0 +1,40 @@ +import { createContext, useContext, useState } from 'react' + +type TRelaySettingsComponentContext = { + renamingGroup: string | null + setRenamingGroup: React.Dispatch> + expandedRelayGroup: string | null + setExpandedRelayGroup: React.Dispatch> +} + +export const RelaySettingsComponentContext = createContext< + TRelaySettingsComponentContext | undefined +>(undefined) + +export const useRelaySettingsComponent = () => { + const context = useContext(RelaySettingsComponentContext) + if (!context) { + throw new Error( + 'useRelaySettingsComponent must be used within a RelaySettingsComponentProvider' + ) + } + return context +} + +export function RelaySettingsComponentProvider({ children }: { children: React.ReactNode }) { + const [renamingGroup, setRenamingGroup] = useState(null) + const [expandedRelayGroup, setExpandedRelayGroup] = useState(null) + + return ( + + {children} + + ) +} diff --git a/src/renderer/src/components/ReplyNoteList/index.tsx b/src/renderer/src/components/ReplyNoteList/index.tsx index fb873ab8..0ef45a11 100644 --- a/src/renderer/src/components/ReplyNoteList/index.tsx +++ b/src/renderer/src/components/ReplyNoteList/index.tsx @@ -19,14 +19,12 @@ export default function ReplyNoteList({ event, className }: { event: Event; clas const loadMore = async () => { setLoading(true) - const events = await client.fetchEvents([ - { - '#e': [event.id], - kinds: [1], - limit: 200, - until - } - ]) + const events = await client.fetchEvents({ + '#e': [event.id], + kinds: [1], + limit: 100, + until + }) const sortedEvents = events.sort((a, b) => a.created_at - b.created_at) if (sortedEvents.length > 0) { const eventMap: Record = {} @@ -38,7 +36,7 @@ export default function ReplyNoteList({ event, className }: { event: Event; clas setEventMap((pre) => ({ ...pre, ...eventMap })) setUntil(sortedEvents[0].created_at - 1) } - setHasMore(sortedEvents.length >= 200) + setHasMore(sortedEvents.length >= 100) setLoading(false) } diff --git a/src/renderer/src/components/UserAvatar/index.tsx b/src/renderer/src/components/UserAvatar/index.tsx index d25ff71a..f5eada8b 100644 --- a/src/renderer/src/components/UserAvatar/index.tsx +++ b/src/renderer/src/components/UserAvatar/index.tsx @@ -7,6 +7,7 @@ import { toProfile } from '@renderer/lib/url' import { cn } from '@renderer/lib/utils' import { SecondaryPageLink } from '@renderer/PageManager' import ProfileCard from '../ProfileCard' +import { useMemo } from 'react' const UserAvatarSizeCnMap = { large: 'w-24 h-24', @@ -25,10 +26,11 @@ export default function UserAvatar({ size?: 'large' | 'normal' | 'small' | 'tiny' }) { const { avatar, pubkey } = useFetchProfile(userId) - if (!pubkey) - return + const defaultAvatar = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey]) - const defaultAvatar = generateImageByPubkey(pubkey) + if (!pubkey) { + return + } return ( diff --git a/src/renderer/src/hooks/useFetchEvent.tsx b/src/renderer/src/hooks/useFetchEvent.tsx index 8225906b..3fdb94e5 100644 --- a/src/renderer/src/hooks/useFetchEvent.tsx +++ b/src/renderer/src/hooks/useFetchEvent.tsx @@ -34,7 +34,7 @@ export function useFetchEventById(id?: string) { if (filter.ids) { event = await client.fetchEventById(filter.ids[0]) } else { - event = await client.fetchEventWithCache(filter) + event = await client.fetchEventByFilter(filter) } if (event) { setEvent(event) diff --git a/src/renderer/src/hooks/useFetchProfile.tsx b/src/renderer/src/hooks/useFetchProfile.tsx index f9bcca56..f3797aaa 100644 --- a/src/renderer/src/hooks/useFetchProfile.tsx +++ b/src/renderer/src/hooks/useFetchProfile.tsx @@ -1,67 +1,47 @@ -import { formatNpub } from '@renderer/lib/pubkey' +import { formatPubkey } from '@renderer/lib/pubkey' import client from '@renderer/services/client.service' +import { TProfile } from '@renderer/types' import { nip19 } from 'nostr-tools' -import { useCallback, useEffect, useState } from 'react' - -type TProfile = { - username: string - pubkey?: string - npub?: `npub1${string}` - banner?: string - avatar?: string - nip05?: string - about?: string -} - -const decodeUserId = (id: string): { pubkey?: string; npub?: `npub1${string}` } => { - if (/^npub1[a-z0-9]{58}$/.test(id)) { - const { data } = nip19.decode(id as `npub1${string}`) - return { pubkey: data, npub: id as `npub1${string}` } - } else if (id.startsWith('nprofile1')) { - const { data } = nip19.decode(id as `nprofile1${string}`) - return { pubkey: data.pubkey, npub: nip19.npubEncode(data.pubkey) } - } else if (/^[0-9a-f]{64}$/.test(id)) { - return { pubkey: id, npub: nip19.npubEncode(id) } - } - return {} -} +import { useEffect, useState } from 'react' export function useFetchProfile(id?: string) { - const initialProfile: TProfile = { + const [profile, setProfile] = useState({ username: id ? (id.length > 9 ? id.slice(0, 4) + '...' + id.slice(-4) : id) : 'username' - } - const [profile, setProfile] = useState(initialProfile) - - const fetchProfile = useCallback(async () => { - try { - if (!id) return - - const { pubkey, npub } = decodeUserId(id) - if (!pubkey || !npub) return - - const profileEvent = await client.fetchProfile(pubkey) - const username = npub ? formatNpub(npub) : initialProfile.username - setProfile({ pubkey, npub, username }) - if (!profileEvent) return - - const profileObj = JSON.parse(profileEvent.content) - setProfile({ - ...initialProfile, - pubkey, - npub, - banner: profileObj.banner, - avatar: profileObj.picture, - username: - profileObj.display_name?.trim() || profileObj.name?.trim() || initialProfile.username, - nip05: profileObj.nip05, - about: profileObj.about - }) - } catch (err) { - console.error(err) - } - }, [id]) + }) useEffect(() => { + const fetchProfile = async () => { + try { + if (!id) return + + let pubkey: string | undefined + + if (/^[0-9a-f]{64}$/.test(id)) { + pubkey = id + } else { + const { data, type } = nip19.decode(id) + switch (type) { + case 'npub': + pubkey = data + break + case 'nprofile': + pubkey = data.pubkey + break + } + } + + if (!pubkey) return + setProfile({ pubkey, username: formatPubkey(pubkey) }) + + const profile = await client.fetchProfile(pubkey) + if (profile) { + setProfile(profile) + } + } catch (err) { + console.error(err) + } + } + fetchProfile() }, [id]) diff --git a/src/renderer/src/layouts/PrimaryPageLayout/RefreshButton.tsx b/src/renderer/src/layouts/PrimaryPageLayout/RefreshButton.tsx new file mode 100644 index 00000000..5791f8c2 --- /dev/null +++ b/src/renderer/src/layouts/PrimaryPageLayout/RefreshButton.tsx @@ -0,0 +1,12 @@ +import { TitlebarButton } from '@renderer/components/Titlebar' +import { usePrimaryPage } from '@renderer/PageManager' +import { RefreshCcw } from 'lucide-react' + +export default function RefreshButton() { + const { refresh } = usePrimaryPage() + return ( + + + + ) +} diff --git a/src/renderer/src/layouts/PrimaryPageLayout/ReloadTimelineButton.tsx b/src/renderer/src/layouts/PrimaryPageLayout/ReloadTimelineButton.tsx deleted file mode 100644 index 725a01b8..00000000 --- a/src/renderer/src/layouts/PrimaryPageLayout/ReloadTimelineButton.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { TitlebarButton } from '@renderer/components/Titlebar' -import { createReloadTimelineEvent, eventBus } from '@renderer/services/event-bus.service' -import { Eraser } from 'lucide-react' - -export default function ReloadTimelineButton() { - return ( - eventBus.emit(createReloadTimelineEvent())} - title="reload timeline" - > - - - ) -} diff --git a/src/renderer/src/layouts/PrimaryPageLayout/index.tsx b/src/renderer/src/layouts/PrimaryPageLayout/index.tsx index 4dab7819..090a303c 100644 --- a/src/renderer/src/layouts/PrimaryPageLayout/index.tsx +++ b/src/renderer/src/layouts/PrimaryPageLayout/index.tsx @@ -3,8 +3,8 @@ import { Titlebar } from '@renderer/components/Titlebar' import { ScrollArea } from '@renderer/components/ui/scroll-area' import { isMacOS } from '@renderer/lib/platform' import { forwardRef, useImperativeHandle, useRef } from 'react' -import ReloadTimelineButton from './ReloadTimelineButton' import RelaySettingsPopover from './RelaySettingsPopover' +import RefreshButton from './RefreshButton' const PrimaryPageLayout = forwardRef( ( @@ -48,7 +48,7 @@ export function PrimaryPageTitlebar({ content }: { content?: React.ReactNode })
{content}
- +
diff --git a/src/renderer/src/layouts/SecondaryPageLayout/ThemeToggle.tsx b/src/renderer/src/layouts/SecondaryPageLayout/ThemeToggle.tsx index b5837be6..c817efc2 100644 --- a/src/renderer/src/layouts/SecondaryPageLayout/ThemeToggle.tsx +++ b/src/renderer/src/layouts/SecondaryPageLayout/ThemeToggle.tsx @@ -1,4 +1,4 @@ -import { useTheme } from '@renderer/components/theme-provider' +import { useTheme } from '@renderer/providers/ThemeProvider' import { TitlebarButton } from '@renderer/components/Titlebar' import { Moon, Sun, SunMoon } from 'lucide-react' diff --git a/src/renderer/src/pages/primary/NoteListPage/index.tsx b/src/renderer/src/pages/primary/NoteListPage/index.tsx index b52ca68f..cc301167 100644 --- a/src/renderer/src/pages/primary/NoteListPage/index.tsx +++ b/src/renderer/src/pages/primary/NoteListPage/index.tsx @@ -4,7 +4,7 @@ import PrimaryPageLayout from '@renderer/layouts/PrimaryPageLayout' export default function NoteListPage() { return ( - + ) } diff --git a/src/renderer/src/pages/secondary/ProfilePage/index.tsx b/src/renderer/src/pages/secondary/ProfilePage/index.tsx index 7c6e16f3..adc5780f 100644 --- a/src/renderer/src/pages/secondary/ProfilePage/index.tsx +++ b/src/renderer/src/pages/secondary/ProfilePage/index.tsx @@ -1,22 +1,24 @@ import Nip05 from '@renderer/components/Nip05' import NoteList from '@renderer/components/NoteList' import ProfileAbout from '@renderer/components/ProfileAbout' +import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar' import { Separator } from '@renderer/components/ui/separator' -import UserAvatar from '@renderer/components/UserAvatar' import { useFetchProfile } from '@renderer/hooks' import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout' import { formatNpub, generateImageByPubkey } from '@renderer/lib/pubkey' import { Copy } from 'lucide-react' -import { useEffect, useState } from 'react' +import { nip19 } from 'nostr-tools' +import { useEffect, useMemo, useState } from 'react' export default function ProfilePage({ pubkey }: { pubkey?: string }) { - const { banner, username, nip05, about, npub } = useFetchProfile(pubkey) + const { banner, username, nip05, about, avatar } = useFetchProfile(pubkey) const [copied, setCopied] = useState(false) + const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : undefined), [pubkey]) + const defaultImage = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey]) if (!pubkey || !npub) return null const copyNpub = () => { - if (!npub) return navigator.clipboard.writeText(npub) setCopied(true) setTimeout(() => setCopied(false), 2000) @@ -27,14 +29,16 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
- + + + + + +
{username}
@@ -44,7 +48,7 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) { onClick={() => copyNpub()} > {copied ? ( -
Copied!
+
copied!
) : ( <>
{formatNpub(npub, 24)}
@@ -56,22 +60,23 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
- + ) } function ProfileBanner({ - banner, + defaultBanner, pubkey, + banner, className }: { - banner?: string + defaultBanner: string pubkey: string + banner?: string className?: string }) { - const defaultBanner = generateImageByPubkey(pubkey) const [bannerUrl, setBannerUrl] = useState(banner || defaultBanner) useEffect(() => { @@ -80,12 +85,12 @@ function ProfileBanner({ } else { setBannerUrl(defaultBanner) } - }, [pubkey, banner]) + }, [defaultBanner, banner]) return ( Banner setBannerUrl(defaultBanner)} /> diff --git a/src/renderer/src/providers/RelaySettingsProvider.tsx b/src/renderer/src/providers/RelaySettingsProvider.tsx new file mode 100644 index 00000000..8c44dec6 --- /dev/null +++ b/src/renderer/src/providers/RelaySettingsProvider.tsx @@ -0,0 +1,123 @@ +import { TRelayGroup } from '@common/types' +import storage from '@renderer/services/storage.service' +import { createContext, useContext, useEffect, useState } from 'react' + +type TRelaySettingsContext = { + relayGroups: TRelayGroup[] + relayUrls: string[] + switchRelayGroup: (groupName: string) => void + renameRelayGroup: (oldGroupName: string, newGroupName: string) => string | null + deleteRelayGroup: (groupName: string) => void + addRelayGroup: (groupName: string) => string | null + updateRelayGroupRelayUrls: (groupName: string, relayUrls: string[]) => void +} + +const RelaySettingsContext = createContext(undefined) + +export const useRelaySettings = () => { + const context = useContext(RelaySettingsContext) + if (!context) { + throw new Error('useRelaySettings must be used within a RelaySettingsProvider') + } + return context +} + +export function RelaySettingsProvider({ children }: { children: React.ReactNode }) { + const [relayGroups, setRelayGroups] = useState([]) + const [relayUrls, setRelayUrls] = useState( + relayGroups.find((group) => group.isActive)?.relayUrls ?? [] + ) + + useEffect(() => { + const init = async () => { + const storedGroups = await storage.getRelayGroups() + setRelayGroups(storedGroups) + } + + init() + }, []) + + useEffect(() => { + setRelayUrls(relayGroups.find((group) => group.isActive)?.relayUrls ?? []) + }, [relayGroups]) + + const updateGroups = async (newGroups: TRelayGroup[]) => { + setRelayGroups(newGroups) + await storage.setRelayGroups(newGroups) + } + + const switchRelayGroup = (groupName: string) => { + updateGroups( + relayGroups.map((group) => ({ + ...group, + isActive: group.groupName === groupName + })) + ) + } + + const deleteRelayGroup = (groupName: string) => { + updateGroups(relayGroups.filter((group) => group.groupName !== groupName || group.isActive)) + } + + const updateRelayGroupRelayUrls = (groupName: string, relayUrls: string[]) => { + updateGroups( + relayGroups.map((group) => ({ + ...group, + relayUrls: group.groupName === groupName ? relayUrls : group.relayUrls + })) + ) + } + + const renameRelayGroup = (oldGroupName: string, newGroupName: string) => { + if (newGroupName === '') { + return null + } + if (oldGroupName === newGroupName) { + return null + } + if (relayGroups.some((group) => group.groupName === newGroupName)) { + return 'already exists' + } + updateGroups( + relayGroups.map((group) => ({ + ...group, + groupName: group.groupName === oldGroupName ? newGroupName : group.groupName + })) + ) + return null + } + + const addRelayGroup = (groupName: string) => { + if (groupName === '') { + return null + } + if (relayGroups.some((group) => group.groupName === groupName)) { + return 'already exists' + } + updateGroups([ + ...relayGroups, + { + groupName, + relayUrls: [], + isActive: false + } + ]) + return null + } + + return ( + + {children} + + ) +} diff --git a/src/renderer/src/components/theme-provider.tsx b/src/renderer/src/providers/ThemeProvider.tsx similarity index 100% rename from src/renderer/src/components/theme-provider.tsx rename to src/renderer/src/providers/ThemeProvider.tsx diff --git a/src/renderer/src/services/client.service.ts b/src/renderer/src/services/client.service.ts index 45b74278..2d7fca7f 100644 --- a/src/renderer/src/services/client.service.ts +++ b/src/renderer/src/services/client.service.ts @@ -1,55 +1,53 @@ import { TRelayGroup } from '@common/types' -import { TEventStats } from '@renderer/types' +import { formatPubkey } from '@renderer/lib/pubkey' +import { TEventStats, TProfile } from '@renderer/types' +import DataLoader from 'dataloader' import { LRUCache } from 'lru-cache' import { Filter, kinds, Event as NEvent, SimplePool } from 'nostr-tools' import { EVENT_TYPES, eventBus } from './event-bus.service' import storage from './storage.service' +const BIG_RELAY_URLS = [ + 'wss://relay.damus.io/', + 'wss://nos.lol/', + 'wss://relay.nostr.band/', + 'wss://relay.noswhere.com/' +] + class ClientService { static instance: ClientService private pool = new SimplePool() + private relayUrls: string[] = BIG_RELAY_URLS private initPromise!: Promise - private relayUrls: string[] = [] - private cache = new LRUCache({ - max: 10000, - fetchMethod: async (filter) => this.fetchEvent(JSON.parse(filter)) - }) - // Event cache - private eventsCache = new LRUCache>({ - max: 10000, - ttl: 1000 * 60 * 10 // 10 minutes - }) - private fetchEventQueue = new Map< - string, - { - resolve: (value: NEvent | undefined) => void - reject: (reason: any) => void - } - >() - private fetchEventTimer: NodeJS.Timeout | null = null - - // Event stats cache private eventStatsCache = new LRUCache>({ max: 10000, ttl: 1000 * 60 * 10, // 10 minutes fetchMethod: async (id) => this._fetchEventStatsById(id) }) - // Profile cache - private profilesCache = new LRUCache>({ + private eventCache = new LRUCache>({ max: 10000, - ttl: 1000 * 60 * 10 // 10 minutes - }) - private fetchProfileQueue = new Map< - string, - { - resolve: (value: NEvent | undefined) => void - reject: (reason: any) => void + fetchMethod: async (filterStr) => { + const [event] = await this.fetchEvents(JSON.parse(filterStr)) + return event } - >() - private fetchProfileTimer: NodeJS.Timeout | null = null + }) + + private eventDataloader = new DataLoader( + this.eventBatchLoadFn.bind(this), + { + cacheMap: new LRUCache>({ max: 10000 }) + } + ) + + private profileDataloader = new DataLoader( + this.profileBatchLoadFn.bind(this), + { + cacheMap: new LRUCache>({ max: 10000 }) + } + ) constructor() { if (!ClientService.instance) { @@ -69,12 +67,6 @@ class ClientService { onRelayGroupsChange(relayGroups: TRelayGroup[]) { const newRelayUrls = relayGroups.find((group) => group.isActive)?.relayUrls ?? [] - if ( - newRelayUrls.length === this.relayUrls.length && - newRelayUrls.every((url) => this.relayUrls.includes(url)) - ) { - return - } this.relayUrls = newRelayUrls } @@ -82,70 +74,40 @@ class ClientService { return this.pool.listConnectionStatus() } - async fetchEvents(filters: Filter[]) { - await this.initPromise - return new Promise((resolve) => { - const events: NEvent[] = [] - this.pool.subscribeManyEose(this.relayUrls, filters, { - onevent(event) { - events.push(event) - }, - onclose() { - resolve(events) - } - }) - }) - } - - async fetchEventWithCache(filter: Filter) { - return this.cache.fetch(JSON.stringify(filter)) - } - - async fetchEvent(filter: Filter) { - const events = await this.fetchEvents([{ ...filter, limit: 1 }]) - return events.length ? events[0] : undefined - } - - async fetchEventById(id: string): Promise { - const cache = this.eventsCache.get(id) - if (cache) { - return cache + subscribeEvents( + urls: string[], + filter: Filter, + opts: { + onEose: (events: NEvent[]) => void + onNew: (evt: NEvent) => void } - - const promise = new Promise((resolve, reject) => { - this.fetchEventQueue.set(id, { resolve, reject }) - if (this.fetchEventTimer) { - return - } - - this.fetchEventTimer = setTimeout(async () => { - this.fetchEventTimer = null - const queue = new Map(this.fetchEventQueue) - this.fetchEventQueue.clear() - - try { - const ids = Array.from(queue.keys()) - const events = await this.fetchEvents([{ ids, limit: ids.length }]) - for (const event of events) { - queue.get(event.id)?.resolve(event) - queue.delete(event.id) - } - - for (const [, job] of queue) { - job.resolve(undefined) - } - queue.clear() - } catch (err) { - for (const [id, job] of queue) { - this.eventsCache.delete(id) - job.reject(err) - } + ) { + console.log('subscribeEvents', urls, filter) + const events: NEvent[] = [] + let eose = false + return this.pool.subscribeMany(urls, [filter], { + onevent: (evt) => { + if (eose) { + opts.onNew(evt) + } else { + events.push(evt) } - }, 20) + }, + oneose: () => { + eose = true + opts.onEose(events.sort((a, b) => b.created_at - a.created_at)) + }, + onclose: () => { + if (!eose) { + opts.onEose(events.sort((a, b) => b.created_at - a.created_at)) + } + } }) + } - this.eventsCache.set(id, promise) - return promise + async fetchEvents(filter: Filter, relayUrls: string[] = this.relayUrls) { + await this.initPromise + return await this.pool.querySync(relayUrls, filter) } async fetchEventStatsById(id: string): Promise { @@ -153,70 +115,116 @@ class ClientService { return stats ?? { reactionCount: 0, repostCount: 0 } } + async fetchEventByFilter(filter: Filter) { + return this.eventCache.fetch(JSON.stringify({ ...filter, limit: 1 })) + } + + async fetchEventById(id: string): Promise { + return this.eventDataloader.load(id) + } + + async fetchProfile(pubkey: string): Promise { + return this.profileDataloader.load(pubkey) + } + private async _fetchEventStatsById(id: string) { const [reactionEvents, repostEvents] = await Promise.all([ - this.fetchEvents([{ '#e': [id], kinds: [kinds.Reaction] }]), - this.fetchEvents([{ '#e': [id], kinds: [kinds.Repost] }]) + this.fetchEvents({ '#e': [id], kinds: [kinds.Reaction] }), + this.fetchEvents({ '#e': [id], kinds: [kinds.Repost] }) ]) return { reactionCount: reactionEvents.length, repostCount: repostEvents.length } } - async fetchProfile(pubkey: string): Promise { - const cache = this.profilesCache.get(pubkey) - if (cache) { - return cache + private async eventBatchLoadFn(ids: readonly string[]) { + const events = await this.fetchEvents({ + ids: ids as string[], + limit: ids.length + }) + const eventsMap = new Map() + for (const event of events) { + eventsMap.set(event.id, event) } - const promise = new Promise((resolve, reject) => { - this.fetchProfileQueue.set(pubkey, { resolve, reject }) - if (this.fetchProfileTimer) { - return + const missingIds = ids.filter((id) => !eventsMap.has(id)) + if (missingIds.length > 0) { + const missingEvents = await this.fetchEvents( + { + ids: missingIds, + limit: missingIds.length + }, + BIG_RELAY_URLS.filter((url) => !this.relayUrls.includes(url)) + ) + for (const event of missingEvents) { + eventsMap.set(event.id, event) } + } - this.fetchProfileTimer = setTimeout(async () => { - this.fetchProfileTimer = null - const queue = new Map(this.fetchProfileQueue) - this.fetchProfileQueue.clear() + return ids.map((id) => eventsMap.get(id)) + } - try { - const pubkeys = Array.from(queue.keys()) - const events = await this.fetchEvents([ - { - authors: pubkeys, - kinds: [0], - limit: pubkeys.length - } - ]) - const eventsMap = new Map() - for (const event of events) { - const pubkey = event.pubkey - const existing = eventsMap.get(pubkey) - if (!existing || existing.created_at < event.created_at) { - eventsMap.set(pubkey, event) - } - } - - for (const [pubkey, job] of queue) { - const event = eventsMap.get(pubkey) - if (event) { - job.resolve(event) - } else { - job.resolve(undefined) - } - queue.delete(pubkey) - } - } catch (err) { - for (const [pubkey, job] of queue) { - this.profilesCache.delete(pubkey) - job.reject(err) - } - } - }, 20) + private async profileBatchLoadFn(pubkeys: readonly string[]) { + const events = await this.fetchEvents({ + authors: pubkeys as string[], + kinds: [kinds.Metadata], + limit: pubkeys.length }) + const eventsMap = new Map() + for (const event of events) { + const pubkey = event.pubkey + const existing = eventsMap.get(pubkey) + if (!existing || existing.created_at < event.created_at) { + eventsMap.set(pubkey, event) + } + } - this.profilesCache.set(pubkey, promise) - return promise + const missingPubkeys = pubkeys.filter((pubkey) => !eventsMap.has(pubkey)) + if (missingPubkeys.length > 0) { + const missingEvents = await this.fetchEvents( + { + authors: missingPubkeys, + kinds: [kinds.Metadata], + limit: missingPubkeys.length + }, + BIG_RELAY_URLS.filter((url) => !this.relayUrls.includes(url)) + ) + for (const event of missingEvents) { + const pubkey = event.pubkey + const existing = eventsMap.get(pubkey) + if (!existing || existing.created_at < event.created_at) { + eventsMap.set(pubkey, event) + } + } + } + + return pubkeys.map((pubkey) => { + const event = eventsMap.get(pubkey) + return event ? this.parseProfileFromEvent(event) : undefined + }) + } + + private parseProfileFromEvent(event: NEvent): TProfile { + try { + const profileObj = JSON.parse(event.content) + return { + pubkey: event.pubkey, + banner: profileObj.banner, + avatar: profileObj.picture, + username: + profileObj.display_name?.trim() || + profileObj.name?.trim() || + profileObj.nip05?.split('@')[0]?.trim() || + formatPubkey(event.pubkey), + nip05: profileObj.nip05, + about: profileObj.about + } + } catch (err) { + console.error(err) + return { + pubkey: event.pubkey, + username: formatPubkey(event.pubkey) + } + } } } diff --git a/src/renderer/src/services/event-bus.service.ts b/src/renderer/src/services/event-bus.service.ts index 31d0f50e..4702e4d0 100644 --- a/src/renderer/src/services/event-bus.service.ts +++ b/src/renderer/src/services/event-bus.service.ts @@ -2,13 +2,11 @@ import { TRelayGroup } from '@common/types' export const EVENT_TYPES = { RELAY_GROUPS_CHANGED: 'relay-groups-changed', - RELOAD_TIMELINE: 'reload-timeline', REPLY_COUNT_CHANGED: 'reply-count-changed' } as const type TEventMap = { [EVENT_TYPES.RELAY_GROUPS_CHANGED]: TRelayGroup[] - [EVENT_TYPES.RELOAD_TIMELINE]: unknown [EVENT_TYPES.REPLY_COUNT_CHANGED]: { eventId: string; replyCount: number } } @@ -19,9 +17,6 @@ type TCustomEventMap = { export const createRelayGroupsChangedEvent = (relayGroups: TRelayGroup[]) => { return new CustomEvent(EVENT_TYPES.RELAY_GROUPS_CHANGED, { detail: relayGroups }) } -export const createReloadTimelineEvent = () => { - return new CustomEvent(EVENT_TYPES.RELOAD_TIMELINE) -} export const createReplyCountChangedEvent = (eventId: string, replyCount: number) => { return new CustomEvent(EVENT_TYPES.REPLY_COUNT_CHANGED, { detail: { eventId, replyCount } }) } diff --git a/src/renderer/src/services/storage.service.ts b/src/renderer/src/services/storage.service.ts index 2085ed99..6ee17280 100644 --- a/src/renderer/src/services/storage.service.ts +++ b/src/renderer/src/services/storage.service.ts @@ -4,20 +4,40 @@ import { createRelayGroupsChangedEvent, eventBus } from './event-bus.service' class StorageService { static instance: StorageService + private initPromise!: Promise + private relayGroups: TRelayGroup[] = [] + private activeRelayUrls: string[] = [] + constructor() { if (!StorageService.instance) { + this.initPromise = this.init() StorageService.instance = this } return StorageService.instance } + async init() { + this.relayGroups = await window.api.storage.getRelayGroups() + this.activeRelayUrls = this.relayGroups.find((group) => group.isActive)?.relayUrls ?? [] + } + async getRelayGroups() { - return await window.api.storage.getRelayGroups() + await this.initPromise + return this.relayGroups } async setRelayGroups(relayGroups: TRelayGroup[]) { + await this.initPromise await window.api.storage.setRelayGroups(relayGroups) - eventBus.emit(createRelayGroupsChangedEvent(relayGroups)) + this.relayGroups = relayGroups + const newActiveRelayUrls = relayGroups.find((group) => group.isActive)?.relayUrls ?? [] + if ( + this.activeRelayUrls.length !== newActiveRelayUrls.length || + this.activeRelayUrls.some((url) => !newActiveRelayUrls.includes(url)) + ) { + eventBus.emit(createRelayGroupsChangedEvent(relayGroups)) + } + this.activeRelayUrls = newActiveRelayUrls } } diff --git a/src/renderer/src/types.ts b/src/renderer/src/types.ts index 828b0e6e..61cd0d8b 100644 --- a/src/renderer/src/types.ts +++ b/src/renderer/src/types.ts @@ -1 +1,10 @@ export type TEventStats = { reactionCount: number; repostCount: number } + +export type TProfile = { + username: string + pubkey?: string + banner?: string + avatar?: string + nip05?: string + about?: string +}