Browse Source

refactor

imwald
codytseng 1 year ago
parent
commit
bfc07545b3
  1. 6
      package-lock.json
  2. 1
      package.json
  3. 13
      src/renderer/src/App.tsx
  4. 78
      src/renderer/src/PageManager.tsx
  5. 6
      src/renderer/src/components/Embedded/EmbeddedMention.tsx
  6. 2
      src/renderer/src/components/ImageGallery/index.tsx
  7. 138
      src/renderer/src/components/NoteList/index.tsx
  8. 5
      src/renderer/src/components/ProfileCard/index.tsx
  9. 207
      src/renderer/src/components/RelaySettings/RelayGroup.tsx
  10. 28
      src/renderer/src/components/RelaySettings/RelayUrl.tsx
  11. 97
      src/renderer/src/components/RelaySettings/index.tsx
  12. 40
      src/renderer/src/components/RelaySettings/provider.tsx
  13. 16
      src/renderer/src/components/ReplyNoteList/index.tsx
  14. 8
      src/renderer/src/components/UserAvatar/index.tsx
  15. 2
      src/renderer/src/hooks/useFetchEvent.tsx
  16. 92
      src/renderer/src/hooks/useFetchProfile.tsx
  17. 12
      src/renderer/src/layouts/PrimaryPageLayout/RefreshButton.tsx
  18. 14
      src/renderer/src/layouts/PrimaryPageLayout/ReloadTimelineButton.tsx
  19. 4
      src/renderer/src/layouts/PrimaryPageLayout/index.tsx
  20. 2
      src/renderer/src/layouts/SecondaryPageLayout/ThemeToggle.tsx
  21. 2
      src/renderer/src/pages/primary/NoteListPage/index.tsx
  22. 37
      src/renderer/src/pages/secondary/ProfilePage/index.tsx
  23. 123
      src/renderer/src/providers/RelaySettingsProvider.tsx
  24. 0
      src/renderer/src/providers/ThemeProvider.tsx
  25. 296
      src/renderer/src/services/client.service.ts
  26. 5
      src/renderer/src/services/event-bus.service.ts
  27. 24
      src/renderer/src/services/storage.service.ts
  28. 9
      src/renderer/src/types.ts

6
package-lock.json generated

@ -25,6 +25,7 @@
"@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-toast": "^1.2.2",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dataloader": "^2.2.2",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"lru-cache": "^11.0.1", "lru-cache": "^11.0.1",
"lucide-react": "^0.453.0", "lucide-react": "^0.453.0",
@ -4361,6 +4362,11 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/dayjs": {
"version": "1.11.13", "version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",

1
package.json

@ -41,6 +41,7 @@
"@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-toast": "^1.2.2",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dataloader": "^2.2.2",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"lru-cache": "^11.0.1", "lru-cache": "^11.0.1",
"lucide-react": "^0.453.0", "lucide-react": "^0.453.0",

13
src/renderer/src/App.tsx

