feat: share relays

This commit is contained in:
codytseng
2024-11-16 19:59:46 +08:00
parent e7f021d03b
commit 8d4b5985db
6 changed files with 212 additions and 47 deletions

View File

@@ -15,7 +15,9 @@ import { TRelayGroup } from './types'
export default function RelayGroup({ group }: { group: TRelayGroup }) {
const { expandedRelayGroup } = useRelaySettingsComponent()
const { groupName, isActive, relayUrls } = group
const { temporaryRelayUrls } = useRelaySettings()
const { groupName, relayUrls } = group
const isActive = temporaryRelayUrls.length === 0 && group.isActive
return (
<div
@@ -23,14 +25,18 @@ export default function RelayGroup({ group }: { group: TRelayGroup }) {
>
<div className="flex justify-between items-center">
<div className="flex space-x-2 items-center">
<RelayGroupActiveToggle groupName={groupName} />
<RelayGroupActiveToggle
groupName={groupName}
isActive={isActive}
canActive={relayUrls.length > 0}
/>
<RelayGroupName groupName={groupName} />
</div>
<div className="flex gap-1">
<RelayUrlsExpandToggle groupName={groupName}>
{relayUrls.length} relays
</RelayUrlsExpandToggle>
<RelayGroupOptions groupName={groupName} />
<RelayGroupOptions group={group} />
</div>
</div>
{expandedRelayGroup === groupName && <RelayUrls groupName={groupName} />}
@@ -38,20 +44,25 @@ export default function RelayGroup({ group }: { group: TRelayGroup }) {
)
}
function RelayGroupActiveToggle({ groupName }: { groupName: string }) {
const { relayGroups, switchRelayGroup } = useRelaySettings()
const isActive = relayGroups.find((group) => group.groupName === groupName)?.isActive
const hasRelayUrls = relayGroups.find((group) => group.groupName === groupName)?.relayUrls.length
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 ${hasRelayUrls ? 'cursor-pointer hover:text-foreground ' : ''}`}
className={`text-muted-foreground shrink-0 ${canActive ? 'cursor-pointer hover:text-foreground ' : ''}`}
onClick={() => {
if (hasRelayUrls) {
if (canActive) {
switchRelayGroup(groupName)
}
}}
@@ -68,6 +79,9 @@ function RelayGroupName({ groupName }: { groupName: string }) {
const hasRelayUrls = relayGroups.find((group) => group.groupName === groupName)?.relayUrls.length
const saveNewGroupName = () => {
if (relayGroups.find((group) => group.groupName === newGroupName)) {
return setNewNameError('already exists')
}
const errMsg = renameRelayGroup(groupName, newGroupName)
if (errMsg) {
setNewNameError(errMsg)
@@ -138,10 +152,9 @@ function RelayUrlsExpandToggle({
)
}
function RelayGroupOptions({ groupName }: { groupName: string }) {
const { relayGroups, deleteRelayGroup } = useRelaySettings()
function RelayGroupOptions({ group }: { group: TRelayGroup }) {
const { deleteRelayGroup } = useRelaySettings()
const { setRenamingGroup } = useRelaySettingsComponent()
const isActive = relayGroups.find((group) => group.groupName === groupName)?.isActive
return (
<DropdownMenu>
@@ -151,11 +164,21 @@ function RelayGroupOptions({ groupName }: { groupName: string }) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => setRenamingGroup(groupName)}>Rename</DropdownMenuItem>
<DropdownMenuItem onClick={() => setRenamingGroup(group.groupName)}>
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(
`https://jumble.social/?${group.relayUrls.map((url) => 'r=' + url).join('&')}`
)
}}
>
Copy share link
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={isActive}
onClick={() => deleteRelayGroup(groupName)}
onClick={() => deleteRelayGroup(group.groupName)}
>
Delete
</DropdownMenuItem>

View File

