63 changed files with 1081 additions and 982 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
|
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 @@ |
|||||||
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