@ -1,13 +1,14 @@
import 'yet-another-react-lightbox/styles.css' import 'yet-another-react-lightbox/styles.css'
import './assets/main.css' import './assets/main.css'
import { ThemeProvider } from '@renderer/components/theme-provider'
import { Toaster } from '@renderer/components/ui/toaster' import { Toaster } from '@renderer/components/ui/toaster'
import { ThemeProvider } from '@renderer/providers/ThemeProvider'
import { PageManager } from './PageManager' import { PageManager } from './PageManager'
import NoteListPage from './pages/primary/NoteListPage' import NoteListPage from './pages/primary/NoteListPage'
import HashtagPage from './pages/secondary/HashtagPage' import HashtagPage from './pages/secondary/HashtagPage'
import NotePage from './pages/secondary/NotePage' import NotePage from './pages/secondary/NotePage'
import ProfilePage from './pages/secondary/ProfilePage' import ProfilePage from './pages/secondary/ProfilePage'
import { RelaySettingsProvider } from './providers/RelaySettingsProvider'
const routes = [ const routes = [
{ pageName: 'note', element: <NotePage /> }, { pageName: 'note', element: <NotePage /> },
@ -19,10 +20,12 @@ export default function App(): JSX.Element {
return ( return (
<div className="h-screen"> <div className="h-screen">
<ThemeProvider> <ThemeProvider>
<PageManager routes={routes}> <RelaySettingsProvider>
<NoteListPage /> <PageManager routes={routes}>
</PageManager> <NoteListPage />
<Toaster /> </PageManager>
<Toaster />
</RelaySettingsProvider>
</ThemeProvider> </ThemeProvider>
</div> </div>
) )

78
src/renderer/src/PageManager.tsx

@ -17,6 +17,10 @@ type TPushParams = {
props: any props: any
} }
type TPrimaryPageContext = {
refresh: () => void
}
type TSecondaryPageContext = { type TSecondaryPageContext = {
push: (params: TPushParams) => void push: (params: TPushParams) => void
pop: () => void pop: () => void
@ -28,13 +32,24 @@ type TStackItem = {
component: React.ReactNode component: React.ReactNode
} }
const SecondaryPageContext = createContext<TSecondaryPageContext>({ const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(undefined)
push: () => {},
pop: () => {} const SecondaryPageContext = createContext<TSecondaryPageContext | undefined>(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() { 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({ export function PageManager({
@ -46,6 +61,7 @@ export function PageManager({
children: React.ReactNode children: React.ReactNode
maxStackSize?: number maxStackSize?: number
}) { }) {
const [primaryPageKey, setPrimaryPageKey] = useState<number>(0)
const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([]) const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
const routeMap = routes.reduce((acc, route) => { const routeMap = routes.reduce((acc, route) => {
@ -63,6 +79,8 @@ export function PageManager({
) )
} }
const refreshPrimary = () => setPrimaryPageKey((prevKey) => prevKey + 1)
const pushSecondary = ({ pageName, props }: TPushParams) => { const pushSecondary = ({ pageName, props }: TPushParams) => {
if (isCurrentPage(secondaryStack, { pageName, props })) return if (isCurrentPage(secondaryStack, { pageName, props })) return
@ -81,29 +99,33 @@ export function PageManager({
const popSecondary = () => setSecondaryStack((prevStack) => prevStack.slice(0, -1)) const popSecondary = () => setSecondaryStack((prevStack) => prevStack.slice(0, -1))
return ( return (
<SecondaryPageContext.Provider value={{ push: pushSecondary, pop: popSecondary }}> <PrimaryPageContext.Provider value={{ refresh: refreshPrimary }}>
<ResizablePanelGroup direction="horizontal"> <SecondaryPageContext.Provider value={{ push: pushSecondary, pop: popSecondary }}>
<ResizablePanel defaultSize={60} minSize={30}> <ResizablePanelGroup direction="horizontal">
{children} <ResizablePanel defaultSize={60} minSize={30}>
</ResizablePanel> <div key={primaryPageKey} className="h-full">
<ResizableHandle /> {children}
<ResizablePanel defaultSize={40} minSize={30} className="relative"> </div>
{secondaryStack.length ? ( </ResizablePanel>
secondaryStack.map((item, index) => ( <ResizableHandle />
<div <ResizablePanel defaultSize={40} minSize={30} className="relative">
key={index} {secondaryStack.length ? (
className="absolute top-0 left-0 w-full h-full bg-background" secondaryStack.map((item, index) => (
style={{ zIndex: index }} <div
> key={index}
{item.component} className="absolute top-0 left-0 w-full h-full bg-background"
</div> style={{ zIndex: index }}
)) >
) : ( {item.component}
<BlankPage /> </div>
)} ))
</ResizablePanel> ) : (
</ResizablePanelGroup> <BlankPage />
</SecondaryPageContext.Provider> )}
</ResizablePanel>
</ResizablePanelGroup>
</SecondaryPageContext.Provider>
</PrimaryPageContext.Provider>
) )
} }

6
src/renderer/src/components/Embedded/EmbeddedMention.tsx

@ -1,9 +1,5 @@
import { useFetchProfile } from '@renderer/hooks'
import Username from '../Username' import Username from '../Username'
export function EmbeddedMention({ userId }: { userId: string }) { export function EmbeddedMention({ userId }: { userId: string }) {
const { pubkey } = useFetchProfile(userId) return <Username userId={userId} showAt className="text-highlight font-normal" />
if (!pubkey) return null
return <Username userId={pubkey} showAt className="text-highlight font-normal" />
} }

2
src/renderer/src/components/ImageGallery/index.tsx

@ -30,7 +30,7 @@ export default function ImageGallery({
{images.map((src, index) => { {images.map((src, index) => {
return ( return (
<img <img
className={`rounded-lg max-w-full ${size === 'small' ? 'max-h-[10vh]' : 'max-h-[30vh]'}`} className={`rounded-lg max-w-full cursor-pointer ${size === 'small' ? 'max-h-[10vh]' : 'max-h-[30vh]'}`}
key={index} key={index}
src={src} src={src}
onClick={(e) => handlePhotoClick(e, index)} onClick={(e) => handlePhotoClick(e, index)}

138
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 { isReplyNoteEvent } from '@renderer/lib/event'
import { cn } from '@renderer/lib/utils' import { cn } from '@renderer/lib/utils'
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
import client from '@renderer/services/client.service' import client from '@renderer/services/client.service'
import { EVENT_TYPES, eventBus } from '@renderer/services/event-bus.service'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { RefreshCcw } from 'lucide-react'
import { Event, Filter, kinds } from 'nostr-tools' import { Event, Filter, kinds } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import NoteCard from '../NoteCard' import NoteCard from '../NoteCard'
export default function NoteList({ export default function NoteList({
filter = {}, filter = {},
className, className
isHomeTimeline = false
}: { }: {
filter?: Filter filter?: Filter
className?: string className?: string
isHomeTimeline?: boolean
}) { }) {
const [events, setEvents] = useState<Event[]>([]) const [events, setEvents] = useState<Event[]>([])
const [since, setSince] = useState<number>(() => dayjs().unix() + 1) const [newEvents, setNewEvents] = useState<Event[]>([])
const [until, setUntil] = useState<number>(() => dayjs().unix()) const [until, setUntil] = useState<number>(() => dayjs().unix())
const [hasMore, setHasMore] = useState<boolean>(true) const [hasMore, setHasMore] = useState<boolean>(true)
const [refreshedAt, setRefreshedAt] = useState<number>(() => dayjs().unix()) const [initialized, setInitialized] = useState(false)
const [refreshing, setRefreshing] = useState<boolean>(false)
const observer = useRef<IntersectionObserver | null>(null) const observer = useRef<IntersectionObserver | null>(null)
const bottomRef = useRef<HTMLDivElement | null>(null) const bottomRef = useRef<HTMLDivElement | null>(null)
const { relayUrls } = useRelaySettings()
const noteFilter = useMemo(() => { const noteFilter = useMemo(() => {
return { return {
kinds: [kinds.ShortTextNote, kinds.Repost], kinds: [kinds.ShortTextNote, kinds.Repost],
limit: 50, limit: 100,
...filter ...filter
} }
}, [filter]) }, [JSON.stringify(filter)])
useEffect(() => { useEffect(() => {
if (!isHomeTimeline) return if (relayUrls.length === 0) return
const handleClearList = () => { setInitialized(false)
setEvents([]) setEvents([])
setSince(dayjs().unix() + 1) setNewEvents([])
setUntil(dayjs().unix()) setHasMore(true)
setHasMore(true)
setRefreshedAt(dayjs().unix()) const sub = client.subscribeEvents(relayUrls, noteFilter, {
setRefreshing(false) onEose: (events) => {
} const processedEvents = events.filter((e) => !isReplyNoteEvent(e))
setEvents((pre) => [...pre, ...processedEvents])
eventBus.on(EVENT_TYPES.RELOAD_TIMELINE, handleClearList) if (events.length > 0) {
setUntil(events[events.length - 1].created_at - 1)
}
setInitialized(true)
},
onNew: (event) => {
if (!isReplyNoteEvent(event)) {
setNewEvents((oldEvents) => [event, ...oldEvents])
}
}
})
return () => { 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
} }
}, [JSON.stringify(relayUrls), JSON.stringify(noteFilter)])
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)
}
useEffect(() => { useEffect(() => {
if (!initialized) return
const options = { const options = {
root: null, root: null,
rootMargin: '10px', rootMargin: '10px',
@ -115,26 +84,39 @@ export default function NoteList({
observer.current.unobserve(bottomRef.current) 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 ( return (
<> <>
{events.length > 0 && ( {newEvents.length > 0 && (
<div <div className="flex justify-center w-full mb-4">
className={`flex justify-center items-center gap-1 mb-2 text-muted-foreground ${!refreshing ? 'hover:text-foreground cursor-pointer' : ''}`} <Button onClick={showNewEvents}>show new notes</Button>
onClick={refresh}
>
<RefreshCcw size={12} className={`${refreshing ? 'animate-spin' : ''}`} />
<div className="text-xs">
{refreshing
? 'refreshing...'
: `last refreshed at ${dayjs(refreshedAt * 1000).format('HH:mm:ss')}`}
</div>
</div> </div>
)} )}
<div className={cn('flex flex-col gap-4', className)}> <div className={cn('flex flex-col gap-4', className)}>
{events.map((event, i) => ( {events.map((event, i) => (
<NoteCard key={i} className="w-full" event={event} /> <NoteCard key={`${i}-${event.id}`} className="w-full" event={event} />
))} ))}
</div> </div>
<div className="text-center text-xs text-muted-foreground mt-2"> <div className="text-center text-xs text-muted-foreground mt-2">

5
src/renderer/src/components/ProfileCard/index.tsx

@ -1,12 +1,13 @@
import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar'
import { generateImageByPubkey } from '@renderer/lib/pubkey'
import { useFetchProfile } from '@renderer/hooks' import { useFetchProfile } from '@renderer/hooks'
import { generateImageByPubkey } from '@renderer/lib/pubkey'
import { useMemo } from 'react'
import Nip05 from '../Nip05' import Nip05 from '../Nip05'
import ProfileAbout from '../ProfileAbout' import ProfileAbout from '../ProfileAbout'
export default function ProfileCard({ pubkey }: { pubkey: string }) { export default function ProfileCard({ pubkey }: { pubkey: string }) {
const { avatar = '', username, nip05, about } = useFetchProfile(pubkey) const { avatar = '', username, nip05, about } = useFetchProfile(pubkey)
const defaultAvatar = generateImageByPubkey(pubkey) const defaultAvatar = useMemo(() => generateImageByPubkey(pubkey), [pubkey])
return ( return (
<div className="w-full flex flex-col gap-2"> <div className="w-full flex flex-col gap-2">

207
src/renderer/src/components/RelaySettings/RelayGroup.tsx

@ -6,29 +6,16 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from '@renderer/components/ui/dropdown-menu' } from '@renderer/components/ui/dropdown-menu'
import { Input } from '@renderer/components/ui/input' import { Input } from '@renderer/components/ui/input'
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
import { Check, ChevronDown, Circle, CircleCheck, EllipsisVertical } from 'lucide-react' import { Check, ChevronDown, Circle, CircleCheck, EllipsisVertical } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { TRelayGroup } from './types'
import RelayUrls from './RelayUrl' import RelayUrls from './RelayUrl'
import { useRelaySettingsComponent } from './provider'
import { TRelayGroup } from './types'
export default function RelayGroup({ export default function RelayGroup({ group }: { group: TRelayGroup }) {
group, const { expandedRelayGroup } = useRelaySettingsComponent()
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
}) {
const { groupName, isActive, relayUrls } = group const { groupName, isActive, relayUrls } = group
const [expanded, setExpanded] = useState(false)
const [renaming, setRenaming] = useState(false)
const toggleExpanded = () => setExpanded((prev) => !prev)
return ( return (
<div <div
@ -36,96 +23,57 @@ export default function RelayGroup({
> >
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="flex space-x-2 items-center"> <div className="flex space-x-2 items-center">
<RelayGroupActiveToggle <RelayGroupActiveToggle groupName={groupName} />
isActive={isActive} <RelayGroupName groupName={groupName} />
onToggle={() => onSwitch(groupName)}
hasRelayUrls={relayUrls.length > 0}
/>
<RelayGroupName
groupName={groupName}
renaming={renaming}
hasRelayUrls={relayUrls.length > 0}
setRenaming={setRenaming}
save={onRename}
onToggle={() => onSwitch(groupName)}
/>
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
<RelayUrlsExpandToggle expanded={expanded} onClick={toggleExpanded}> <RelayUrlsExpandToggle groupName={groupName}>
{relayUrls.length} relays {relayUrls.length} relays
</RelayUrlsExpandToggle> </RelayUrlsExpandToggle>
<RelayGroupOptions <RelayGroupOptions groupName={groupName} />
groupName={groupName}
isActive={isActive}
onDelete={onDelete}
setRenaming={setRenaming}
/>
</div> </div>
</div> </div>
{expanded && ( {expandedRelayGroup === groupName && <RelayUrls groupName={groupName} />}
<RelayUrls
isActive={isActive}
relayUrls={relayUrls}
update={(urls) => onRelayUrlsUpdate(groupName, urls)}
/>
)}
</div> </div>
) )
} }
function RelayGroupActiveToggle({ function RelayGroupActiveToggle({ groupName }: { groupName: string }) {
isActive, const { relayGroups, switchRelayGroup } = useRelaySettings()
hasRelayUrls,
onToggle const isActive = relayGroups.find((group) => group.groupName === groupName)?.isActive
}: { const hasRelayUrls = relayGroups.find((group) => group.groupName === groupName)?.relayUrls.length
isActive: boolean
hasRelayUrls: boolean return isActive ? (
onToggle: () => void <CircleCheck size={18} className="text-highlight shrink-0" />
}) { ) : (
return ( <Circle
<> size={18}
{isActive ? ( className={`text-muted-foreground shrink-0 ${hasRelayUrls ? 'cursor-pointer hover:text-foreground ' : ''}`}
<CircleCheck size={18} className="text-highlight shrink-0" /> onClick={() => {
) : ( if (hasRelayUrls) {
<Circle switchRelayGroup(groupName)
size={18} }
className={`text-muted-foreground shrink-0 ${hasRelayUrls ? 'cursor-pointer hover:text-foreground ' : ''}`} }}
onClick={() => { />
if (hasRelayUrls) {
onToggle()
}
}}
/>
)}
</>
) )
} }
function RelayGroupName({ function RelayGroupName({ groupName }: { groupName: string }) {
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
}) {
const [newGroupName, setNewGroupName] = useState(groupName) const [newGroupName, setNewGroupName] = useState(groupName)
const [newNameError, setNewNameError] = useState<string | null>(null) const [newNameError, setNewNameError] = useState<string | null>(null)
const { relayGroups, switchRelayGroup, renameRelayGroup } = useRelaySettings()
const { renamingGroup, setRenamingGroup } = useRelaySettingsComponent()
const hasRelayUrls = relayGroups.find((group) => group.groupName === groupName)?.relayUrls.length
const saveNewGroupName = () => { const saveNewGroupName = () => {
const errMsg = save(groupName, newGroupName) const errMsg = renameRelayGroup(groupName, newGroupName)
if (errMsg) { if (errMsg) {
setNewNameError(errMsg) setNewNameError(errMsg)
return return
} }
setRenaming(false) setRenamingGroup(null)
} }
const handleRenameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleRenameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -140,72 +88,61 @@ function RelayGroupName({
} }
} }
return ( return renamingGroup === groupName ? (
<> <div className="flex gap-1 items-center">
{renaming ? ( <Input
<div className="flex gap-1 items-center"> value={newGroupName}
<Input onChange={handleRenameInputChange}
value={newGroupName} onBlur={saveNewGroupName}
onChange={handleRenameInputChange} onKeyDown={handleRenameInputKeyDown}
onBlur={saveNewGroupName} className={`font-semibold w-24 h-8 ${newNameError ? 'border-destructive' : ''}`}
onKeyDown={handleRenameInputKeyDown} />
className={`font-semibold w-24 h-8 ${newNameError ? 'border-destructive' : ''}`} <Button variant="ghost" className="h-8 w-8" onClick={saveNewGroupName}>
/> <Check size={18} className="text-green-500" />
<Button variant="ghost" className="h-8 w-8" onClick={saveNewGroupName}> </Button>
<Check size={18} className="text-green-500" /> {newNameError && <div className="text-xs text-destructive">{newNameError}</div>}
</Button> </div>
{newNameError && <div className="text-xs text-destructive">{newNameError}</div>} ) : (
</div> <div
) : ( className={`h-8 font-semibold flex items-center ${hasRelayUrls ? 'cursor-pointer' : 'text-muted-foreground'}`}
<div onClick={() => {
className={`h-8 font-semibold flex items-center ${hasRelayUrls ? 'cursor-pointer' : 'text-muted-foreground'}`} if (hasRelayUrls) {
onClick={() => { switchRelayGroup(groupName)
if (hasRelayUrls) { }
onToggle() }}
} >
}} {groupName}
> </div>
{groupName}
</div>
)}
</>
) )
} }
function RelayUrlsExpandToggle({ function RelayUrlsExpandToggle({
expanded, groupName,
onClick,
children children
}: { }: {
expanded: boolean groupName: string
onClick: () => void
children: React.ReactNode children: React.ReactNode
}) { }) {
const { expandedRelayGroup, setExpandedRelayGroup } = useRelaySettingsComponent()
return ( return (
<div <div
className="text-sm text-muted-foreground flex items-center gap-1 cursor-pointer hover:text-foreground" className="text-sm text-muted-foreground flex items-center gap-1 cursor-pointer hover:text-foreground"
onClick={onClick} onClick={() => setExpandedRelayGroup((pre) => (pre === groupName ? null : groupName))}
> >
<div className="select-none">{children}</div> <div className="select-none">{children}</div>
<ChevronDown <ChevronDown
size={16} size={16}
className={`transition-transform duration-200 ${expanded ? 'rotate-180' : ''}`} className={`transition-transform duration-200 ${expandedRelayGroup === groupName ? 'rotate-180' : ''}`}
/> />
</div> </div>
) )
} }
function RelayGroupOptions({ function RelayGroupOptions({ groupName }: { groupName: string }) {
groupName, const { relayGroups, deleteRelayGroup } = useRelaySettings()
isActive, const { setRenamingGroup } = useRelaySettingsComponent()
onDelete, const isActive = relayGroups.find((group) => group.groupName === groupName)?.isActive
setRenaming
}: {
groupName: string
isActive: boolean
onDelete: (groupName: string) => void
setRenaming: (renaming: boolean) => void
}) {
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
@ -215,11 +152,11 @@ function RelayGroupOptions({
/> />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem onClick={() => setRenaming(true)}>Rename</DropdownMenuItem> <DropdownMenuItem onClick={() => setRenamingGroup(groupName)}>Rename</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
disabled={isActive} disabled={isActive}
onClick={() => onDelete(groupName)} onClick={() => deleteRelayGroup(groupName)}
> >
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>

28
src/renderer/src/components/RelaySettings/RelayUrl.tsx

@ -1,18 +1,15 @@
import { Button } from '@renderer/components/ui/button' import { Button } from '@renderer/components/ui/button'
import { Input } from '@renderer/components/ui/input' import { Input } from '@renderer/components/ui/input'
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
import client from '@renderer/services/client.service' import client from '@renderer/services/client.service'
import { CircleX } from 'lucide-react' import { CircleX } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
export default function RelayUrls({ export default function RelayUrls({ groupName }: { groupName: string }) {
isActive, const { relayGroups, updateRelayGroupRelayUrls } = useRelaySettings()
relayUrls: rawRelayUrls, const rawRelayUrls = relayGroups.find((group) => group.groupName === groupName)?.relayUrls ?? []
update const isActive = relayGroups.find((group) => group.groupName === groupName)?.isActive ?? false
}: {
isActive: boolean
relayUrls: string[]
update: (urls: string[]) => void
}) {
const [newRelayUrl, setNewRelayUrl] = useState('') const [newRelayUrl, setNewRelayUrl] = useState('')
const [newRelayUrlError, setNewRelayUrlError] = useState<string | null>(null) const [newRelayUrlError, setNewRelayUrlError] = useState<string | null>(null)
const [relays, setRelays] = useState< const [relays, setRelays] = useState<
@ -38,7 +35,10 @@ export default function RelayUrls({
const removeRelayUrl = (url: string) => { const removeRelayUrl = (url: string) => {
setRelays((relays) => relays.filter((relay) => relay.url !== url)) 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 = () => { const saveNewRelayUrl = () => {
@ -51,7 +51,7 @@ export default function RelayUrls({
} }
setRelays((pre) => [...pre, { url: normalizedUrl, isConnected: false }]) setRelays((pre) => [...pre, { url: normalizedUrl, isConnected: false }])
const newRelayUrls = [...relays.map(({ url }) => url), normalizedUrl] const newRelayUrls = [...relays.map(({ url }) => url), normalizedUrl]
update(newRelayUrls) updateRelayGroupRelayUrls(groupName, newRelayUrls)
setNewRelayUrl('') setNewRelayUrl('')
} }
@ -113,11 +113,11 @@ function RelayUrl({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
{!isActive ? ( {!isActive ? (
<div className="text-muted-foreground"></div> <div className="text-muted-foreground text-xs"></div>
) : isConnected ? ( ) : isConnected ? (
<div className="text-green-500"></div> <div className="text-green-500 text-xs"></div>
) : ( ) : (
<div className="text-red-500"></div> <div className="text-red-500 text-xs"></div>
)} )}
<div className="text-muted-foreground text-sm">{url}</div> <div className="text-muted-foreground text-sm">{url}</div>
</div> </div>

97
src/renderer/src/components/RelaySettings/index.tsx

@ -1,91 +1,29 @@
import { Button } from '@renderer/components/ui/button' import { Button } from '@renderer/components/ui/button'
import { Input } from '@renderer/components/ui/input' import { Input } from '@renderer/components/ui/input'
import { Separator } from '@renderer/components/ui/separator' 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 { useEffect, useRef, useState } from 'react'
import { RelaySettingsComponentProvider } from './provider'
import RelayGroup from './RelayGroup' import RelayGroup from './RelayGroup'
import { TRelayGroup } from './types'
export default function RelaySettings() { export default function RelaySettings() {
const [groups, setGroups] = useState<TRelayGroup[]>([]) const { relayGroups, addRelayGroup } = useRelaySettings()
const [newGroupName, setNewGroupName] = useState('') const [newGroupName, setNewGroupName] = useState('')
const [newNameError, setNewNameError] = useState<string | null>(null) const [newNameError, setNewNameError] = useState<string | null>(null)
const dummyRef = useRef<HTMLDivElement>(null) const dummyRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
const init = async () => {
const storedGroups = await storage.getRelayGroups()
setGroups(storedGroups)
}
if (dummyRef.current) { if (dummyRef.current) {
dummyRef.current.focus() dummyRef.current.focus()
} }
init()
}, []) }, [])
const updateGroups = async (newGroups: TRelayGroup[]) => { const saveRelayGroup = () => {
setGroups(newGroups) const errMsg = addRelayGroup(newGroupName)
await storage.setRelayGroups(newGroups) if (errMsg) {
} return setNewNameError(errMsg)
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')
} }
setNewGroupName('') setNewGroupName('')
updateGroups([
...groups,
{
groupName: newGroupName,
relayUrls: [],
isActive: false
}
])
} }
const handleNewGroupNameChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleNewGroupNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -96,27 +34,20 @@ export default function RelaySettings() {
const handleNewGroupNameKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { const handleNewGroupNameKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
event.preventDefault() event.preventDefault()
addRelayGroup() saveRelayGroup()
} }
} }
return ( return (
<div> <RelaySettingsComponentProvider>
<div ref={dummyRef} tabIndex={-1} style={{ position: 'absolute', opacity: 0 }}></div> <div ref={dummyRef} tabIndex={-1} style={{ position: 'absolute', opacity: 0 }}></div>
<div className="text-lg font-semibold mb-4">Relay Settings</div> <div className="text-lg font-semibold mb-4">Relay Settings</div>
<div className="space-y-2"> <div className="space-y-2">
{groups.map((group, index) => ( {relayGroups.map((group, index) => (
<RelayGroup <RelayGroup key={index} group={group} />
key={index}
group={group}
onSwitch={switchRelayGroup}
onDelete={deleteRelayGroup}
onRename={renameRelayGroup}
onRelayUrlsUpdate={updateRelayGroupRelayUrls}
/>
))} ))}
</div> </div>
{groups.length < 5 && ( {relayGroups.length < 5 && (
<> <>
<Separator className="my-4" /> <Separator className="my-4" />
<div className="w-full border rounded-lg p-4"> <div className="w-full border rounded-lg p-4">
@ -130,7 +61,7 @@ export default function RelaySettings() {
value={newGroupName} value={newGroupName}
onChange={handleNewGroupNameChange} onChange={handleNewGroupNameChange}
onKeyDown={handleNewGroupNameKeyDown} onKeyDown={handleNewGroupNameKeyDown}
onBlur={addRelayGroup} onBlur={saveRelayGroup}
/> />
<Button className="h-8 w-12">Add</Button> <Button className="h-8 w-12">Add</Button>
</div> </div>
@ -138,6 +69,6 @@ export default function RelaySettings() {
</div> </div>
</> </>
)} )}
</div> </RelaySettingsComponentProvider>
) )
} }

40
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<React.SetStateAction<string | null>>
expandedRelayGroup: string | null
setExpandedRelayGroup: React.Dispatch<React.SetStateAction<string | null>>
}
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<string | null>(null)
const [expandedRelayGroup, setExpandedRelayGroup] = useState<string | null>(null)
return (
<RelaySettingsComponentContext.Provider
value={{
renamingGroup,
setRenamingGroup,
expandedRelayGroup,
setExpandedRelayGroup
}}
>
{children}
</RelaySettingsComponentContext.Provider>
)
}

16
src/renderer/src/components/ReplyNoteList/index.tsx

@ -19,14 +19,12 @@ export default function ReplyNoteList({ event, className }: { event: Event; clas
const loadMore = async () => { const loadMore = async () => {
setLoading(true) setLoading(true)
const events = await client.fetchEvents([ const events = await client.fetchEvents({
{ '#e': [event.id],
'#e': [event.id], kinds: [1],
kinds: [1], limit: 100,
limit: 200, until
until })
}
])
const sortedEvents = events.sort((a, b) => a.created_at - b.created_at) const sortedEvents = events.sort((a, b) => a.created_at - b.created_at)
if (sortedEvents.length > 0) { if (sortedEvents.length > 0) {
const eventMap: Record<string, Event> = {} const eventMap: Record<string, Event> = {}
@ -38,7 +36,7 @@ export default function ReplyNoteList({ event, className }: { event: Event; clas
setEventMap((pre) => ({ ...pre, ...eventMap })) setEventMap((pre) => ({ ...pre, ...eventMap }))
setUntil(sortedEvents[0].created_at - 1) setUntil(sortedEvents[0].created_at - 1)
} }
setHasMore(sortedEvents.length >= 200) setHasMore(sortedEvents.length >= 100)
setLoading(false) setLoading(false)
} }

8
src/renderer/src/components/UserAvatar/index.tsx

@ -7,6 +7,7 @@ import { toProfile } from '@renderer/lib/url'
import { cn } from '@renderer/lib/utils' import { cn } from '@renderer/lib/utils'
import { SecondaryPageLink } from '@renderer/PageManager' import { SecondaryPageLink } from '@renderer/PageManager'
import ProfileCard from '../ProfileCard' import ProfileCard from '../ProfileCard'
import { useMemo } from 'react'
const UserAvatarSizeCnMap = { const UserAvatarSizeCnMap = {
large: 'w-24 h-24', large: 'w-24 h-24',
@ -25,10 +26,11 @@ export default function UserAvatar({
size?: 'large' | 'normal' | 'small' | 'tiny' size?: 'large' | 'normal' | 'small' | 'tiny'
}) { }) {
const { avatar, pubkey } = useFetchProfile(userId) const { avatar, pubkey } = useFetchProfile(userId)
if (!pubkey) const defaultAvatar = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey])
return <Skeleton className={cn(UserAvatarSizeCnMap[size], 'rounded-full', className)} />
const defaultAvatar = generateImageByPubkey(pubkey) if (!pubkey) {
return <Skeleton className={cn(UserAvatarSizeCnMap[size], 'rounded-full', className)} />
}
return ( return (
<HoverCard> <HoverCard>

2
src/renderer/src/hooks/useFetchEvent.tsx

@ -34,7 +34,7 @@ export function useFetchEventById(id?: string) {
if (filter.ids) { if (filter.ids) {
event = await client.fetchEventById(filter.ids[0]) event = await client.fetchEventById(filter.ids[0])
} else { } else {
event = await client.fetchEventWithCache(filter) event = await client.fetchEventByFilter(filter)
} }
if (event) { if (event) {
setEvent(event) setEvent(event)

92
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 client from '@renderer/services/client.service'
import { TProfile } from '@renderer/types'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { useCallback, useEffect, useState } from 'react' import { 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 {}
}
export function useFetchProfile(id?: string) { export function useFetchProfile(id?: string) {
const initialProfile: TProfile = { const [profile, setProfile] = useState<TProfile>({
username: id ? (id.length > 9 ? id.slice(0, 4) + '...' + id.slice(-4) : id) : 'username' username: id ? (id.length > 9 ? id.slice(0, 4) + '...' + id.slice(-4) : id) : 'username'
} })
const [profile, setProfile] = useState<TProfile>(initialProfile)
const fetchProfile = useCallback(async () => {
try {
if (!id) return
const { pubkey, npub } = decodeUserId(id) useEffect(() => {
if (!pubkey || !npub) return const fetchProfile = async () => {
try {
const profileEvent = await client.fetchProfile(pubkey) if (!id) return
const username = npub ? formatNpub(npub) : initialProfile.username
setProfile({ pubkey, npub, username }) let pubkey: string | undefined
if (!profileEvent) return
if (/^[0-9a-f]{64}$/.test(id)) {
const profileObj = JSON.parse(profileEvent.content) pubkey = id
setProfile({ } else {
...initialProfile, const { data, type } = nip19.decode(id)
pubkey, switch (type) {
npub, case 'npub':
banner: profileObj.banner, pubkey = data
avatar: profileObj.picture, break
username: case 'nprofile':
profileObj.display_name?.trim() || profileObj.name?.trim() || initialProfile.username, pubkey = data.pubkey
nip05: profileObj.nip05, break
about: profileObj.about }
}) }
} catch (err) {
console.error(err) if (!pubkey) return
setProfile({ pubkey, username: formatPubkey(pubkey) })
const profile = await client.fetchProfile(pubkey)
if (profile) {
setProfile(profile)
}
} catch (err) {
console.error(err)
}
} }
}, [id])
useEffect(() => {
fetchProfile() fetchProfile()
}, [id]) }, [id])

12
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 (
<TitlebarButton onClick={refresh} title="reload">
<RefreshCcw />
</TitlebarButton>
)
}

14
src/renderer/src/layouts/PrimaryPageLayout/ReloadTimelineButton.tsx

@ -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 (
<TitlebarButton
onClick={() => eventBus.emit(createReloadTimelineEvent())}
title="reload timeline"
>
<Eraser />
</TitlebarButton>
)
}

4
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 { ScrollArea } from '@renderer/components/ui/scroll-area'
import { isMacOS } from '@renderer/lib/platform' import { isMacOS } from '@renderer/lib/platform'
import { forwardRef, useImperativeHandle, useRef } from 'react' import { forwardRef, useImperativeHandle, useRef } from 'react'
import ReloadTimelineButton from './ReloadTimelineButton'
import RelaySettingsPopover from './RelaySettingsPopover' import RelaySettingsPopover from './RelaySettingsPopover'
import RefreshButton from './RefreshButton'
const PrimaryPageLayout = forwardRef( const PrimaryPageLayout = forwardRef(
( (
@ -48,7 +48,7 @@ export function PrimaryPageTitlebar({ content }: { content?: React.ReactNode })
<Titlebar className={`justify-between ${isMacOS() ? 'pl-20' : ''}`}> <Titlebar className={`justify-between ${isMacOS() ? 'pl-20' : ''}`}>
<div>{content}</div> <div>{content}</div>
<div className="flex gap-1"> <div className="flex gap-1">
<ReloadTimelineButton /> <RefreshButton />
<RelaySettingsPopover /> <RelaySettingsPopover />
</div> </div>
</Titlebar> </Titlebar>

2
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 { TitlebarButton } from '@renderer/components/Titlebar'
import { Moon, Sun, SunMoon } from 'lucide-react' import { Moon, Sun, SunMoon } from 'lucide-react'

2
src/renderer/src/pages/primary/NoteListPage/index.tsx

@ -4,7 +4,7 @@ import PrimaryPageLayout from '@renderer/layouts/PrimaryPageLayout'
export default function NoteListPage() { export default function NoteListPage() {
return ( return (
<PrimaryPageLayout> <PrimaryPageLayout>
<NoteList isHomeTimeline filter={{ limit: 200 }} /> <NoteList />
</PrimaryPageLayout> </PrimaryPageLayout>
) )
} }

37
src/renderer/src/pages/secondary/ProfilePage/index.tsx

@ -1,22 +1,24 @@
import Nip05 from '@renderer/components/Nip05' import Nip05 from '@renderer/components/Nip05'
import NoteList from '@renderer/components/NoteList' import NoteList from '@renderer/components/NoteList'
import ProfileAbout from '@renderer/components/ProfileAbout' import ProfileAbout from '@renderer/components/ProfileAbout'
import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar'
import { Separator } from '@renderer/components/ui/separator' import { Separator } from '@renderer/components/ui/separator'
import UserAvatar from '@renderer/components/UserAvatar'
import { useFetchProfile } from '@renderer/hooks' import { useFetchProfile } from '@renderer/hooks'
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
import { formatNpub, generateImageByPubkey } from '@renderer/lib/pubkey' import { formatNpub, generateImageByPubkey } from '@renderer/lib/pubkey'
import { Copy } from 'lucide-react' 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 }) { 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 [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 if (!pubkey || !npub) return null
const copyNpub = () => { const copyNpub = () => {
if (!npub) return
navigator.clipboard.writeText(npub) navigator.clipboard.writeText(npub)
setCopied(true) setCopied(true)
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000)
@ -27,14 +29,16 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-12"> <div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-12">
<ProfileBanner <ProfileBanner
banner={banner} banner={banner}
defaultBanner={defaultImage}
pubkey={pubkey} pubkey={pubkey}
className="w-full h-full object-cover rounded-lg" className="w-full h-full object-cover rounded-lg"
/> />
<UserAvatar <Avatar className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background">
userId={pubkey} <AvatarImage src={avatar} className="object-cover object-center" />
size="large" <AvatarFallback>
className="absolute bottom-0 left-4 translate-y-1/2 border-4 border-background" <img src={defaultImage} />
/> </AvatarFallback>
</Avatar>
</div> </div>
<div className="px-4 space-y-1"> <div className="px-4 space-y-1">
<div className="text-xl font-semibold">{username}</div> <div className="text-xl font-semibold">{username}</div>
@ -44,7 +48,7 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
onClick={() => copyNpub()} onClick={() => copyNpub()}
> >
{copied ? ( {copied ? (
<div>Copied!</div> <div>copied!</div>
) : ( ) : (
<> <>
<div>{formatNpub(npub, 24)}</div> <div>{formatNpub(npub, 24)}</div>
@ -56,22 +60,23 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
<ProfileAbout about={about} /> <ProfileAbout about={about} />
</div> </div>
</div> </div>
<Separator className="my-2" /> <Separator className="my-4" />
<NoteList key={pubkey} filter={{ authors: [pubkey] }} /> <NoteList key={pubkey} filter={{ authors: [pubkey] }} />
</SecondaryPageLayout> </SecondaryPageLayout>
) )
} }
function ProfileBanner({ function ProfileBanner({
banner, defaultBanner,
pubkey, pubkey,
banner,
className className
}: { }: {
banner?: string defaultBanner: string
pubkey: string pubkey: string
banner?: string
className?: string className?: string
}) { }) {
const defaultBanner = generateImageByPubkey(pubkey)
const [bannerUrl, setBannerUrl] = useState(banner || defaultBanner) const [bannerUrl, setBannerUrl] = useState(banner || defaultBanner)
useEffect(() => { useEffect(() => {
@ -80,12 +85,12 @@ function ProfileBanner({
} else { } else {
setBannerUrl(defaultBanner) setBannerUrl(defaultBanner)
} }
}, [pubkey, banner]) }, [defaultBanner, banner])
return ( return (
<img <img
src={bannerUrl} src={bannerUrl}
alt="Banner" alt={`${pubkey} banner`}
className={className} className={className}
onError={() => setBannerUrl(defaultBanner)} onError={() => setBannerUrl(defaultBanner)}
/> />

123
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<TRelaySettingsContext | undefined>(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<TRelayGroup[]>([])
const [relayUrls, setRelayUrls] = useState<string[]>(
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 (
<RelaySettingsContext.Provider
value={{
relayGroups,
relayUrls,
switchRelayGroup,
renameRelayGroup,
deleteRelayGroup,
addRelayGroup,
updateRelayGroupRelayUrls
}}
>
{children}
</RelaySettingsContext.Provider>
)
}

0
src/renderer/src/components/theme-provider.tsx → src/renderer/src/providers/ThemeProvider.tsx

296
src/renderer/src/services/client.service.ts

@ -1,55 +1,53 @@
import { TRelayGroup } from '@common/types' 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 { LRUCache } from 'lru-cache'
import { Filter, kinds, Event as NEvent, SimplePool } from 'nostr-tools' import { Filter, kinds, Event as NEvent, SimplePool } from 'nostr-tools'
import { EVENT_TYPES, eventBus } from './event-bus.service' import { EVENT_TYPES, eventBus } from './event-bus.service'
import storage from './storage.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 { class ClientService {
static instance: ClientService static instance: ClientService
private pool = new SimplePool() private pool = new SimplePool()
private relayUrls: string[] = BIG_RELAY_URLS
private initPromise!: Promise<void> private initPromise!: Promise<void>
private relayUrls: string[] = []
private cache = new LRUCache<string, NEvent>({
max: 10000,
fetchMethod: async (filter) => this.fetchEvent(JSON.parse(filter))
})
// Event cache
private eventsCache = new LRUCache<string, Promise<NEvent | undefined>>({
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<string, Promise<TEventStats>>({ private eventStatsCache = new LRUCache<string, Promise<TEventStats>>({
max: 10000, max: 10000,
ttl: 1000 * 60 * 10, // 10 minutes ttl: 1000 * 60 * 10, // 10 minutes
fetchMethod: async (id) => this._fetchEventStatsById(id) fetchMethod: async (id) => this._fetchEventStatsById(id)
}) })
// Profile cache private eventCache = new LRUCache<string, Promise<NEvent | undefined>>({
private profilesCache = new LRUCache<string, Promise<NEvent | undefined>>({
max: 10000, max: 10000,
ttl: 1000 * 60 * 10 // 10 minutes fetchMethod: async (filterStr) => {
const [event] = await this.fetchEvents(JSON.parse(filterStr))
return event
}
}) })
private fetchProfileQueue = new Map<
string, private eventDataloader = new DataLoader<string, NEvent | undefined>(
this.eventBatchLoadFn.bind(this),
{
cacheMap: new LRUCache<string, Promise<NEvent | undefined>>({ max: 10000 })
}
)
private profileDataloader = new DataLoader<string, TProfile | undefined>(
this.profileBatchLoadFn.bind(this),
{ {
resolve: (value: NEvent | undefined) => void cacheMap: new LRUCache<string, Promise<TProfile | undefined>>({ max: 10000 })
reject: (reason: any) => void
} }
>() )
private fetchProfileTimer: NodeJS.Timeout | null = null
constructor() { constructor() {
if (!ClientService.instance) { if (!ClientService.instance) {
@ -69,12 +67,6 @@ class ClientService {
onRelayGroupsChange(relayGroups: TRelayGroup[]) { onRelayGroupsChange(relayGroups: TRelayGroup[]) {
const newRelayUrls = relayGroups.find((group) => group.isActive)?.relayUrls ?? [] 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 this.relayUrls = newRelayUrls
} }
@ -82,141 +74,157 @@ class ClientService {
return this.pool.listConnectionStatus() return this.pool.listConnectionStatus()
} }
async fetchEvents(filters: Filter[]) { subscribeEvents(
await this.initPromise urls: string[],
return new Promise<NEvent[]>((resolve) => { filter: Filter,
const events: NEvent[] = [] opts: {
this.pool.subscribeManyEose(this.relayUrls, filters, { onEose: (events: NEvent[]) => void
onevent(event) { onNew: (evt: NEvent) => void
events.push(event) }
}, ) {
onclose() { console.log('subscribeEvents', urls, filter)
resolve(events) const events: NEvent[] = []
let eose = false
return this.pool.subscribeMany(urls, [filter], {
onevent: (evt) => {
if (eose) {
opts.onNew(evt)
} else {
events.push(evt)
} }
}) },
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))
}
}
}) })
} }
async fetchEventWithCache(filter: Filter) { async fetchEvents(filter: Filter, relayUrls: string[] = this.relayUrls) {
return this.cache.fetch(JSON.stringify(filter)) await this.initPromise
return await this.pool.querySync(relayUrls, filter)
} }
async fetchEvent(filter: Filter) { async fetchEventStatsById(id: string): Promise<TEventStats> {
const events = await this.fetchEvents([{ ...filter, limit: 1 }]) const stats = await this.eventStatsCache.fetch(id)
return events.length ? events[0] : undefined return stats ?? { reactionCount: 0, repostCount: 0 }
} }
async fetchEventById(id: string): Promise<NEvent | undefined> { async fetchEventByFilter(filter: Filter) {
const cache = this.eventsCache.get(id) return this.eventCache.fetch(JSON.stringify({ ...filter, limit: 1 }))
if (cache) { }
return cache
}
const promise = new Promise<NEvent | undefined>((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)
}
}
}, 20)
})
this.eventsCache.set(id, promise) async fetchEventById(id: string): Promise<NEvent | undefined> {
return promise return this.eventDataloader.load(id)
} }
async fetchEventStatsById(id: string): Promise<TEventStats> { async fetchProfile(pubkey: string): Promise<TProfile | undefined> {
const stats = await this.eventStatsCache.fetch(id) return this.profileDataloader.load(pubkey)
return stats ?? { reactionCount: 0, repostCount: 0 }
} }
private async _fetchEventStatsById(id: string) { private async _fetchEventStatsById(id: string) {
const [reactionEvents, repostEvents] = await Promise.all([ const [reactionEvents, repostEvents] = await Promise.all([
this.fetchEvents([{ '#e': [id], kinds: [kinds.Reaction] }]), this.fetchEvents({ '#e': [id], kinds: [kinds.Reaction] }),
this.fetchEvents([{ '#e': [id], kinds: [kinds.Repost] }]) this.fetchEvents({ '#e': [id], kinds: [kinds.Repost] })
]) ])
return { reactionCount: reactionEvents.length, repostCount: repostEvents.length } return { reactionCount: reactionEvents.length, repostCount: repostEvents.length }
} }
async fetchProfile(pubkey: string): Promise<NEvent | undefined> { private async eventBatchLoadFn(ids: readonly string[]) {
const cache = this.profilesCache.get(pubkey) const events = await this.fetchEvents({
if (cache) { ids: ids as string[],
return cache limit: ids.length
})
const eventsMap = new Map<string, NEvent>()
for (const event of events) {
eventsMap.set(event.id, event)
}
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)
}
} }
const promise = new Promise<NEvent | undefined>((resolve, reject) => { return ids.map((id) => eventsMap.get(id))
this.fetchProfileQueue.set(pubkey, { resolve, reject }) }
if (this.fetchProfileTimer) {
return 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<string, NEvent>()
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.fetchProfileTimer = setTimeout(async () => { const missingPubkeys = pubkeys.filter((pubkey) => !eventsMap.has(pubkey))
this.fetchProfileTimer = null if (missingPubkeys.length > 0) {
const queue = new Map(this.fetchProfileQueue) const missingEvents = await this.fetchEvents(
this.fetchProfileQueue.clear() {
authors: missingPubkeys,
try { kinds: [kinds.Metadata],
const pubkeys = Array.from(queue.keys()) limit: missingPubkeys.length
const events = await this.fetchEvents([ },
{ BIG_RELAY_URLS.filter((url) => !this.relayUrls.includes(url))
authors: pubkeys, )
kinds: [0], for (const event of missingEvents) {
limit: pubkeys.length const pubkey = event.pubkey
} const existing = eventsMap.get(pubkey)
]) if (!existing || existing.created_at < event.created_at) {
const eventsMap = new Map<string, NEvent>() eventsMap.set(pubkey, event)
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) }
}
return pubkeys.map((pubkey) => {
const event = eventsMap.get(pubkey)
return event ? this.parseProfileFromEvent(event) : undefined
}) })
}
this.profilesCache.set(pubkey, promise) private parseProfileFromEvent(event: NEvent): TProfile {
return promise 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)
}
}
} }
} }

5
src/renderer/src/services/event-bus.service.ts

@ -2,13 +2,11 @@ import { TRelayGroup } from '@common/types'
export const EVENT_TYPES = { export const EVENT_TYPES = {
RELAY_GROUPS_CHANGED: 'relay-groups-changed', RELAY_GROUPS_CHANGED: 'relay-groups-changed',
RELOAD_TIMELINE: 'reload-timeline',
REPLY_COUNT_CHANGED: 'reply-count-changed' REPLY_COUNT_CHANGED: 'reply-count-changed'
} as const } as const
type TEventMap = { type TEventMap = {
[EVENT_TYPES.RELAY_GROUPS_CHANGED]: TRelayGroup[] [EVENT_TYPES.RELAY_GROUPS_CHANGED]: TRelayGroup[]
[EVENT_TYPES.RELOAD_TIMELINE]: unknown
[EVENT_TYPES.REPLY_COUNT_CHANGED]: { eventId: string; replyCount: number } [EVENT_TYPES.REPLY_COUNT_CHANGED]: { eventId: string; replyCount: number }
} }
@ -19,9 +17,6 @@ type TCustomEventMap = {
export const createRelayGroupsChangedEvent = (relayGroups: TRelayGroup[]) => { export const createRelayGroupsChangedEvent = (relayGroups: TRelayGroup[]) => {
return new CustomEvent(EVENT_TYPES.RELAY_GROUPS_CHANGED, { detail: relayGroups }) 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) => { export const createReplyCountChangedEvent = (eventId: string, replyCount: number) => {
return new CustomEvent(EVENT_TYPES.REPLY_COUNT_CHANGED, { detail: { eventId, replyCount } }) return new CustomEvent(EVENT_TYPES.REPLY_COUNT_CHANGED, { detail: { eventId, replyCount } })
} }

24
src/renderer/src/services/storage.service.ts

@ -4,20 +4,40 @@ import { createRelayGroupsChangedEvent, eventBus } from './event-bus.service'
class StorageService { class StorageService {
static instance: StorageService static instance: StorageService
private initPromise!: Promise<void>
private relayGroups: TRelayGroup[] = []
private activeRelayUrls: string[] = []
constructor() { constructor() {
if (!StorageService.instance) { if (!StorageService.instance) {
this.initPromise = this.init()
StorageService.instance = this StorageService.instance = this
} }
return StorageService.instance return StorageService.instance
} }
async init() {
this.relayGroups = await window.api.storage.getRelayGroups()
this.activeRelayUrls = this.relayGroups.find((group) => group.isActive)?.relayUrls ?? []
}
async getRelayGroups() { async getRelayGroups() {
return await window.api.storage.getRelayGroups() await this.initPromise
return this.relayGroups
} }
async setRelayGroups(relayGroups: TRelayGroup[]) { async setRelayGroups(relayGroups: TRelayGroup[]) {
await this.initPromise
await window.api.storage.setRelayGroups(relayGroups) 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
} }
} }

9
src/renderer/src/types.ts

@ -1 +1,10 @@
export type TEventStats = { reactionCount: number; repostCount: number } export type TEventStats = { reactionCount: number; repostCount: number }
export type TProfile = {
username: string
pubkey?: string
banner?: string
avatar?: string
nip05?: string
about?: string
}

Loading…
Cancel
Save