This commit is contained in:
codytseng
2024-11-03 22:43:51 +08:00
parent 9beaffb272
commit bfc07545b3
28 changed files with 665 additions and 608 deletions

6
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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>
)

View File

@@ -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>
)
}

View File

@@ -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" />
}

View File

@@ -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)}

View File

@@ -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">

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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)
}

View File

@@ -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>

View File

@@ -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)

View File

@@ -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])

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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'

View File

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

View File

@@ -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)}
/>

View 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>
)
}

View File

@@ -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)
}
}
}
}

View File

@@ -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 } })
}

View File

@@ -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
}
}

View File

@@ -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
}