refactor
This commit is contained in:
parent
9beaffb272
commit
bfc07545b3
28 changed files with 665 additions and 608 deletions
6
package-lock.json
generated
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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
|
||||||
setRefreshing(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 () => {
|
return () => {
|
||||||
eventBus.remove(EVENT_TYPES.RELOAD_TIMELINE, handleClearList)
|
sub.close()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [JSON.stringify(relayUrls), JSON.stringify(noteFilter)])
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
Normal file
40
src/renderer/src/components/RelaySettings/provider.tsx
Normal file
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
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(() => {
|
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()
|
fetchProfile()
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
|
|
|
||||||
12
src/renderer/src/layouts/PrimaryPageLayout/RefreshButton.tsx
Normal file
12
src/renderer/src/layouts/PrimaryPageLayout/RefreshButton.tsx
Normal file
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
Normal file
123
src/renderer/src/providers/RelaySettingsProvider.tsx
Normal file
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
private fetchProfileQueue = new Map<
|
return event
|
||||||
string,
|
|
||||||
{
|
|
||||||
resolve: (value: NEvent | undefined) => void
|
|
||||||
reject: (reason: any) => void
|
|
||||||
}
|
}
|
||||||
>()
|
})
|
||||||
private fetchProfileTimer: NodeJS.Timeout | null = null
|
|
||||||
|
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),
|
||||||
|
{
|
||||||
|
cacheMap: new LRUCache<string, Promise<TProfile | undefined>>({ max: 10000 })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
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,70 +74,40 @@ 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() {
|
|
||||||
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<NEvent | undefined> {
|
|
||||||
const cache = this.eventsCache.get(id)
|
|
||||||
if (cache) {
|
|
||||||
return cache
|
|
||||||
}
|
}
|
||||||
|
) {
|
||||||
const promise = new Promise<NEvent | undefined>((resolve, reject) => {
|
console.log('subscribeEvents', urls, filter)
|
||||||
this.fetchEventQueue.set(id, { resolve, reject })
|
const events: NEvent[] = []
|
||||||
if (this.fetchEventTimer) {
|
let eose = false
|
||||||
return
|
return this.pool.subscribeMany(urls, [filter], {
|
||||||
}
|
onevent: (evt) => {
|
||||||
|
if (eose) {
|
||||||
this.fetchEventTimer = setTimeout(async () => {
|
opts.onNew(evt)
|
||||||
this.fetchEventTimer = null
|
} else {
|
||||||
const queue = new Map(this.fetchEventQueue)
|
events.push(evt)
|
||||||
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)
|
},
|
||||||
|
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)
|
async fetchEvents(filter: Filter, relayUrls: string[] = this.relayUrls) {
|
||||||
return promise
|
await this.initPromise
|
||||||
|
return await this.pool.querySync(relayUrls, filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchEventStatsById(id: string): Promise<TEventStats> {
|
async fetchEventStatsById(id: string): Promise<TEventStats> {
|
||||||
|
|
@ -153,70 +115,116 @@ class ClientService {
|
||||||
return stats ?? { reactionCount: 0, repostCount: 0 }
|
return stats ?? { reactionCount: 0, repostCount: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchEventByFilter(filter: Filter) {
|
||||||
|
return this.eventCache.fetch(JSON.stringify({ ...filter, limit: 1 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchEventById(id: string): Promise<NEvent | undefined> {
|
||||||
|
return this.eventDataloader.load(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchProfile(pubkey: string): Promise<TProfile | undefined> {
|
||||||
|
return this.profileDataloader.load(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
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 promise = new Promise<NEvent | undefined>((resolve, reject) => {
|
const missingIds = ids.filter((id) => !eventsMap.has(id))
|
||||||
this.fetchProfileQueue.set(pubkey, { resolve, reject })
|
if (missingIds.length > 0) {
|
||||||
if (this.fetchProfileTimer) {
|
const missingEvents = await this.fetchEvents(
|
||||||
return
|
{
|
||||||
|
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 () => {
|
return ids.map((id) => eventsMap.get(id))
|
||||||
this.fetchProfileTimer = null
|
}
|
||||||
const queue = new Map(this.fetchProfileQueue)
|
|
||||||
this.fetchProfileQueue.clear()
|
|
||||||
|
|
||||||
try {
|
private async profileBatchLoadFn(pubkeys: readonly string[]) {
|
||||||
const pubkeys = Array.from(queue.keys())
|
const events = await this.fetchEvents({
|
||||||
const events = await this.fetchEvents([
|
authors: pubkeys as string[],
|
||||||
{
|
kinds: [kinds.Metadata],
|
||||||
authors: pubkeys,
|
limit: pubkeys.length
|
||||||
kinds: [0],
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
|
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.profilesCache.set(pubkey, promise)
|
const missingPubkeys = pubkeys.filter((pubkey) => !eventsMap.has(pubkey))
|
||||||
return promise
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 } })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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…
Add table
Add a link
Reference in a new issue