refactor
This commit is contained in:
6
package-lock.json
generated
6
package-lock.json
generated
@@ -25,6 +25,7 @@
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dataloader": "^2.2.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"lru-cache": "^11.0.1",
|
||||
"lucide-react": "^0.453.0",
|
||||
@@ -4361,6 +4362,11 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/dataloader": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.2.tgz",
|
||||
"integrity": "sha512-8YnDaaf7N3k/q5HnTJVuzSyLETjoZjVmHc4AeKAzOvKHEFQKcn64OKBfzHYtE9zGjctNM7V9I0MfnUVLpi7M5g=="
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.13",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dataloader": "^2.2.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"lru-cache": "^11.0.1",
|
||||
"lucide-react": "^0.453.0",
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import 'yet-another-react-lightbox/styles.css'
|
||||
import './assets/main.css'
|
||||
|
||||
import { ThemeProvider } from '@renderer/components/theme-provider'
|
||||
import { Toaster } from '@renderer/components/ui/toaster'
|
||||
import { ThemeProvider } from '@renderer/providers/ThemeProvider'
|
||||
import { PageManager } from './PageManager'
|
||||
import NoteListPage from './pages/primary/NoteListPage'
|
||||
import HashtagPage from './pages/secondary/HashtagPage'
|
||||
import NotePage from './pages/secondary/NotePage'
|
||||
import ProfilePage from './pages/secondary/ProfilePage'
|
||||
import { RelaySettingsProvider } from './providers/RelaySettingsProvider'
|
||||
|
||||
const routes = [
|
||||
{ pageName: 'note', element: <NotePage /> },
|
||||
@@ -19,10 +20,12 @@ export default function App(): JSX.Element {
|
||||
return (
|
||||
<div className="h-screen">
|
||||
<ThemeProvider>
|
||||
<PageManager routes={routes}>
|
||||
<NoteListPage />
|
||||
</PageManager>
|
||||
<Toaster />
|
||||
<RelaySettingsProvider>
|
||||
<PageManager routes={routes}>
|
||||
<NoteListPage />
|
||||
</PageManager>
|
||||
<Toaster />
|
||||
</RelaySettingsProvider>
|
||||
</ThemeProvider>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -17,6 +17,10 @@ type TPushParams = {
|
||||
props: any
|
||||
}
|
||||
|
||||
type TPrimaryPageContext = {
|
||||
refresh: () => void
|
||||
}
|
||||
|
||||
type TSecondaryPageContext = {
|
||||
push: (params: TPushParams) => void
|
||||
pop: () => void
|
||||
@@ -28,13 +32,24 @@ type TStackItem = {
|
||||
component: React.ReactNode
|
||||
}
|
||||
|
||||
const SecondaryPageContext = createContext<TSecondaryPageContext>({
|
||||
push: () => {},
|
||||
pop: () => {}
|
||||
})
|
||||
const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(undefined)
|
||||
|
||||
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() {
|
||||
return useContext(SecondaryPageContext)
|
||||
const context = useContext(SecondaryPageContext)
|
||||
if (!context) {
|
||||
throw new Error('usePrimaryPage must be used within a SecondaryPageContext.Provider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function PageManager({
|
||||
@@ -46,6 +61,7 @@ export function PageManager({
|
||||
children: React.ReactNode
|
||||
maxStackSize?: number
|
||||
}) {
|
||||
const [primaryPageKey, setPrimaryPageKey] = useState<number>(0)
|
||||
const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
|
||||
|
||||
const routeMap = routes.reduce((acc, route) => {
|
||||
@@ -63,6 +79,8 @@ export function PageManager({
|
||||
)
|
||||
}
|
||||
|
||||
const refreshPrimary = () => setPrimaryPageKey((prevKey) => prevKey + 1)
|
||||
|
||||
const pushSecondary = ({ pageName, props }: TPushParams) => {
|
||||
if (isCurrentPage(secondaryStack, { pageName, props })) return
|
||||
|
||||
@@ -81,29 +99,33 @@ export function PageManager({
|
||||
const popSecondary = () => setSecondaryStack((prevStack) => prevStack.slice(0, -1))
|
||||
|
||||
return (
|
||||
<SecondaryPageContext.Provider value={{ push: pushSecondary, pop: popSecondary }}>
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
<ResizablePanel defaultSize={60} minSize={30}>
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel defaultSize={40} minSize={30} className="relative">
|
||||
{secondaryStack.length ? (
|
||||
secondaryStack.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="absolute top-0 left-0 w-full h-full bg-background"
|
||||
style={{ zIndex: index }}
|
||||
>
|
||||
{item.component}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<BlankPage />
|
||||
)}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</SecondaryPageContext.Provider>
|
||||
<PrimaryPageContext.Provider value={{ refresh: refreshPrimary }}>
|
||||
<SecondaryPageContext.Provider value={{ push: pushSecondary, pop: popSecondary }}>
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
<ResizablePanel defaultSize={60} minSize={30}>
|
||||
<div key={primaryPageKey} className="h-full">
|
||||
{children}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel defaultSize={40} minSize={30} className="relative">
|
||||
{secondaryStack.length ? (
|
||||
secondaryStack.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="absolute top-0 left-0 w-full h-full bg-background"
|
||||
style={{ zIndex: index }}
|
||||
>
|
||||
{item.component}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<BlankPage />
|
||||
)}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</SecondaryPageContext.Provider>
|
||||
</PrimaryPageContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { useFetchProfile } from '@renderer/hooks'
|
||||
import Username from '../Username'
|
||||
|
||||
export function EmbeddedMention({ userId }: { userId: string }) {
|
||||
const { pubkey } = useFetchProfile(userId)
|
||||
if (!pubkey) return null
|
||||
|
||||
return <Username userId={pubkey} showAt className="text-highlight font-normal" />
|
||||
return <Username userId={userId} showAt className="text-highlight font-normal" />
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function ImageGallery({
|
||||
{images.map((src, index) => {
|
||||
return (
|
||||
<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}
|
||||
src={src}
|
||||
onClick={(e) => handlePhotoClick(e, index)}
|
||||
|
||||
@@ -1,99 +1,68 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { isReplyNoteEvent } from '@renderer/lib/event'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
|
||||
import client from '@renderer/services/client.service'
|
||||
import { EVENT_TYPES, eventBus } from '@renderer/services/event-bus.service'
|
||||
import dayjs from 'dayjs'
|
||||
import { RefreshCcw } from 'lucide-react'
|
||||
import { Event, Filter, kinds } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import NoteCard from '../NoteCard'
|
||||
|
||||
export default function NoteList({
|
||||
filter = {},
|
||||
className,
|
||||
isHomeTimeline = false
|
||||
className
|
||||
}: {
|
||||
filter?: Filter
|
||||
className?: string
|
||||
isHomeTimeline?: boolean
|
||||
}) {
|
||||
const [events, setEvents] = useState<Event[]>([])
|
||||
const [since, setSince] = useState<number>(() => dayjs().unix() + 1)
|
||||
const [newEvents, setNewEvents] = useState<Event[]>([])
|
||||
const [until, setUntil] = useState<number>(() => dayjs().unix())
|
||||
const [hasMore, setHasMore] = useState<boolean>(true)
|
||||
const [refreshedAt, setRefreshedAt] = useState<number>(() => dayjs().unix())
|
||||
const [refreshing, setRefreshing] = useState<boolean>(false)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
const observer = useRef<IntersectionObserver | null>(null)
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const { relayUrls } = useRelaySettings()
|
||||
const noteFilter = useMemo(() => {
|
||||
return {
|
||||
kinds: [kinds.ShortTextNote, kinds.Repost],
|
||||
limit: 50,
|
||||
limit: 100,
|
||||
...filter
|
||||
}
|
||||
}, [filter])
|
||||
}, [JSON.stringify(filter)])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHomeTimeline) return
|
||||
if (relayUrls.length === 0) return
|
||||
|
||||
const handleClearList = () => {
|
||||
setEvents([])
|
||||
setSince(dayjs().unix() + 1)
|
||||
setUntil(dayjs().unix())
|
||||
setHasMore(true)
|
||||
setRefreshedAt(dayjs().unix())
|
||||
setRefreshing(false)
|
||||
}
|
||||
setInitialized(false)
|
||||
setEvents([])
|
||||
setNewEvents([])
|
||||
setHasMore(true)
|
||||
|
||||
eventBus.on(EVENT_TYPES.RELOAD_TIMELINE, handleClearList)
|
||||
const sub = client.subscribeEvents(relayUrls, noteFilter, {
|
||||
onEose: (events) => {
|
||||
const processedEvents = events.filter((e) => !isReplyNoteEvent(e))
|
||||
setEvents((pre) => [...pre, ...processedEvents])
|
||||
if (events.length > 0) {
|
||||
setUntil(events[events.length - 1].created_at - 1)
|
||||
}
|
||||
setInitialized(true)
|
||||
},
|
||||
onNew: (event) => {
|
||||
if (!isReplyNoteEvent(event)) {
|
||||
setNewEvents((oldEvents) => [event, ...oldEvents])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
eventBus.remove(EVENT_TYPES.RELOAD_TIMELINE, handleClearList)
|
||||
sub.close()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadMore = async () => {
|
||||
const events = await client.fetchEvents([{ ...noteFilter, until }])
|
||||
if (events.length === 0) {
|
||||
setHasMore(false)
|
||||
return
|
||||
}
|
||||
|
||||
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
|
||||
const processedEvents = sortedEvents.filter((e) => !isReplyNoteEvent(e))
|
||||
if (processedEvents.length > 0) {
|
||||
setEvents((oldEvents) => [...oldEvents, ...processedEvents])
|
||||
}
|
||||
|
||||
setUntil(sortedEvents[sortedEvents.length - 1].created_at - 1)
|
||||
}
|
||||
|
||||
const refresh = async () => {
|
||||
const now = dayjs().unix()
|
||||
setRefreshing(true)
|
||||
const events = await client.fetchEvents([{ ...noteFilter, until: now, since }])
|
||||
|
||||
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
|
||||
const processedEvents = sortedEvents.filter((e) => !isReplyNoteEvent(e))
|
||||
if (sortedEvents.length >= noteFilter.limit) {
|
||||
// reset
|
||||
setEvents(processedEvents)
|
||||
setUntil(sortedEvents[sortedEvents.length - 1].created_at - 1)
|
||||
} else if (processedEvents.length > 0) {
|
||||
// append
|
||||
setEvents((oldEvents) => [...processedEvents, ...oldEvents])
|
||||
}
|
||||
|
||||
if (sortedEvents.length > 0) {
|
||||
setSince(sortedEvents[0].created_at + 1)
|
||||
}
|
||||
|
||||
setRefreshedAt(now)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [JSON.stringify(relayUrls), JSON.stringify(noteFilter)])
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized) return
|
||||
|
||||
const options = {
|
||||
root: null,
|
||||
rootMargin: '10px',
|
||||
@@ -115,26 +84,39 @@ export default function NoteList({
|
||||
observer.current.unobserve(bottomRef.current)
|
||||
}
|
||||
}
|
||||
}, [until])
|
||||
}, [initialized])
|
||||
|
||||
const loadMore = async () => {
|
||||
const events = await client.fetchEvents({ ...noteFilter, until })
|
||||
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
|
||||
if (sortedEvents.length === 0) {
|
||||
setHasMore(false)
|
||||
return
|
||||
}
|
||||
|
||||
const processedEvents = sortedEvents.filter((e) => !isReplyNoteEvent(e))
|
||||
if (processedEvents.length > 0) {
|
||||
setEvents((oldEvents) => [...oldEvents, ...processedEvents])
|
||||
}
|
||||
|
||||
setUntil(sortedEvents[sortedEvents.length - 1].created_at - 1)
|
||||
}
|
||||
|
||||
const showNewEvents = () => {
|
||||
setEvents((oldEvents) => [...newEvents, ...oldEvents])
|
||||
setNewEvents([])
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{events.length > 0 && (
|
||||
<div
|
||||
className={`flex justify-center items-center gap-1 mb-2 text-muted-foreground ${!refreshing ? 'hover:text-foreground cursor-pointer' : ''}`}
|
||||
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>
|
||||
{newEvents.length > 0 && (
|
||||
<div className="flex justify-center w-full mb-4">
|
||||
<Button onClick={showNewEvents}>show new notes</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn('flex flex-col gap-4', className)}>
|
||||
{events.map((event, i) => (
|
||||
<NoteCard key={i} className="w-full" event={event} />
|
||||
<NoteCard key={`${i}-${event.id}`} className="w-full" event={event} />
|
||||
))}
|
||||
</div>
|
||||
<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 { generateImageByPubkey } from '@renderer/lib/pubkey'
|
||||
import { useFetchProfile } from '@renderer/hooks'
|
||||
import { generateImageByPubkey } from '@renderer/lib/pubkey'
|
||||
import { useMemo } from 'react'
|
||||
import Nip05 from '../Nip05'
|
||||
import ProfileAbout from '../ProfileAbout'
|
||||
|
||||
export default function ProfileCard({ pubkey }: { pubkey: string }) {
|
||||
const { avatar = '', username, nip05, about } = useFetchProfile(pubkey)
|
||||
const defaultAvatar = generateImageByPubkey(pubkey)
|
||||
const defaultAvatar = useMemo(() => generateImageByPubkey(pubkey), [pubkey])
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
|
||||
@@ -6,29 +6,16 @@ import {
|
||||
DropdownMenuTrigger
|
||||
} from '@renderer/components/ui/dropdown-menu'
|
||||
import { Input } from '@renderer/components/ui/input'
|
||||
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
|
||||
import { Check, ChevronDown, Circle, CircleCheck, EllipsisVertical } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { TRelayGroup } from './types'
|
||||
import RelayUrls from './RelayUrl'
|
||||
import { useRelaySettingsComponent } from './provider'
|
||||
import { TRelayGroup } from './types'
|
||||
|
||||
export default function RelayGroup({
|
||||
group,
|
||||
onSwitch,
|
||||
onDelete,
|
||||
onRename,
|
||||
onRelayUrlsUpdate
|
||||
}: {
|
||||
group: TRelayGroup
|
||||
onSwitch: (groupName: string) => void
|
||||
onDelete: (groupName: string) => void
|
||||
onRename: (oldGroupName: string, newGroupName: string) => string | null
|
||||
onRelayUrlsUpdate: (groupName: string, relayUrls: string[]) => void
|
||||
}) {
|
||||
export default function RelayGroup({ group }: { group: TRelayGroup }) {
|
||||
const { expandedRelayGroup } = useRelaySettingsComponent()
|
||||
const { groupName, isActive, relayUrls } = group
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
|
||||
const toggleExpanded = () => setExpanded((prev) => !prev)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -36,96 +23,57 @@ export default function RelayGroup({
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex space-x-2 items-center">
|
||||
<RelayGroupActiveToggle
|
||||
isActive={isActive}
|
||||
onToggle={() => onSwitch(groupName)}
|
||||
hasRelayUrls={relayUrls.length > 0}
|
||||
/>
|
||||
<RelayGroupName
|
||||
groupName={groupName}
|
||||
renaming={renaming}
|
||||
hasRelayUrls={relayUrls.length > 0}
|
||||
setRenaming={setRenaming}
|
||||
save={onRename}
|
||||
onToggle={() => onSwitch(groupName)}
|
||||
/>
|
||||
<RelayGroupActiveToggle groupName={groupName} />
|
||||
<RelayGroupName groupName={groupName} />
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<RelayUrlsExpandToggle expanded={expanded} onClick={toggleExpanded}>
|
||||
<RelayUrlsExpandToggle groupName={groupName}>
|
||||
{relayUrls.length} relays
|
||||
</RelayUrlsExpandToggle>
|
||||
<RelayGroupOptions
|
||||
groupName={groupName}
|
||||
isActive={isActive}
|
||||
onDelete={onDelete}
|
||||
setRenaming={setRenaming}
|
||||
/>
|
||||
<RelayGroupOptions groupName={groupName} />
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<RelayUrls
|
||||
isActive={isActive}
|
||||
relayUrls={relayUrls}
|
||||
update={(urls) => onRelayUrlsUpdate(groupName, urls)}
|
||||
/>
|
||||
)}
|
||||
{expandedRelayGroup === groupName && <RelayUrls groupName={groupName} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RelayGroupActiveToggle({
|
||||
isActive,
|
||||
hasRelayUrls,
|
||||
onToggle
|
||||
}: {
|
||||
isActive: boolean
|
||||
hasRelayUrls: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{isActive ? (
|
||||
<CircleCheck size={18} className="text-highlight shrink-0" />
|
||||
) : (
|
||||
<Circle
|
||||
size={18}
|
||||
className={`text-muted-foreground shrink-0 ${hasRelayUrls ? 'cursor-pointer hover:text-foreground ' : ''}`}
|
||||
onClick={() => {
|
||||
if (hasRelayUrls) {
|
||||
onToggle()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
function RelayGroupActiveToggle({ groupName }: { groupName: string }) {
|
||||
const { relayGroups, switchRelayGroup } = useRelaySettings()
|
||||
|
||||
const isActive = relayGroups.find((group) => group.groupName === groupName)?.isActive
|
||||
const hasRelayUrls = relayGroups.find((group) => group.groupName === groupName)?.relayUrls.length
|
||||
|
||||
return isActive ? (
|
||||
<CircleCheck size={18} className="text-highlight shrink-0" />
|
||||
) : (
|
||||
<Circle
|
||||
size={18}
|
||||
className={`text-muted-foreground shrink-0 ${hasRelayUrls ? 'cursor-pointer hover:text-foreground ' : ''}`}
|
||||
onClick={() => {
|
||||
if (hasRelayUrls) {
|
||||
switchRelayGroup(groupName)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RelayGroupName({
|
||||
groupName,
|
||||
renaming,
|
||||
hasRelayUrls,
|
||||
setRenaming,
|
||||
save,
|
||||
onToggle
|
||||
}: {
|
||||
groupName: string
|
||||
renaming: boolean
|
||||
hasRelayUrls: boolean
|
||||
setRenaming: (renaming: boolean) => void
|
||||
save: (oldGroupName: string, newGroupName: string) => string | null
|
||||
onToggle: () => void
|
||||
}) {
|
||||
function RelayGroupName({ groupName }: { groupName: string }) {
|
||||
const [newGroupName, setNewGroupName] = useState(groupName)
|
||||
const [newNameError, setNewNameError] = useState<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 errMsg = save(groupName, newGroupName)
|
||||
const errMsg = renameRelayGroup(groupName, newGroupName)
|
||||
if (errMsg) {
|
||||
setNewNameError(errMsg)
|
||||
return
|
||||
}
|
||||
setRenaming(false)
|
||||
setRenamingGroup(null)
|
||||
}
|
||||
|
||||
const handleRenameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -140,72 +88,61 @@ function RelayGroupName({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{renaming ? (
|
||||
<div className="flex gap-1 items-center">
|
||||
<Input
|
||||
value={newGroupName}
|
||||
onChange={handleRenameInputChange}
|
||||
onBlur={saveNewGroupName}
|
||||
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>
|
||||
{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'}`}
|
||||
onClick={() => {
|
||||
if (hasRelayUrls) {
|
||||
onToggle()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{groupName}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
return renamingGroup === groupName ? (
|
||||
<div className="flex gap-1 items-center">
|
||||
<Input
|
||||
value={newGroupName}
|
||||
onChange={handleRenameInputChange}
|
||||
onBlur={saveNewGroupName}
|
||||
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>
|
||||
{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'}`}
|
||||
onClick={() => {
|
||||
if (hasRelayUrls) {
|
||||
switchRelayGroup(groupName)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{groupName}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RelayUrlsExpandToggle({
|
||||
expanded,
|
||||
onClick,
|
||||
groupName,
|
||||
children
|
||||
}: {
|
||||
expanded: boolean
|
||||
onClick: () => void
|
||||
groupName: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { expandedRelayGroup, setExpandedRelayGroup } = useRelaySettingsComponent()
|
||||
return (
|
||||
<div
|
||||
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>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`transition-transform duration-200 ${expanded ? 'rotate-180' : ''}`}
|
||||
className={`transition-transform duration-200 ${expandedRelayGroup === groupName ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RelayGroupOptions({
|
||||
groupName,
|
||||
isActive,
|
||||
onDelete,
|
||||
setRenaming
|
||||
}: {
|
||||
groupName: string
|
||||
isActive: boolean
|
||||
onDelete: (groupName: string) => void
|
||||
setRenaming: (renaming: boolean) => void
|
||||
}) {
|
||||
function RelayGroupOptions({ groupName }: { groupName: string }) {
|
||||
const { relayGroups, deleteRelayGroup } = useRelaySettings()
|
||||
const { setRenamingGroup } = useRelaySettingsComponent()
|
||||
const isActive = relayGroups.find((group) => group.groupName === groupName)?.isActive
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
@@ -215,11 +152,11 @@ function RelayGroupOptions({
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => setRenaming(true)}>Rename</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setRenamingGroup(groupName)}>Rename</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={isActive}
|
||||
onClick={() => onDelete(groupName)}
|
||||
onClick={() => deleteRelayGroup(groupName)}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { Input } from '@renderer/components/ui/input'
|
||||
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
|
||||
import client from '@renderer/services/client.service'
|
||||
import { CircleX } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export default function RelayUrls({
|
||||
isActive,
|
||||
relayUrls: rawRelayUrls,
|
||||
update
|
||||
}: {
|
||||
isActive: boolean
|
||||
relayUrls: string[]
|
||||
update: (urls: string[]) => void
|
||||
}) {
|
||||
export default function RelayUrls({ groupName }: { groupName: string }) {
|
||||
const { relayGroups, updateRelayGroupRelayUrls } = useRelaySettings()
|
||||
const rawRelayUrls = relayGroups.find((group) => group.groupName === groupName)?.relayUrls ?? []
|
||||
const isActive = relayGroups.find((group) => group.groupName === groupName)?.isActive ?? false
|
||||
|
||||
const [newRelayUrl, setNewRelayUrl] = useState('')
|
||||
const [newRelayUrlError, setNewRelayUrlError] = useState<string | null>(null)
|
||||
const [relays, setRelays] = useState<
|
||||
@@ -38,7 +35,10 @@ export default function RelayUrls({
|
||||
|
||||
const removeRelayUrl = (url: string) => {
|
||||
setRelays((relays) => relays.filter((relay) => relay.url !== url))
|
||||
update(relays.map(({ url }) => url).filter((u) => u !== url))
|
||||
updateRelayGroupRelayUrls(
|
||||
groupName,
|
||||
relays.map(({ url }) => url).filter((u) => u !== url)
|
||||
)
|
||||
}
|
||||
|
||||
const saveNewRelayUrl = () => {
|
||||
@@ -51,7 +51,7 @@ export default function RelayUrls({
|
||||
}
|
||||
setRelays((pre) => [...pre, { url: normalizedUrl, isConnected: false }])
|
||||
const newRelayUrls = [...relays.map(({ url }) => url), normalizedUrl]
|
||||
update(newRelayUrls)
|
||||
updateRelayGroupRelayUrls(groupName, newRelayUrls)
|
||||
setNewRelayUrl('')
|
||||
}
|
||||
|
||||
@@ -113,11 +113,11 @@ function RelayUrl({
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2 items-center">
|
||||
{!isActive ? (
|
||||
<div className="text-muted-foreground">●</div>
|
||||
<div className="text-muted-foreground text-xs">●</div>
|
||||
) : 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>
|
||||
|
||||
@@ -1,91 +1,29 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { Input } from '@renderer/components/ui/input'
|
||||
import { Separator } from '@renderer/components/ui/separator'
|
||||
import storage from '@renderer/services/storage.service'
|
||||
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { RelaySettingsComponentProvider } from './provider'
|
||||
import RelayGroup from './RelayGroup'
|
||||
import { TRelayGroup } from './types'
|
||||
|
||||
export default function RelaySettings() {
|
||||
const [groups, setGroups] = useState<TRelayGroup[]>([])
|
||||
const { relayGroups, addRelayGroup } = useRelaySettings()
|
||||
const [newGroupName, setNewGroupName] = useState('')
|
||||
const [newNameError, setNewNameError] = useState<string | null>(null)
|
||||
const dummyRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const storedGroups = await storage.getRelayGroups()
|
||||
setGroups(storedGroups)
|
||||
}
|
||||
|
||||
if (dummyRef.current) {
|
||||
dummyRef.current.focus()
|
||||
}
|
||||
init()
|
||||
}, [])
|
||||
|
||||
const updateGroups = async (newGroups: TRelayGroup[]) => {
|
||||
setGroups(newGroups)
|
||||
await storage.setRelayGroups(newGroups)
|
||||
}
|
||||
|
||||
const switchRelayGroup = (groupName: string) => {
|
||||
updateGroups(
|
||||
groups.map((group) => ({
|
||||
...group,
|
||||
isActive: group.groupName === groupName
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
const deleteRelayGroup = (groupName: string) => {
|
||||
updateGroups(groups.filter((group) => group.groupName !== groupName || group.isActive))
|
||||
}
|
||||
|
||||
const updateRelayGroupRelayUrls = (groupName: string, relayUrls: string[]) => {
|
||||
updateGroups(
|
||||
groups.map((group) => ({
|
||||
...group,
|
||||
relayUrls: group.groupName === groupName ? relayUrls : group.relayUrls
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
const renameRelayGroup = (oldGroupName: string, newGroupName: string) => {
|
||||
if (newGroupName === '') {
|
||||
return null
|
||||
}
|
||||
if (oldGroupName === newGroupName) {
|
||||
return null
|
||||
}
|
||||
if (groups.some((group) => group.groupName === newGroupName)) {
|
||||
return 'already exists'
|
||||
}
|
||||
updateGroups(
|
||||
groups.map((group) => ({
|
||||
...group,
|
||||
groupName: group.groupName === oldGroupName ? newGroupName : group.groupName
|
||||
}))
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const addRelayGroup = () => {
|
||||
if (newGroupName === '') {
|
||||
return
|
||||
}
|
||||
if (groups.some((group) => group.groupName === newGroupName)) {
|
||||
return setNewNameError('already exists')
|
||||
const saveRelayGroup = () => {
|
||||
const errMsg = addRelayGroup(newGroupName)
|
||||
if (errMsg) {
|
||||
return setNewNameError(errMsg)
|
||||
}
|
||||
setNewGroupName('')
|
||||
updateGroups([
|
||||
...groups,
|
||||
{
|
||||
groupName: newGroupName,
|
||||
relayUrls: [],
|
||||
isActive: false
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
const handleNewGroupNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -96,27 +34,20 @@ export default function RelaySettings() {
|
||||
const handleNewGroupNameKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
addRelayGroup()
|
||||
saveRelayGroup()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<RelaySettingsComponentProvider>
|
||||
<div ref={dummyRef} tabIndex={-1} style={{ position: 'absolute', opacity: 0 }}></div>
|
||||
<div className="text-lg font-semibold mb-4">Relay Settings</div>
|
||||
<div className="space-y-2">
|
||||
{groups.map((group, index) => (
|
||||
<RelayGroup
|
||||
key={index}
|
||||
group={group}
|
||||
onSwitch={switchRelayGroup}
|
||||
onDelete={deleteRelayGroup}
|
||||
onRename={renameRelayGroup}
|
||||
onRelayUrlsUpdate={updateRelayGroupRelayUrls}
|
||||
/>
|
||||
{relayGroups.map((group, index) => (
|
||||
<RelayGroup key={index} group={group} />
|
||||
))}
|
||||
</div>
|
||||
{groups.length < 5 && (
|
||||
{relayGroups.length < 5 && (
|
||||
<>
|
||||
<Separator className="my-4" />
|
||||
<div className="w-full border rounded-lg p-4">
|
||||
@@ -130,7 +61,7 @@ export default function RelaySettings() {
|
||||
value={newGroupName}
|
||||
onChange={handleNewGroupNameChange}
|
||||
onKeyDown={handleNewGroupNameKeyDown}
|
||||
onBlur={addRelayGroup}
|
||||
onBlur={saveRelayGroup}
|
||||
/>
|
||||
<Button className="h-8 w-12">Add</Button>
|
||||
</div>
|
||||
@@ -138,6 +69,6 @@ export default function RelaySettings() {
|
||||
</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 () => {
|
||||
setLoading(true)
|
||||
const events = await client.fetchEvents([
|
||||
{
|
||||
'#e': [event.id],
|
||||
kinds: [1],
|
||||
limit: 200,
|
||||
until
|
||||
}
|
||||
])
|
||||
const events = await client.fetchEvents({
|
||||
'#e': [event.id],
|
||||
kinds: [1],
|
||||
limit: 100,
|
||||
until
|
||||
})
|
||||
const sortedEvents = events.sort((a, b) => a.created_at - b.created_at)
|
||||
if (sortedEvents.length > 0) {
|
||||
const eventMap: Record<string, Event> = {}
|
||||
@@ -38,7 +36,7 @@ export default function ReplyNoteList({ event, className }: { event: Event; clas
|
||||
setEventMap((pre) => ({ ...pre, ...eventMap }))
|
||||
setUntil(sortedEvents[0].created_at - 1)
|
||||
}
|
||||
setHasMore(sortedEvents.length >= 200)
|
||||
setHasMore(sortedEvents.length >= 100)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { toProfile } from '@renderer/lib/url'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import { SecondaryPageLink } from '@renderer/PageManager'
|
||||
import ProfileCard from '../ProfileCard'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
const UserAvatarSizeCnMap = {
|
||||
large: 'w-24 h-24',
|
||||
@@ -25,10 +26,11 @@ export default function UserAvatar({
|
||||
size?: 'large' | 'normal' | 'small' | 'tiny'
|
||||
}) {
|
||||
const { avatar, pubkey } = useFetchProfile(userId)
|
||||
if (!pubkey)
|
||||
return <Skeleton className={cn(UserAvatarSizeCnMap[size], 'rounded-full', className)} />
|
||||
const defaultAvatar = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey])
|
||||
|
||||
const defaultAvatar = generateImageByPubkey(pubkey)
|
||||
if (!pubkey) {
|
||||
return <Skeleton className={cn(UserAvatarSizeCnMap[size], 'rounded-full', className)} />
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
|
||||
@@ -34,7 +34,7 @@ export function useFetchEventById(id?: string) {
|
||||
if (filter.ids) {
|
||||
event = await client.fetchEventById(filter.ids[0])
|
||||
} else {
|
||||
event = await client.fetchEventWithCache(filter)
|
||||
event = await client.fetchEventByFilter(filter)
|
||||
}
|
||||
if (event) {
|
||||
setEvent(event)
|
||||
|
||||
@@ -1,67 +1,47 @@
|
||||
import { formatNpub } from '@renderer/lib/pubkey'
|
||||
import { formatPubkey } from '@renderer/lib/pubkey'
|
||||
import client from '@renderer/services/client.service'
|
||||
import { TProfile } from '@renderer/types'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
type TProfile = {
|
||||
username: string
|
||||
pubkey?: string
|
||||
npub?: `npub1${string}`
|
||||
banner?: string
|
||||
avatar?: string
|
||||
nip05?: string
|
||||
about?: string
|
||||
}
|
||||
|
||||
const decodeUserId = (id: string): { pubkey?: string; npub?: `npub1${string}` } => {
|
||||
if (/^npub1[a-z0-9]{58}$/.test(id)) {
|
||||
const { data } = nip19.decode(id as `npub1${string}`)
|
||||
return { pubkey: data, npub: id as `npub1${string}` }
|
||||
} else if (id.startsWith('nprofile1')) {
|
||||
const { data } = nip19.decode(id as `nprofile1${string}`)
|
||||
return { pubkey: data.pubkey, npub: nip19.npubEncode(data.pubkey) }
|
||||
} else if (/^[0-9a-f]{64}$/.test(id)) {
|
||||
return { pubkey: id, npub: nip19.npubEncode(id) }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function useFetchProfile(id?: string) {
|
||||
const initialProfile: TProfile = {
|
||||
const [profile, setProfile] = useState<TProfile>({
|
||||
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(() => {
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
if (!id) return
|
||||
|
||||
let pubkey: string | undefined
|
||||
|
||||
if (/^[0-9a-f]{64}$/.test(id)) {
|
||||
pubkey = id
|
||||
} else {
|
||||
const { data, type } = nip19.decode(id)
|
||||
switch (type) {
|
||||
case 'npub':
|
||||
pubkey = data
|
||||
break
|
||||
case 'nprofile':
|
||||
pubkey = data.pubkey
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!pubkey) return
|
||||
setProfile({ pubkey, username: formatPubkey(pubkey) })
|
||||
|
||||
const profile = await client.fetchProfile(pubkey)
|
||||
if (profile) {
|
||||
setProfile(profile)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
fetchProfile()
|
||||
}, [id])
|
||||
|
||||
|
||||
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 { isMacOS } from '@renderer/lib/platform'
|
||||
import { forwardRef, useImperativeHandle, useRef } from 'react'
|
||||
import ReloadTimelineButton from './ReloadTimelineButton'
|
||||
import RelaySettingsPopover from './RelaySettingsPopover'
|
||||
import RefreshButton from './RefreshButton'
|
||||
|
||||
const PrimaryPageLayout = forwardRef(
|
||||
(
|
||||
@@ -48,7 +48,7 @@ export function PrimaryPageTitlebar({ content }: { content?: React.ReactNode })
|
||||
<Titlebar className={`justify-between ${isMacOS() ? 'pl-20' : ''}`}>
|
||||
<div>{content}</div>
|
||||
<div className="flex gap-1">
|
||||
<ReloadTimelineButton />
|
||||
<RefreshButton />
|
||||
<RelaySettingsPopover />
|
||||
</div>
|
||||
</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 { Moon, Sun, SunMoon } from 'lucide-react'
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import PrimaryPageLayout from '@renderer/layouts/PrimaryPageLayout'
|
||||
export default function NoteListPage() {
|
||||
return (
|
||||
<PrimaryPageLayout>
|
||||
<NoteList isHomeTimeline filter={{ limit: 200 }} />
|
||||
<NoteList />
|
||||
</PrimaryPageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import Nip05 from '@renderer/components/Nip05'
|
||||
import NoteList from '@renderer/components/NoteList'
|
||||
import ProfileAbout from '@renderer/components/ProfileAbout'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar'
|
||||
import { Separator } from '@renderer/components/ui/separator'
|
||||
import UserAvatar from '@renderer/components/UserAvatar'
|
||||
import { useFetchProfile } from '@renderer/hooks'
|
||||
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
||||
import { formatNpub, generateImageByPubkey } from '@renderer/lib/pubkey'
|
||||
import { Copy } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
export default function ProfilePage({ pubkey }: { pubkey?: string }) {
|
||||
const { banner, username, nip05, about, npub } = useFetchProfile(pubkey)
|
||||
const { banner, username, nip05, about, avatar } = useFetchProfile(pubkey)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : undefined), [pubkey])
|
||||
const defaultImage = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey])
|
||||
|
||||
if (!pubkey || !npub) return null
|
||||
|
||||
const copyNpub = () => {
|
||||
if (!npub) return
|
||||
navigator.clipboard.writeText(npub)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
@@ -27,14 +29,16 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
|
||||
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-12">
|
||||
<ProfileBanner
|
||||
banner={banner}
|
||||
defaultBanner={defaultImage}
|
||||
pubkey={pubkey}
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
/>
|
||||
<UserAvatar
|
||||
userId={pubkey}
|
||||
size="large"
|
||||
className="absolute bottom-0 left-4 translate-y-1/2 border-4 border-background"
|
||||
/>
|
||||
<Avatar className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background">
|
||||
<AvatarImage src={avatar} className="object-cover object-center" />
|
||||
<AvatarFallback>
|
||||
<img src={defaultImage} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="px-4 space-y-1">
|
||||
<div className="text-xl font-semibold">{username}</div>
|
||||
@@ -44,7 +48,7 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
|
||||
onClick={() => copyNpub()}
|
||||
>
|
||||
{copied ? (
|
||||
<div>Copied!</div>
|
||||
<div>copied!</div>
|
||||
) : (
|
||||
<>
|
||||
<div>{formatNpub(npub, 24)}</div>
|
||||
@@ -56,22 +60,23 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
|
||||
<ProfileAbout about={about} />
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
<Separator className="my-4" />
|
||||
<NoteList key={pubkey} filter={{ authors: [pubkey] }} />
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileBanner({
|
||||
banner,
|
||||
defaultBanner,
|
||||
pubkey,
|
||||
banner,
|
||||
className
|
||||
}: {
|
||||
banner?: string
|
||||
defaultBanner: string
|
||||
pubkey: string
|
||||
banner?: string
|
||||
className?: string
|
||||
}) {
|
||||
const defaultBanner = generateImageByPubkey(pubkey)
|
||||
const [bannerUrl, setBannerUrl] = useState(banner || defaultBanner)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -80,12 +85,12 @@ function ProfileBanner({
|
||||
} else {
|
||||
setBannerUrl(defaultBanner)
|
||||
}
|
||||
}, [pubkey, banner])
|
||||
}, [defaultBanner, banner])
|
||||
|
||||
return (
|
||||
<img
|
||||
src={bannerUrl}
|
||||
alt="Banner"
|
||||
alt={`${pubkey} banner`}
|
||||
className={className}
|
||||
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 { TEventStats } from '@renderer/types'
|
||||
import { formatPubkey } from '@renderer/lib/pubkey'
|
||||
import { TEventStats, TProfile } from '@renderer/types'
|
||||
import DataLoader from 'dataloader'
|
||||
import { LRUCache } from 'lru-cache'
|
||||
import { Filter, kinds, Event as NEvent, SimplePool } from 'nostr-tools'
|
||||
import { EVENT_TYPES, eventBus } from './event-bus.service'
|
||||
import storage from './storage.service'
|
||||
|
||||
const BIG_RELAY_URLS = [
|
||||
'wss://relay.damus.io/',
|
||||
'wss://nos.lol/',
|
||||
'wss://relay.nostr.band/',
|
||||
'wss://relay.noswhere.com/'
|
||||
]
|
||||
|
||||
class ClientService {
|
||||
static instance: ClientService
|
||||
|
||||
private pool = new SimplePool()
|
||||
private relayUrls: string[] = BIG_RELAY_URLS
|
||||
private initPromise!: Promise<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>>({
|
||||
max: 10000,
|
||||
ttl: 1000 * 60 * 10, // 10 minutes
|
||||
fetchMethod: async (id) => this._fetchEventStatsById(id)
|
||||
})
|
||||
|
||||
// Profile cache
|
||||
private profilesCache = new LRUCache<string, Promise<NEvent | undefined>>({
|
||||
private eventCache = new LRUCache<string, Promise<NEvent | undefined>>({
|
||||
max: 10000,
|
||||
ttl: 1000 * 60 * 10 // 10 minutes
|
||||
})
|
||||
private fetchProfileQueue = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (value: NEvent | undefined) => void
|
||||
reject: (reason: any) => void
|
||||
fetchMethod: async (filterStr) => {
|
||||
const [event] = await this.fetchEvents(JSON.parse(filterStr))
|
||||
return event
|
||||
}
|
||||
>()
|
||||
private fetchProfileTimer: NodeJS.Timeout | null = null
|
||||
})
|
||||
|
||||
private eventDataloader = new DataLoader<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() {
|
||||
if (!ClientService.instance) {
|
||||
@@ -69,12 +67,6 @@ class ClientService {
|
||||
|
||||
onRelayGroupsChange(relayGroups: TRelayGroup[]) {
|
||||
const newRelayUrls = relayGroups.find((group) => group.isActive)?.relayUrls ?? []
|
||||
if (
|
||||
newRelayUrls.length === this.relayUrls.length &&
|
||||
newRelayUrls.every((url) => this.relayUrls.includes(url))
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.relayUrls = newRelayUrls
|
||||
}
|
||||
|
||||
@@ -82,70 +74,40 @@ class ClientService {
|
||||
return this.pool.listConnectionStatus()
|
||||
}
|
||||
|
||||
async fetchEvents(filters: Filter[]) {
|
||||
await this.initPromise
|
||||
return new Promise<NEvent[]>((resolve) => {
|
||||
const events: NEvent[] = []
|
||||
this.pool.subscribeManyEose(this.relayUrls, filters, {
|
||||
onevent(event) {
|
||||
events.push(event)
|
||||
},
|
||||
onclose() {
|
||||
resolve(events)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async fetchEventWithCache(filter: Filter) {
|
||||
return this.cache.fetch(JSON.stringify(filter))
|
||||
}
|
||||
|
||||
async fetchEvent(filter: Filter) {
|
||||
const events = await this.fetchEvents([{ ...filter, limit: 1 }])
|
||||
return events.length ? events[0] : undefined
|
||||
}
|
||||
|
||||
async fetchEventById(id: string): Promise<NEvent | undefined> {
|
||||
const cache = this.eventsCache.get(id)
|
||||
if (cache) {
|
||||
return cache
|
||||
subscribeEvents(
|
||||
urls: string[],
|
||||
filter: Filter,
|
||||
opts: {
|
||||
onEose: (events: NEvent[]) => void
|
||||
onNew: (evt: NEvent) => void
|
||||
}
|
||||
|
||||
const promise = new Promise<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)
|
||||
}
|
||||
) {
|
||||
console.log('subscribeEvents', urls, filter)
|
||||
const events: NEvent[] = []
|
||||
let eose = false
|
||||
return this.pool.subscribeMany(urls, [filter], {
|
||||
onevent: (evt) => {
|
||||
if (eose) {
|
||||
opts.onNew(evt)
|
||||
} else {
|
||||
events.push(evt)
|
||||
}
|
||||
}, 20)
|
||||
},
|
||||
oneose: () => {
|
||||
eose = true
|
||||
opts.onEose(events.sort((a, b) => b.created_at - a.created_at))
|
||||
},
|
||||
onclose: () => {
|
||||
if (!eose) {
|
||||
opts.onEose(events.sort((a, b) => b.created_at - a.created_at))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
this.eventsCache.set(id, promise)
|
||||
return promise
|
||||
async fetchEvents(filter: Filter, relayUrls: string[] = this.relayUrls) {
|
||||
await this.initPromise
|
||||
return await this.pool.querySync(relayUrls, filter)
|
||||
}
|
||||
|
||||
async fetchEventStatsById(id: string): Promise<TEventStats> {
|
||||
@@ -153,70 +115,116 @@ class ClientService {
|
||||
return stats ?? { reactionCount: 0, repostCount: 0 }
|
||||
}
|
||||
|
||||
async fetchEventByFilter(filter: Filter) {
|
||||
return this.eventCache.fetch(JSON.stringify({ ...filter, limit: 1 }))
|
||||
}
|
||||
|
||||
async fetchEventById(id: string): Promise<NEvent | undefined> {
|
||||
return this.eventDataloader.load(id)
|
||||
}
|
||||
|
||||
async fetchProfile(pubkey: string): Promise<TProfile | undefined> {
|
||||
return this.profileDataloader.load(pubkey)
|
||||
}
|
||||
|
||||
private async _fetchEventStatsById(id: string) {
|
||||
const [reactionEvents, repostEvents] = await Promise.all([
|
||||
this.fetchEvents([{ '#e': [id], kinds: [kinds.Reaction] }]),
|
||||
this.fetchEvents([{ '#e': [id], kinds: [kinds.Repost] }])
|
||||
this.fetchEvents({ '#e': [id], kinds: [kinds.Reaction] }),
|
||||
this.fetchEvents({ '#e': [id], kinds: [kinds.Repost] })
|
||||
])
|
||||
|
||||
return { reactionCount: reactionEvents.length, repostCount: repostEvents.length }
|
||||
}
|
||||
|
||||
async fetchProfile(pubkey: string): Promise<NEvent | undefined> {
|
||||
const cache = this.profilesCache.get(pubkey)
|
||||
if (cache) {
|
||||
return cache
|
||||
private async eventBatchLoadFn(ids: readonly string[]) {
|
||||
const events = await this.fetchEvents({
|
||||
ids: ids as string[],
|
||||
limit: ids.length
|
||||
})
|
||||
const eventsMap = new Map<string, NEvent>()
|
||||
for (const event of events) {
|
||||
eventsMap.set(event.id, event)
|
||||
}
|
||||
|
||||
const promise = new Promise<NEvent | undefined>((resolve, reject) => {
|
||||
this.fetchProfileQueue.set(pubkey, { resolve, reject })
|
||||
if (this.fetchProfileTimer) {
|
||||
return
|
||||
const missingIds = ids.filter((id) => !eventsMap.has(id))
|
||||
if (missingIds.length > 0) {
|
||||
const missingEvents = await this.fetchEvents(
|
||||
{
|
||||
ids: missingIds,
|
||||
limit: missingIds.length
|
||||
},
|
||||
BIG_RELAY_URLS.filter((url) => !this.relayUrls.includes(url))
|
||||
)
|
||||
for (const event of missingEvents) {
|
||||
eventsMap.set(event.id, event)
|
||||
}
|
||||
}
|
||||
|
||||
this.fetchProfileTimer = setTimeout(async () => {
|
||||
this.fetchProfileTimer = null
|
||||
const queue = new Map(this.fetchProfileQueue)
|
||||
this.fetchProfileQueue.clear()
|
||||
return ids.map((id) => eventsMap.get(id))
|
||||
}
|
||||
|
||||
try {
|
||||
const pubkeys = Array.from(queue.keys())
|
||||
const events = await this.fetchEvents([
|
||||
{
|
||||
authors: pubkeys,
|
||||
kinds: [0],
|
||||
limit: pubkeys.length
|
||||
}
|
||||
])
|
||||
const eventsMap = new Map<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)
|
||||
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.profilesCache.set(pubkey, promise)
|
||||
return promise
|
||||
const missingPubkeys = pubkeys.filter((pubkey) => !eventsMap.has(pubkey))
|
||||
if (missingPubkeys.length > 0) {
|
||||
const missingEvents = await this.fetchEvents(
|
||||
{
|
||||
authors: missingPubkeys,
|
||||
kinds: [kinds.Metadata],
|
||||
limit: missingPubkeys.length
|
||||
},
|
||||
BIG_RELAY_URLS.filter((url) => !this.relayUrls.includes(url))
|
||||
)
|
||||
for (const event of missingEvents) {
|
||||
const pubkey = event.pubkey
|
||||
const existing = eventsMap.get(pubkey)
|
||||
if (!existing || existing.created_at < event.created_at) {
|
||||
eventsMap.set(pubkey, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pubkeys.map((pubkey) => {
|
||||
const event = eventsMap.get(pubkey)
|
||||
return event ? this.parseProfileFromEvent(event) : undefined
|
||||
})
|
||||
}
|
||||
|
||||
private parseProfileFromEvent(event: NEvent): TProfile {
|
||||
try {
|
||||
const profileObj = JSON.parse(event.content)
|
||||
return {
|
||||
pubkey: event.pubkey,
|
||||
banner: profileObj.banner,
|
||||
avatar: profileObj.picture,
|
||||
username:
|
||||
profileObj.display_name?.trim() ||
|
||||
profileObj.name?.trim() ||
|
||||
profileObj.nip05?.split('@')[0]?.trim() ||
|
||||
formatPubkey(event.pubkey),
|
||||
nip05: profileObj.nip05,
|
||||
about: profileObj.about
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return {
|
||||
pubkey: event.pubkey,
|
||||
username: formatPubkey(event.pubkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,11 @@ import { TRelayGroup } from '@common/types'
|
||||
|
||||
export const EVENT_TYPES = {
|
||||
RELAY_GROUPS_CHANGED: 'relay-groups-changed',
|
||||
RELOAD_TIMELINE: 'reload-timeline',
|
||||
REPLY_COUNT_CHANGED: 'reply-count-changed'
|
||||
} as const
|
||||
|
||||
type TEventMap = {
|
||||
[EVENT_TYPES.RELAY_GROUPS_CHANGED]: TRelayGroup[]
|
||||
[EVENT_TYPES.RELOAD_TIMELINE]: unknown
|
||||
[EVENT_TYPES.REPLY_COUNT_CHANGED]: { eventId: string; replyCount: number }
|
||||
}
|
||||
|
||||
@@ -19,9 +17,6 @@ type TCustomEventMap = {
|
||||
export const createRelayGroupsChangedEvent = (relayGroups: TRelayGroup[]) => {
|
||||
return new CustomEvent(EVENT_TYPES.RELAY_GROUPS_CHANGED, { detail: relayGroups })
|
||||
}
|
||||
export const createReloadTimelineEvent = () => {
|
||||
return new CustomEvent(EVENT_TYPES.RELOAD_TIMELINE)
|
||||
}
|
||||
export const createReplyCountChangedEvent = (eventId: string, replyCount: number) => {
|
||||
return new CustomEvent(EVENT_TYPES.REPLY_COUNT_CHANGED, { detail: { eventId, replyCount } })
|
||||
}
|
||||
|
||||
@@ -4,20 +4,40 @@ import { createRelayGroupsChangedEvent, eventBus } from './event-bus.service'
|
||||
class StorageService {
|
||||
static instance: StorageService
|
||||
|
||||
private initPromise!: Promise<void>
|
||||
private relayGroups: TRelayGroup[] = []
|
||||
private activeRelayUrls: string[] = []
|
||||
|
||||
constructor() {
|
||||
if (!StorageService.instance) {
|
||||
this.initPromise = this.init()
|
||||
StorageService.instance = this
|
||||
}
|
||||
return StorageService.instance
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.relayGroups = await window.api.storage.getRelayGroups()
|
||||
this.activeRelayUrls = this.relayGroups.find((group) => group.isActive)?.relayUrls ?? []
|
||||
}
|
||||
|
||||
async getRelayGroups() {
|
||||
return await window.api.storage.getRelayGroups()
|
||||
await this.initPromise
|
||||
return this.relayGroups
|
||||
}
|
||||
|
||||
async setRelayGroups(relayGroups: TRelayGroup[]) {
|
||||
await this.initPromise
|
||||
await window.api.storage.setRelayGroups(relayGroups)
|
||||
eventBus.emit(createRelayGroupsChangedEvent(relayGroups))
|
||||
this.relayGroups = relayGroups
|
||||
const newActiveRelayUrls = relayGroups.find((group) => group.isActive)?.relayUrls ?? []
|
||||
if (
|
||||
this.activeRelayUrls.length !== newActiveRelayUrls.length ||
|
||||
this.activeRelayUrls.some((url) => !newActiveRelayUrls.includes(url))
|
||||
) {
|
||||
eventBus.emit(createRelayGroupsChangedEvent(relayGroups))
|
||||
}
|
||||
this.activeRelayUrls = newActiveRelayUrls
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1,10 @@
|
||||
export type TEventStats = { reactionCount: number; repostCount: number }
|
||||
|
||||
export type TProfile = {
|
||||
username: string
|
||||
pubkey?: string
|
||||
banner?: string
|
||||
avatar?: string
|
||||
nip05?: string
|
||||
about?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user