feat: sync relay sets
This commit is contained in:
10
src/App.tsx
10
src/App.tsx
@@ -8,25 +8,25 @@ import { FeedProvider } from './providers/FeedProvider'
|
|||||||
import { FollowListProvider } from './providers/FollowListProvider'
|
import { FollowListProvider } from './providers/FollowListProvider'
|
||||||
import { NostrProvider } from './providers/NostrProvider'
|
import { NostrProvider } from './providers/NostrProvider'
|
||||||
import { NoteStatsProvider } from './providers/NoteStatsProvider'
|
import { NoteStatsProvider } from './providers/NoteStatsProvider'
|
||||||
import { RelaySettingsProvider } from './providers/RelaySettingsProvider'
|
import { RelaySetsProvider } from './providers/RelaySetsProvider'
|
||||||
import { ScreenSizeProvider } from './providers/ScreenSizeProvider'
|
import { ScreenSizeProvider } from './providers/ScreenSizeProvider'
|
||||||
|
|
||||||
export default function App(): JSX.Element {
|
export default function App(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<ScreenSizeProvider>
|
<ScreenSizeProvider>
|
||||||
<FeedProvider>
|
|
||||||
<RelaySettingsProvider>
|
|
||||||
<NostrProvider>
|
<NostrProvider>
|
||||||
|
<RelaySetsProvider>
|
||||||
|
<FeedProvider>
|
||||||
<FollowListProvider>
|
<FollowListProvider>
|
||||||
<NoteStatsProvider>
|
<NoteStatsProvider>
|
||||||
<PageManager />
|
<PageManager />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</NoteStatsProvider>
|
</NoteStatsProvider>
|
||||||
</FollowListProvider>
|
</FollowListProvider>
|
||||||
</NostrProvider>
|
|
||||||
</RelaySettingsProvider>
|
|
||||||
</FeedProvider>
|
</FeedProvider>
|
||||||
|
</RelaySetsProvider>
|
||||||
|
</NostrProvider>
|
||||||
</ScreenSizeProvider>
|
</ScreenSizeProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,15 +3,16 @@ import { simplifyUrl } from '@/lib/url'
|
|||||||
import { SecondaryPageLink } from '@/PageManager'
|
import { SecondaryPageLink } from '@/PageManager'
|
||||||
import { useFeed } from '@/providers/FeedProvider'
|
import { useFeed } from '@/providers/FeedProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
import { useRelaySets } from '@/providers/RelaySetsProvider'
|
||||||
import { Circle, CircleCheck } from 'lucide-react'
|
import { Circle, CircleCheck } from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import RelaySetCard from '../RelaySetCard'
|
||||||
|
|
||||||
export default function FeedSwitcher({ close }: { close?: () => void }) {
|
export default function FeedSwitcher({ close }: { close?: () => void }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { feedType, setFeedType } = useFeed()
|
const { feedType, switchFeed, activeRelaySetId, temporaryRelayUrls } = useFeed()
|
||||||
const { pubkey } = useNostr()
|
const { pubkey } = useNostr()
|
||||||
const { relayGroups, temporaryRelayUrls, switchRelayGroup } = useRelaySettings()
|
const { relaySets } = useRelaySets()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -20,14 +21,14 @@ export default function FeedSwitcher({ close }: { close?: () => void }) {
|
|||||||
itemName={t('Following')}
|
itemName={t('Following')}
|
||||||
isActive={feedType === 'following'}
|
isActive={feedType === 'following'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFeedType('following')
|
switchFeed('following')
|
||||||
close?.()
|
close?.()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between px-2">
|
<div className="flex justify-between px-2">
|
||||||
<div className="text-muted-foreground text-sm font-semibold">{t('relay feeds')}</div>
|
<div className="text-muted-foreground text-sm font-semibold">{t('relay sets')}</div>
|
||||||
<SecondaryPageLink
|
<SecondaryPageLink
|
||||||
to={toRelaySettings()}
|
to={toRelaySettings()}
|
||||||
className="text-highlight text-sm font-semibold"
|
className="text-highlight text-sm font-semibold"
|
||||||
@@ -42,25 +43,25 @@ export default function FeedSwitcher({ close }: { close?: () => void }) {
|
|||||||
itemName={
|
itemName={
|
||||||
temporaryRelayUrls.length === 1 ? simplifyUrl(temporaryRelayUrls[0]) : t('Temporary')
|
temporaryRelayUrls.length === 1 ? simplifyUrl(temporaryRelayUrls[0]) : t('Temporary')
|
||||||
}
|
}
|
||||||
isActive={feedType === 'relays'}
|
isActive={feedType === 'temporary'}
|
||||||
temporary
|
temporary
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFeedType('relays')
|
switchFeed('temporary')
|
||||||
close?.()
|
close?.()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{relayGroups
|
{relaySets
|
||||||
.filter((group) => group.relayUrls.length > 0)
|
.filter((set) => set.relayUrls.length > 0)
|
||||||
.map((group) => (
|
.map((set) => (
|
||||||
<FeedSwitcherItem
|
<RelaySetCard
|
||||||
key={group.groupName}
|
key={set.id}
|
||||||
itemName={
|
relaySet={set}
|
||||||
group.relayUrls.length === 1 ? simplifyUrl(group.relayUrls[0]) : group.groupName
|
select={feedType === 'relays' && set.id === activeRelaySetId}
|
||||||
}
|
showConnectionStatus={feedType === 'relays' && set.id === activeRelaySetId}
|
||||||
isActive={feedType === 'relays' && group.isActive && temporaryRelayUrls.length === 0}
|
onSelectChange={(select) => {
|
||||||
onClick={() => {
|
if (!select) return
|
||||||
switchRelayGroup(group.groupName)
|
switchFeed('relays', { activeRelaySetId: set.id })
|
||||||
close?.()
|
close?.()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -22,12 +22,7 @@ export default function LoginDialog({
|
|||||||
if (isSmallScreen) {
|
if (isSmallScreen) {
|
||||||
return (
|
return (
|
||||||
<Drawer open={open} onOpenChange={setOpen}>
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
<DrawerContent
|
<DrawerContent className="max-h-[90vh]">
|
||||||
className="max-h-[90vh]"
|
|
||||||
style={{
|
|
||||||
paddingBottom: 'env(safe-area-inset-bottom)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col p-4 gap-4 overflow-auto">
|
<div className="flex flex-col p-4 gap-4 overflow-auto">
|
||||||
<AccountManager close={() => setOpen(false)} />
|
<AccountManager close={() => setOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
124
src/components/RelaySetCard/index.tsx
Normal file
124
src/components/RelaySetCard/index.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import client from '@/services/client.service'
|
||||||
|
import { TRelaySet } from '@/types'
|
||||||
|
import { ChevronDown, Circle, CircleCheck } from 'lucide-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export default function RelaySetCard({
|
||||||
|
relaySet,
|
||||||
|
select,
|
||||||
|
onSelectChange,
|
||||||
|
showConnectionStatus = false
|
||||||
|
}: {
|
||||||
|
relaySet: TRelaySet
|
||||||
|
select: boolean
|
||||||
|
onSelectChange: (select: boolean) => void
|
||||||
|
showConnectionStatus?: boolean
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [expand, setExpand] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`w-full border rounded-lg p-4 ${select ? 'border-highlight bg-highlight/5' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div
|
||||||
|
className="flex space-x-2 items-center cursor-pointer"
|
||||||
|
onClick={() => onSelectChange(!select)}
|
||||||
|
>
|
||||||
|
<RelaySetActiveToggle select={select} />
|
||||||
|
<div className="h-8 font-semibold flex items-center select-none">{relaySet.name}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<RelayUrlsExpandToggle expand={expand} onExpandChange={setExpand}>
|
||||||
|
{t('n relays', { n: relaySet.relayUrls.length })}
|
||||||
|
</RelayUrlsExpandToggle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{expand && (
|
||||||
|
<RelayUrls urls={relaySet.relayUrls} showConnectionStatus={showConnectionStatus} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RelaySetActiveToggle({ select }: { select: boolean }) {
|
||||||
|
return select ? (
|
||||||
|
<CircleCheck size={18} className="text-highlight shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Circle size={18} className="shrink-0" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RelayUrlsExpandToggle({
|
||||||
|
children,
|
||||||
|
expand,
|
||||||
|
onExpandChange
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
expand: boolean
|
||||||
|
onExpandChange: (expand: boolean) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="text-sm text-muted-foreground flex items-center gap-1 cursor-pointer hover:text-foreground"
|
||||||
|
onClick={() => onExpandChange(!expand)}
|
||||||
|
>
|
||||||
|
<div className="select-none">{children}</div>
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={`transition-transform duration-200 ${expand ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RelayUrls({
|
||||||
|
showConnectionStatus = false,
|
||||||
|
urls
|
||||||
|
}: {
|
||||||
|
showConnectionStatus?: boolean
|
||||||
|
urls: string[]
|
||||||
|
}) {
|
||||||
|
const [relays, setRelays] = useState<
|
||||||
|
{
|
||||||
|
url: string
|
||||||
|
isConnected: boolean
|
||||||
|
}[]
|
||||||
|
>(urls.map((url) => ({ url, isConnected: false })) ?? [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showConnectionStatus || urls.length === 0) return
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const connectionStatusMap = client.listConnectionStatus()
|
||||||
|
setRelays((pre) => {
|
||||||
|
return pre.map((relay) => {
|
||||||
|
const isConnected = connectionStatusMap.get(relay.url) || false
|
||||||
|
return { ...relay, isConnected }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [showConnectionStatus, urls])
|
||||||
|
|
||||||
|
if (!urls) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{relays.map(({ url, isConnected: isConnected }, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
{showConnectionStatus &&
|
||||||
|
(isConnected ? (
|
||||||
|
<div className="text-green-500 text-xs">●</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-red-500 text-xs">●</div>
|
||||||
|
))}
|
||||||
|
<div className="text-muted-foreground text-sm">{url}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
168
src/components/RelaySetsSetting/PullFromRelaysButton.tsx
Normal file
168
src/components/RelaySetsSetting/PullFromRelaysButton.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerTrigger
|
||||||
|
} from '@/components/ui/drawer'
|
||||||
|
import { BIG_RELAY_URLS } from '@/constants'
|
||||||
|
import { tagNameEquals } from '@/lib/tag'
|
||||||
|
import { isWebsocketUrl, simplifyUrl } from '@/lib/url'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
|
import client from '@/services/client.service'
|
||||||
|
import { TRelaySet } from '@/types'
|
||||||
|
import { CloudDownload } from 'lucide-react'
|
||||||
|
import { kinds } from 'nostr-tools'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import RelaySetCard from '../RelaySetCard'
|
||||||
|
import { useRelaySets } from '@/providers/RelaySetsProvider'
|
||||||
|
|
||||||
|
export default function PullFromRelaysButton() {
|
||||||
|
const { pubkey } = useNostr()
|
||||||
|
const { isSmallScreen } = useScreenSize()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<Button variant="secondary" className="w-full" disabled={!pubkey}>
|
||||||
|
<CloudDownload />
|
||||||
|
Pull from relays
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isSmallScreen) {
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
|
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||||
|
<DrawerContent className="max-h-[90vh]">
|
||||||
|
<div className="flex flex-col p-4 gap-4 overflow-auto">
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle>Select the relay sets you want to pull</DrawerTitle>
|
||||||
|
<DrawerDescription className="hidden" />
|
||||||
|
</DrawerHeader>
|
||||||
|
<RemoteRelaySets close={() => setOpen(false)} />
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Select the relay sets you want to pull</DialogTitle>
|
||||||
|
<DialogDescription className="hidden" />
|
||||||
|
</DialogHeader>
|
||||||
|
<RemoteRelaySets close={() => setOpen(false)} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RemoteRelaySets({ close }: { close?: () => void }) {
|
||||||
|
const { pubkey, relayList } = useNostr()
|
||||||
|
const { mergeRelaySets } = useRelaySets()
|
||||||
|
const [initialed, setInitialed] = useState(false)
|
||||||
|
const [relaySets, setRelaySets] = useState<TRelaySet[]>([])
|
||||||
|
const [selectedRelaySetIds, setSelectedRelaySetIds] = useState<string[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pubkey) return
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
setInitialed(false)
|
||||||
|
const events = await client.fetchEvents(
|
||||||
|
(relayList?.write ?? []).concat(BIG_RELAY_URLS).slice(0, 4),
|
||||||
|
{
|
||||||
|
kinds: [kinds.Relaysets],
|
||||||
|
authors: [pubkey],
|
||||||
|
limit: 50
|
||||||
|
}
|
||||||
|
)
|
||||||
|
setRelaySets(
|
||||||
|
events
|
||||||
|
.map((evt) => {
|
||||||
|
const id = evt.tags.find(tagNameEquals('d'))?.[1]
|
||||||
|
if (!id) return null
|
||||||
|
|
||||||
|
const relayUrls = evt.tags
|
||||||
|
.filter(tagNameEquals('relay'))
|
||||||
|
.map((tag) => tag[1])
|
||||||
|
.filter((url) => url && isWebsocketUrl(url))
|
||||||
|
if (!relayUrls.length) return null
|
||||||
|
|
||||||
|
let title = evt.tags.find(tagNameEquals('title'))?.[1]
|
||||||
|
if (!title) {
|
||||||
|
title = relayUrls.length === 1 ? simplifyUrl(relayUrls[0]) : id
|
||||||
|
}
|
||||||
|
return { id, name: title, relayUrls }
|
||||||
|
})
|
||||||
|
.filter(Boolean) as TRelaySet[]
|
||||||
|
)
|
||||||
|
setInitialed(true)
|
||||||
|
}
|
||||||
|
init()
|
||||||
|
}, [pubkey])
|
||||||
|
|
||||||
|
if (!pubkey) return null
|
||||||
|
if (!initialed) return <div className="text-center text-muted-foreground">Loading...</div>
|
||||||
|
if (!relaySets.length) {
|
||||||
|
return <div className="text-center text-muted-foreground">No relay sets found</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{relaySets.map((relaySet) => (
|
||||||
|
<RelaySetCard
|
||||||
|
key={relaySet.id}
|
||||||
|
relaySet={relaySet}
|
||||||
|
select={selectedRelaySetIds.includes(relaySet.id)}
|
||||||
|
onSelectChange={(select) => {
|
||||||
|
if (select) {
|
||||||
|
setSelectedRelaySetIds([...selectedRelaySetIds, relaySet.id])
|
||||||
|
} else {
|
||||||
|
setSelectedRelaySetIds(selectedRelaySetIds.filter((id) => id !== relaySet.id))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
className="w-20 shrink-0"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setSelectedRelaySetIds(relaySets.map((r) => r.id))}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
disabled={!selectedRelaySetIds.length}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedRelaySetIds.length > 0) {
|
||||||
|
mergeRelaySets(relaySets.filter((set) => selectedRelaySetIds.includes(set.id)))
|
||||||
|
close?.()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedRelaySetIds.length > 0
|
||||||
|
? `Pull ${selectedRelaySetIds.length} relay sets`
|
||||||
|
: 'Pull'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
src/components/RelaySetsSetting/PushToRelaysButton.tsx
Normal file
43
src/components/RelaySetsSetting/PushToRelaysButton.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useToast } from '@/hooks'
|
||||||
|
import { createRelaySetDraftEvent } from '@/lib/draft-event'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import { useRelaySets } from '@/providers/RelaySetsProvider'
|
||||||
|
import { CloudUpload, Loader } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRelaySetsSettingComponent } from './provider'
|
||||||
|
|
||||||
|
export default function PushToRelaysButton() {
|
||||||
|
const { toast } = useToast()
|
||||||
|
const { pubkey, publish } = useNostr()
|
||||||
|
const { relaySets } = useRelaySets()
|
||||||
|
const { selectedRelaySetIds } = useRelaySetsSettingComponent()
|
||||||
|
const [pushing, setPushing] = useState(false)
|
||||||
|
|
||||||
|
const push = async () => {
|
||||||
|
const selectedRelaySets = relaySets.filter((r) => selectedRelaySetIds.includes(r.id))
|
||||||
|
if (!selectedRelaySets.length) return
|
||||||
|
|
||||||
|
setPushing(true)
|
||||||
|
const draftEvents = selectedRelaySets.map((relaySet) => createRelaySetDraftEvent(relaySet))
|
||||||
|
await Promise.allSettled(draftEvents.map((event) => publish(event)))
|
||||||
|
toast({
|
||||||
|
title: 'Push Successful',
|
||||||
|
description: 'Successfully pushed relay sets to relays'
|
||||||
|
})
|
||||||
|
setPushing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full"
|
||||||
|
disabled={!pubkey || pushing || selectedRelaySetIds.length === 0}
|
||||||
|
onClick={push}
|
||||||
|
>
|
||||||
|
<CloudUpload />
|
||||||
|
Push to relays
|
||||||
|
{pushing && <Loader className="animate-spin" />}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
176
src/components/RelaySetsSetting/RelaySet.tsx
Normal file
176
src/components/RelaySetsSetting/RelaySet.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { useRelaySets } from '@/providers/RelaySetsProvider'
|
||||||
|
import { TRelaySet } from '@/types'
|
||||||
|
import { Check, ChevronDown, Circle, CircleCheck, EllipsisVertical } from 'lucide-react'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import RelayUrls from './RelayUrl'
|
||||||
|
import { useRelaySetsSettingComponent } from './provider'
|
||||||
|
|
||||||
|
export default function RelaySet({ relaySet }: { relaySet: TRelaySet }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { expandedRelaySetId, selectedRelaySetIds } = useRelaySetsSettingComponent()
|
||||||
|
const isSelected = useMemo(
|
||||||
|
() => selectedRelaySetIds.includes(relaySet.id),
|
||||||
|
[selectedRelaySetIds, relaySet.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`w-full border rounded-lg p-4 ${isSelected ? 'border-highlight bg-highlight/5' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex space-x-2 items-center">
|
||||||
|
<RelaySetActiveToggle relaySetId={relaySet.id} />
|
||||||
|
<RelaySetName relaySet={relaySet} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<RelayUrlsExpandToggle relaySetId={relaySet.id}>
|
||||||
|
{t('n relays', { n: relaySet.relayUrls.length })}
|
||||||
|
</RelayUrlsExpandToggle>
|
||||||
|
<RelaySetOptions relaySet={relaySet} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{expandedRelaySetId === relaySet.id && <RelayUrls relaySetId={relaySet.id} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RelaySetActiveToggle({ relaySetId }: { relaySetId: string }) {
|
||||||
|
const { selectedRelaySetIds, toggleSelectedRelaySetId } = useRelaySetsSettingComponent()
|
||||||
|
const isSelected = useMemo(
|
||||||
|
() => selectedRelaySetIds.includes(relaySetId),
|
||||||
|
[selectedRelaySetIds, relaySetId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
toggleSelectedRelaySetId(relaySetId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return isSelected ? (
|
||||||
|
<CircleCheck
|
||||||
|
size={18}
|
||||||
|
className="text-highlight shrink-0 cursor-pointer"
|
||||||
|
onClick={handleClick}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Circle
|
||||||
|
size={18}
|
||||||
|
className="text-muted-foreground shrink-0 cursor-pointer hover:text-foreground"
|
||||||
|
onClick={handleClick}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RelaySetName({ relaySet }: { relaySet: TRelaySet }) {
|
||||||
|
const [newSetName, setNewSetName] = useState(relaySet.name)
|
||||||
|
const { updateRelaySet } = useRelaySets()
|
||||||
|
const { renamingRelaySetId, setRenamingRelaySetId, toggleSelectedRelaySetId } =
|
||||||
|
useRelaySetsSettingComponent()
|
||||||
|
|
||||||
|
const saveNewRelaySetName = () => {
|
||||||
|
if (relaySet.name === newSetName) {
|
||||||
|
return setRenamingRelaySetId(null)
|
||||||
|
}
|
||||||
|
updateRelaySet({ ...relaySet, name: newSetName })
|
||||||
|
setRenamingRelaySetId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRenameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setNewSetName(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRenameInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
saveNewRelaySetName()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return renamingRelaySetId === relaySet.id ? (
|
||||||
|
<div className="flex gap-1 items-center">
|
||||||
|
<Input
|
||||||
|
value={newSetName}
|
||||||
|
onChange={handleRenameInputChange}
|
||||||
|
onBlur={saveNewRelaySetName}
|
||||||
|
onKeyDown={handleRenameInputKeyDown}
|
||||||
|
className="font-semibold w-28"
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" size="icon" onClick={saveNewRelaySetName}>
|
||||||
|
<Check size={18} className="text-green-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-8 font-semibold flex items-center cursor-pointer select-none"
|
||||||
|
onClick={() => toggleSelectedRelaySetId(relaySet.id)}
|
||||||
|
>
|
||||||
|
{relaySet.name}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RelayUrlsExpandToggle({
|
||||||
|
relaySetId,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
relaySetId: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const { expandedRelaySetId, setExpandedRelaySetId } = useRelaySetsSettingComponent()
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="text-sm text-muted-foreground flex items-center gap-1 cursor-pointer hover:text-foreground"
|
||||||
|
onClick={() => setExpandedRelaySetId((pre) => (pre === relaySetId ? null : relaySetId))}
|
||||||
|
>
|
||||||
|
<div className="select-none">{children}</div>
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={`transition-transform duration-200 ${expandedRelaySetId === relaySetId ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RelaySetOptions({ relaySet }: { relaySet: TRelaySet }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { deleteRelaySet } = useRelaySets()
|
||||||
|
const { setRenamingRelaySetId } = useRelaySetsSettingComponent()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<EllipsisVertical />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem onClick={() => setRenamingRelaySetId(relaySet.id)}>
|
||||||
|
{t('Rename')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
`https://jumble.social/?${relaySet.relayUrls.map((url) => 'r=' + url).join('&')}`
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Copy share link')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => deleteRelaySet(relaySet.id)}
|
||||||
|
>
|
||||||
|
{t('Delete')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,31 +2,30 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { useFetchRelayInfos } from '@/hooks'
|
import { useFetchRelayInfos } from '@/hooks'
|
||||||
import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
|
import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
|
||||||
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
import { useFeed } from '@/providers/FeedProvider'
|
||||||
|
import { useRelaySets } from '@/providers/RelaySetsProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import { CircleX, SearchCheck } from 'lucide-react'
|
import { CircleX, SearchCheck } from 'lucide-react'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function RelayUrls({ groupName }: { groupName: string }) {
|
export default function RelayUrls({ relaySetId }: { relaySetId: string }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { relayGroups, updateRelayGroupRelayUrls } = useRelaySettings()
|
const { relaySets, updateRelaySet } = useRelaySets()
|
||||||
const isActive = useMemo(
|
const { activeRelaySetId } = useFeed()
|
||||||
() => relayGroups.find((group) => group.groupName === groupName)?.isActive ?? false,
|
|
||||||
[relayGroups, groupName]
|
|
||||||
)
|
|
||||||
const [newRelayUrl, setNewRelayUrl] = useState('')
|
const [newRelayUrl, setNewRelayUrl] = useState('')
|
||||||
const [newRelayUrlError, setNewRelayUrlError] = useState<string | null>(null)
|
const [newRelayUrlError, setNewRelayUrlError] = useState<string | null>(null)
|
||||||
|
const relaySet = useMemo(
|
||||||
|
() => relaySets.find((r) => r.id === relaySetId),
|
||||||
|
[relaySets, relaySetId]
|
||||||
|
)
|
||||||
const [relays, setRelays] = useState<
|
const [relays, setRelays] = useState<
|
||||||
{
|
{
|
||||||
url: string
|
url: string
|
||||||
isConnected: boolean
|
isConnected: boolean
|
||||||
}[]
|
}[]
|
||||||
>(
|
>(relaySet?.relayUrls.map((url) => ({ url, isConnected: false })) ?? [])
|
||||||
relayGroups
|
const isActive = relaySet?.id === activeRelaySetId
|
||||||
.find((group) => group.groupName === groupName)
|
|
||||||
?.relayUrls.map((url) => ({ url, isConnected: false })) ?? []
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
@@ -42,12 +41,14 @@ export default function RelayUrls({ groupName }: { groupName: string }) {
|
|||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
if (!relaySet) return null
|
||||||
|
|
||||||
const removeRelayUrl = (url: string) => {
|
const removeRelayUrl = (url: string) => {
|
||||||
setRelays((relays) => relays.filter((relay) => relay.url !== url))
|
setRelays((relays) => relays.filter((relay) => relay.url !== url))
|
||||||
updateRelayGroupRelayUrls(
|
updateRelaySet({
|
||||||
groupName,
|
...relaySet,
|
||||||
relays.map(({ url }) => url).filter((u) => u !== url)
|
relayUrls: relays.map(({ url }) => url).filter((u) => u !== url)
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveNewRelayUrl = () => {
|
const saveNewRelayUrl = () => {
|
||||||
@@ -61,7 +62,7 @@ export default function RelayUrls({ groupName }: { groupName: string }) {
|
|||||||
}
|
}
|
||||||
setRelays((pre) => [...pre, { url: normalizedUrl, isConnected: false }])
|
setRelays((pre) => [...pre, { url: normalizedUrl, isConnected: false }])
|
||||||
const newRelayUrls = [...relays.map(({ url }) => url), normalizedUrl]
|
const newRelayUrls = [...relays.map(({ url }) => url), normalizedUrl]
|
||||||
updateRelayGroupRelayUrls(groupName, newRelayUrls)
|
updateRelaySet({ ...relaySet, relayUrls: newRelayUrls })
|
||||||
setNewRelayUrl('')
|
setNewRelayUrl('')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useFetchRelayInfos } from '@/hooks'
|
import { useFetchRelayInfos } from '@/hooks'
|
||||||
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
import { simplifyUrl } from '@/lib/url'
|
||||||
|
import { useFeed } from '@/providers/FeedProvider'
|
||||||
|
import { useRelaySets } from '@/providers/RelaySetsProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import { Save, SearchCheck } from 'lucide-react'
|
import { Save, SearchCheck } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function TemporaryRelayGroup() {
|
export default function TemporaryRelaySet() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { temporaryRelayUrls, relayGroups, addRelayGroup, switchRelayGroup } = useRelaySettings()
|
const { temporaryRelayUrls, switchFeed } = useFeed()
|
||||||
|
const { addRelaySet } = useRelaySets()
|
||||||
const [relays, setRelays] = useState<
|
const [relays, setRelays] = useState<
|
||||||
{
|
{
|
||||||
url: string
|
url: string
|
||||||
@@ -40,15 +43,10 @@ export default function TemporaryRelayGroup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
const existingTemporaryIndexes = relayGroups
|
const relaySetName =
|
||||||
.filter((group) => /^Temporary \d+$/.test(group.groupName))
|
temporaryRelayUrls.length === 1 ? simplifyUrl(temporaryRelayUrls[0]) : 'Temporary'
|
||||||
.map((group) => group.groupName.split(' ')[1])
|
const id = addRelaySet(relaySetName, temporaryRelayUrls)
|
||||||
.map(Number)
|
switchFeed('relays', { activeRelaySetId: id })
|
||||||
.filter((index) => !isNaN(index))
|
|
||||||
const nextIndex = Math.max(...existingTemporaryIndexes, 0) + 1
|
|
||||||
const groupName = `Temporary ${nextIndex}`
|
|
||||||
addRelayGroup(groupName, temporaryRelayUrls)
|
|
||||||
switchRelayGroup(groupName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
76
src/components/RelaySetsSetting/index.tsx
Normal file
76
src/components/RelaySetsSetting/index.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { useRelaySets } from '@/providers/RelaySetsProvider'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RelaySetsSettingComponentProvider } from './provider'
|
||||||
|
import RelaySet from './RelaySet'
|
||||||
|
import TemporaryRelaySet from './TemporaryRelaySet'
|
||||||
|
import PushToRelaysButton from './PushToRelaysButton'
|
||||||
|
import PullFromRelaysButton from './PullFromRelaysButton'
|
||||||
|
|
||||||
|
export default function RelaySetsSetting() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { relaySets, addRelaySet } = useRelaySets()
|
||||||
|
const [newRelaySetName, setNewRelaySetName] = useState('')
|
||||||
|
const dummyRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dummyRef.current) {
|
||||||
|
dummyRef.current.focus()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const saveRelaySet = () => {
|
||||||
|
if (!newRelaySetName) return
|
||||||
|
addRelaySet(newRelaySetName)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNewRelaySetNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setNewRelaySetName(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNewRelaySetNameKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
saveRelaySet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RelaySetsSettingComponentProvider>
|
||||||
|
<div ref={dummyRef} tabIndex={-1} style={{ position: 'absolute', opacity: 0 }}></div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<PushToRelaysButton />
|
||||||
|
<PullFromRelaysButton />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 mt-4">
|
||||||
|
<TemporaryRelaySet />
|
||||||
|
{relaySets.map((relaySet) => (
|
||||||
|
<RelaySet key={relaySet.id} relaySet={relaySet} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{relaySets.length < 10 && (
|
||||||
|
<>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<div className="w-full border rounded-lg p-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="font-semibold">{t('Add a new relay set')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder={t('Relay set name')}
|
||||||
|
value={newRelaySetName}
|
||||||
|
onChange={handleNewRelaySetNameChange}
|
||||||
|
onKeyDown={handleNewRelaySetNameKeyDown}
|
||||||
|
onBlur={saveRelaySet}
|
||||||
|
/>
|
||||||
|
<Button onClick={saveRelaySet}>{t('Add')}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</RelaySetsSettingComponentProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
52
src/components/RelaySetsSetting/provider.tsx
Normal file
52
src/components/RelaySetsSetting/provider.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { createContext, useContext, useState } from 'react'
|
||||||
|
|
||||||
|
type TRelaySetsSettingComponentContext = {
|
||||||
|
renamingRelaySetId: string | null
|
||||||
|
setRenamingRelaySetId: React.Dispatch<React.SetStateAction<string | null>>
|
||||||
|
expandedRelaySetId: string | null
|
||||||
|
setExpandedRelaySetId: React.Dispatch<React.SetStateAction<string | null>>
|
||||||
|
selectedRelaySetIds: string[]
|
||||||
|
toggleSelectedRelaySetId: (relaySetId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RelaySetsSettingComponentContext = createContext<
|
||||||
|
TRelaySetsSettingComponentContext | undefined
|
||||||
|
>(undefined)
|
||||||
|
|
||||||
|
export const useRelaySetsSettingComponent = () => {
|
||||||
|
const context = useContext(RelaySetsSettingComponentContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
'useRelaySetsSettingComponent must be used within a RelaySetsSettingComponentProvider'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RelaySetsSettingComponentProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [renamingRelaySetId, setRenamingRelaySetId] = useState<string | null>(null)
|
||||||
|
const [expandedRelaySetId, setExpandedRelaySetId] = useState<string | null>(null)
|
||||||
|
const [selectedRelaySetIds, setSelectedRelaySetIds] = useState<string[]>([])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RelaySetsSettingComponentContext.Provider
|
||||||
|
value={{
|
||||||
|
renamingRelaySetId,
|
||||||
|
setRenamingRelaySetId,
|
||||||
|
expandedRelaySetId,
|
||||||
|
setExpandedRelaySetId,
|
||||||
|
selectedRelaySetIds,
|
||||||
|
toggleSelectedRelaySetId: (relaySetId) => {
|
||||||
|
setSelectedRelaySetIds((pre) => {
|
||||||
|
if (pre.includes(relaySetId)) {
|
||||||
|
return pre.filter((id) => id !== relaySetId)
|
||||||
|
}
|
||||||
|
return [...pre, relaySetId]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</RelaySetsSettingComponentContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
|
||||||
import { Check, ChevronDown, Circle, CircleCheck, EllipsisVertical } from 'lucide-react'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import RelayUrls from './RelayUrl'
|
|
||||||
import { useRelaySettingsComponent } from './provider'
|
|
||||||
import { TRelayGroup } from './types'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
export default function RelayGroup({ group }: { group: TRelayGroup }) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { expandedRelayGroup } = useRelaySettingsComponent()
|
|
||||||
const { temporaryRelayUrls } = useRelaySettings()
|
|
||||||
const { groupName, relayUrls } = group
|
|
||||||
const isActive = temporaryRelayUrls.length === 0 && group.isActive
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`w-full border rounded-lg p-4 ${isActive ? 'border-highlight bg-highlight/5' : ''}`}
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<div className="flex space-x-2 items-center">
|
|
||||||
<RelayGroupActiveToggle
|
|
||||||
groupName={groupName}
|
|
||||||
isActive={isActive}
|
|
||||||
canActive={relayUrls.length > 0}
|
|
||||||
/>
|
|
||||||
<RelayGroupName groupName={groupName} />
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<RelayUrlsExpandToggle groupName={groupName}>
|
|
||||||
{t('n relays', { n: relayUrls.length })}
|
|
||||||
</RelayUrlsExpandToggle>
|
|
||||||
<RelayGroupOptions group={group} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{expandedRelayGroup === groupName && <RelayUrls groupName={groupName} />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function RelayGroupActiveToggle({
|
|
||||||
groupName,
|
|
||||||
isActive,
|
|
||||||
canActive
|
|
||||||
}: {
|
|
||||||
groupName: string
|
|
||||||
isActive: boolean
|
|
||||||
canActive: boolean
|
|
||||||
}) {
|
|
||||||
const { switchRelayGroup } = useRelaySettings()
|
|
||||||
|
|
||||||
return isActive ? (
|
|
||||||
<CircleCheck size={18} className="text-highlight shrink-0" />
|
|
||||||
) : (
|
|
||||||
<Circle
|
|
||||||
size={18}
|
|
||||||
className={`text-muted-foreground shrink-0 ${canActive ? 'cursor-pointer hover:text-foreground ' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (canActive) {
|
|
||||||
switchRelayGroup(groupName)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function RelayGroupName({ groupName }: { groupName: string }) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
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 = () => {
|
|
||||||
if (groupName === newGroupName) {
|
|
||||||
return setRenamingGroup(null)
|
|
||||||
}
|
|
||||||
if (relayGroups.find((group) => group.groupName === newGroupName)) {
|
|
||||||
return setNewNameError(t('relay collection name already exists'))
|
|
||||||
}
|
|
||||||
const errMsg = renameRelayGroup(groupName, newGroupName)
|
|
||||||
if (errMsg) {
|
|
||||||
setNewNameError(errMsg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setRenamingGroup(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRenameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setNewGroupName(e.target.value)
|
|
||||||
setNewNameError(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRenameInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault()
|
|
||||||
saveNewGroupName()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return renamingGroup === groupName ? (
|
|
||||||
<div className="flex gap-1 items-center">
|
|
||||||
<Input
|
|
||||||
value={newGroupName}
|
|
||||||
onChange={handleRenameInputChange}
|
|
||||||
onBlur={saveNewGroupName}
|
|
||||||
onKeyDown={handleRenameInputKeyDown}
|
|
||||||
className={`font-semibold w-28 ${newNameError ? 'border-destructive' : ''}`}
|
|
||||||
/>
|
|
||||||
<Button variant="ghost" size="icon" 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({
|
|
||||||
groupName,
|
|
||||||
children
|
|
||||||
}: {
|
|
||||||
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={() => setExpandedRelayGroup((pre) => (pre === groupName ? null : groupName))}
|
|
||||||
>
|
|
||||||
<div className="select-none">{children}</div>
|
|
||||||
<ChevronDown
|
|
||||||
size={16}
|
|
||||||
className={`transition-transform duration-200 ${expandedRelayGroup === groupName ? 'rotate-180' : ''}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function RelayGroupOptions({ group }: { group: TRelayGroup }) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { deleteRelayGroup } = useRelaySettings()
|
|
||||||
const { setRenamingGroup } = useRelaySettingsComponent()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<EllipsisVertical />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent>
|
|
||||||
<DropdownMenuItem onClick={() => setRenamingGroup(group.groupName)}>
|
|
||||||
{t('Rename')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
navigator.clipboard.writeText(
|
|
||||||
`https://jumble.social/?${group.relayUrls.map((url) => 'r=' + url).join('&')}`
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('Copy share link')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
onClick={() => deleteRelayGroup(group.groupName)}
|
|
||||||
>
|
|
||||||
{t('Delete')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Separator } from '@/components/ui/separator'
|
|
||||||
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { RelaySettingsComponentProvider } from './provider'
|
|
||||||
import RelayGroup from './RelayGroup'
|
|
||||||
import TemporaryRelayGroup from './TemporaryRelayGroup'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
export default function RelaySettings({ hideTitle = false }: { hideTitle?: boolean }) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { relayGroups, addRelayGroup } = useRelaySettings()
|
|
||||||
const [newGroupName, setNewGroupName] = useState('')
|
|
||||||
const [newNameError, setNewNameError] = useState<string | null>(null)
|
|
||||||
const dummyRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (dummyRef.current) {
|
|
||||||
dummyRef.current.focus()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const saveRelayGroup = () => {
|
|
||||||
if (relayGroups.find((group) => group.groupName === newGroupName)) {
|
|
||||||
return setNewNameError(t('relay collection name already exists'))
|
|
||||||
}
|
|
||||||
const errMsg = addRelayGroup(newGroupName)
|
|
||||||
if (errMsg) {
|
|
||||||
return setNewNameError(errMsg)
|
|
||||||
}
|
|
||||||
setNewGroupName('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNewGroupNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setNewGroupName(e.target.value)
|
|
||||||
setNewNameError(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNewGroupNameKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault()
|
|
||||||
saveRelayGroup()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RelaySettingsComponentProvider>
|
|
||||||
<div ref={dummyRef} tabIndex={-1} style={{ position: 'absolute', opacity: 0 }}></div>
|
|
||||||
{!hideTitle && <div className="text-lg font-semibold mb-4">{t('Relay Settings')}</div>}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<TemporaryRelayGroup />
|
|
||||||
{relayGroups.map((group, index) => (
|
|
||||||
<RelayGroup key={index} group={group} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{relayGroups.length < 10 && (
|
|
||||||
<>
|
|
||||||
<Separator className="my-4" />
|
|
||||||
<div className="w-full border rounded-lg p-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<div className="font-semibold">{t('Add a new relay collection')}</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex gap-2">
|
|
||||||
<Input
|
|
||||||
className={newNameError ? 'border-destructive' : ''}
|
|
||||||
placeholder={t('Relay collection name')}
|
|
||||||
value={newGroupName}
|
|
||||||
onChange={handleNewGroupNameChange}
|
|
||||||
onKeyDown={handleNewGroupNameKeyDown}
|
|
||||||
onBlur={saveRelayGroup}
|
|
||||||
/>
|
|
||||||
<Button onClick={saveRelayGroup}>{t('Add')}</Button>
|
|
||||||
</div>
|
|
||||||
{newNameError && <div className="text-xs text-destructive mt-1">{newNameError}</div>}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</RelaySettingsComponentProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export type TRelayGroup = {
|
|
||||||
groupName: string
|
|
||||||
relayUrls: string[]
|
|
||||||
isActive: boolean
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import { CommandDialog, CommandInput, CommandItem, CommandList } from '@/compone
|
|||||||
import { useSearchProfiles } from '@/hooks'
|
import { useSearchProfiles } from '@/hooks'
|
||||||
import { toNote, toNoteList, toProfile, toProfileList } from '@/lib/link'
|
import { toNote, toNoteList, toProfile, toProfileList } from '@/lib/link'
|
||||||
import { generateImageByPubkey } from '@/lib/pubkey'
|
import { generateImageByPubkey } from '@/lib/pubkey'
|
||||||
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
|
||||||
import { TProfile } from '@/types'
|
import { TProfile } from '@/types'
|
||||||
import { Hash, Notebook, UserRound } from 'lucide-react'
|
import { Hash, Notebook, UserRound } from 'lucide-react'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
@@ -83,12 +82,6 @@ export function SearchDialog({ open, setOpen }: { open: boolean; setOpen: Dispat
|
|||||||
}
|
}
|
||||||
|
|
||||||
function NormalItem({ search, onClick }: { search: string; onClick?: () => void }) {
|
function NormalItem({ search, onClick }: { search: string; onClick?: () => void }) {
|
||||||
const { searchableRelayUrls } = useRelaySettings()
|
|
||||||
|
|
||||||
if (searchableRelayUrls.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLink to={toNoteList({ search })} onClick={onClick}>
|
<SecondaryPageLink to={toNoteList({ search })} onClick={onClick}>
|
||||||
<CommandItem>
|
<CommandItem>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export const StorageKey = {
|
export const StorageKey = {
|
||||||
THEME_SETTING: 'themeSetting',
|
THEME_SETTING: 'themeSetting',
|
||||||
RELAY_GROUPS: 'relayGroups',
|
RELAY_SETS: 'relaySets',
|
||||||
|
ACTIVE_RELAY_SET_ID: 'activeRelaySetId',
|
||||||
ACCOUNTS: 'accounts',
|
ACCOUNTS: 'accounts',
|
||||||
CURRENT_ACCOUNT: 'currentAccount',
|
CURRENT_ACCOUNT: 'currentAccount',
|
||||||
ADD_CLIENT_TAG: 'addClientTag'
|
ADD_CLIENT_TAG: 'addClientTag'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { tagNameEquals } from '@/lib/tag'
|
import { getFollowingsFromFollowListEvent } from '@/lib/event'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
@@ -18,13 +18,7 @@ export function useFetchFollowings(pubkey?: string | null) {
|
|||||||
if (!event) return
|
if (!event) return
|
||||||
|
|
||||||
setFollowListEvent(event)
|
setFollowListEvent(event)
|
||||||
setFollowings(
|
setFollowings(getFollowingsFromFollowListEvent(event))
|
||||||
event.tags
|
|
||||||
.filter(tagNameEquals('p'))
|
|
||||||
.map(([, pubkey]) => pubkey)
|
|
||||||
.filter(Boolean)
|
|
||||||
.reverse()
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsFetching(false)
|
setIsFetching(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export function useFetchRelayInfos(urls: string[]) {
|
|||||||
const [isFetching, setIsFetching] = useState(true)
|
const [isFetching, setIsFetching] = useState(true)
|
||||||
const [relayInfos, setRelayInfos] = useState<(TRelayInfo | undefined)[]>([])
|
const [relayInfos, setRelayInfos] = useState<(TRelayInfo | undefined)[]>([])
|
||||||
const [areAlgoRelays, setAreAlgoRelays] = useState(false)
|
const [areAlgoRelays, setAreAlgoRelays] = useState(false)
|
||||||
|
const [searchableRelayUrls, setSearchableRelayUrls] = useState<string[]>([])
|
||||||
const urlsString = JSON.stringify(urls)
|
const urlsString = JSON.stringify(urls)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -22,6 +23,15 @@ export function useFetchRelayInfos(urls: string[]) {
|
|||||||
const relayInfos = await client.fetchRelayInfos(urls)
|
const relayInfos = await client.fetchRelayInfos(urls)
|
||||||
setRelayInfos(relayInfos)
|
setRelayInfos(relayInfos)
|
||||||
setAreAlgoRelays(relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo)))
|
setAreAlgoRelays(relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo)))
|
||||||
|
setSearchableRelayUrls(
|
||||||
|
relayInfos
|
||||||
|
.map((relayInfo, index) => ({
|
||||||
|
url: urls[index],
|
||||||
|
searchable: relayInfo?.supported_nips?.includes(50)
|
||||||
|
}))
|
||||||
|
.filter((relayInfo) => relayInfo.searchable)
|
||||||
|
.map((relayInfo) => relayInfo.url)
|
||||||
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -33,5 +43,5 @@ export function useFetchRelayInfos(urls: string[]) {
|
|||||||
fetchRelayInfos()
|
fetchRelayInfos()
|
||||||
}, [urlsString])
|
}, [urlsString])
|
||||||
|
|
||||||
return { relayInfos, isFetching, areAlgoRelays }
|
return { relayInfos, isFetching, areAlgoRelays, searchableRelayUrls }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
import { useFeed } from '@/providers/FeedProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import { TProfile } from '@/types'
|
import { TProfile } from '@/types'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useFetchRelayInfos } from './useFetchRelayInfos'
|
||||||
|
|
||||||
export function useSearchProfiles(search: string, limit: number) {
|
export function useSearchProfiles(search: string, limit: number) {
|
||||||
const { searchableRelayUrls } = useRelaySettings()
|
const { relayUrls } = useFeed()
|
||||||
|
const { searchableRelayUrls } = useFetchRelayInfos(relayUrls)
|
||||||
const [isFetching, setIsFetching] = useState(true)
|
const [isFetching, setIsFetching] = useState(true)
|
||||||
const [error, setError] = useState<Error | null>(null)
|
const [error, setError] = useState<Error | null>(null)
|
||||||
const [profiles, setProfiles] = useState<TProfile[]>([])
|
const [profiles, setProfiles] = useState<TProfile[]>([])
|
||||||
|
|||||||
@@ -50,10 +50,9 @@ export default {
|
|||||||
"username's following": "{{username}}'s following",
|
"username's following": "{{username}}'s following",
|
||||||
Login: 'Login',
|
Login: 'Login',
|
||||||
'Follows you': 'Follows you',
|
'Follows you': 'Follows you',
|
||||||
'relay collection name already exists': 'relay collection name already exists',
|
|
||||||
'Relay Settings': 'Relay Settings',
|
'Relay Settings': 'Relay Settings',
|
||||||
'Relay collection name': 'Relay collection name',
|
'Relay set name': 'Relay set name',
|
||||||
'Add a new relay collection': 'Add a new relay collection',
|
'Add a new relay set': 'Add a new relay set',
|
||||||
Add: 'Add',
|
Add: 'Add',
|
||||||
'n relays': '{{n}} relays',
|
'n relays': '{{n}} relays',
|
||||||
Rename: 'Rename',
|
Rename: 'Rename',
|
||||||
@@ -90,7 +89,7 @@ export default {
|
|||||||
'Add client tag': 'Add client tag',
|
'Add client tag': 'Add client tag',
|
||||||
'Show others this was sent via Jumble': 'Show others this was sent via Jumble',
|
'Show others this was sent via Jumble': 'Show others this was sent via Jumble',
|
||||||
'Are you sure you want to logout?': 'Are you sure you want to logout?',
|
'Are you sure you want to logout?': 'Are you sure you want to logout?',
|
||||||
'relay feeds': 'relay feeds',
|
'relay sets': 'relay sets',
|
||||||
edit: 'edit',
|
edit: 'edit',
|
||||||
Languages: 'Languages',
|
Languages: 'Languages',
|
||||||
Theme: 'Theme',
|
Theme: 'Theme',
|
||||||
@@ -98,7 +97,7 @@ export default {
|
|||||||
Light: 'Light',
|
Light: 'Light',
|
||||||
Dark: 'Dark',
|
Dark: 'Dark',
|
||||||
Temporary: 'Temporary',
|
Temporary: 'Temporary',
|
||||||
'Choose a relay collection': 'Choose a relay collection',
|
'Choose a relay set': 'Choose a relay set',
|
||||||
'Switch account': 'Switch account',
|
'Switch account': 'Switch account',
|
||||||
Pictures: 'Pictures',
|
Pictures: 'Pictures',
|
||||||
'Picture note': 'Picture note',
|
'Picture note': 'Picture note',
|
||||||
|
|||||||
@@ -50,10 +50,9 @@ export default {
|
|||||||
"username's following": '{{username}} 的关注',
|
"username's following": '{{username}} 的关注',
|
||||||
Login: '登录',
|
Login: '登录',
|
||||||
'Follows you': '关注了你',
|
'Follows you': '关注了你',
|
||||||
'relay collection name already exists': '服务器组名已存在',
|
|
||||||
'Relay Settings': '服务器设置',
|
'Relay Settings': '服务器设置',
|
||||||
'Relay collection name': '服务器组名',
|
'Relay set name': '服务器组名',
|
||||||
'Add a new relay collection': '添加新的服务器组',
|
'Add a new relay set': '添加新的服务器组',
|
||||||
Add: '添加',
|
Add: '添加',
|
||||||
'n relays': '{{n}} 个服务器',
|
'n relays': '{{n}} 个服务器',
|
||||||
Rename: '重命名',
|
Rename: '重命名',
|
||||||
@@ -89,7 +88,7 @@ export default {
|
|||||||
'Add client tag': '添加客户端标签',
|
'Add client tag': '添加客户端标签',
|
||||||
'Show others this was sent via Jumble': '告诉别人这是通过 Jumble 发送的',
|
'Show others this was sent via Jumble': '告诉别人这是通过 Jumble 发送的',
|
||||||
'Are you sure you want to logout?': '确定要退出登录吗?',
|
'Are you sure you want to logout?': '确定要退出登录吗?',
|
||||||
'relay feeds': '服务器信息流',
|
'relay sets': '服务器组',
|
||||||
edit: '编辑',
|
edit: '编辑',
|
||||||
Languages: '语言',
|
Languages: '语言',
|
||||||
Theme: '主题',
|
Theme: '主题',
|
||||||
@@ -97,7 +96,7 @@ export default {
|
|||||||
Light: '浅色',
|
Light: '浅色',
|
||||||
Dark: '深色',
|
Dark: '深色',
|
||||||
Temporary: '临时',
|
Temporary: '临时',
|
||||||
'Choose a relay collection': '选择一个服务器组',
|
'Choose a relay set': '选择一个服务器组',
|
||||||
'Switch account': '切换账户',
|
'Switch account': '切换账户',
|
||||||
Pictures: '图片',
|
Pictures: '图片',
|
||||||
'Picture note': '图片笔记',
|
'Picture note': '图片笔记',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { COMMENT_EVENT_KIND, PICTURE_EVENT_KIND } from '@/constants'
|
import { COMMENT_EVENT_KIND, PICTURE_EVENT_KIND } from '@/constants'
|
||||||
import { TDraftEvent } from '@/types'
|
import { TDraftEvent, TRelaySet } from '@/types'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
import {
|
import {
|
||||||
@@ -82,6 +82,20 @@ export async function createShortTextNoteDraftEvent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// https://github.com/nostr-protocol/nips/blob/master/51.md
|
||||||
|
export function createRelaySetDraftEvent(relaySet: TRelaySet): TDraftEvent {
|
||||||
|
return {
|
||||||
|
kind: kinds.Relaysets,
|
||||||
|
content: '',
|
||||||
|
tags: [
|
||||||
|
['d', relaySet.id],
|
||||||
|
['title', relaySet.name],
|
||||||
|
...relaySet.relayUrls.map((url) => ['relay', url])
|
||||||
|
],
|
||||||
|
created_at: dayjs().unix()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function createPictureNoteDraftEvent(
|
export async function createPictureNoteDraftEvent(
|
||||||
content: string,
|
content: string,
|
||||||
options: {
|
options: {
|
||||||
|
|||||||
@@ -64,6 +64,18 @@ export function getUsingClient(event: Event) {
|
|||||||
return event.tags.find(tagNameEquals('client'))?.[1]
|
return event.tags.find(tagNameEquals('client'))?.[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getFollowingsFromFollowListEvent(event: Event) {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
event.tags
|
||||||
|
.filter(tagNameEquals('p'))
|
||||||
|
.map(([, pubkey]) => pubkey)
|
||||||
|
.filter(Boolean)
|
||||||
|
.reverse()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export async function extractMentions(content: string, parentEvent?: Event) {
|
export async function extractMentions(content: string, parentEvent?: Event) {
|
||||||
const pubkeySet = new Set<string>()
|
const pubkeySet = new Set<string>()
|
||||||
const relatedEventIdSet = new Set<string>()
|
const relatedEventIdSet = new Set<string>()
|
||||||
|
|||||||
9
src/lib/random.ts
Normal file
9
src/lib/random.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
const SEED = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||||
|
|
||||||
|
export function randomString(len = 32) {
|
||||||
|
let str = ''
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
str += SEED[Math.floor(Math.random() * SEED.length)]
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { Drawer, DrawerContent } from '@/components/ui/drawer'
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
import { simplifyUrl } from '@/lib/url'
|
import { simplifyUrl } from '@/lib/url'
|
||||||
import { useFeed } from '@/providers/FeedProvider'
|
import { useFeed } from '@/providers/FeedProvider'
|
||||||
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
import { useRelaySets } from '@/providers/RelaySetsProvider'
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import { ChevronDown, Server, UsersRound } from 'lucide-react'
|
import { ChevronDown, Server, UsersRound } from 'lucide-react'
|
||||||
import { forwardRef, HTMLAttributes, useState } from 'react'
|
import { forwardRef, HTMLAttributes, useState } from 'react'
|
||||||
@@ -43,21 +43,21 @@ export default function FeedButton() {
|
|||||||
const FeedSwitcherTrigger = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
const FeedSwitcherTrigger = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||||
(props, ref) => {
|
(props, ref) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { feedType } = useFeed()
|
const { feedType, relayUrls, activeRelaySetId } = useFeed()
|
||||||
const { relayGroups, temporaryRelayUrls } = useRelaySettings()
|
const { relaySets } = useRelaySets()
|
||||||
const activeGroup = relayGroups.find((group) => group.isActive)
|
const activeRelaySet = activeRelaySetId
|
||||||
|
? relaySets.find((set) => set.id === activeRelaySetId)
|
||||||
|
: undefined
|
||||||
const title =
|
const title =
|
||||||
feedType === 'following'
|
feedType === 'following'
|
||||||
? t('Following')
|
? t('Following')
|
||||||
: temporaryRelayUrls.length > 0
|
: relayUrls.length > 0
|
||||||
? temporaryRelayUrls.length === 1
|
? relayUrls.length === 1
|
||||||
? simplifyUrl(temporaryRelayUrls[0])
|
? simplifyUrl(relayUrls[0])
|
||||||
|
: activeRelaySet
|
||||||
|
? activeRelaySet.name
|
||||||
: t('Temporary')
|
: t('Temporary')
|
||||||
: activeGroup
|
: t('Choose a relay set')
|
||||||
? activeGroup.relayUrls.length === 1
|
|
||||||
? simplifyUrl(activeGroup.relayUrls[0])
|
|
||||||
: activeGroup.groupName
|
|
||||||
: t('Choose a relay collection')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import NoteList from '@/components/NoteList'
|
import NoteList from '@/components/NoteList'
|
||||||
import { BIG_RELAY_URLS } from '@/constants'
|
|
||||||
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
|
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
|
||||||
import { useFeed } from '@/providers/FeedProvider'
|
import { useFeed } from '@/providers/FeedProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
|
||||||
import { useEffect, useMemo, useRef } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import FeedButton from './FeedButton'
|
import FeedButton from './FeedButton'
|
||||||
import SearchButton from './SearchButton'
|
import SearchButton from './SearchButton'
|
||||||
@@ -12,18 +9,7 @@ import SearchButton from './SearchButton'
|
|||||||
export default function NoteListPage() {
|
export default function NoteListPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const layoutRef = useRef<{ scrollToTop: () => void }>(null)
|
const layoutRef = useRef<{ scrollToTop: () => void }>(null)
|
||||||
const { feedType } = useFeed()
|
const { feedType, relayUrls, isReady, filter } = useFeed()
|
||||||
const { relayUrls, temporaryRelayUrls } = useRelaySettings()
|
|
||||||
const { pubkey, relayList, followings } = useNostr()
|
|
||||||
const urls = useMemo(() => {
|
|
||||||
return feedType === 'following'
|
|
||||||
? relayList?.read.length
|
|
||||||
? relayList.read.slice(0, 4)
|
|
||||||
: BIG_RELAY_URLS
|
|
||||||
: temporaryRelayUrls.length > 0
|
|
||||||
? temporaryRelayUrls
|
|
||||||
: relayUrls
|
|
||||||
}, [feedType, relayUrls, relayList, temporaryRelayUrls])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (layoutRef.current) {
|
if (layoutRef.current) {
|
||||||
@@ -38,20 +24,8 @@ export default function NoteListPage() {
|
|||||||
titlebar={<NoteListPageTitlebar />}
|
titlebar={<NoteListPageTitlebar />}
|
||||||
displayScrollToTopButton
|
displayScrollToTopButton
|
||||||
>
|
>
|
||||||
{!!urls.length && (feedType === 'relays' || (relayList && followings)) ? (
|
{isReady ? (
|
||||||
<NoteList
|
<NoteList relayUrls={relayUrls} filter={filter} />
|
||||||
relayUrls={urls}
|
|
||||||
filter={
|
|
||||||
feedType === 'following'
|
|
||||||
? {
|
|
||||||
authors:
|
|
||||||
pubkey && !followings?.includes(pubkey)
|
|
||||||
? [...(followings ?? []), pubkey]
|
|
||||||
: (followings ?? [])
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center text-sm text-muted-foreground">{t('loading...')}</div>
|
<div className="text-center text-sm text-muted-foreground">{t('loading...')}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import NoteList from '@/components/NoteList'
|
import NoteList from '@/components/NoteList'
|
||||||
import { SEARCHABLE_RELAY_URLS } from '@/constants'
|
import { SEARCHABLE_RELAY_URLS } from '@/constants'
|
||||||
import { useSearchParams } from '@/hooks'
|
import { useFetchRelayInfos, useSearchParams } from '@/hooks'
|
||||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||||
import { isWebsocketUrl, simplifyUrl } from '@/lib/url'
|
import { isWebsocketUrl, simplifyUrl } from '@/lib/url'
|
||||||
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
import { useFeed } from '@/providers/FeedProvider'
|
||||||
import { Filter } from 'nostr-tools'
|
import { Filter } from 'nostr-tools'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function NoteListPage({ index }: { index?: number }) {
|
export default function NoteListPage({ index }: { index?: number }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { relayUrls, searchableRelayUrls } = useRelaySettings()
|
const { relayUrls } = useFeed()
|
||||||
|
const { searchableRelayUrls } = useFetchRelayInfos(relayUrls)
|
||||||
const { searchParams } = useSearchParams()
|
const { searchParams } = useSearchParams()
|
||||||
const relayUrlsString = JSON.stringify(relayUrls)
|
const relayUrlsString = JSON.stringify(relayUrls)
|
||||||
const {
|
const {
|
||||||
@@ -31,10 +32,7 @@ export default function NoteListPage({ index }: { index?: number }) {
|
|||||||
return {
|
return {
|
||||||
title: `${t('Search')}: ${search}`,
|
title: `${t('Search')}: ${search}`,
|
||||||
filter: { search },
|
filter: { search },
|
||||||
urls:
|
urls: searchableRelayUrls.concat(SEARCHABLE_RELAY_URLS).slice(0, 4)
|
||||||
searchableRelayUrls.length < 4
|
|
||||||
? searchableRelayUrls.concat(SEARCHABLE_RELAY_URLS).slice(0, 4)
|
|
||||||
: searchableRelayUrls
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const relayUrl = searchParams.get('relay')
|
const relayUrl = searchParams.get('relay')
|
||||||
@@ -44,16 +42,6 @@ export default function NoteListPage({ index }: { index?: number }) {
|
|||||||
return { urls: relayUrls }
|
return { urls: relayUrls }
|
||||||
}, [searchParams, relayUrlsString])
|
}, [searchParams, relayUrlsString])
|
||||||
|
|
||||||
if (filter?.search && searchableRelayUrls.length === 0) {
|
|
||||||
return (
|
|
||||||
<SecondaryPageLayout index={index} titlebarContent={title}>
|
|
||||||
<div className="text-center text-sm text-muted-foreground">
|
|
||||||
{t('The relays you are connected to do not support search')}
|
|
||||||
</div>
|
|
||||||
</SecondaryPageLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout index={index} titlebarContent={title} displayScrollToTopButton>
|
<SecondaryPageLayout index={index} titlebarContent={title} displayScrollToTopButton>
|
||||||
<NoteList key={title} filter={filter} relayUrls={urls} />
|
<NoteList key={title} filter={filter} relayUrls={urls} />
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import UserItem from '@/components/UserItem'
|
import UserItem from '@/components/UserItem'
|
||||||
import { useSearchParams } from '@/hooks'
|
import { SEARCHABLE_RELAY_URLS } from '@/constants'
|
||||||
|
import { useFetchRelayInfos, useSearchParams } from '@/hooks'
|
||||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||||
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
import { useFeed } from '@/providers/FeedProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { Filter } from 'nostr-tools'
|
import { Filter } from 'nostr-tools'
|
||||||
@@ -13,7 +14,8 @@ const LIMIT = 50
|
|||||||
export default function ProfileListPage({ index }: { index?: number }) {
|
export default function ProfileListPage({ index }: { index?: number }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { searchParams } = useSearchParams()
|
const { searchParams } = useSearchParams()
|
||||||
const { relayUrls, searchableRelayUrls } = useRelaySettings()
|
const { relayUrls } = useFeed()
|
||||||
|
const { searchableRelayUrls } = useFetchRelayInfos(relayUrls)
|
||||||
const [until, setUntil] = useState<number>(() => dayjs().unix())
|
const [until, setUntil] = useState<number>(() => dayjs().unix())
|
||||||
const [hasMore, setHasMore] = useState<boolean>(true)
|
const [hasMore, setHasMore] = useState<boolean>(true)
|
||||||
const [pubkeySet, setPubkeySet] = useState(new Set<string>())
|
const [pubkeySet, setPubkeySet] = useState(new Set<string>())
|
||||||
@@ -27,7 +29,7 @@ export default function ProfileListPage({ index }: { index?: number }) {
|
|||||||
return f
|
return f
|
||||||
}, [searchParams, until])
|
}, [searchParams, until])
|
||||||
const urls = useMemo(() => {
|
const urls = useMemo(() => {
|
||||||
return filter.search ? searchableRelayUrls : relayUrls
|
return filter.search ? searchableRelayUrls.concat(SEARCHABLE_RELAY_URLS).slice(0, 4) : relayUrls
|
||||||
}, [relayUrls, searchableRelayUrls, filter])
|
}, [relayUrls, searchableRelayUrls, filter])
|
||||||
const title = useMemo(() => {
|
const title = useMemo(() => {
|
||||||
return filter.search ? `${t('Search')}: ${filter.search}` : t('All users')
|
return filter.search ? `${t('Search')}: ${filter.search}` : t('All users')
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
|||||||
import { toFollowingList } from '@/lib/link'
|
import { toFollowingList } from '@/lib/link'
|
||||||
import { generateImageByPubkey } from '@/lib/pubkey'
|
import { generateImageByPubkey } from '@/lib/pubkey'
|
||||||
import { SecondaryPageLink } from '@/PageManager'
|
import { SecondaryPageLink } from '@/PageManager'
|
||||||
|
import { useFeed } from '@/providers/FeedProvider'
|
||||||
import { useFollowList } from '@/providers/FollowListProvider'
|
import { useFollowList } from '@/providers/FollowListProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import NotFoundPage from '../NotFoundPage'
|
import NotFoundPage from '../NotFoundPage'
|
||||||
@@ -24,9 +24,12 @@ export default function ProfilePage({ id, index }: { id?: string; index?: number
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { profile, isFetching } = useFetchProfile(id)
|
const { profile, isFetching } = useFetchProfile(id)
|
||||||
const { relayList, isFetching: isFetchingRelayInfo } = useFetchRelayList(profile?.pubkey)
|
const { relayList, isFetching: isFetchingRelayInfo } = useFetchRelayList(profile?.pubkey)
|
||||||
const { relayUrls: currentRelayUrls } = useRelaySettings()
|
const { relayUrls: currentRelayUrls } = useFeed()
|
||||||
const relayUrls = useMemo(
|
const relayUrls = useMemo(
|
||||||
() => relayList.write.slice(0, 4).concat(currentRelayUrls.slice(0, 1)),
|
() =>
|
||||||
|
relayList.write.length < 4
|
||||||
|
? relayList.write.concat(currentRelayUrls).slice(0, 4)
|
||||||
|
: relayList.write.slice(0, 4),
|
||||||
[relayList, currentRelayUrls]
|
[relayList, currentRelayUrls]
|
||||||
)
|
)
|
||||||
const { pubkey: accountPubkey } = useNostr()
|
const { pubkey: accountPubkey } = useNostr()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import RelaySettings from '@/components/RelaySettings'
|
import RelaySetsSetting from '@/components/RelaySetsSetting'
|
||||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ export default function RelaySettingsPage({ index }: { index?: number }) {
|
|||||||
return (
|
return (
|
||||||
<SecondaryPageLayout index={index} titlebarContent={t('Relay settings')}>
|
<SecondaryPageLayout index={index} titlebarContent={t('Relay settings')}>
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<RelaySettings hideTitle />
|
<RelaySetsSetting />
|
||||||
</div>
|
</div>
|
||||||
</SecondaryPageLayout>
|
</SecondaryPageLayout>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
|
import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
|
||||||
|
import client from '@/services/client.service'
|
||||||
|
import storage from '@/services/storage.service'
|
||||||
import { TFeedType } from '@/types'
|
import { TFeedType } from '@/types'
|
||||||
import { createContext, useContext, useState } from 'react'
|
import { Filter } from 'nostr-tools'
|
||||||
|
import { createContext, useContext, useEffect, useState } from 'react'
|
||||||
|
import { useNostr } from './NostrProvider'
|
||||||
|
import { useRelaySets } from './RelaySetsProvider'
|
||||||
|
|
||||||
type TFeedContext = {
|
type TFeedContext = {
|
||||||
feedType: TFeedType
|
feedType: TFeedType
|
||||||
setFeedType: (feedType: TFeedType) => void
|
relayUrls: string[]
|
||||||
|
temporaryRelayUrls: string[]
|
||||||
|
filter: Filter
|
||||||
|
isReady: boolean
|
||||||
|
activeRelaySetId: string | null
|
||||||
|
switchFeed: (feedType: TFeedType, options?: { activeRelaySetId?: string }) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const FeedContext = createContext<TFeedContext | undefined>(undefined)
|
const FeedContext = createContext<TFeedContext | undefined>(undefined)
|
||||||
@@ -17,7 +28,113 @@ export const useFeed = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FeedProvider({ children }: { children: React.ReactNode }) {
|
export function FeedProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { pubkey } = useNostr()
|
||||||
|
const { relaySets } = useRelaySets()
|
||||||
const [feedType, setFeedType] = useState<TFeedType>('relays')
|
const [feedType, setFeedType] = useState<TFeedType>('relays')
|
||||||
|
const [relayUrls, setRelayUrls] = useState<string[]>([])
|
||||||
|
const [temporaryRelayUrls, setTemporaryRelayUrls] = useState<string[]>([])
|
||||||
|
const [filter, setFilter] = useState<Filter>({})
|
||||||
|
const [isReady, setIsReady] = useState(false)
|
||||||
|
const [activeRelaySetId, setActiveRelaySetId] = useState<string | null>(() =>
|
||||||
|
storage.getActiveRelaySetId()
|
||||||
|
)
|
||||||
|
|
||||||
return <FeedContext.Provider value={{ feedType, setFeedType }}>{children}</FeedContext.Provider>
|
useEffect(() => {
|
||||||
|
const init = async () => {
|
||||||
|
// temporary relay urls from query params
|
||||||
|
const searchParams = new URLSearchParams(window.location.search)
|
||||||
|
const tempRelays = searchParams
|
||||||
|
.getAll('r')
|
||||||
|
.map((url) =>
|
||||||
|
!url.startsWith('ws://') && !url.startsWith('wss://') ? `wss://${url}` : url
|
||||||
|
)
|
||||||
|
.filter((url) => isWebsocketUrl(url))
|
||||||
|
.map((url) => normalizeUrl(url))
|
||||||
|
if (tempRelays.length) {
|
||||||
|
setTemporaryRelayUrls(tempRelays)
|
||||||
|
return await switchFeed('temporary')
|
||||||
|
}
|
||||||
|
|
||||||
|
await switchFeed('relays', { activeRelaySetId })
|
||||||
|
}
|
||||||
|
|
||||||
|
init()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (feedType !== 'following') return
|
||||||
|
|
||||||
|
switchFeed('following')
|
||||||
|
}, [pubkey])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (feedType !== 'relays') return
|
||||||
|
|
||||||
|
const relaySet = relaySets.find((set) => set.id === activeRelaySetId)
|
||||||
|
if (!relaySet) return
|
||||||
|
|
||||||
|
setRelayUrls(relaySet.relayUrls)
|
||||||
|
}, [relaySets])
|
||||||
|
|
||||||
|
const switchFeed = async (
|
||||||
|
feedType: TFeedType,
|
||||||
|
options: { activeRelaySetId?: string | null } = {}
|
||||||
|
) => {
|
||||||
|
setIsReady(false)
|
||||||
|
if (feedType === 'relays') {
|
||||||
|
const relaySetId = options.activeRelaySetId ?? (relaySets.length > 0 ? relaySets[0].id : null)
|
||||||
|
if (!relaySetId) return
|
||||||
|
|
||||||
|
const relaySet =
|
||||||
|
relaySets.find((set) => set.id === options.activeRelaySetId) ??
|
||||||
|
(relaySets.length > 0 ? relaySets[0] : null)
|
||||||
|
if (relaySet) {
|
||||||
|
setFeedType(feedType)
|
||||||
|
setRelayUrls(relaySet.relayUrls)
|
||||||
|
setActiveRelaySetId(relaySet.id)
|
||||||
|
setFilter({})
|
||||||
|
setIsReady(true)
|
||||||
|
storage.setActiveRelaySetId(relaySet.id)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (feedType === 'following') {
|
||||||
|
if (!pubkey) return
|
||||||
|
setFeedType(feedType)
|
||||||
|
setActiveRelaySetId(null)
|
||||||
|
const [relayList, followings] = await Promise.all([
|
||||||
|
client.fetchRelayList(pubkey),
|
||||||
|
client.fetchFollowings(pubkey)
|
||||||
|
])
|
||||||
|
setRelayUrls(relayList.read.slice(0, 4))
|
||||||
|
setFilter({ authors: followings.includes(pubkey) ? followings : [...followings, pubkey] })
|
||||||
|
setIsReady(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (feedType === 'temporary') {
|
||||||
|
setFeedType(feedType)
|
||||||
|
setRelayUrls(temporaryRelayUrls)
|
||||||
|
setActiveRelaySetId(null)
|
||||||
|
setFilter({})
|
||||||
|
setIsReady(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setIsReady(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FeedContext.Provider
|
||||||
|
value={{
|
||||||
|
feedType,
|
||||||
|
relayUrls,
|
||||||
|
temporaryRelayUrls,
|
||||||
|
filter,
|
||||||
|
isReady,
|
||||||
|
activeRelaySetId,
|
||||||
|
switchFeed
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</FeedContext.Provider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { ISigner, TAccount, TAccountPointer, TDraftEvent, TRelayList } from '@/t
|
|||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
import { createContext, useContext, useEffect, useState } from 'react'
|
import { createContext, useContext, useEffect, useState } from 'react'
|
||||||
import { useRelaySettings } from '../RelaySettingsProvider'
|
|
||||||
import { BunkerSigner } from './bunker.signer'
|
import { BunkerSigner } from './bunker.signer'
|
||||||
import { Nip07Signer } from './nip-07.signer'
|
import { Nip07Signer } from './nip-07.signer'
|
||||||
import { NsecSigner } from './nsec.signer'
|
import { NsecSigner } from './nsec.signer'
|
||||||
@@ -47,7 +46,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [account, setAccount] = useState<TAccountPointer | null>(null)
|
const [account, setAccount] = useState<TAccountPointer | null>(null)
|
||||||
const [signer, setSigner] = useState<ISigner | null>(null)
|
const [signer, setSigner] = useState<ISigner | null>(null)
|
||||||
const [openLoginDialog, setOpenLoginDialog] = useState(false)
|
const [openLoginDialog, setOpenLoginDialog] = useState(false)
|
||||||
const { relayUrls: currentRelayUrls } = useRelaySettings()
|
|
||||||
const { relayList, isFetching: isFetchingRelayList } = useFetchRelayList(account?.pubkey)
|
const { relayList, isFetching: isFetchingRelayList } = useFetchRelayList(account?.pubkey)
|
||||||
const { followings, isFetching: isFetchingFollowings } = useFetchFollowings(account?.pubkey)
|
const { followings, isFetching: isFetchingFollowings } = useFetchFollowings(account?.pubkey)
|
||||||
|
|
||||||
@@ -196,10 +194,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const publish = async (draftEvent: TDraftEvent, additionalRelayUrls: string[] = []) => {
|
const publish = async (draftEvent: TDraftEvent, additionalRelayUrls: string[] = []) => {
|
||||||
const event = await signEvent(draftEvent)
|
const event = await signEvent(draftEvent)
|
||||||
await client.publishEvent(
|
await client.publishEvent(relayList.write.concat(additionalRelayUrls), event)
|
||||||
relayList.write.concat(additionalRelayUrls).concat(currentRelayUrls),
|
|
||||||
event
|
|
||||||
)
|
|
||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
80
src/providers/RelaySetsProvider.tsx
Normal file
80
src/providers/RelaySetsProvider.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { randomString } from '@/lib/random'
|
||||||
|
import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
|
||||||
|
import storage from '@/services/storage.service'
|
||||||
|
import { TRelaySet } from '@/types'
|
||||||
|
import { createContext, useContext, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
type TRelaySetsContext = {
|
||||||
|
relaySets: TRelaySet[]
|
||||||
|
addRelaySet: (relaySetName: string, relayUrls?: string[]) => string
|
||||||
|
deleteRelaySet: (id: string) => void
|
||||||
|
updateRelaySet: (newSet: TRelaySet) => void
|
||||||
|
mergeRelaySets: (newSets: TRelaySet[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const RelaySetsContext = createContext<TRelaySetsContext | undefined>(undefined)
|
||||||
|
|
||||||
|
export const useRelaySets = () => {
|
||||||
|
const context = useContext(RelaySetsContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useRelaySets must be used within a RelaySetsProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RelaySetsProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [relaySets, setRelaySets] = useState<TRelaySet[]>(() => storage.getRelaySets())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
storage.setRelaySets(relaySets)
|
||||||
|
}, [relaySets])
|
||||||
|
|
||||||
|
const deleteRelaySet = (id: string) => {
|
||||||
|
setRelaySets((pre) => pre.filter((set) => set.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateRelaySet = (newSet: TRelaySet) => {
|
||||||
|
setRelaySets((pre) => {
|
||||||
|
return pre.map((set) => (set.id === newSet.id ? newSet : set))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addRelaySet = (relaySetName: string, relayUrls: string[] = []) => {
|
||||||
|
const normalizedUrls = relayUrls
|
||||||
|
.filter((url) => isWebsocketUrl(url))
|
||||||
|
.map((url) => normalizeUrl(url))
|
||||||
|
const id = randomString()
|
||||||
|
setRelaySets((pre) => {
|
||||||
|
return [
|
||||||
|
...pre,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
name: relaySetName,
|
||||||
|
relayUrls: normalizedUrls
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergeRelaySets = (newSets: TRelaySet[]) => {
|
||||||
|
setRelaySets((pre) => {
|
||||||
|
const newIds = newSets.map((set) => set.id)
|
||||||
|
return pre.filter((set) => !newIds.includes(set.id)).concat(newSets)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RelaySetsContext.Provider
|
||||||
|
value={{
|
||||||
|
relaySets: relaySets,
|
||||||
|
addRelaySet,
|
||||||
|
deleteRelaySet,
|
||||||
|
updateRelaySet,
|
||||||
|
mergeRelaySets
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</RelaySetsContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
import { SEARCHABLE_RELAY_URLS } from '@/constants'
|
|
||||||
import { checkAlgoRelay, checkSearchRelay } from '@/lib/relay'
|
|
||||||
import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
|
|
||||||
import client from '@/services/client.service'
|
|
||||||
import storage from '@/services/storage.service'
|
|
||||||
import { TRelayGroup } from '@/types'
|
|
||||||
import { createContext, Dispatch, useContext, useEffect, useState } from 'react'
|
|
||||||
import { useFeed } from './FeedProvider'
|
|
||||||
|
|
||||||
type TRelaySettingsContext = {
|
|
||||||
relayGroups: TRelayGroup[]
|
|
||||||
temporaryRelayUrls: string[]
|
|
||||||
relayUrls: string[]
|
|
||||||
searchableRelayUrls: string[]
|
|
||||||
areAlgoRelays: boolean
|
|
||||||
switchRelayGroup: (groupName: string) => void
|
|
||||||
renameRelayGroup: (oldGroupName: string, newGroupName: string) => string | null
|
|
||||||
deleteRelayGroup: (groupName: string) => void
|
|
||||||
addRelayGroup: (groupName: string, relayUrls?: string[]) => string | null
|
|
||||||
updateRelayGroupRelayUrls: (groupName: string, relayUrls: string[]) => void
|
|
||||||
setTemporaryRelayUrls: Dispatch<string[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
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 { setFeedType } = useFeed()
|
|
||||||
const [relayGroups, setRelayGroups] = useState<TRelayGroup[]>([])
|
|
||||||
const [temporaryRelayUrls, setTemporaryRelayUrls] = useState<string[]>([])
|
|
||||||
const [relayUrls, setRelayUrls] = useState<string[]>(
|
|
||||||
temporaryRelayUrls.length
|
|
||||||
? temporaryRelayUrls
|
|
||||||
: (relayGroups.find((group) => group.isActive)?.relayUrls ?? [])
|
|
||||||
)
|
|
||||||
const [searchableRelayUrls, setSearchableRelayUrls] = useState<string[]>(SEARCHABLE_RELAY_URLS)
|
|
||||||
const [areAlgoRelays, setAreAlgoRelays] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const searchParams = new URLSearchParams(window.location.search)
|
|
||||||
const tempRelays = searchParams
|
|
||||||
.getAll('r')
|
|
||||||
.map((url) => (url.startsWith('wss://') || url.startsWith('ws://') ? url : `wss://${url}`))
|
|
||||||
.filter((url) => isWebsocketUrl(url))
|
|
||||||
.map((url) => normalizeUrl(url))
|
|
||||||
if (tempRelays.length) {
|
|
||||||
setTemporaryRelayUrls(tempRelays)
|
|
||||||
setFeedType('relays')
|
|
||||||
}
|
|
||||||
const storedGroups = storage.getRelayGroups()
|
|
||||||
setRelayGroups(storedGroups)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = async () => {
|
|
||||||
const newRelayUrls = temporaryRelayUrls.length
|
|
||||||
? temporaryRelayUrls
|
|
||||||
: (relayGroups.find((group) => group.isActive)?.relayUrls ?? [])
|
|
||||||
|
|
||||||
if (JSON.stringify(relayUrls) !== JSON.stringify(newRelayUrls)) {
|
|
||||||
setRelayUrls(newRelayUrls)
|
|
||||||
}
|
|
||||||
const relayInfos = await client.fetchRelayInfos(newRelayUrls)
|
|
||||||
const searchableRelayUrls = newRelayUrls.filter((_, index) =>
|
|
||||||
checkSearchRelay(relayInfos[index])
|
|
||||||
)
|
|
||||||
setSearchableRelayUrls(
|
|
||||||
searchableRelayUrls.length ? searchableRelayUrls : SEARCHABLE_RELAY_URLS
|
|
||||||
)
|
|
||||||
const nonAlgoRelayUrls = newRelayUrls.filter((_, index) => !checkAlgoRelay(relayInfos[index]))
|
|
||||||
setAreAlgoRelays(newRelayUrls.length > 0 && nonAlgoRelayUrls.length === 0)
|
|
||||||
client.setCurrentRelayUrls(nonAlgoRelayUrls)
|
|
||||||
}
|
|
||||||
handler()
|
|
||||||
}, [relayGroups, temporaryRelayUrls, relayUrls])
|
|
||||||
|
|
||||||
const updateGroups = (fn: (pre: TRelayGroup[]) => TRelayGroup[]) => {
|
|
||||||
let newGroups = relayGroups
|
|
||||||
setRelayGroups((pre) => {
|
|
||||||
newGroups = fn(pre)
|
|
||||||
return newGroups
|
|
||||||
})
|
|
||||||
storage.setRelayGroups(newGroups)
|
|
||||||
}
|
|
||||||
|
|
||||||
const switchRelayGroup = (groupName: string) => {
|
|
||||||
updateGroups((pre) =>
|
|
||||||
pre.map((group) => ({
|
|
||||||
...group,
|
|
||||||
isActive: group.groupName === groupName
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
setFeedType('relays')
|
|
||||||
setTemporaryRelayUrls([])
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteRelayGroup = (groupName: string) => {
|
|
||||||
updateGroups((pre) => pre.filter((group) => group.groupName !== groupName))
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateRelayGroupRelayUrls = (groupName: string, relayUrls: string[]) => {
|
|
||||||
updateGroups((pre) =>
|
|
||||||
pre.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
|
|
||||||
}
|
|
||||||
updateGroups((pre) => {
|
|
||||||
if (pre.some((group) => group.groupName === newGroupName)) {
|
|
||||||
return pre
|
|
||||||
}
|
|
||||||
return pre.map((group) => ({
|
|
||||||
...group,
|
|
||||||
groupName: group.groupName === oldGroupName ? newGroupName : group.groupName
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const addRelayGroup = (groupName: string, relayUrls: string[] = []) => {
|
|
||||||
if (groupName === '') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const normalizedUrls = relayUrls
|
|
||||||
.filter((url) => isWebsocketUrl(url))
|
|
||||||
.map((url) => normalizeUrl(url))
|
|
||||||
updateGroups((pre) => {
|
|
||||||
if (pre.some((group) => group.groupName === groupName)) {
|
|
||||||
return pre
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
...pre,
|
|
||||||
{
|
|
||||||
groupName,
|
|
||||||
relayUrls: normalizedUrls,
|
|
||||||
isActive: false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RelaySettingsContext.Provider
|
|
||||||
value={{
|
|
||||||
relayGroups,
|
|
||||||
temporaryRelayUrls,
|
|
||||||
relayUrls,
|
|
||||||
searchableRelayUrls,
|
|
||||||
areAlgoRelays,
|
|
||||||
switchRelayGroup,
|
|
||||||
renameRelayGroup,
|
|
||||||
deleteRelayGroup,
|
|
||||||
addRelayGroup,
|
|
||||||
updateRelayGroupRelayUrls,
|
|
||||||
setTemporaryRelayUrls
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</RelaySettingsContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BIG_RELAY_URLS } from '@/constants'
|
import { BIG_RELAY_URLS } from '@/constants'
|
||||||
|
import { getFollowingsFromFollowListEvent } from '@/lib/event'
|
||||||
import { formatPubkey } from '@/lib/pubkey'
|
import { formatPubkey } from '@/lib/pubkey'
|
||||||
import { tagNameEquals } from '@/lib/tag'
|
import { tagNameEquals } from '@/lib/tag'
|
||||||
import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
|
import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
|
||||||
@@ -100,7 +101,9 @@ class ClientService extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async publishEvent(relayUrls: string[], event: NEvent) {
|
async publishEvent(relayUrls: string[], event: NEvent) {
|
||||||
const result = await Promise.any(this.pool.publish(relayUrls, event))
|
const result = await Promise.any(
|
||||||
|
this.pool.publish(relayUrls.concat(this.defaultRelayUrls), event)
|
||||||
|
)
|
||||||
this.dispatchEvent(new CustomEvent('eventPublished', { detail: event }))
|
this.dispatchEvent(new CustomEvent('eventPublished', { detail: event }))
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -417,6 +420,11 @@ class ClientService extends EventTarget {
|
|||||||
return this.followListCache.fetch(pubkey)
|
return this.followListCache.fetch(pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchFollowings(pubkey: string) {
|
||||||
|
const followListEvent = await this.fetchFollowListEvent(pubkey)
|
||||||
|
return followListEvent ? getFollowingsFromFollowListEvent(followListEvent) : []
|
||||||
|
}
|
||||||
|
|
||||||
updateFollowListCache(pubkey: string, event: NEvent) {
|
updateFollowListCache(pubkey: string, event: NEvent) {
|
||||||
this.followListCache.set(pubkey, Promise.resolve(event))
|
this.followListCache.set(pubkey, Promise.resolve(event))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
import { StorageKey } from '@/constants'
|
import { StorageKey } from '@/constants'
|
||||||
import { isSameAccount } from '@/lib/account'
|
import { isSameAccount } from '@/lib/account'
|
||||||
import { TAccount, TRelayGroup, TAccountPointer, TThemeSetting } from '@/types'
|
import { randomString } from '@/lib/random'
|
||||||
|
import { TAccount, TAccountPointer, TRelaySet, TThemeSetting } from '@/types'
|
||||||
|
|
||||||
const DEFAULT_RELAY_GROUPS: TRelayGroup[] = [
|
const DEFAULT_RELAY_SETS: TRelaySet[] = [
|
||||||
{
|
{
|
||||||
groupName: 'Global',
|
id: randomString(),
|
||||||
relayUrls: ['wss://relay.damus.io/', 'wss://nos.lol/'],
|
name: 'Global',
|
||||||
isActive: true
|
relayUrls: ['wss://relay.damus.io/', 'wss://nos.lol/']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: randomString(),
|
||||||
|
name: 'Algo',
|
||||||
|
relayUrls: ['wss://algo.utxo.one']
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
class StorageService {
|
class StorageService {
|
||||||
static instance: StorageService
|
static instance: StorageService
|
||||||
|
|
||||||
private relayGroups: TRelayGroup[] = []
|
private relaySets: TRelaySet[] = []
|
||||||
|
private activeRelaySetId: string | null = null
|
||||||
private themeSetting: TThemeSetting = 'system'
|
private themeSetting: TThemeSetting = 'system'
|
||||||
private accounts: TAccount[] = []
|
private accounts: TAccount[] = []
|
||||||
private currentAccount: TAccount | null = null
|
private currentAccount: TAccount | null = null
|
||||||
@@ -27,23 +34,64 @@ class StorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
const relayGroupsStr = window.localStorage.getItem(StorageKey.RELAY_GROUPS)
|
|
||||||
this.relayGroups = relayGroupsStr ? JSON.parse(relayGroupsStr) : DEFAULT_RELAY_GROUPS
|
|
||||||
this.themeSetting =
|
this.themeSetting =
|
||||||
(window.localStorage.getItem(StorageKey.THEME_SETTING) as TThemeSetting) ?? 'system'
|
(window.localStorage.getItem(StorageKey.THEME_SETTING) as TThemeSetting) ?? 'system'
|
||||||
const accountsStr = window.localStorage.getItem(StorageKey.ACCOUNTS)
|
const accountsStr = window.localStorage.getItem(StorageKey.ACCOUNTS)
|
||||||
this.accounts = accountsStr ? JSON.parse(accountsStr) : []
|
this.accounts = accountsStr ? JSON.parse(accountsStr) : []
|
||||||
const currentAccountStr = window.localStorage.getItem(StorageKey.CURRENT_ACCOUNT)
|
const currentAccountStr = window.localStorage.getItem(StorageKey.CURRENT_ACCOUNT)
|
||||||
this.currentAccount = currentAccountStr ? JSON.parse(currentAccountStr) : null
|
this.currentAccount = currentAccountStr ? JSON.parse(currentAccountStr) : null
|
||||||
|
|
||||||
|
const relaySetsStr = window.localStorage.getItem(StorageKey.RELAY_SETS)
|
||||||
|
if (!relaySetsStr) {
|
||||||
|
let relaySets: TRelaySet[] = []
|
||||||
|
const legacyRelayGroupsStr = window.localStorage.getItem('relayGroups')
|
||||||
|
if (legacyRelayGroupsStr) {
|
||||||
|
const legacyRelayGroups = JSON.parse(legacyRelayGroupsStr)
|
||||||
|
relaySets = legacyRelayGroups.map((group: any) => {
|
||||||
|
return {
|
||||||
|
id: randomString(),
|
||||||
|
name: group.groupName,
|
||||||
|
relayUrls: group.relayUrls
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!relaySets.length) {
|
||||||
|
relaySets = DEFAULT_RELAY_SETS
|
||||||
|
}
|
||||||
|
const activeRelaySetId = relaySets[0].id
|
||||||
|
window.localStorage.setItem(StorageKey.RELAY_SETS, JSON.stringify(relaySets))
|
||||||
|
window.localStorage.setItem(StorageKey.ACTIVE_RELAY_SET_ID, activeRelaySetId)
|
||||||
|
this.relaySets = relaySets
|
||||||
|
this.activeRelaySetId = activeRelaySetId
|
||||||
|
} else {
|
||||||
|
this.relaySets = JSON.parse(relaySetsStr)
|
||||||
|
this.activeRelaySetId = window.localStorage.getItem(StorageKey.ACTIVE_RELAY_SET_ID) ?? null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getRelayGroups() {
|
getRelaySets() {
|
||||||
return this.relayGroups
|
return this.relaySets
|
||||||
}
|
}
|
||||||
|
|
||||||
setRelayGroups(relayGroups: TRelayGroup[]) {
|
setRelaySets(relaySets: TRelaySet[]) {
|
||||||
window.localStorage.setItem(StorageKey.RELAY_GROUPS, JSON.stringify(relayGroups))
|
this.relaySets = relaySets
|
||||||
this.relayGroups = relayGroups
|
window.localStorage.setItem(StorageKey.RELAY_SETS, JSON.stringify(this.relaySets))
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveRelaySetId() {
|
||||||
|
return this.activeRelaySetId
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveRelaySetId(id: string | null) {
|
||||||
|
this.activeRelaySetId = id
|
||||||
|
if (id) {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
StorageKey.ACTIVE_RELAY_SET_ID,
|
||||||
|
JSON.stringify(this.activeRelaySetId)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
window.localStorage.removeItem(StorageKey.ACTIVE_RELAY_SET_ID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getThemeSetting() {
|
getThemeSetting() {
|
||||||
|
|||||||
10
src/types.ts
10
src/types.ts
@@ -26,14 +26,14 @@ export type TWebMetadata = {
|
|||||||
image?: string | null
|
image?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TRelayGroup = {
|
export type TRelaySet = {
|
||||||
groupName: string
|
id: string
|
||||||
|
name: string
|
||||||
relayUrls: string[]
|
relayUrls: string[]
|
||||||
isActive: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TConfig = {
|
export type TConfig = {
|
||||||
relayGroups: TRelayGroup[]
|
relayGroups: TRelaySet[]
|
||||||
theme: TThemeSetting
|
theme: TThemeSetting
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +64,6 @@ export type TAccount = {
|
|||||||
|
|
||||||
export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'>
|
export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'>
|
||||||
|
|
||||||
export type TFeedType = 'following' | 'relays'
|
export type TFeedType = 'following' | 'relays' | 'temporary'
|
||||||
|
|
||||||
export type TLanguage = 'en' | 'zh'
|
export type TLanguage = 'en' | 'zh'
|
||||||
|
|||||||
Reference in New Issue
Block a user