63 changed files with 1081 additions and 982 deletions
@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import { DrawerClose } from '@/components/ui/drawer' |
||||
import { cn } from '@/lib/utils' |
||||
|
||||
export default function DrawerMenuItem({ |
||||
children, |
||||
className, |
||||
onClick |
||||
}: { |
||||
children: React.ReactNode |
||||
className?: string |
||||
onClick?: (e: React.MouseEvent) => void |
||||
}) { |
||||
return ( |
||||
<DrawerClose className="w-full"> |
||||
<Button |
||||
onClick={onClick} |
||||
className={cn('w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5', className)} |
||||
variant="ghost" |
||||
> |
||||
{children} |
||||
</Button> |
||||
</DrawerClose> |
||||
) |
||||
} |
||||
@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import { Input } from '@/components/ui/input' |
||||
import { normalizeUrl } from '@/lib/url' |
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||
import { useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
export default function AddNewRelay() { |
||||
const { t } = useTranslation() |
||||
const { favoriteRelays, addFavoriteRelays } = useFavoriteRelays() |
||||
const [input, setInput] = useState('') |
||||
const [errorMsg, setErrorMsg] = useState('') |
||||
|
||||
const saveRelay = async () => { |
||||
if (!input) return |
||||
const normalizedUrl = normalizeUrl(input) |
||||
if (!normalizedUrl) { |
||||
setErrorMsg(t('Invalid URL')) |
||||
return |
||||
} |
||||
if (favoriteRelays.includes(normalizedUrl)) { |
||||
setErrorMsg(t('Already saved')) |
||||
return |
||||
} |
||||
await addFavoriteRelays([normalizedUrl]) |
||||
setInput('') |
||||
} |
||||
|
||||
const handleNewRelayInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
||||
setInput(e.target.value) |
||||
setErrorMsg('') |
||||
} |
||||
|
||||
const handleNewRelayInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { |
||||
if (event.key === 'Enter') { |
||||
event.preventDefault() |
||||
saveRelay() |
||||
} |
||||
} |
||||
|
||||
return ( |
||||
<div className="space-y-1"> |
||||
<div className="flex gap-2 items-center"> |
||||
<Input |
||||
placeholder={t('Add a new relay')} |
||||
value={input} |
||||
onChange={handleNewRelayInputChange} |
||||
onKeyDown={handleNewRelayInputKeyDown} |
||||
className={errorMsg ? 'border-destructive' : ''} |
||||
/> |
||||
<Button onClick={saveRelay}>{t('Add')}</Button> |
||||
</div> |
||||
{errorMsg && <div className="text-destructive text-sm pl-8">{errorMsg}</div>} |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import { Input } from '@/components/ui/input' |
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||
import { useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
export default function AddNewRelaySet() { |
||||
const { t } = useTranslation() |
||||
const { addRelaySet } = useFavoriteRelays() |
||||
const [newRelaySetName, setNewRelaySetName] = useState('') |
||||
|
||||
const saveRelaySet = () => { |
||||
if (!newRelaySetName) return |
||||
addRelaySet(newRelaySetName) |
||||
setNewRelaySetName('') |
||||
} |
||||
|
||||
const handleNewRelaySetNameChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
||||
setNewRelaySetName(e.target.value) |
||||
} |
||||
|
||||
const handleNewRelaySetNameKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { |
||||
if (event.key === 'Enter') { |
||||
event.preventDefault() |
||||
saveRelaySet() |
||||
} |
||||
} |
||||
|
||||
return ( |
||||
<div className="space-y-1"> |
||||
<div className="flex gap-2 items-center"> |
||||
<Input |
||||
placeholder={t('Add a new relay set')} |
||||
value={newRelaySetName} |
||||
onChange={handleNewRelaySetNameChange} |
||||
onKeyDown={handleNewRelaySetNameKeyDown} |
||||
/> |
||||
<Button onClick={saveRelaySet}>{t('Add')}</Button> |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
|
||||
import { toRelay } from '@/lib/link' |
||||
import { useSecondaryPage } from '@/PageManager' |
||||
import RelayIcon from '../RelayIcon' |
||||
import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu' |
||||
|
||||
export default function RelayItem({ relay }: { relay: string }) { |
||||
const { push } = useSecondaryPage() |
||||
|
||||
return ( |
||||
<div |
||||
className="flex gap-2 border rounded-lg p-4 items-center clickable select-none" |
||||
onClick={() => push(toRelay(relay))} |
||||
> |
||||
<RelayIcon url={relay} /> |
||||
<div className="flex-1 w-0 truncate font-semibold">{relay}</div> |
||||
<SaveRelayDropdownMenu urls={[relay]} /> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
|
||||
import { useFeed } from '@/providers/FeedProvider' |
||||
import RelayIcon from '../RelayIcon' |
||||
import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu' |
||||
|
||||
export default function TemporaryRelaySet() { |
||||
const { temporaryRelayUrls } = useFeed() |
||||
|
||||
if (!temporaryRelayUrls.length) { |
||||
return null |
||||
} |
||||
|
||||
return ( |
||||
<div className="w-full border border-dashed rounded-lg p-4 border-highlight bg-highlight/5 flex gap-4 justify-between"> |
||||
<div className="flex-1 w-0"> |
||||
<div className="flex justify-between items-center"> |
||||
<div className="h-8 font-semibold">Temporary</div> |
||||
</div> |
||||
{temporaryRelayUrls.map((url) => ( |
||||
<div className="flex gap-3 items-center"> |
||||
<RelayIcon url={url} className="w-4 h-4" iconSize={10} /> |
||||
<div className="text-muted-foreground text-sm truncate">{url}</div> |
||||
</div> |
||||
))} |
||||
</div> |
||||
<SaveRelayDropdownMenu urls={temporaryRelayUrls} /> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
|
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||
import { useTranslation } from 'react-i18next' |
||||
import AddNewRelay from './AddNewRelay' |
||||
import AddNewRelaySet from './AddNewRelaySet' |
||||
import { RelaySetsSettingComponentProvider } from './provider' |
||||
import RelayItem from './RelayItem' |
||||
import RelaySet from './RelaySet' |
||||
import TemporaryRelaySet from './TemporaryRelaySet' |
||||
|
||||
export default function FavoriteRelaysSetting() { |
||||
const { t } = useTranslation() |
||||
const { relaySets, favoriteRelays } = useFavoriteRelays() |
||||
|
||||
return ( |
||||
<RelaySetsSettingComponentProvider> |
||||
<div className="space-y-4"> |
||||
<TemporaryRelaySet /> |
||||
<div className="space-y-2"> |
||||
<div className="text-muted-foreground font-semibold select-none">{t('Relay sets')}</div> |
||||
{relaySets.map((relaySet) => ( |
||||
<RelaySet key={relaySet.id} relaySet={relaySet} /> |
||||
))} |
||||
</div> |
||||
<AddNewRelaySet /> |
||||
<div className="space-y-2"> |
||||
<div className="text-muted-foreground font-semibold select-none">{t('Relays')}</div> |
||||
{favoriteRelays.map((relay) => ( |
||||
<RelayItem key={relay} relay={relay} /> |
||||
))} |
||||
</div> |
||||
<AddNewRelay /> |
||||
</div> |
||||
</RelaySetsSettingComponentProvider> |
||||
) |
||||
} |
||||
@ -1,174 +0,0 @@
@@ -1,174 +0,0 @@
|
||||
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 { useRelaySets } from '@/providers/RelaySetsProvider' |
||||
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 { useTranslation } from 'react-i18next' |
||||
import RelaySetCard from '../RelaySetCard' |
||||
|
||||
export default function PullFromRelaysButton() { |
||||
const { t } = useTranslation() |
||||
const { pubkey } = useNostr() |
||||
const { isSmallScreen } = useScreenSize() |
||||
const [open, setOpen] = useState(false) |
||||
|
||||
const trigger = ( |
||||
<Button variant="secondary" className="w-full" disabled={!pubkey}> |
||||
<CloudDownload /> |
||||
{t('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>{t('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 className="max-h-[90vh] overflow-auto"> |
||||
<DialogHeader> |
||||
<DialogTitle>{t('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 { t } = useTranslation() |
||||
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 |
||||
} |
||||
) |
||||
events.sort((a, b) => b.created_at - a.created_at) |
||||
|
||||
const relaySetIds = new Set<string>() |
||||
const relaySets: TRelaySet[] = [] |
||||
events.forEach((evt) => { |
||||
const id = evt.tags.find(tagNameEquals('d'))?.[1] |
||||
if (!id || relaySetIds.has(id)) return |
||||
|
||||
relaySetIds.add(id) |
||||
const relayUrls = evt.tags |
||||
.filter(tagNameEquals('relay')) |
||||
.map((tag) => tag[1]) |
||||
.filter((url) => url && isWebsocketUrl(url)) |
||||
if (!relayUrls.length) return |
||||
|
||||
let title = evt.tags.find(tagNameEquals('title'))?.[1] |
||||
if (!title) { |
||||
title = relayUrls.length === 1 ? simplifyUrl(relayUrls[0]) : id |
||||
} |
||||
relaySets.push({ id, name: title, relayUrls }) |
||||
}) |
||||
|
||||
setRelaySets(relaySets) |
||||
setInitialed(true) |
||||
} |
||||
init() |
||||
}, [pubkey]) |
||||
|
||||
if (!pubkey) return null |
||||
if (!initialed) return <div className="text-center text-muted-foreground">{t('loading...')}</div> |
||||
if (!relaySets.length) { |
||||
return <div className="text-center text-muted-foreground">{t('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))} |
||||
> |
||||
{t('Select 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 |
||||
? t('Pull n relay sets', { n: selectedRelaySetIds.length }) |
||||
: t('Pull')} |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
@ -1,45 +0,0 @@
@@ -1,45 +0,0 @@
|
||||
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 { useTranslation } from 'react-i18next' |
||||
import { useRelaySetsSettingComponent } from './provider' |
||||
|
||||
export default function PushToRelaysButton() { |
||||
const { t } = useTranslation() |
||||
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: t('Push Successful'), |
||||
description: t('Successfully pushed relay sets to relays') |
||||
}) |
||||
setPushing(false) |
||||
} |
||||
|
||||
return ( |
||||
<Button |
||||
variant="secondary" |
||||
className="w-full" |
||||
disabled={!pubkey || pushing || selectedRelaySetIds.length === 0} |
||||
onClick={push} |
||||
> |
||||
<CloudUpload /> |
||||
{t('Push to relays')} |
||||
{pushing && <Loader className="animate-spin" />} |
||||
</Button> |
||||
) |
||||
} |
||||
@ -1,69 +0,0 @@
@@ -1,69 +0,0 @@
|
||||
import { useFetchRelayInfos } from '@/hooks' |
||||
import { useFeed } from '@/providers/FeedProvider' |
||||
import client from '@/services/client.service' |
||||
import { SearchCheck } from 'lucide-react' |
||||
import { useEffect, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu' |
||||
|
||||
export default function TemporaryRelaySet() { |
||||
const { t } = useTranslation() |
||||
const { temporaryRelayUrls } = useFeed() |
||||
const [relays, setRelays] = useState< |
||||
{ |
||||
url: string |
||||
isConnected: boolean |
||||
}[] |
||||
>(temporaryRelayUrls.map((url) => ({ url, isConnected: false }))) |
||||
const { relayInfos } = useFetchRelayInfos(relays.map((relay) => relay.url)) |
||||
|
||||
useEffect(() => { |
||||
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) |
||||
}, []) |
||||
|
||||
useEffect(() => { |
||||
setRelays(temporaryRelayUrls.map((url) => ({ url, isConnected: false }))) |
||||
}, [temporaryRelayUrls]) |
||||
|
||||
if (!relays.length) { |
||||
return null |
||||
} |
||||
|
||||
return ( |
||||
<div className="w-full border border-dashed rounded-lg p-4 border-highlight bg-highlight/5 flex gap-4 justify-between"> |
||||
<div> |
||||
<div className="flex justify-between items-center"> |
||||
<div className="h-8 font-semibold">Temporary</div> |
||||
</div> |
||||
{relays.map((relay, index) => ( |
||||
<div key={index} className="flex items-center justify-between"> |
||||
<div className="flex gap-2 items-center"> |
||||
{relay.isConnected ? ( |
||||
<div className="text-green-500 text-xs">●</div> |
||||
) : ( |
||||
<div className="text-red-500 text-xs">●</div> |
||||
)} |
||||
<div className="text-muted-foreground text-sm">{relay.url}</div> |
||||
{relayInfos[index]?.supported_nips?.includes(50) && ( |
||||
<div title={t('supports search')} className="text-highlight"> |
||||
<SearchCheck size={14} /> |
||||
</div> |
||||
)} |
||||
</div> |
||||
</div> |
||||
))} |
||||
</div> |
||||
<SaveRelayDropdownMenu urls={temporaryRelayUrls} /> |
||||
</div> |
||||
) |
||||
} |
||||
@ -1,77 +0,0 @@
@@ -1,77 +0,0 @@
|
||||
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) |
||||
setNewRelaySetName('') |
||||
} |
||||
|
||||
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> |
||||
) |
||||
} |
||||
@ -0,0 +1,226 @@
@@ -0,0 +1,226 @@
|
||||
import { BIG_RELAY_URLS, DEFAULT_FAVORITE_RELAYS } from '@/constants' |
||||
import { createFavoriteRelaysDraftEvent, createRelaySetDraftEvent } from '@/lib/draft-event' |
||||
import { getRelaySetFromRelaySetEvent, getReplaceableEventIdentifier } from '@/lib/event' |
||||
import { randomString } from '@/lib/random' |
||||
import { isWebsocketUrl, normalizeUrl } from '@/lib/url' |
||||
import client from '@/services/client.service' |
||||
import indexedDb from '@/services/indexed-db.service' |
||||
import storage from '@/services/local-storage.service' |
||||
import { TRelaySet } from '@/types' |
||||
import { Event, kinds } from 'nostr-tools' |
||||
import { createContext, useContext, useEffect, useState } from 'react' |
||||
import { useNostr } from './NostrProvider' |
||||
|
||||
type TFavoriteRelaysContext = { |
||||
favoriteRelays: string[] |
||||
addFavoriteRelays: (relayUrls: string[]) => Promise<void> |
||||
deleteFavoriteRelays: (relayUrls: string[]) => Promise<void> |
||||
relaySets: TRelaySet[] |
||||
addRelaySet: (relaySetName: string, relayUrls?: string[]) => Promise<void> |
||||
deleteRelaySet: (id: string) => Promise<void> |
||||
updateRelaySet: (newSet: TRelaySet) => Promise<void> |
||||
} |
||||
|
||||
const FavoriteRelaysContext = createContext<TFavoriteRelaysContext | undefined>(undefined) |
||||
|
||||
export const useFavoriteRelays = () => { |
||||
const context = useContext(FavoriteRelaysContext) |
||||
if (!context) { |
||||
throw new Error('useFavoriteRelays must be used within a FavoriteRelaysProvider') |
||||
} |
||||
return context |
||||
} |
||||
|
||||
export function FavoriteRelaysProvider({ children }: { children: React.ReactNode }) { |
||||
const { favoriteRelaysEvent, updateFavoriteRelaysEvent, pubkey, relayList, publish } = useNostr() |
||||
const [favoriteRelays, setFavoriteRelays] = useState<string[]>([]) |
||||
const [relaySetEvents, setRelaySetEvents] = useState<Event[]>([]) |
||||
const [relaySets, setRelaySets] = useState<TRelaySet[]>([]) |
||||
|
||||
useEffect(() => { |
||||
if (!favoriteRelaysEvent) { |
||||
const favoriteRelays: string[] = DEFAULT_FAVORITE_RELAYS |
||||
const storedRelaySets = storage.getRelaySets() |
||||
storedRelaySets.forEach(({ relayUrls }) => { |
||||
relayUrls.forEach((url) => { |
||||
if (!favoriteRelays.includes(url)) { |
||||
favoriteRelays.push(url) |
||||
} |
||||
}) |
||||
}) |
||||
|
||||
setFavoriteRelays(favoriteRelays) |
||||
setRelaySetEvents([]) |
||||
return |
||||
} |
||||
|
||||
const init = async () => { |
||||
const relays: string[] = [] |
||||
const relaySetIds: string[] = [] |
||||
|
||||
favoriteRelaysEvent.tags.forEach(([tagName, tagValue]) => { |
||||
if (!tagValue) return |
||||
|
||||
if (tagName === 'relay') { |
||||
const normalizedUrl = normalizeUrl(tagValue) |
||||
if (normalizedUrl && !relays.includes(normalizedUrl)) { |
||||
relays.push(normalizedUrl) |
||||
} |
||||
} else if (tagName === 'a') { |
||||
const [kind, author, relaySetId] = tagValue.split(':') |
||||
if (kind !== kinds.Relaysets.toString()) return |
||||
if (!pubkey || author !== pubkey) return // TODO: support others relay sets
|
||||
if (!relaySetId) return |
||||
|
||||
if (!relaySetIds.includes(relaySetId)) { |
||||
relaySetIds.push(relaySetId) |
||||
} |
||||
} |
||||
}) |
||||
|
||||
setFavoriteRelays(relays) |
||||
|
||||
if (!pubkey) return |
||||
const relaySetEvents = await Promise.all( |
||||
relaySetIds.map((id) => indexedDb.getReplaceableEvent(pubkey, kinds.Relaysets, id)) |
||||
) |
||||
const nonExistingRelaySetIds = relaySetIds.filter((_, index) => { |
||||
return !relaySetEvents[index] |
||||
}) |
||||
if (nonExistingRelaySetIds.length) { |
||||
const newRelaySetEvents = await client.fetchEvents( |
||||
(relayList?.write ?? []).concat(BIG_RELAY_URLS).slice(0, 5), |
||||
{ |
||||
kinds: [kinds.Relaysets], |
||||
authors: [pubkey], |
||||
'#d': nonExistingRelaySetIds |
||||
} |
||||
) |
||||
const relaySetEventMap = new Map<string, Event>() |
||||
newRelaySetEvents.forEach((event) => { |
||||
const d = getReplaceableEventIdentifier(event) |
||||
if (!d) return |
||||
|
||||
const old = relaySetEventMap.get(d) |
||||
if (!old || old.created_at < event.created_at) { |
||||
relaySetEventMap.set(d, event) |
||||
} |
||||
}) |
||||
await Promise.all( |
||||
Array.from(relaySetEventMap.values()).map((event) => { |
||||
return indexedDb.putReplaceableEvent(event) |
||||
}) |
||||
) |
||||
nonExistingRelaySetIds.forEach((id) => { |
||||
const event = relaySetEventMap.get(id) |
||||
if (event) { |
||||
const index = relaySetIds.indexOf(id) |
||||
if (index !== -1) { |
||||
relaySetEvents[index] = event |
||||
} |
||||
} |
||||
}) |
||||
} |
||||
|
||||
setRelaySetEvents(relaySetEvents.filter(Boolean) as Event[]) |
||||
} |
||||
init() |
||||
}, [favoriteRelaysEvent]) |
||||
|
||||
useEffect(() => { |
||||
setRelaySets( |
||||
relaySetEvents.map((evt) => getRelaySetFromRelaySetEvent(evt)).filter(Boolean) as TRelaySet[] |
||||
) |
||||
}, [relaySetEvents]) |
||||
|
||||
const addFavoriteRelays = async (relayUrls: string[]) => { |
||||
const normalizedUrls = relayUrls |
||||
.map((relayUrl) => normalizeUrl(relayUrl)) |
||||
.filter((url) => !!url && !favoriteRelays.includes(url)) |
||||
if (!normalizedUrls.length) return |
||||
|
||||
const draftEvent = createFavoriteRelaysDraftEvent( |
||||
[...favoriteRelays, ...normalizedUrls], |
||||
relaySetEvents |
||||
) |
||||
const newFavoriteRelaysEvent = await publish(draftEvent) |
||||
updateFavoriteRelaysEvent(newFavoriteRelaysEvent) |
||||
} |
||||
|
||||
const deleteFavoriteRelays = async (relayUrls: string[]) => { |
||||
const normalizedUrls = relayUrls |
||||
.map((relayUrl) => normalizeUrl(relayUrl)) |
||||
.filter((url) => !!url && favoriteRelays.includes(url)) |
||||
if (!normalizedUrls.length) return |
||||
|
||||
const draftEvent = createFavoriteRelaysDraftEvent( |
||||
favoriteRelays.filter((url) => !normalizedUrls.includes(url)), |
||||
relaySetEvents |
||||
) |
||||
const newFavoriteRelaysEvent = await publish(draftEvent) |
||||
updateFavoriteRelaysEvent(newFavoriteRelaysEvent) |
||||
} |
||||
|
||||
const addRelaySet = async (relaySetName: string, relayUrls: string[] = []) => { |
||||
const normalizedUrls = relayUrls |
||||
.map((url) => normalizeUrl(url)) |
||||
.filter((url) => isWebsocketUrl(url)) |
||||
const id = randomString() |
||||
const relaySetDraftEvent = createRelaySetDraftEvent({ |
||||
id, |
||||
name: relaySetName, |
||||
relayUrls: normalizedUrls |
||||
}) |
||||
const newRelaySetEvent = await publish(relaySetDraftEvent) |
||||
await indexedDb.putReplaceableEvent(newRelaySetEvent) |
||||
|
||||
const favoriteRelaysDraftEvent = createFavoriteRelaysDraftEvent(favoriteRelays, [ |
||||
...relaySetEvents, |
||||
newRelaySetEvent |
||||
]) |
||||
const newFavoriteRelaysEvent = await publish(favoriteRelaysDraftEvent) |
||||
updateFavoriteRelaysEvent(newFavoriteRelaysEvent) |
||||
} |
||||
|
||||
const deleteRelaySet = async (id: string) => { |
||||
const newRelaySetEvents = relaySetEvents.filter((event) => { |
||||
return getReplaceableEventIdentifier(event) !== id |
||||
}) |
||||
if (newRelaySetEvents.length === relaySetEvents.length) return |
||||
|
||||
const draftEvent = createFavoriteRelaysDraftEvent(favoriteRelays, newRelaySetEvents) |
||||
const newFavoriteRelaysEvent = await publish(draftEvent) |
||||
updateFavoriteRelaysEvent(newFavoriteRelaysEvent) |
||||
} |
||||
|
||||
const updateRelaySet = async (newSet: TRelaySet) => { |
||||
const draftEvent = createRelaySetDraftEvent(newSet) |
||||
const newRelaySetEvent = await publish(draftEvent) |
||||
await indexedDb.putReplaceableEvent(newRelaySetEvent) |
||||
|
||||
setRelaySetEvents((prev) => { |
||||
return prev.map((event) => { |
||||
if (getReplaceableEventIdentifier(event) === newSet.id) { |
||||
return newRelaySetEvent |
||||
} |
||||
return event |
||||
}) |
||||
}) |
||||
} |
||||
|
||||
return ( |
||||
<FavoriteRelaysContext.Provider |
||||
value={{ |
||||
favoriteRelays, |
||||
addFavoriteRelays, |
||||
deleteFavoriteRelays, |
||||
relaySets, |
||||
addRelaySet, |
||||
deleteRelaySet, |
||||
updateRelaySet |
||||
}} |
||||
> |
||||
{children} |
||||
</FavoriteRelaysContext.Provider> |
||||
) |
||||
} |
||||
@ -1,80 +0,0 @@
@@ -1,80 +0,0 @@
|
||||
import { randomString } from '@/lib/random' |
||||
import { isWebsocketUrl, normalizeUrl } from '@/lib/url' |
||||
import storage from '@/services/local-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, |
||||
addRelaySet, |
||||
deleteRelaySet, |
||||
updateRelaySet, |
||||
mergeRelaySets |
||||
}} |
||||
> |
||||
{children} |
||||
</RelaySetsContext.Provider> |
||||
) |
||||
} |
||||
Loading…
Reference in new issue