@@ -0,0 +1,72 @@
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
import client from '@renderer/services/client.service'
import { Save } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Button } from '../ui/button'
export default function TemporaryRelayGroup() {
const { temporaryRelayUrls, relayGroups, addRelayGroup, switchRelayGroup } = useRelaySettings()
const [relays, setRelays] = useState<
{
url: string
isConnected: boolean
}[]
>(temporaryRelayUrls.map((url) => ({ url, isConnected: false })))
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
}
const handleSave = () => {
const existingTemporaryIndexes = relayGroups
.filter((group) => /^Temporary \d+$/.test(group.groupName))
.map((group) => group.groupName.split(' ')[1])
.map(Number)
.filter((index) => !isNaN(index))
const nextIndex = Math.max(...existingTemporaryIndexes, 0) + 1
const groupName = `Temporary ${nextIndex}`
addRelayGroup(groupName, temporaryRelayUrls)
switchRelayGroup(groupName)
}
return (
<div className={`w-full border border-dashed rounded-lg p-4 border-highlight bg-highlight/5`}>
<div className="flex justify-between items-center">
<div className="h-8 font-semibold">Temporary</div>
<Button title="save" size="icon" variant="ghost" onClick={handleSave}>
<Save />
</Button>
</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>
</div>
</div>
))}
</div>
)
}

View File

