38 changed files with 1069 additions and 686 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
import { createContext, useContext, useState } from 'react' |
||||||
|
|
||||||
|
type TRelaySetsSettingComponentContext = { |
||||||
|
renamingRelaySetId: string | null |
||||||
|
setRenamingRelaySetId: React.Dispatch<React.SetStateAction<string | null>> |
||||||
|
expandedRelaySetId: string | null |
||||||
|
setExpandedRelaySetId: React.Dispatch<React.SetStateAction<string | null>> |
||||||
|
selectedRelaySetIds: string[] |
||||||
|
toggleSelectedRelaySetId: (relaySetId: string) => void |
||||||
|
} |
||||||
|
|
||||||
|
export const RelaySetsSettingComponentContext = createContext< |
||||||
|
TRelaySetsSettingComponentContext | undefined |
||||||
|
>(undefined) |
||||||
|
|
||||||
|
export const useRelaySetsSettingComponent = () => { |
||||||
|
const context = useContext(RelaySetsSettingComponentContext) |
||||||
|
if (!context) { |
||||||
|
throw new Error( |
||||||
|
'useRelaySetsSettingComponent must be used within a RelaySetsSettingComponentProvider' |
||||||
|
) |
||||||
|
} |
||||||
|
return context |
||||||
|
} |
||||||
|
|
||||||
|
export function RelaySetsSettingComponentProvider({ children }: { children: React.ReactNode }) { |
||||||
|
const [renamingRelaySetId, setRenamingRelaySetId] = useState<string | null>(null) |
||||||
|
const [expandedRelaySetId, setExpandedRelaySetId] = useState<string | null>(null) |
||||||
|
const [selectedRelaySetIds, setSelectedRelaySetIds] = useState<string[]>([]) |
||||||
|
|
||||||
|
return ( |
||||||
|
<RelaySetsSettingComponentContext.Provider |
||||||
|
value={{ |
||||||
|
renamingRelaySetId, |
||||||
|
setRenamingRelaySetId, |
||||||
|
expandedRelaySetId, |
||||||
|
setExpandedRelaySetId, |
||||||
|
selectedRelaySetIds, |
||||||
|
toggleSelectedRelaySetId: (relaySetId) => { |
||||||
|
setSelectedRelaySetIds((pre) => { |
||||||
|
if (pre.includes(relaySetId)) { |
||||||
|
return pre.filter((id) => id !== relaySetId) |
||||||
|
} |
||||||
|
return [...pre, relaySetId] |
||||||
|
}) |
||||||
|
} |
||||||
|
}} |
||||||
|
> |
||||||
|
{children} |
||||||
|
</RelaySetsSettingComponentContext.Provider> |
||||||
|
) |
||||||
|
} |
||||||
@ -1,195 +0,0 @@ |
|||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { |
|
||||||
DropdownMenu, |
|
||||||
DropdownMenuContent, |
|
||||||
DropdownMenuItem, |
|
||||||
DropdownMenuTrigger |
|
||||||
} from '@/components/ui/dropdown-menu' |
|
||||||
import { Input } from '@/components/ui/input' |
|
||||||
import { useRelaySettings } from '@/providers/RelaySettingsProvider' |
|
||||||
import { Check, ChevronDown, Circle, CircleCheck, EllipsisVertical } from 'lucide-react' |
|
||||||
import { useState } from 'react' |
|
||||||
import RelayUrls from './RelayUrl' |
|
||||||
import { useRelaySettingsComponent } from './provider' |
|
||||||
import { TRelayGroup } from './types' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
|
|
||||||
export default function RelayGroup({ group }: { group: TRelayGroup }) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { expandedRelayGroup } = useRelaySettingsComponent() |
|
||||||
const { temporaryRelayUrls } = useRelaySettings() |
|
||||||
const { groupName, relayUrls } = group |
|
||||||
const isActive = temporaryRelayUrls.length === 0 && group.isActive |
|
||||||
|
|
||||||
return ( |
|
||||||
<div |
|
||||||
className={`w-full border rounded-lg p-4 ${isActive ? 'border-highlight bg-highlight/5' : ''}`} |
|
||||||
> |
|
||||||
<div className="flex justify-between items-center"> |
|
||||||
<div className="flex space-x-2 items-center"> |
|
||||||
<RelayGroupActiveToggle |
|
||||||
groupName={groupName} |
|
||||||
isActive={isActive} |
|
||||||
canActive={relayUrls.length > 0} |
|
||||||
/> |
|
||||||
<RelayGroupName groupName={groupName} /> |
|
||||||
</div> |
|
||||||
<div className="flex gap-1"> |
|
||||||
<RelayUrlsExpandToggle groupName={groupName}> |
|
||||||
{t('n relays', { n: relayUrls.length })} |
|
||||||
</RelayUrlsExpandToggle> |
|
||||||
<RelayGroupOptions group={group} /> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
{expandedRelayGroup === groupName && <RelayUrls groupName={groupName} />} |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
function RelayGroupActiveToggle({ |
|
||||||
groupName, |
|
||||||
isActive, |
|
||||||
canActive |
|
||||||
}: { |
|
||||||
groupName: string |
|
||||||
isActive: boolean |
|
||||||
canActive: boolean |
|
||||||
}) { |
|
||||||
const { switchRelayGroup } = useRelaySettings() |
|
||||||
|
|
||||||
return isActive ? ( |
|
||||||
<CircleCheck size={18} className="text-highlight shrink-0" /> |
|
||||||
) : ( |
|
||||||
<Circle |
|
||||||
size={18} |
|
||||||
className={`text-muted-foreground shrink-0 ${canActive ? 'cursor-pointer hover:text-foreground ' : ''}`} |
|
||||||
onClick={() => { |
|
||||||
if (canActive) { |
|
||||||
switchRelayGroup(groupName) |
|
||||||
} |
|
||||||
}} |
|
||||||
/> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
function RelayGroupName({ groupName }: { groupName: string }) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const [newGroupName, setNewGroupName] = useState(groupName) |
|
||||||
const [newNameError, setNewNameError] = useState<string | null>(null) |
|
||||||
const { relayGroups, switchRelayGroup, renameRelayGroup } = useRelaySettings() |
|
||||||
const { renamingGroup, setRenamingGroup } = useRelaySettingsComponent() |
|
||||||
|
|
||||||
const hasRelayUrls = relayGroups.find((group) => group.groupName === groupName)?.relayUrls.length |
|
||||||
|
|
||||||
const saveNewGroupName = () => { |
|
||||||
if (groupName === newGroupName) { |
|
||||||
return setRenamingGroup(null) |
|
||||||
} |
|
||||||
if (relayGroups.find((group) => group.groupName === newGroupName)) { |
|
||||||
return setNewNameError(t('relay collection name already exists')) |
|
||||||
} |
|
||||||
const errMsg = renameRelayGroup(groupName, newGroupName) |
|
||||||
if (errMsg) { |
|
||||||
setNewNameError(errMsg) |
|
||||||
return |
|
||||||
} |
|
||||||
setRenamingGroup(null) |
|
||||||
} |
|
||||||
|
|
||||||
const handleRenameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
|
||||||
setNewGroupName(e.target.value) |
|
||||||
setNewNameError(null) |
|
||||||
} |
|
||||||
|
|
||||||
const handleRenameInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { |
|
||||||
if (event.key === 'Enter') { |
|
||||||
event.preventDefault() |
|
||||||
saveNewGroupName() |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return renamingGroup === groupName ? ( |
|
||||||
<div className="flex gap-1 items-center"> |
|
||||||
<Input |
|
||||||
value={newGroupName} |
|
||||||
onChange={handleRenameInputChange} |
|
||||||
onBlur={saveNewGroupName} |
|
||||||
onKeyDown={handleRenameInputKeyDown} |
|
||||||
className={`font-semibold w-28 ${newNameError ? 'border-destructive' : ''}`} |
|
||||||
/> |
|
||||||
<Button variant="ghost" size="icon" onClick={saveNewGroupName}> |
|
||||||
<Check size={18} className="text-green-500" /> |
|
||||||
</Button> |
|
||||||
{newNameError && <div className="text-xs text-destructive">{newNameError}</div>} |
|
||||||
</div> |
|
||||||
) : ( |
|
||||||
<div |
|
||||||
className={`h-8 font-semibold flex items-center ${hasRelayUrls ? 'cursor-pointer' : 'text-muted-foreground'}`} |
|
||||||
onClick={() => { |
|
||||||
if (hasRelayUrls) { |
|
||||||
switchRelayGroup(groupName) |
|
||||||
} |
|
||||||
}} |
|
||||||
> |
|
||||||
{groupName} |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
function RelayUrlsExpandToggle({ |
|
||||||
groupName, |
|
||||||
children |
|
||||||
}: { |
|
||||||
groupName: string |
|
||||||
children: React.ReactNode |
|
||||||
}) { |
|
||||||
const { expandedRelayGroup, setExpandedRelayGroup } = useRelaySettingsComponent() |
|
||||||
return ( |
|
||||||
<div |
|
||||||
className="text-sm text-muted-foreground flex items-center gap-1 cursor-pointer hover:text-foreground" |
|
||||||
onClick={() => setExpandedRelayGroup((pre) => (pre === groupName ? null : groupName))} |
|
||||||
> |
|
||||||
<div className="select-none">{children}</div> |
|
||||||
<ChevronDown |
|
||||||
size={16} |
|
||||||
className={`transition-transform duration-200 ${expandedRelayGroup === groupName ? 'rotate-180' : ''}`} |
|
||||||
/> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
function RelayGroupOptions({ group }: { group: TRelayGroup }) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { deleteRelayGroup } = useRelaySettings() |
|
||||||
const { setRenamingGroup } = useRelaySettingsComponent() |
|
||||||
|
|
||||||
return ( |
|
||||||
<DropdownMenu> |
|
||||||
<DropdownMenuTrigger asChild> |
|
||||||
<Button variant="ghost" size="icon"> |
|
||||||
<EllipsisVertical /> |
|
||||||
</Button> |
|
||||||
</DropdownMenuTrigger> |
|
||||||
<DropdownMenuContent> |
|
||||||
<DropdownMenuItem onClick={() => setRenamingGroup(group.groupName)}> |
|
||||||
{t('Rename')} |
|
||||||
</DropdownMenuItem> |
|
||||||
<DropdownMenuItem |
|
||||||
onClick={() => { |
|
||||||
navigator.clipboard.writeText( |
|
||||||
`https://jumble.social/?${group.relayUrls.map((url) => 'r=' + url).join('&')}` |
|
||||||
) |
|
||||||
}} |
|
||||||
> |
|
||||||
{t('Copy share link')} |
|
||||||
</DropdownMenuItem> |
|
||||||
<DropdownMenuItem |
|
||||||
className="text-destructive focus:text-destructive" |
|
||||||
onClick={() => deleteRelayGroup(group.groupName)} |
|
||||||
> |
|
||||||
{t('Delete')} |
|
||||||
</DropdownMenuItem> |
|
||||||
</DropdownMenuContent> |
|
||||||
</DropdownMenu> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,81 +0,0 @@ |
|||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { Input } from '@/components/ui/input' |
|
||||||
import { Separator } from '@/components/ui/separator' |
|
||||||
import { useRelaySettings } from '@/providers/RelaySettingsProvider' |
|
||||||
import { useEffect, useRef, useState } from 'react' |
|
||||||
import { RelaySettingsComponentProvider } from './provider' |
|
||||||
import RelayGroup from './RelayGroup' |
|
||||||
import TemporaryRelayGroup from './TemporaryRelayGroup' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
|
|
||||||
export default function RelaySettings({ hideTitle = false }: { hideTitle?: boolean }) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { relayGroups, addRelayGroup } = useRelaySettings() |
|
||||||
const [newGroupName, setNewGroupName] = useState('') |
|
||||||
const [newNameError, setNewNameError] = useState<string | null>(null) |
|
||||||
const dummyRef = useRef<HTMLDivElement>(null) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
if (dummyRef.current) { |
|
||||||
dummyRef.current.focus() |
|
||||||
} |
|
||||||
}, []) |
|
||||||
|
|
||||||
const saveRelayGroup = () => { |
|
||||||
if (relayGroups.find((group) => group.groupName === newGroupName)) { |
|
||||||
return setNewNameError(t('relay collection name already exists')) |
|
||||||
} |
|
||||||
const errMsg = addRelayGroup(newGroupName) |
|
||||||
if (errMsg) { |
|
||||||
return setNewNameError(errMsg) |
|
||||||
} |
|
||||||
setNewGroupName('') |
|
||||||
} |
|
||||||
|
|
||||||
const handleNewGroupNameChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
|
||||||
setNewGroupName(e.target.value) |
|
||||||
setNewNameError(null) |
|
||||||
} |
|
||||||
|
|
||||||
const handleNewGroupNameKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { |
|
||||||
if (event.key === 'Enter') { |
|
||||||
event.preventDefault() |
|
||||||
saveRelayGroup() |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<RelaySettingsComponentProvider> |
|
||||||
<div ref={dummyRef} tabIndex={-1} style={{ position: 'absolute', opacity: 0 }}></div> |
|
||||||
{!hideTitle && <div className="text-lg font-semibold mb-4">{t('Relay Settings')}</div>} |
|
||||||
<div className="space-y-2"> |
|
||||||
<TemporaryRelayGroup /> |
|
||||||
{relayGroups.map((group, index) => ( |
|
||||||
<RelayGroup key={index} group={group} /> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
{relayGroups.length < 10 && ( |
|
||||||
<> |
|
||||||
<Separator className="my-4" /> |
|
||||||
<div className="w-full border rounded-lg p-4"> |
|
||||||
<div className="flex justify-between items-center"> |
|
||||||
<div className="font-semibold">{t('Add a new relay collection')}</div> |
|
||||||
</div> |
|
||||||
<div className="mt-2 flex gap-2"> |
|
||||||
<Input |
|
||||||
className={newNameError ? 'border-destructive' : ''} |
|
||||||
placeholder={t('Relay collection name')} |
|
||||||
value={newGroupName} |
|
||||||
onChange={handleNewGroupNameChange} |
|
||||||
onKeyDown={handleNewGroupNameKeyDown} |
|
||||||
onBlur={saveRelayGroup} |
|
||||||
/> |
|
||||||
<Button onClick={saveRelayGroup}>{t('Add')}</Button> |
|
||||||
</div> |
|
||||||
{newNameError && <div className="text-xs text-destructive mt-1">{newNameError}</div>} |
|
||||||
</div> |
|
||||||
</> |
|
||||||
)} |
|
||||||
</RelaySettingsComponentProvider> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,40 +0,0 @@ |
|||||||
import { createContext, useContext, useState } from 'react' |
|
||||||
|
|
||||||
type TRelaySettingsComponentContext = { |
|
||||||
renamingGroup: string | null |
|
||||||
setRenamingGroup: React.Dispatch<React.SetStateAction<string | null>> |
|
||||||
expandedRelayGroup: string | null |
|
||||||
setExpandedRelayGroup: React.Dispatch<React.SetStateAction<string | null>> |
|
||||||
} |
|
||||||
|
|
||||||
export const RelaySettingsComponentContext = createContext< |
|
||||||
TRelaySettingsComponentContext | undefined |
|
||||||
>(undefined) |
|
||||||
|
|
||||||
export const useRelaySettingsComponent = () => { |
|
||||||
const context = useContext(RelaySettingsComponentContext) |
|
||||||
if (!context) { |
|
||||||
throw new Error( |
|
||||||
'useRelaySettingsComponent must be used within a RelaySettingsComponentProvider' |
|
||||||
) |
|
||||||
} |
|
||||||
return context |
|
||||||
} |
|
||||||
|
|
||||||
export function RelaySettingsComponentProvider({ children }: { children: React.ReactNode }) { |
|
||||||
const [renamingGroup, setRenamingGroup] = useState<string | null>(null) |
|
||||||
const [expandedRelayGroup, setExpandedRelayGroup] = useState<string | null>(null) |
|
||||||
|
|
||||||
return ( |
|
||||||
<RelaySettingsComponentContext.Provider |
|
||||||
value={{ |
|
||||||
renamingGroup, |
|
||||||
setRenamingGroup, |
|
||||||
expandedRelayGroup, |
|
||||||
setExpandedRelayGroup |
|
||||||
}} |
|
||||||
> |
|
||||||
{children} |
|
||||||
</RelaySettingsComponentContext.Provider> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,5 +0,0 @@ |
|||||||
export type TRelayGroup = { |
|
||||||
groupName: string |
|
||||||
relayUrls: string[] |
|
||||||
isActive: boolean |
|
||||||
} |
|
||||||
@ -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 @@ |
|||||||
|
import { randomString } from '@/lib/random' |
||||||
|
import { isWebsocketUrl, normalizeUrl } from '@/lib/url' |
||||||
|
import storage from '@/services/storage.service' |
||||||
|
import { TRelaySet } from '@/types' |
||||||
|
import { createContext, useContext, useEffect, useState } from 'react' |
||||||
|
|
||||||
|
type TRelaySetsContext = { |
||||||
|
relaySets: TRelaySet[] |
||||||
|
addRelaySet: (relaySetName: string, relayUrls?: string[]) => string |
||||||
|
deleteRelaySet: (id: string) => void |
||||||
|
updateRelaySet: (newSet: TRelaySet) => void |
||||||
|
mergeRelaySets: (newSets: TRelaySet[]) => void |
||||||
|
} |
||||||
|
|
||||||
|
const RelaySetsContext = createContext<TRelaySetsContext | undefined>(undefined) |
||||||
|
|
||||||
|
export const useRelaySets = () => { |
||||||
|
const context = useContext(RelaySetsContext) |
||||||
|
if (!context) { |
||||||
|
throw new Error('useRelaySets must be used within a RelaySetsProvider') |
||||||
|
} |
||||||
|
return context |
||||||
|
} |
||||||
|
|
||||||
|
export function RelaySetsProvider({ children }: { children: React.ReactNode }) { |
||||||
|
const [relaySets, setRelaySets] = useState<TRelaySet[]>(() => storage.getRelaySets()) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
storage.setRelaySets(relaySets) |
||||||
|
}, [relaySets]) |
||||||
|
|
||||||
|
const deleteRelaySet = (id: string) => { |
||||||
|
setRelaySets((pre) => pre.filter((set) => set.id !== id)) |
||||||
|
} |
||||||
|
|
||||||
|
const updateRelaySet = (newSet: TRelaySet) => { |
||||||
|
setRelaySets((pre) => { |
||||||
|
return pre.map((set) => (set.id === newSet.id ? newSet : set)) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
const addRelaySet = (relaySetName: string, relayUrls: string[] = []) => { |
||||||
|
const normalizedUrls = relayUrls |
||||||
|
.filter((url) => isWebsocketUrl(url)) |
||||||
|
.map((url) => normalizeUrl(url)) |
||||||
|
const id = randomString() |
||||||
|
setRelaySets((pre) => { |
||||||
|
return [ |
||||||
|
...pre, |
||||||
|
{ |
||||||
|
id, |
||||||
|
name: relaySetName, |
||||||
|
relayUrls: normalizedUrls |
||||||
|
} |
||||||
|
] |
||||||
|
}) |
||||||
|
return id |
||||||
|
} |
||||||
|
|
||||||
|
const mergeRelaySets = (newSets: TRelaySet[]) => { |
||||||
|
setRelaySets((pre) => { |
||||||
|
const newIds = newSets.map((set) => set.id) |
||||||
|
return pre.filter((set) => !newIds.includes(set.id)).concat(newSets) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<RelaySetsContext.Provider |
||||||
|
value={{ |
||||||
|
relaySets: relaySets, |
||||||
|
addRelaySet, |
||||||
|
deleteRelaySet, |
||||||
|
updateRelaySet, |
||||||
|
mergeRelaySets |
||||||
|
}} |
||||||
|
> |
||||||
|
{children} |
||||||
|
</RelaySetsContext.Provider> |
||||||
|
) |
||||||
|
} |
||||||
@ -1,178 +0,0 @@ |
|||||||
import { SEARCHABLE_RELAY_URLS } from '@/constants' |
|
||||||
import { checkAlgoRelay, checkSearchRelay } from '@/lib/relay' |
|
||||||
import { isWebsocketUrl, normalizeUrl } from '@/lib/url' |
|
||||||
import client from '@/services/client.service' |
|
||||||
import storage from '@/services/storage.service' |
|
||||||
import { TRelayGroup } from '@/types' |
|
||||||
import { createContext, Dispatch, useContext, useEffect, useState } from 'react' |
|
||||||
import { useFeed } from './FeedProvider' |
|
||||||
|
|
||||||
type TRelaySettingsContext = { |
|
||||||
relayGroups: TRelayGroup[] |
|
||||||
temporaryRelayUrls: string[] |
|
||||||
relayUrls: string[] |
|
||||||
searchableRelayUrls: string[] |
|
||||||
areAlgoRelays: boolean |
|
||||||
switchRelayGroup: (groupName: string) => void |
|
||||||
renameRelayGroup: (oldGroupName: string, newGroupName: string) => string | null |
|
||||||
deleteRelayGroup: (groupName: string) => void |
|
||||||
addRelayGroup: (groupName: string, relayUrls?: string[]) => string | null |
|
||||||
updateRelayGroupRelayUrls: (groupName: string, relayUrls: string[]) => void |
|
||||||
setTemporaryRelayUrls: Dispatch<string[]> |
|
||||||
} |
|
||||||
|
|
||||||
const RelaySettingsContext = createContext<TRelaySettingsContext | undefined>(undefined) |
|
||||||
|
|
||||||
export const useRelaySettings = () => { |
|
||||||
const context = useContext(RelaySettingsContext) |
|
||||||
if (!context) { |
|
||||||
throw new Error('useRelaySettings must be used within a RelaySettingsProvider') |
|
||||||
} |
|
||||||
return context |
|
||||||
} |
|
||||||
|
|
||||||
export function RelaySettingsProvider({ children }: { children: React.ReactNode }) { |
|
||||||
const { setFeedType } = useFeed() |
|
||||||
const [relayGroups, setRelayGroups] = useState<TRelayGroup[]>([]) |
|
||||||
const [temporaryRelayUrls, setTemporaryRelayUrls] = useState<string[]>([]) |
|
||||||
const [relayUrls, setRelayUrls] = useState<string[]>( |
|
||||||
temporaryRelayUrls.length |
|
||||||
? temporaryRelayUrls |
|
||||||
: (relayGroups.find((group) => group.isActive)?.relayUrls ?? []) |
|
||||||
) |
|
||||||
const [searchableRelayUrls, setSearchableRelayUrls] = useState<string[]>(SEARCHABLE_RELAY_URLS) |
|
||||||
const [areAlgoRelays, setAreAlgoRelays] = useState(false) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
const searchParams = new URLSearchParams(window.location.search) |
|
||||||
const tempRelays = searchParams |
|
||||||
.getAll('r') |
|
||||||
.map((url) => (url.startsWith('wss://') || url.startsWith('ws://') ? url : `wss://${url}`)) |
|
||||||
.filter((url) => isWebsocketUrl(url)) |
|
||||||
.map((url) => normalizeUrl(url)) |
|
||||||
if (tempRelays.length) { |
|
||||||
setTemporaryRelayUrls(tempRelays) |
|
||||||
setFeedType('relays') |
|
||||||
} |
|
||||||
const storedGroups = storage.getRelayGroups() |
|
||||||
setRelayGroups(storedGroups) |
|
||||||
}, []) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
const handler = async () => { |
|
||||||
const newRelayUrls = temporaryRelayUrls.length |
|
||||||
? temporaryRelayUrls |
|
||||||
: (relayGroups.find((group) => group.isActive)?.relayUrls ?? []) |
|
||||||
|
|
||||||
if (JSON.stringify(relayUrls) !== JSON.stringify(newRelayUrls)) { |
|
||||||
setRelayUrls(newRelayUrls) |
|
||||||
} |
|
||||||
const relayInfos = await client.fetchRelayInfos(newRelayUrls) |
|
||||||
const searchableRelayUrls = newRelayUrls.filter((_, index) => |
|
||||||
checkSearchRelay(relayInfos[index]) |
|
||||||
) |
|
||||||
setSearchableRelayUrls( |
|
||||||
searchableRelayUrls.length ? searchableRelayUrls : SEARCHABLE_RELAY_URLS |
|
||||||
) |
|
||||||
const nonAlgoRelayUrls = newRelayUrls.filter((_, index) => !checkAlgoRelay(relayInfos[index])) |
|
||||||
setAreAlgoRelays(newRelayUrls.length > 0 && nonAlgoRelayUrls.length === 0) |
|
||||||
client.setCurrentRelayUrls(nonAlgoRelayUrls) |
|
||||||
} |
|
||||||
handler() |
|
||||||
}, [relayGroups, temporaryRelayUrls, relayUrls]) |
|
||||||
|
|
||||||
const updateGroups = (fn: (pre: TRelayGroup[]) => TRelayGroup[]) => { |
|
||||||
let newGroups = relayGroups |
|
||||||
setRelayGroups((pre) => { |
|
||||||
newGroups = fn(pre) |
|
||||||
return newGroups |
|
||||||
}) |
|
||||||
storage.setRelayGroups(newGroups) |
|
||||||
} |
|
||||||
|
|
||||||
const switchRelayGroup = (groupName: string) => { |
|
||||||
updateGroups((pre) => |
|
||||||
pre.map((group) => ({ |
|
||||||
...group, |
|
||||||
isActive: group.groupName === groupName |
|
||||||
})) |
|
||||||
) |
|
||||||
setFeedType('relays') |
|
||||||
setTemporaryRelayUrls([]) |
|
||||||
} |
|
||||||
|
|
||||||
const deleteRelayGroup = (groupName: string) => { |
|
||||||
updateGroups((pre) => pre.filter((group) => group.groupName !== groupName)) |
|
||||||
} |
|
||||||
|
|
||||||
const updateRelayGroupRelayUrls = (groupName: string, relayUrls: string[]) => { |
|
||||||
updateGroups((pre) => |
|
||||||
pre.map((group) => ({ |
|
||||||
...group, |
|
||||||
relayUrls: group.groupName === groupName ? relayUrls : group.relayUrls |
|
||||||
})) |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
const renameRelayGroup = (oldGroupName: string, newGroupName: string) => { |
|
||||||
if (newGroupName === '') { |
|
||||||
return null |
|
||||||
} |
|
||||||
if (oldGroupName === newGroupName) { |
|
||||||
return null |
|
||||||
} |
|
||||||
updateGroups((pre) => { |
|
||||||
if (pre.some((group) => group.groupName === newGroupName)) { |
|
||||||
return pre |
|
||||||
} |
|
||||||
return pre.map((group) => ({ |
|
||||||
...group, |
|
||||||
groupName: group.groupName === oldGroupName ? newGroupName : group.groupName |
|
||||||
})) |
|
||||||
}) |
|
||||||
return null |
|
||||||
} |
|
||||||
|
|
||||||
const addRelayGroup = (groupName: string, relayUrls: string[] = []) => { |
|
||||||
if (groupName === '') { |
|
||||||
return null |
|
||||||
} |
|
||||||
const normalizedUrls = relayUrls |
|
||||||
.filter((url) => isWebsocketUrl(url)) |
|
||||||
.map((url) => normalizeUrl(url)) |
|
||||||
updateGroups((pre) => { |
|
||||||
if (pre.some((group) => group.groupName === groupName)) { |
|
||||||
return pre |
|
||||||
} |
|
||||||
return [ |
|
||||||
...pre, |
|
||||||
{ |
|
||||||
groupName, |
|
||||||
relayUrls: normalizedUrls, |
|
||||||
isActive: false |
|
||||||
} |
|
||||||
] |
|
||||||
}) |
|
||||||
return null |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<RelaySettingsContext.Provider |
|
||||||
value={{ |
|
||||||
relayGroups, |
|
||||||
temporaryRelayUrls, |
|
||||||
relayUrls, |
|
||||||
searchableRelayUrls, |
|
||||||
areAlgoRelays, |
|
||||||
switchRelayGroup, |
|
||||||
renameRelayGroup, |
|
||||||
deleteRelayGroup, |
|
||||||
addRelayGroup, |
|
||||||
updateRelayGroupRelayUrls, |
|
||||||
setTemporaryRelayUrls |
|
||||||
}} |
|
||||||
> |
|
||||||
{children} |
|
||||||
</RelaySettingsContext.Provider> |
|
||||||
) |
|
||||||
} |
|
||||||
Loading…
Reference in new issue