- {refreshing
- ? 'refreshing...'
- : `last refreshed at ${dayjs(refreshedAt * 1000).format('HH:mm:ss')}`}
-
diff --git a/src/renderer/src/components/ProfileCard/index.tsx b/src/renderer/src/components/ProfileCard/index.tsx
index cba63f5a..3abf14a7 100644
--- a/src/renderer/src/components/ProfileCard/index.tsx
+++ b/src/renderer/src/components/ProfileCard/index.tsx
@@ -1,12 +1,13 @@
import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar'
-import { generateImageByPubkey } from '@renderer/lib/pubkey'
import { useFetchProfile } from '@renderer/hooks'
+import { generateImageByPubkey } from '@renderer/lib/pubkey'
+import { useMemo } from 'react'
import Nip05 from '../Nip05'
import ProfileAbout from '../ProfileAbout'
export default function ProfileCard({ pubkey }: { pubkey: string }) {
const { avatar = '', username, nip05, about } = useFetchProfile(pubkey)
- const defaultAvatar = generateImageByPubkey(pubkey)
+ const defaultAvatar = useMemo(() => generateImageByPubkey(pubkey), [pubkey])
return (
diff --git a/src/renderer/src/components/RelaySettings/RelayGroup.tsx b/src/renderer/src/components/RelaySettings/RelayGroup.tsx
index 47ef82d6..c9c74880 100644
--- a/src/renderer/src/components/RelaySettings/RelayGroup.tsx
+++ b/src/renderer/src/components/RelaySettings/RelayGroup.tsx
@@ -6,29 +6,16 @@ import {
DropdownMenuTrigger
} from '@renderer/components/ui/dropdown-menu'
import { Input } from '@renderer/components/ui/input'
+import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
import { Check, ChevronDown, Circle, CircleCheck, EllipsisVertical } from 'lucide-react'
import { useState } from 'react'
-import { TRelayGroup } from './types'
import RelayUrls from './RelayUrl'
+import { useRelaySettingsComponent } from './provider'
+import { TRelayGroup } from './types'
-export default function RelayGroup({
- group,
- onSwitch,
- onDelete,
- onRename,
- onRelayUrlsUpdate
-}: {
- group: TRelayGroup
- onSwitch: (groupName: string) => void
- onDelete: (groupName: string) => void
- onRename: (oldGroupName: string, newGroupName: string) => string | null
- onRelayUrlsUpdate: (groupName: string, relayUrls: string[]) => void
-}) {
+export default function RelayGroup({ group }: { group: TRelayGroup }) {
+ const { expandedRelayGroup } = useRelaySettingsComponent()
const { groupName, isActive, relayUrls } = group
- const [expanded, setExpanded] = useState(false)
- const [renaming, setRenaming] = useState(false)
-
- const toggleExpanded = () => setExpanded((prev) => !prev)
return (
- onSwitch(groupName)}
- hasRelayUrls={relayUrls.length > 0}
- />
- 0}
- setRenaming={setRenaming}
- save={onRename}
- onToggle={() => onSwitch(groupName)}
- />
+
+
-
+
{relayUrls.length} relays
-
+
- {expanded && (
-
onRelayUrlsUpdate(groupName, urls)}
- />
- )}
+ {expandedRelayGroup === groupName && }
)
}
-function RelayGroupActiveToggle({
- isActive,
- hasRelayUrls,
- onToggle
-}: {
- isActive: boolean
- hasRelayUrls: boolean
- onToggle: () => void
-}) {
- return (
- <>
- {isActive ? (
-
- ) : (
-
{
- if (hasRelayUrls) {
- onToggle()
- }
- }}
- />
- )}
- >
+function RelayGroupActiveToggle({ groupName }: { groupName: string }) {
+ const { relayGroups, switchRelayGroup } = useRelaySettings()
+
+ const isActive = relayGroups.find((group) => group.groupName === groupName)?.isActive
+ const hasRelayUrls = relayGroups.find((group) => group.groupName === groupName)?.relayUrls.length
+
+ return isActive ? (
+
+ ) : (
+ {
+ if (hasRelayUrls) {
+ switchRelayGroup(groupName)
+ }
+ }}
+ />
)
}
-function RelayGroupName({
- groupName,
- renaming,
- hasRelayUrls,
- setRenaming,
- save,
- onToggle
-}: {
- groupName: string
- renaming: boolean
- hasRelayUrls: boolean
- setRenaming: (renaming: boolean) => void
- save: (oldGroupName: string, newGroupName: string) => string | null
- onToggle: () => void
-}) {
+function RelayGroupName({ groupName }: { groupName: string }) {
const [newGroupName, setNewGroupName] = useState(groupName)
const [newNameError, setNewNameError] = useState(null)
+ const { relayGroups, switchRelayGroup, renameRelayGroup } = useRelaySettings()
+ const { renamingGroup, setRenamingGroup } = useRelaySettingsComponent()
+
+ const hasRelayUrls = relayGroups.find((group) => group.groupName === groupName)?.relayUrls.length
const saveNewGroupName = () => {
- const errMsg = save(groupName, newGroupName)
+ const errMsg = renameRelayGroup(groupName, newGroupName)
if (errMsg) {
setNewNameError(errMsg)
return
}
- setRenaming(false)
+ setRenamingGroup(null)
}
const handleRenameInputChange = (e: React.ChangeEvent) => {
@@ -140,72 +88,61 @@ function RelayGroupName({
}
}
- return (
- <>
- {renaming ? (
-
-
-
- {newNameError &&
{newNameError}
}
-
- ) : (
- {
- if (hasRelayUrls) {
- onToggle()
- }
- }}
- >
- {groupName}
-
- )}
- >
+ return renamingGroup === groupName ? (
+
+
+
+ {newNameError &&
{newNameError}
}
+
+ ) : (
+ {
+ if (hasRelayUrls) {
+ switchRelayGroup(groupName)
+ }
+ }}
+ >
+ {groupName}
+
)
}
function RelayUrlsExpandToggle({
- expanded,
- onClick,
+ groupName,
children
}: {
- expanded: boolean
- onClick: () => void
+ groupName: string
children: React.ReactNode
}) {
+ const { expandedRelayGroup, setExpandedRelayGroup } = useRelaySettingsComponent()
return (
setExpandedRelayGroup((pre) => (pre === groupName ? null : groupName))}
>
{children}
)
}
-function RelayGroupOptions({
- groupName,
- isActive,
- onDelete,
- setRenaming
-}: {
- groupName: string
- isActive: boolean
- onDelete: (groupName: string) => void
- setRenaming: (renaming: boolean) => void
-}) {
+function RelayGroupOptions({ groupName }: { groupName: string }) {
+ const { relayGroups, deleteRelayGroup } = useRelaySettings()
+ const { setRenamingGroup } = useRelaySettingsComponent()
+ const isActive = relayGroups.find((group) => group.groupName === groupName)?.isActive
+
return (
@@ -215,11 +152,11 @@ function RelayGroupOptions({
/>
- setRenaming(true)}>Rename
+ setRenamingGroup(groupName)}>Rename
onDelete(groupName)}
+ onClick={() => deleteRelayGroup(groupName)}
>
Delete
diff --git a/src/renderer/src/components/RelaySettings/RelayUrl.tsx b/src/renderer/src/components/RelaySettings/RelayUrl.tsx
index bfbac9e5..2d936d8c 100644
--- a/src/renderer/src/components/RelaySettings/RelayUrl.tsx
+++ b/src/renderer/src/components/RelaySettings/RelayUrl.tsx
@@ -1,18 +1,15 @@
import { Button } from '@renderer/components/ui/button'
import { Input } from '@renderer/components/ui/input'
+import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
import client from '@renderer/services/client.service'
import { CircleX } from 'lucide-react'
import { useEffect, useState } from 'react'
-export default function RelayUrls({
- isActive,
- relayUrls: rawRelayUrls,
- update
-}: {
- isActive: boolean
- relayUrls: string[]
- update: (urls: string[]) => void
-}) {
+export default function RelayUrls({ groupName }: { groupName: string }) {
+ const { relayGroups, updateRelayGroupRelayUrls } = useRelaySettings()
+ const rawRelayUrls = relayGroups.find((group) => group.groupName === groupName)?.relayUrls ?? []
+ const isActive = relayGroups.find((group) => group.groupName === groupName)?.isActive ?? false
+
const [newRelayUrl, setNewRelayUrl] = useState('')
const [newRelayUrlError, setNewRelayUrlError] = useState(null)
const [relays, setRelays] = useState<
@@ -38,7 +35,10 @@ export default function RelayUrls({
const removeRelayUrl = (url: string) => {
setRelays((relays) => relays.filter((relay) => relay.url !== url))
- update(relays.map(({ url }) => url).filter((u) => u !== url))
+ updateRelayGroupRelayUrls(
+ groupName,
+ relays.map(({ url }) => url).filter((u) => u !== url)
+ )
}
const saveNewRelayUrl = () => {
@@ -51,7 +51,7 @@ export default function RelayUrls({
}
setRelays((pre) => [...pre, { url: normalizedUrl, isConnected: false }])
const newRelayUrls = [...relays.map(({ url }) => url), normalizedUrl]
- update(newRelayUrls)
+ updateRelayGroupRelayUrls(groupName, newRelayUrls)
setNewRelayUrl('')
}
@@ -113,11 +113,11 @@ function RelayUrl({
{!isActive ? (
-
●
+
●
) : isConnected ? (
-
●
+
●
) : (
-
●
+
●
)}
{url}
diff --git a/src/renderer/src/components/RelaySettings/index.tsx b/src/renderer/src/components/RelaySettings/index.tsx
index 168862fe..09f20e5d 100644
--- a/src/renderer/src/components/RelaySettings/index.tsx
+++ b/src/renderer/src/components/RelaySettings/index.tsx
@@ -1,91 +1,29 @@
import { Button } from '@renderer/components/ui/button'
import { Input } from '@renderer/components/ui/input'
import { Separator } from '@renderer/components/ui/separator'
-import storage from '@renderer/services/storage.service'
+import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
import { useEffect, useRef, useState } from 'react'
+import { RelaySettingsComponentProvider } from './provider'
import RelayGroup from './RelayGroup'
-import { TRelayGroup } from './types'
export default function RelaySettings() {
- const [groups, setGroups] = useState
([])
+ const { relayGroups, addRelayGroup } = useRelaySettings()
const [newGroupName, setNewGroupName] = useState('')
const [newNameError, setNewNameError] = useState(null)
const dummyRef = useRef(null)
useEffect(() => {
- const init = async () => {
- const storedGroups = await storage.getRelayGroups()
- setGroups(storedGroups)
- }
-
if (dummyRef.current) {
dummyRef.current.focus()
}
- init()
}, [])
- const updateGroups = async (newGroups: TRelayGroup[]) => {
- setGroups(newGroups)
- await storage.setRelayGroups(newGroups)
- }
-
- const switchRelayGroup = (groupName: string) => {
- updateGroups(
- groups.map((group) => ({
- ...group,
- isActive: group.groupName === groupName
- }))
- )
- }
-
- const deleteRelayGroup = (groupName: string) => {
- updateGroups(groups.filter((group) => group.groupName !== groupName || group.isActive))
- }
-
- const updateRelayGroupRelayUrls = (groupName: string, relayUrls: string[]) => {
- updateGroups(
- groups.map((group) => ({
- ...group,
- relayUrls: group.groupName === groupName ? relayUrls : group.relayUrls
- }))
- )
- }
-
- const renameRelayGroup = (oldGroupName: string, newGroupName: string) => {
- if (newGroupName === '') {
- return null
- }
- if (oldGroupName === newGroupName) {
- return null
- }
- if (groups.some((group) => group.groupName === newGroupName)) {
- return 'already exists'
- }
- updateGroups(
- groups.map((group) => ({
- ...group,
- groupName: group.groupName === oldGroupName ? newGroupName : group.groupName
- }))
- )
- return null
- }
-
- const addRelayGroup = () => {
- if (newGroupName === '') {
- return
- }
- if (groups.some((group) => group.groupName === newGroupName)) {
- return setNewNameError('already exists')
+ const saveRelayGroup = () => {
+ const errMsg = addRelayGroup(newGroupName)
+ if (errMsg) {
+ return setNewNameError(errMsg)
}
setNewGroupName('')
- updateGroups([
- ...groups,
- {
- groupName: newGroupName,
- relayUrls: [],
- isActive: false
- }
- ])
}
const handleNewGroupNameChange = (e: React.ChangeEvent) => {
@@ -96,27 +34,20 @@ export default function RelaySettings() {
const handleNewGroupNameKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
event.preventDefault()
- addRelayGroup()
+ saveRelayGroup()
}
}
return (
-
+
Relay Settings
- {groups.map((group, index) => (
-
+ {relayGroups.map((group, index) => (
+
))}
- {groups.length < 5 && (
+ {relayGroups.length < 5 && (
<>
@@ -130,7 +61,7 @@ export default function RelaySettings() {
value={newGroupName}
onChange={handleNewGroupNameChange}
onKeyDown={handleNewGroupNameKeyDown}
- onBlur={addRelayGroup}
+ onBlur={saveRelayGroup}
/>
@@ -138,6 +69,6 @@ export default function RelaySettings() {
>
)}
-
+
)
}
diff --git a/src/renderer/src/components/RelaySettings/provider.tsx b/src/renderer/src/components/RelaySettings/provider.tsx
new file mode 100644
index 00000000..81346dfe
--- /dev/null
+++ b/src/renderer/src/components/RelaySettings/provider.tsx
@@ -0,0 +1,40 @@
+import { createContext, useContext, useState } from 'react'
+
+type TRelaySettingsComponentContext = {
+ renamingGroup: string | null
+ setRenamingGroup: React.Dispatch>
+ expandedRelayGroup: string | null
+ setExpandedRelayGroup: React.Dispatch>
+}
+
+export const RelaySettingsComponentContext = createContext<
+ TRelaySettingsComponentContext | undefined
+>(undefined)
+
+export const useRelaySettingsComponent = () => {
+ const context = useContext(RelaySettingsComponentContext)
+ if (!context) {
+ throw new Error(
+ 'useRelaySettingsComponent must be used within a RelaySettingsComponentProvider'
+ )
+ }
+ return context
+}
+
+export function RelaySettingsComponentProvider({ children }: { children: React.ReactNode }) {
+ const [renamingGroup, setRenamingGroup] = useState(null)
+ const [expandedRelayGroup, setExpandedRelayGroup] = useState(null)
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/renderer/src/components/ReplyNoteList/index.tsx b/src/renderer/src/components/ReplyNoteList/index.tsx
index fb873ab8..0ef45a11 100644
--- a/src/renderer/src/components/ReplyNoteList/index.tsx
+++ b/src/renderer/src/components/ReplyNoteList/index.tsx
@@ -19,14 +19,12 @@ export default function ReplyNoteList({ event, className }: { event: Event; clas
const loadMore = async () => {
setLoading(true)
- const events = await client.fetchEvents([
- {
- '#e': [event.id],
- kinds: [1],
- limit: 200,
- until
- }
- ])
+ const events = await client.fetchEvents({
+ '#e': [event.id],
+ kinds: [1],
+ limit: 100,
+ until
+ })
const sortedEvents = events.sort((a, b) => a.created_at - b.created_at)
if (sortedEvents.length > 0) {
const eventMap: Record = {}
@@ -38,7 +36,7 @@ export default function ReplyNoteList({ event, className }: { event: Event; clas
setEventMap((pre) => ({ ...pre, ...eventMap }))
setUntil(sortedEvents[0].created_at - 1)
}
- setHasMore(sortedEvents.length >= 200)
+ setHasMore(sortedEvents.length >= 100)
setLoading(false)
}
diff --git a/src/renderer/src/components/UserAvatar/index.tsx b/src/renderer/src/components/UserAvatar/index.tsx
index d25ff71a..f5eada8b 100644
--- a/src/renderer/src/components/UserAvatar/index.tsx
+++ b/src/renderer/src/components/UserAvatar/index.tsx
@@ -7,6 +7,7 @@ import { toProfile } from '@renderer/lib/url'
import { cn } from '@renderer/lib/utils'
import { SecondaryPageLink } from '@renderer/PageManager'
import ProfileCard from '../ProfileCard'
+import { useMemo } from 'react'
const UserAvatarSizeCnMap = {
large: 'w-24 h-24',
@@ -25,10 +26,11 @@ export default function UserAvatar({
size?: 'large' | 'normal' | 'small' | 'tiny'
}) {
const { avatar, pubkey } = useFetchProfile(userId)
- if (!pubkey)
- return
+ const defaultAvatar = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey])
- const defaultAvatar = generateImageByPubkey(pubkey)
+ if (!pubkey) {
+ return
+ }
return (
diff --git a/src/renderer/src/hooks/useFetchEvent.tsx b/src/renderer/src/hooks/useFetchEvent.tsx
index 8225906b..3fdb94e5 100644
--- a/src/renderer/src/hooks/useFetchEvent.tsx
+++ b/src/renderer/src/hooks/useFetchEvent.tsx
@@ -34,7 +34,7 @@ export function useFetchEventById(id?: string) {
if (filter.ids) {
event = await client.fetchEventById(filter.ids[0])
} else {
- event = await client.fetchEventWithCache(filter)
+ event = await client.fetchEventByFilter(filter)
}
if (event) {
setEvent(event)
diff --git a/src/renderer/src/hooks/useFetchProfile.tsx b/src/renderer/src/hooks/useFetchProfile.tsx
index f9bcca56..f3797aaa 100644
--- a/src/renderer/src/hooks/useFetchProfile.tsx
+++ b/src/renderer/src/hooks/useFetchProfile.tsx
@@ -1,67 +1,47 @@
-import { formatNpub } from '@renderer/lib/pubkey'
+import { formatPubkey } from '@renderer/lib/pubkey'
import client from '@renderer/services/client.service'
+import { TProfile } from '@renderer/types'
import { nip19 } from 'nostr-tools'
-import { useCallback, useEffect, useState } from 'react'
-
-type TProfile = {
- username: string
- pubkey?: string
- npub?: `npub1${string}`
- banner?: string
- avatar?: string
- nip05?: string
- about?: string
-}
-
-const decodeUserId = (id: string): { pubkey?: string; npub?: `npub1${string}` } => {
- if (/^npub1[a-z0-9]{58}$/.test(id)) {
- const { data } = nip19.decode(id as `npub1${string}`)
- return { pubkey: data, npub: id as `npub1${string}` }
- } else if (id.startsWith('nprofile1')) {
- const { data } = nip19.decode(id as `nprofile1${string}`)
- return { pubkey: data.pubkey, npub: nip19.npubEncode(data.pubkey) }
- } else if (/^[0-9a-f]{64}$/.test(id)) {
- return { pubkey: id, npub: nip19.npubEncode(id) }
- }
- return {}
-}
+import { useEffect, useState } from 'react'
export function useFetchProfile(id?: string) {
- const initialProfile: TProfile = {
+ const [profile, setProfile] = useState({
username: id ? (id.length > 9 ? id.slice(0, 4) + '...' + id.slice(-4) : id) : 'username'
- }
- const [profile, setProfile] = useState(initialProfile)
-
- const fetchProfile = useCallback(async () => {
- try {
- if (!id) return
-
- const { pubkey, npub } = decodeUserId(id)
- if (!pubkey || !npub) return
-
- const profileEvent = await client.fetchProfile(pubkey)
- const username = npub ? formatNpub(npub) : initialProfile.username
- setProfile({ pubkey, npub, username })
- if (!profileEvent) return
-
- const profileObj = JSON.parse(profileEvent.content)
- setProfile({
- ...initialProfile,
- pubkey,
- npub,
- banner: profileObj.banner,
- avatar: profileObj.picture,
- username:
- profileObj.display_name?.trim() || profileObj.name?.trim() || initialProfile.username,
- nip05: profileObj.nip05,
- about: profileObj.about
- })
- } catch (err) {
- console.error(err)
- }
- }, [id])
+ })
useEffect(() => {
+ const fetchProfile = async () => {
+ try {
+ if (!id) return
+
+ let pubkey: string | undefined
+
+ if (/^[0-9a-f]{64}$/.test(id)) {
+ pubkey = id
+ } else {
+ const { data, type } = nip19.decode(id)
+ switch (type) {
+ case 'npub':
+ pubkey = data
+ break
+ case 'nprofile':
+ pubkey = data.pubkey
+ break
+ }
+ }
+
+ if (!pubkey) return
+ setProfile({ pubkey, username: formatPubkey(pubkey) })
+
+ const profile = await client.fetchProfile(pubkey)
+ if (profile) {
+ setProfile(profile)
+ }
+ } catch (err) {
+ console.error(err)
+ }
+ }
+
fetchProfile()
}, [id])
diff --git a/src/renderer/src/layouts/PrimaryPageLayout/RefreshButton.tsx b/src/renderer/src/layouts/PrimaryPageLayout/RefreshButton.tsx
new file mode 100644
index 00000000..5791f8c2
--- /dev/null
+++ b/src/renderer/src/layouts/PrimaryPageLayout/RefreshButton.tsx
@@ -0,0 +1,12 @@
+import { TitlebarButton } from '@renderer/components/Titlebar'
+import { usePrimaryPage } from '@renderer/PageManager'
+import { RefreshCcw } from 'lucide-react'
+
+export default function RefreshButton() {
+ const { refresh } = usePrimaryPage()
+ return (
+
+
+
+ )
+}
diff --git a/src/renderer/src/layouts/PrimaryPageLayout/ReloadTimelineButton.tsx b/src/renderer/src/layouts/PrimaryPageLayout/ReloadTimelineButton.tsx
deleted file mode 100644
index 725a01b8..00000000
--- a/src/renderer/src/layouts/PrimaryPageLayout/ReloadTimelineButton.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import { TitlebarButton } from '@renderer/components/Titlebar'
-import { createReloadTimelineEvent, eventBus } from '@renderer/services/event-bus.service'
-import { Eraser } from 'lucide-react'
-
-export default function ReloadTimelineButton() {
- return (
- eventBus.emit(createReloadTimelineEvent())}
- title="reload timeline"
- >
-
-
- )
-}
diff --git a/src/renderer/src/layouts/PrimaryPageLayout/index.tsx b/src/renderer/src/layouts/PrimaryPageLayout/index.tsx
index 4dab7819..090a303c 100644
--- a/src/renderer/src/layouts/PrimaryPageLayout/index.tsx
+++ b/src/renderer/src/layouts/PrimaryPageLayout/index.tsx
@@ -3,8 +3,8 @@ import { Titlebar } from '@renderer/components/Titlebar'
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import { isMacOS } from '@renderer/lib/platform'
import { forwardRef, useImperativeHandle, useRef } from 'react'
-import ReloadTimelineButton from './ReloadTimelineButton'
import RelaySettingsPopover from './RelaySettingsPopover'
+import RefreshButton from './RefreshButton'
const PrimaryPageLayout = forwardRef(
(
@@ -48,7 +48,7 @@ export function PrimaryPageTitlebar({ content }: { content?: React.ReactNode })
{content}
-
+
diff --git a/src/renderer/src/layouts/SecondaryPageLayout/ThemeToggle.tsx b/src/renderer/src/layouts/SecondaryPageLayout/ThemeToggle.tsx
index b5837be6..c817efc2 100644
--- a/src/renderer/src/layouts/SecondaryPageLayout/ThemeToggle.tsx
+++ b/src/renderer/src/layouts/SecondaryPageLayout/ThemeToggle.tsx
@@ -1,4 +1,4 @@
-import { useTheme } from '@renderer/components/theme-provider'
+import { useTheme } from '@renderer/providers/ThemeProvider'
import { TitlebarButton } from '@renderer/components/Titlebar'
import { Moon, Sun, SunMoon } from 'lucide-react'
diff --git a/src/renderer/src/pages/primary/NoteListPage/index.tsx b/src/renderer/src/pages/primary/NoteListPage/index.tsx
index b52ca68f..cc301167 100644
--- a/src/renderer/src/pages/primary/NoteListPage/index.tsx
+++ b/src/renderer/src/pages/primary/NoteListPage/index.tsx
@@ -4,7 +4,7 @@ import PrimaryPageLayout from '@renderer/layouts/PrimaryPageLayout'
export default function NoteListPage() {
return (
-
+
)
}
diff --git a/src/renderer/src/pages/secondary/ProfilePage/index.tsx b/src/renderer/src/pages/secondary/ProfilePage/index.tsx
index 7c6e16f3..adc5780f 100644
--- a/src/renderer/src/pages/secondary/ProfilePage/index.tsx
+++ b/src/renderer/src/pages/secondary/ProfilePage/index.tsx
@@ -1,22 +1,24 @@
import Nip05 from '@renderer/components/Nip05'
import NoteList from '@renderer/components/NoteList'
import ProfileAbout from '@renderer/components/ProfileAbout'
+import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar'
import { Separator } from '@renderer/components/ui/separator'
-import UserAvatar from '@renderer/components/UserAvatar'
import { useFetchProfile } from '@renderer/hooks'
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
import { formatNpub, generateImageByPubkey } from '@renderer/lib/pubkey'
import { Copy } from 'lucide-react'
-import { useEffect, useState } from 'react'
+import { nip19 } from 'nostr-tools'
+import { useEffect, useMemo, useState } from 'react'
export default function ProfilePage({ pubkey }: { pubkey?: string }) {
- const { banner, username, nip05, about, npub } = useFetchProfile(pubkey)
+ const { banner, username, nip05, about, avatar } = useFetchProfile(pubkey)
const [copied, setCopied] = useState(false)
+ const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : undefined), [pubkey])
+ const defaultImage = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey])
if (!pubkey || !npub) return null
const copyNpub = () => {
- if (!npub) return
navigator.clipboard.writeText(npub)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
@@ -27,14 +29,16 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
-
+
+
+
+
+
+
{username}
@@ -44,7 +48,7 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
onClick={() => copyNpub()}
>
{copied ? (
-
Copied!
+
copied!
) : (
<>
{formatNpub(npub, 24)}
@@ -56,22 +60,23 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
-
+
)
}
function ProfileBanner({
- banner,
+ defaultBanner,
pubkey,
+ banner,
className
}: {
- banner?: string
+ defaultBanner: string
pubkey: string
+ banner?: string
className?: string
}) {
- const defaultBanner = generateImageByPubkey(pubkey)
const [bannerUrl, setBannerUrl] = useState(banner || defaultBanner)
useEffect(() => {
@@ -80,12 +85,12 @@ function ProfileBanner({
} else {
setBannerUrl(defaultBanner)
}
- }, [pubkey, banner])
+ }, [defaultBanner, banner])
return (

setBannerUrl(defaultBanner)}
/>
diff --git a/src/renderer/src/providers/RelaySettingsProvider.tsx b/src/renderer/src/providers/RelaySettingsProvider.tsx
new file mode 100644
index 00000000..8c44dec6
--- /dev/null
+++ b/src/renderer/src/providers/RelaySettingsProvider.tsx
@@ -0,0 +1,123 @@
+import { TRelayGroup } from '@common/types'
+import storage from '@renderer/services/storage.service'
+import { createContext, useContext, useEffect, useState } from 'react'
+
+type TRelaySettingsContext = {
+ relayGroups: TRelayGroup[]
+ relayUrls: string[]
+ switchRelayGroup: (groupName: string) => void
+ renameRelayGroup: (oldGroupName: string, newGroupName: string) => string | null
+ deleteRelayGroup: (groupName: string) => void
+ addRelayGroup: (groupName: string) => string | null
+ updateRelayGroupRelayUrls: (groupName: string, relayUrls: string[]) => void
+}
+
+const RelaySettingsContext = createContext
(undefined)
+
+export const useRelaySettings = () => {
+ const context = useContext(RelaySettingsContext)
+ if (!context) {
+ throw new Error('useRelaySettings must be used within a RelaySettingsProvider')
+ }
+ return context
+}
+
+export function RelaySettingsProvider({ children }: { children: React.ReactNode }) {
+ const [relayGroups, setRelayGroups] = useState([])
+ const [relayUrls, setRelayUrls] = useState(
+ relayGroups.find((group) => group.isActive)?.relayUrls ?? []
+ )
+
+ useEffect(() => {
+ const init = async () => {
+ const storedGroups = await storage.getRelayGroups()
+ setRelayGroups(storedGroups)
+ }
+
+ init()
+ }, [])
+
+ useEffect(() => {
+ setRelayUrls(relayGroups.find((group) => group.isActive)?.relayUrls ?? [])
+ }, [relayGroups])
+
+ const updateGroups = async (newGroups: TRelayGroup[]) => {
+ setRelayGroups(newGroups)
+ await storage.setRelayGroups(newGroups)
+ }
+
+ const switchRelayGroup = (groupName: string) => {
+ updateGroups(
+ relayGroups.map((group) => ({
+ ...group,
+ isActive: group.groupName === groupName
+ }))
+ )
+ }
+
+ const deleteRelayGroup = (groupName: string) => {
+ updateGroups(relayGroups.filter((group) => group.groupName !== groupName || group.isActive))
+ }
+
+ const updateRelayGroupRelayUrls = (groupName: string, relayUrls: string[]) => {
+ updateGroups(
+ relayGroups.map((group) => ({
+ ...group,
+ relayUrls: group.groupName === groupName ? relayUrls : group.relayUrls
+ }))
+ )
+ }
+
+ const renameRelayGroup = (oldGroupName: string, newGroupName: string) => {
+ if (newGroupName === '') {
+ return null
+ }
+ if (oldGroupName === newGroupName) {
+ return null
+ }
+ if (relayGroups.some((group) => group.groupName === newGroupName)) {
+ return 'already exists'
+ }
+ updateGroups(
+ relayGroups.map((group) => ({
+ ...group,
+ groupName: group.groupName === oldGroupName ? newGroupName : group.groupName
+ }))
+ )
+ return null
+ }
+
+ const addRelayGroup = (groupName: string) => {
+ if (groupName === '') {
+ return null
+ }
+ if (relayGroups.some((group) => group.groupName === groupName)) {
+ return 'already exists'
+ }
+ updateGroups([
+ ...relayGroups,
+ {
+ groupName,
+ relayUrls: [],
+ isActive: false
+ }
+ ])
+ return null
+ }
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/renderer/src/components/theme-provider.tsx b/src/renderer/src/providers/ThemeProvider.tsx
similarity index 100%
rename from src/renderer/src/components/theme-provider.tsx
rename to src/renderer/src/providers/ThemeProvider.tsx
diff --git a/src/renderer/src/services/client.service.ts b/src/renderer/src/services/client.service.ts
index 45b74278..2d7fca7f 100644
--- a/src/renderer/src/services/client.service.ts
+++ b/src/renderer/src/services/client.service.ts
@@ -1,55 +1,53 @@
import { TRelayGroup } from '@common/types'
-import { TEventStats } from '@renderer/types'
+import { formatPubkey } from '@renderer/lib/pubkey'
+import { TEventStats, TProfile } from '@renderer/types'
+import DataLoader from 'dataloader'
import { LRUCache } from 'lru-cache'
import { Filter, kinds, Event as NEvent, SimplePool } from 'nostr-tools'
import { EVENT_TYPES, eventBus } from './event-bus.service'
import storage from './storage.service'
+const BIG_RELAY_URLS = [
+ 'wss://relay.damus.io/',
+ 'wss://nos.lol/',
+ 'wss://relay.nostr.band/',
+ 'wss://relay.noswhere.com/'
+]
+
class ClientService {
static instance: ClientService
private pool = new SimplePool()
+ private relayUrls: string[] = BIG_RELAY_URLS
private initPromise!: Promise
- private relayUrls: string[] = []
- private cache = new LRUCache({
- max: 10000,
- fetchMethod: async (filter) => this.fetchEvent(JSON.parse(filter))
- })
- // Event cache
- private eventsCache = new LRUCache>({
- max: 10000,
- ttl: 1000 * 60 * 10 // 10 minutes
- })
- private fetchEventQueue = new Map<
- string,
- {
- resolve: (value: NEvent | undefined) => void
- reject: (reason: any) => void
- }
- >()
- private fetchEventTimer: NodeJS.Timeout | null = null
-
- // Event stats cache
private eventStatsCache = new LRUCache>({
max: 10000,
ttl: 1000 * 60 * 10, // 10 minutes
fetchMethod: async (id) => this._fetchEventStatsById(id)
})
- // Profile cache
- private profilesCache = new LRUCache>({
+ private eventCache = new LRUCache>({
max: 10000,
- ttl: 1000 * 60 * 10 // 10 minutes
- })
- private fetchProfileQueue = new Map<
- string,
- {
- resolve: (value: NEvent | undefined) => void
- reject: (reason: any) => void
+ fetchMethod: async (filterStr) => {
+ const [event] = await this.fetchEvents(JSON.parse(filterStr))
+ return event
}
- >()
- private fetchProfileTimer: NodeJS.Timeout | null = null
+ })
+
+ private eventDataloader = new DataLoader(
+ this.eventBatchLoadFn.bind(this),
+ {
+ cacheMap: new LRUCache>({ max: 10000 })
+ }
+ )
+
+ private profileDataloader = new DataLoader(
+ this.profileBatchLoadFn.bind(this),
+ {
+ cacheMap: new LRUCache>({ max: 10000 })
+ }
+ )
constructor() {
if (!ClientService.instance) {
@@ -69,12 +67,6 @@ class ClientService {
onRelayGroupsChange(relayGroups: TRelayGroup[]) {
const newRelayUrls = relayGroups.find((group) => group.isActive)?.relayUrls ?? []
- if (
- newRelayUrls.length === this.relayUrls.length &&
- newRelayUrls.every((url) => this.relayUrls.includes(url))
- ) {
- return
- }
this.relayUrls = newRelayUrls
}
@@ -82,70 +74,40 @@ class ClientService {
return this.pool.listConnectionStatus()
}
- async fetchEvents(filters: Filter[]) {
- await this.initPromise
- return new Promise((resolve) => {
- const events: NEvent[] = []
- this.pool.subscribeManyEose(this.relayUrls, filters, {
- onevent(event) {
- events.push(event)
- },
- onclose() {
- resolve(events)
- }
- })
- })
- }
-
- async fetchEventWithCache(filter: Filter) {
- return this.cache.fetch(JSON.stringify(filter))
- }
-
- async fetchEvent(filter: Filter) {
- const events = await this.fetchEvents([{ ...filter, limit: 1 }])
- return events.length ? events[0] : undefined
- }
-
- async fetchEventById(id: string): Promise {
- const cache = this.eventsCache.get(id)
- if (cache) {
- return cache
+ subscribeEvents(
+ urls: string[],
+ filter: Filter,
+ opts: {
+ onEose: (events: NEvent[]) => void
+ onNew: (evt: NEvent) => void
}
-
- const promise = new Promise((resolve, reject) => {
- this.fetchEventQueue.set(id, { resolve, reject })
- if (this.fetchEventTimer) {
- return
- }
-
- this.fetchEventTimer = setTimeout(async () => {
- this.fetchEventTimer = null
- const queue = new Map(this.fetchEventQueue)
- this.fetchEventQueue.clear()
-
- try {
- const ids = Array.from(queue.keys())
- const events = await this.fetchEvents([{ ids, limit: ids.length }])
- for (const event of events) {
- queue.get(event.id)?.resolve(event)
- queue.delete(event.id)
- }
-
- for (const [, job] of queue) {
- job.resolve(undefined)
- }
- queue.clear()
- } catch (err) {
- for (const [id, job] of queue) {
- this.eventsCache.delete(id)
- job.reject(err)
- }
+ ) {
+ console.log('subscribeEvents', urls, filter)
+ const events: NEvent[] = []
+ let eose = false
+ return this.pool.subscribeMany(urls, [filter], {
+ onevent: (evt) => {
+ if (eose) {
+ opts.onNew(evt)
+ } else {
+ events.push(evt)
}
- }, 20)
+ },
+ oneose: () => {
+ eose = true
+ opts.onEose(events.sort((a, b) => b.created_at - a.created_at))
+ },
+ onclose: () => {
+ if (!eose) {
+ opts.onEose(events.sort((a, b) => b.created_at - a.created_at))
+ }
+ }
})
+ }
- this.eventsCache.set(id, promise)
- return promise
+ async fetchEvents(filter: Filter, relayUrls: string[] = this.relayUrls) {
+ await this.initPromise
+ return await this.pool.querySync(relayUrls, filter)
}
async fetchEventStatsById(id: string): Promise {
@@ -153,70 +115,116 @@ class ClientService {
return stats ?? { reactionCount: 0, repostCount: 0 }
}
+ async fetchEventByFilter(filter: Filter) {
+ return this.eventCache.fetch(JSON.stringify({ ...filter, limit: 1 }))
+ }
+
+ async fetchEventById(id: string): Promise {
+ return this.eventDataloader.load(id)
+ }
+
+ async fetchProfile(pubkey: string): Promise {
+ return this.profileDataloader.load(pubkey)
+ }
+
private async _fetchEventStatsById(id: string) {
const [reactionEvents, repostEvents] = await Promise.all([
- this.fetchEvents([{ '#e': [id], kinds: [kinds.Reaction] }]),
- this.fetchEvents([{ '#e': [id], kinds: [kinds.Repost] }])
+ this.fetchEvents({ '#e': [id], kinds: [kinds.Reaction] }),
+ this.fetchEvents({ '#e': [id], kinds: [kinds.Repost] })
])
return { reactionCount: reactionEvents.length, repostCount: repostEvents.length }
}
- async fetchProfile(pubkey: string): Promise {
- const cache = this.profilesCache.get(pubkey)
- if (cache) {
- return cache
+ private async eventBatchLoadFn(ids: readonly string[]) {
+ const events = await this.fetchEvents({
+ ids: ids as string[],
+ limit: ids.length
+ })
+ const eventsMap = new Map()
+ for (const event of events) {
+ eventsMap.set(event.id, event)
}
- const promise = new Promise((resolve, reject) => {
- this.fetchProfileQueue.set(pubkey, { resolve, reject })
- if (this.fetchProfileTimer) {
- return
+ const missingIds = ids.filter((id) => !eventsMap.has(id))
+ if (missingIds.length > 0) {
+ const missingEvents = await this.fetchEvents(
+ {
+ ids: missingIds,
+ limit: missingIds.length
+ },
+ BIG_RELAY_URLS.filter((url) => !this.relayUrls.includes(url))
+ )
+ for (const event of missingEvents) {
+ eventsMap.set(event.id, event)
}
+ }
- this.fetchProfileTimer = setTimeout(async () => {
- this.fetchProfileTimer = null
- const queue = new Map(this.fetchProfileQueue)
- this.fetchProfileQueue.clear()
+ return ids.map((id) => eventsMap.get(id))
+ }
- try {
- const pubkeys = Array.from(queue.keys())
- const events = await this.fetchEvents([
- {
- authors: pubkeys,
- kinds: [0],
- limit: pubkeys.length
- }
- ])
- const eventsMap = new Map()
- for (const event of events) {
- const pubkey = event.pubkey
- const existing = eventsMap.get(pubkey)
- if (!existing || existing.created_at < event.created_at) {
- eventsMap.set(pubkey, event)
- }
- }
-
- for (const [pubkey, job] of queue) {
- const event = eventsMap.get(pubkey)
- if (event) {
- job.resolve(event)
- } else {
- job.resolve(undefined)
- }
- queue.delete(pubkey)
- }
- } catch (err) {
- for (const [pubkey, job] of queue) {
- this.profilesCache.delete(pubkey)
- job.reject(err)
- }
- }
- }, 20)
+ private async profileBatchLoadFn(pubkeys: readonly string[]) {
+ const events = await this.fetchEvents({
+ authors: pubkeys as string[],
+ kinds: [kinds.Metadata],
+ limit: pubkeys.length
})
+ const eventsMap = new Map()
+ for (const event of events) {
+ const pubkey = event.pubkey
+ const existing = eventsMap.get(pubkey)
+ if (!existing || existing.created_at < event.created_at) {
+ eventsMap.set(pubkey, event)
+ }
+ }
- this.profilesCache.set(pubkey, promise)
- return promise
+ const missingPubkeys = pubkeys.filter((pubkey) => !eventsMap.has(pubkey))
+ if (missingPubkeys.length > 0) {
+ const missingEvents = await this.fetchEvents(
+ {
+ authors: missingPubkeys,
+ kinds: [kinds.Metadata],
+ limit: missingPubkeys.length
+ },
+ BIG_RELAY_URLS.filter((url) => !this.relayUrls.includes(url))
+ )
+ for (const event of missingEvents) {
+ const pubkey = event.pubkey
+ const existing = eventsMap.get(pubkey)
+ if (!existing || existing.created_at < event.created_at) {
+ eventsMap.set(pubkey, event)
+ }
+ }
+ }
+
+ return pubkeys.map((pubkey) => {
+ const event = eventsMap.get(pubkey)
+ return event ? this.parseProfileFromEvent(event) : undefined
+ })
+ }
+
+ private parseProfileFromEvent(event: NEvent): TProfile {
+ try {
+ const profileObj = JSON.parse(event.content)
+ return {
+ pubkey: event.pubkey,
+ banner: profileObj.banner,
+ avatar: profileObj.picture,
+ username:
+ profileObj.display_name?.trim() ||
+ profileObj.name?.trim() ||
+ profileObj.nip05?.split('@')[0]?.trim() ||
+ formatPubkey(event.pubkey),
+ nip05: profileObj.nip05,
+ about: profileObj.about
+ }
+ } catch (err) {
+ console.error(err)
+ return {
+ pubkey: event.pubkey,
+ username: formatPubkey(event.pubkey)
+ }
+ }
}
}
diff --git a/src/renderer/src/services/event-bus.service.ts b/src/renderer/src/services/event-bus.service.ts
index 31d0f50e..4702e4d0 100644
--- a/src/renderer/src/services/event-bus.service.ts
+++ b/src/renderer/src/services/event-bus.service.ts
@@ -2,13 +2,11 @@ import { TRelayGroup } from '@common/types'
export const EVENT_TYPES = {
RELAY_GROUPS_CHANGED: 'relay-groups-changed',
- RELOAD_TIMELINE: 'reload-timeline',
REPLY_COUNT_CHANGED: 'reply-count-changed'
} as const
type TEventMap = {
[EVENT_TYPES.RELAY_GROUPS_CHANGED]: TRelayGroup[]
- [EVENT_TYPES.RELOAD_TIMELINE]: unknown
[EVENT_TYPES.REPLY_COUNT_CHANGED]: { eventId: string; replyCount: number }
}
@@ -19,9 +17,6 @@ type TCustomEventMap = {
export const createRelayGroupsChangedEvent = (relayGroups: TRelayGroup[]) => {
return new CustomEvent(EVENT_TYPES.RELAY_GROUPS_CHANGED, { detail: relayGroups })
}
-export const createReloadTimelineEvent = () => {
- return new CustomEvent(EVENT_TYPES.RELOAD_TIMELINE)
-}
export const createReplyCountChangedEvent = (eventId: string, replyCount: number) => {
return new CustomEvent(EVENT_TYPES.REPLY_COUNT_CHANGED, { detail: { eventId, replyCount } })
}
diff --git a/src/renderer/src/services/storage.service.ts b/src/renderer/src/services/storage.service.ts
index 2085ed99..6ee17280 100644
--- a/src/renderer/src/services/storage.service.ts
+++ b/src/renderer/src/services/storage.service.ts
@@ -4,20 +4,40 @@ import { createRelayGroupsChangedEvent, eventBus } from './event-bus.service'
class StorageService {
static instance: StorageService
+ private initPromise!: Promise
+ private relayGroups: TRelayGroup[] = []
+ private activeRelayUrls: string[] = []
+
constructor() {
if (!StorageService.instance) {
+ this.initPromise = this.init()
StorageService.instance = this
}
return StorageService.instance
}
+ async init() {
+ this.relayGroups = await window.api.storage.getRelayGroups()
+ this.activeRelayUrls = this.relayGroups.find((group) => group.isActive)?.relayUrls ?? []
+ }
+
async getRelayGroups() {
- return await window.api.storage.getRelayGroups()
+ await this.initPromise
+ return this.relayGroups
}
async setRelayGroups(relayGroups: TRelayGroup[]) {
+ await this.initPromise
await window.api.storage.setRelayGroups(relayGroups)
- eventBus.emit(createRelayGroupsChangedEvent(relayGroups))
+ this.relayGroups = relayGroups
+ const newActiveRelayUrls = relayGroups.find((group) => group.isActive)?.relayUrls ?? []
+ if (
+ this.activeRelayUrls.length !== newActiveRelayUrls.length ||
+ this.activeRelayUrls.some((url) => !newActiveRelayUrls.includes(url))
+ ) {
+ eventBus.emit(createRelayGroupsChangedEvent(relayGroups))
+ }
+ this.activeRelayUrls = newActiveRelayUrls
}
}
diff --git a/src/renderer/src/types.ts b/src/renderer/src/types.ts
index 828b0e6e..61cd0d8b 100644
--- a/src/renderer/src/types.ts
+++ b/src/renderer/src/types.ts
@@ -1 +1,10 @@
export type TEventStats = { reactionCount: number; repostCount: number }
+
+export type TProfile = {
+ username: string
+ pubkey?: string
+ banner?: string
+ avatar?: string
+ nip05?: string
+ about?: string
+}