38 changed files with 1069 additions and 686 deletions
@ -0,0 +1,124 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,168 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,43 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,176 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,76 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,52 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -1,5 +0,0 @@
|
||||
export type TRelayGroup = { |
||||
groupName: string |
||||
relayUrls: string[] |
||||
isActive: boolean |
||||
} |
||||
@ -0,0 +1,9 @@
@@ -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 |
||||
} |
||||
@ -0,0 +1,80 @@
@@ -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 @@
@@ -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> |
||||
) |
||||
} |
||||
Loading…
Reference in new issue