@@ -5,6 +5,7 @@ import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
import { useEffect, useRef, useState } from 'react'
import { RelaySettingsComponentProvider } from './provider'
import RelayGroup from './RelayGroup'
import TemporaryRelayGroup from './TemporaryRelayGroup'
export default function RelaySettings() {
const { relayGroups, addRelayGroup } = useRelaySettings()
@@ -19,6 +20,9 @@ export default function RelaySettings() {
}, [])
const saveRelayGroup = () => {
if (relayGroups.find((group) => group.groupName === newGroupName)) {
return setNewNameError('already exists')
}
const errMsg = addRelayGroup(newGroupName)
if (errMsg) {
return setNewNameError(errMsg)
@@ -43,6 +47,7 @@ export default function RelaySettings() {
<div ref={dummyRef} tabIndex={-1} style={{ position: 'absolute', opacity: 0 }}></div>
<div className="text-lg font-semibold mb-4">Relay Settings</div>
<div className="space-y-2">
<TemporaryRelayGroup />
{relayGroups.map((group, index) => (
<RelayGroup key={index} group={group} />
))}
@@ -63,7 +68,7 @@ export default function RelaySettings() {
onKeyDown={handleNewGroupNameKeyDown}
onBlur={saveRelayGroup}
/>
<Button>Add</Button>
<Button onClick={saveRelayGroup}>Add</Button>
</div>
{newNameError && <div className="text-xs text-destructive mt-1">{newNameError}</div>}
</div>

View File

@@ -10,7 +10,10 @@ import { forwardRef, useImperativeHandle, useRef } from 'react'
const PrimaryPageLayout = forwardRef(
(
{ children, titlebarContent }: { children: React.ReactNode; titlebarContent?: React.ReactNode },
{
children,
titlebarContent
}: { children?: React.ReactNode; titlebarContent?: React.ReactNode },
ref
) => {
const scrollAreaRef = useRef<HTMLDivElement>(null)

View File

@@ -1,10 +1,36 @@
import NoteList from '@renderer/components/NoteList'
import RelaySettings from '@renderer/components/RelaySettings'
import { Button } from '@renderer/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import PrimaryPageLayout from '@renderer/layouts/PrimaryPageLayout'
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
export default function NoteListPage() {
const { relayUrls } = useRelaySettings()
if (!relayUrls.length) return null
if (!relayUrls.length) {
return (
<PrimaryPageLayout>
<div className="w-full text-center">
<Popover>
<PopoverTrigger asChild>
<Button title="relay settings" size="lg">
Choose a relay group
</Button>
</PopoverTrigger>
<PopoverContent className="w-96 h-[450px] p-0">
<ScrollArea className="h-full">
<div className="p-4">
<RelaySettings />
</div>
</ScrollArea>
</PopoverContent>
</Popover>
</div>
</PrimaryPageLayout>
)
}
return (
<PrimaryPageLayout>

View File

@@ -1,14 +1,16 @@
import { TRelayGroup } from '@common/types'
import { isWebsocketUrl, normalizeUrl } from '@renderer/lib/url'
import storage from '@renderer/services/storage.service'
import { createContext, useContext, useEffect, useState } from 'react'
type TRelaySettingsContext = {
relayGroups: TRelayGroup[]
temporaryRelayUrls: string[]
relayUrls: string[]
switchRelayGroup: (groupName: string) => void
renameRelayGroup: (oldGroupName: string, newGroupName: string) => string | null
deleteRelayGroup: (groupName: string) => void
addRelayGroup: (groupName: string) => string | null
addRelayGroup: (groupName: string, relayUrls?: string[]) => string | null
updateRelayGroupRelayUrls: (groupName: string, relayUrls: string[]) => void
}
@@ -24,12 +26,31 @@ export const useRelaySettings = () => {
export function RelaySettingsProvider({ children }: { children: React.ReactNode }) {
const [relayGroups, setRelayGroups] = useState<TRelayGroup[]>([])
const [temporaryRelayUrls, setTemporaryRelayUrls] = useState<string[]>([])
const [relayUrls, setRelayUrls] = useState<string[]>(
relayGroups.find((group) => group.isActive)?.relayUrls ?? []
temporaryRelayUrls.length
? temporaryRelayUrls
: (relayGroups.find((group) => group.isActive)?.relayUrls ?? [])
)
useEffect(() => {
const init = async () => {
const searchParams = new URLSearchParams(window.location.search)
const tempRelays = searchParams
.getAll('r')
.filter((url) => isWebsocketUrl(url))
.map((url) => normalizeUrl(url))
if (tempRelays.length) {
setTemporaryRelayUrls(tempRelays)
// remove relay urls from query string
searchParams.delete('r')
const newSearch = searchParams.toString()
window.history.replaceState(
{},
'',
`${window.location.pathname}${newSearch.length ? `?${newSearch}` : ''}`
)
}
const storedGroups = await storage.getRelayGroups()
setRelayGroups(storedGroups)
}
@@ -38,30 +59,39 @@ export function RelaySettingsProvider({ children }: { children: React.ReactNode
}, [])
useEffect(() => {
setRelayUrls(relayGroups.find((group) => group.isActive)?.relayUrls ?? [])
}, [relayGroups])
setRelayUrls(
temporaryRelayUrls.length
? temporaryRelayUrls
: (relayGroups.find((group) => group.isActive)?.relayUrls ?? [])
)
}, [relayGroups, temporaryRelayUrls])
const updateGroups = async (newGroups: TRelayGroup[]) => {
setRelayGroups(newGroups)
const updateGroups = async (fn: (pre: TRelayGroup[]) => TRelayGroup[]) => {
let newGroups = relayGroups
setRelayGroups((pre) => {
newGroups = fn(pre)
return newGroups
})
await storage.setRelayGroups(newGroups)
}
const switchRelayGroup = (groupName: string) => {
updateGroups(
relayGroups.map((group) => ({
updateGroups((pre) =>
pre.map((group) => ({
...group,
isActive: group.groupName === groupName
}))
)
setTemporaryRelayUrls([])
}
const deleteRelayGroup = (groupName: string) => {
updateGroups(relayGroups.filter((group) => group.groupName !== groupName || group.isActive))
updateGroups((pre) => pre.filter((group) => group.groupName !== groupName))
}
const updateRelayGroupRelayUrls = (groupName: string, relayUrls: string[]) => {
updateGroups(
relayGroups.map((group) => ({
updateGroups((pre) =>
pre.map((group) => ({
...group,
relayUrls: group.groupName === groupName ? relayUrls : group.relayUrls
}))
@@ -75,33 +105,38 @@ export function RelaySettingsProvider({ children }: { children: React.ReactNode
if (oldGroupName === newGroupName) {
return null
}
if (relayGroups.some((group) => group.groupName === newGroupName)) {
return 'already exists'
}
updateGroups(
relayGroups.map((group) => ({
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) => {
const addRelayGroup = (groupName: string, relayUrls: string[] = []) => {
if (groupName === '') {
return null
}
if (relayGroups.some((group) => group.groupName === groupName)) {
return 'already exists'
}
updateGroups([
...relayGroups,
{
groupName,
relayUrls: [],
isActive: false
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
}
@@ -109,6 +144,7 @@ export function RelaySettingsProvider({ children }: { children: React.ReactNode
<RelaySettingsContext.Provider
value={{
relayGroups,
temporaryRelayUrls,
relayUrls,
switchRelayGroup,
renameRelayGroup,