feat: favorite relays (#250)
This commit is contained in:
parent
fab9ff88b5
commit
c739d9d28c
63 changed files with 1081 additions and 982 deletions
56
src/components/FavoriteRelaysSetting/AddNewRelay.tsx
Normal file
56
src/components/FavoriteRelaysSetting/AddNewRelay.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { normalizeUrl } from '@/lib/url'
|
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function AddNewRelay() {
|
||||
const { t } = useTranslation()
|
||||
const { favoriteRelays, addFavoriteRelays } = useFavoriteRelays()
|
||||
const [input, setInput] = useState('')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
const saveRelay = async () => {
|
||||
if (!input) return
|
||||
const normalizedUrl = normalizeUrl(input)
|
||||
if (!normalizedUrl) {
|
||||
setErrorMsg(t('Invalid URL'))
|
||||
return
|
||||
}
|
||||
if (favoriteRelays.includes(normalizedUrl)) {
|
||||
setErrorMsg(t('Already saved'))
|
||||
return
|
||||
}
|
||||
await addFavoriteRelays([normalizedUrl])
|
||||
setInput('')
|
||||
}
|
||||
|
||||
const handleNewRelayInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInput(e.target.value)
|
||||
setErrorMsg('')
|
||||
}
|
||||
|
||||
const handleNewRelayInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
saveRelay()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
placeholder={t('Add a new relay')}
|
||||
value={input}
|
||||
onChange={handleNewRelayInputChange}
|
||||
onKeyDown={handleNewRelayInputKeyDown}
|
||||
className={errorMsg ? 'border-destructive' : ''}
|
||||
/>
|
||||
<Button onClick={saveRelay}>{t('Add')}</Button>
|
||||
</div>
|
||||
{errorMsg && <div className="text-destructive text-sm pl-8">{errorMsg}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
src/components/FavoriteRelaysSetting/AddNewRelaySet.tsx
Normal file
42
src/components/FavoriteRelaysSetting/AddNewRelaySet.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function AddNewRelaySet() {
|
||||
const { t } = useTranslation()
|
||||
const { addRelaySet } = useFavoriteRelays()
|
||||
const [newRelaySetName, setNewRelaySetName] = useState('')
|
||||
|
||||
const saveRelaySet = () => {
|
||||
if (!newRelaySetName) return
|
||||
addRelaySet(newRelaySetName)
|
||||
setNewRelaySetName('')
|
||||
}
|
||||
|
||||
const handleNewRelaySetNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNewRelaySetName(e.target.value)
|
||||
}
|
||||
|
||||
const handleNewRelaySetNameKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
saveRelaySet()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
placeholder={t('Add a new relay set')}
|
||||
value={newRelaySetName}
|
||||
onChange={handleNewRelaySetNameChange}
|
||||
onKeyDown={handleNewRelaySetNameKeyDown}
|
||||
/>
|
||||
<Button onClick={saveRelaySet}>{t('Add')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
src/components/FavoriteRelaysSetting/RelayItem.tsx
Normal file
19
src/components/FavoriteRelaysSetting/RelayItem.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { toRelay } from '@/lib/link'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import RelayIcon from '../RelayIcon'
|
||||
import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu'
|
||||
|
||||
export default function RelayItem({ relay }: { relay: string }) {
|
||||
const { push } = useSecondaryPage()
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex gap-2 border rounded-lg p-4 items-center clickable select-none"
|
||||
onClick={() => push(toRelay(relay))}
|
||||
>
|
||||
<RelayIcon url={relay} />
|
||||
<div className="flex-1 w-0 truncate font-semibold">{relay}</div>
|
||||
<SaveRelayDropdownMenu urls={[relay]} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
188
src/components/FavoriteRelaysSetting/RelaySet.tsx
Normal file
188
src/components/FavoriteRelaysSetting/RelaySet.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { TRelaySet } from '@/types'
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Edit,
|
||||
EllipsisVertical,
|
||||
FolderClosed,
|
||||
Link,
|
||||
Trash2
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DrawerMenuItem from '../DrawerMenuItem'
|
||||
import RelayUrls from './RelayUrl'
|
||||
import { useRelaySetsSettingComponent } from './provider'
|
||||
|
||||
export default function RelaySet({ relaySet }: { relaySet: TRelaySet }) {
|
||||
const { t } = useTranslation()
|
||||
const { expandedRelaySetId } = useRelaySetsSettingComponent()
|
||||
|
||||
return (
|
||||
<div className="w-full border rounded-lg pl-4 pr-2 py-2.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex justify-center items-center w-6 h-6 shrink-0">
|
||||
<FolderClosed className="size-4" />
|
||||
</div>
|
||||
<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 RelaySetName({ relaySet }: { relaySet: TRelaySet }) {
|
||||
const [newSetName, setNewSetName] = useState(relaySet.name)
|
||||
const { updateRelaySet } = useFavoriteRelays()
|
||||
const { renamingRelaySetId, setRenamingRelaySetId } = 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 select-none">{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 { isSmallScreen } = useScreenSize()
|
||||
const { deleteRelaySet } = useFavoriteRelays()
|
||||
const { setRenamingRelaySetId } = useRelaySetsSettingComponent()
|
||||
|
||||
const trigger = (
|
||||
<Button variant="ghost" size="icon">
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
)
|
||||
|
||||
const rename = () => {
|
||||
setRenamingRelaySetId(relaySet.id)
|
||||
}
|
||||
|
||||
const copyShareLink = () => {
|
||||
navigator.clipboard.writeText(
|
||||
`https://jumble.social/?${relaySet.relayUrls.map((url) => 'r=' + url).join('&')}`
|
||||
)
|
||||
}
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<div className="py-2">
|
||||
<DrawerMenuItem onClick={rename}>
|
||||
<Edit />
|
||||
{t('Rename')}
|
||||
</DrawerMenuItem>
|
||||
<DrawerMenuItem onClick={copyShareLink}>
|
||||
<Link />
|
||||
{t('Copy share link')}
|
||||
</DrawerMenuItem>
|
||||
<DrawerMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => deleteRelaySet(relaySet.id)}
|
||||
>
|
||||
<Trash2 />
|
||||
{t('Delete')}
|
||||
</DrawerMenuItem>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={rename}>
|
||||
<Edit />
|
||||
{t('Rename')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={copyShareLink}>
|
||||
<Link />
|
||||
{t('Copy share link')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => deleteRelaySet(relaySet.id)}
|
||||
>
|
||||
<Trash2 />
|
||||
{t('Delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
97
src/components/FavoriteRelaysSetting/RelayUrl.tsx
Normal file
97
src/components/FavoriteRelaysSetting/RelayUrl.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
|
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
|
||||
import { CircleX } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RelayIcon from '../RelayIcon'
|
||||
|
||||
export default function RelayUrls({ relaySetId }: { relaySetId: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { relaySets, updateRelaySet } = useFavoriteRelays()
|
||||
const [newRelayUrl, setNewRelayUrl] = useState('')
|
||||
const [newRelayUrlError, setNewRelayUrlError] = useState<string | null>(null)
|
||||
const relaySet = useMemo(
|
||||
() => relaySets.find((r) => r.id === relaySetId),
|
||||
[relaySets, relaySetId]
|
||||
)
|
||||
|
||||
if (!relaySet) return null
|
||||
|
||||
const removeRelayUrl = (url: string) => {
|
||||
updateRelaySet({
|
||||
...relaySet,
|
||||
relayUrls: relaySet.relayUrls.filter((u) => u !== url)
|
||||
})
|
||||
}
|
||||
|
||||
const saveNewRelayUrl = () => {
|
||||
if (newRelayUrl === '') return
|
||||
const normalizedUrl = normalizeUrl(newRelayUrl)
|
||||
if (!normalizedUrl) {
|
||||
return setNewRelayUrlError(t('Invalid relay URL'))
|
||||
}
|
||||
if (relaySet.relayUrls.includes(normalizedUrl)) {
|
||||
return setNewRelayUrlError(t('Relay already exists'))
|
||||
}
|
||||
if (!isWebsocketUrl(normalizedUrl)) {
|
||||
return setNewRelayUrlError(t('invalid relay URL'))
|
||||
}
|
||||
const newRelayUrls = [...relaySet.relayUrls, normalizedUrl]
|
||||
updateRelaySet({ ...relaySet, relayUrls: newRelayUrls })
|
||||
setNewRelayUrl('')
|
||||
}
|
||||
|
||||
const handleRelayUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNewRelayUrl(e.target.value)
|
||||
setNewRelayUrlError(null)
|
||||
}
|
||||
|
||||
const handleRelayUrlInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
saveNewRelayUrl()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-1">
|
||||
{relaySet.relayUrls.map((url, index) => (
|
||||
<RelayUrl key={index} url={url} onRemove={() => removeRelayUrl(url)} />
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Input
|
||||
className={newRelayUrlError ? 'border-destructive' : ''}
|
||||
placeholder={t('Add a new relay')}
|
||||
value={newRelayUrl}
|
||||
onKeyDown={handleRelayUrlInputKeyDown}
|
||||
onChange={handleRelayUrlInputChange}
|
||||
onBlur={saveNewRelayUrl}
|
||||
/>
|
||||
<Button onClick={saveNewRelayUrl}>{t('Add')}</Button>
|
||||
</div>
|
||||
{newRelayUrlError && <div className="text-xs text-destructive mt-1">{newRelayUrlError}</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function RelayUrl({ url, onRemove }: { url: string; onRemove: () => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between pl-1 pr-3">
|
||||
<div className="flex gap-3 items-center flex-1 w-0">
|
||||
<RelayIcon url={url} className="w-4 h-4" iconSize={10} />
|
||||
<div className="text-muted-foreground text-sm truncate">{url}</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<CircleX
|
||||
size={16}
|
||||
onClick={onRemove}
|
||||
className="text-muted-foreground hover:text-destructive cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
src/components/FavoriteRelaysSetting/TemporaryRelaySet.tsx
Normal file
28
src/components/FavoriteRelaysSetting/TemporaryRelaySet.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { useFeed } from '@/providers/FeedProvider'
|
||||
import RelayIcon from '../RelayIcon'
|
||||
import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu'
|
||||
|
||||
export default function TemporaryRelaySet() {
|
||||
const { temporaryRelayUrls } = useFeed()
|
||||
|
||||
if (!temporaryRelayUrls.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full border border-dashed rounded-lg p-4 border-highlight bg-highlight/5 flex gap-4 justify-between">
|
||||
<div className="flex-1 w-0">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="h-8 font-semibold">Temporary</div>
|
||||
</div>
|
||||
{temporaryRelayUrls.map((url) => (
|
||||
<div className="flex gap-3 items-center">
|
||||
<RelayIcon url={url} className="w-4 h-4" iconSize={10} />
|
||||
<div className="text-muted-foreground text-sm truncate">{url}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<SaveRelayDropdownMenu urls={temporaryRelayUrls} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
35
src/components/FavoriteRelaysSetting/index.tsx
Normal file
35
src/components/FavoriteRelaysSetting/index.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AddNewRelay from './AddNewRelay'
|
||||
import AddNewRelaySet from './AddNewRelaySet'
|
||||
import { RelaySetsSettingComponentProvider } from './provider'
|
||||
import RelayItem from './RelayItem'
|
||||
import RelaySet from './RelaySet'
|
||||
import TemporaryRelaySet from './TemporaryRelaySet'
|
||||
|
||||
export default function FavoriteRelaysSetting() {
|
||||
const { t } = useTranslation()
|
||||
const { relaySets, favoriteRelays } = useFavoriteRelays()
|
||||
|
||||
return (
|
||||
<RelaySetsSettingComponentProvider>
|
||||
<div className="space-y-4">
|
||||
<TemporaryRelaySet />
|
||||
<div className="space-y-2">
|
||||
<div className="text-muted-foreground font-semibold select-none">{t('Relay sets')}</div>
|
||||
{relaySets.map((relaySet) => (
|
||||
<RelaySet key={relaySet.id} relaySet={relaySet} />
|
||||
))}
|
||||
</div>
|
||||
<AddNewRelaySet />
|
||||
<div className="space-y-2">
|
||||
<div className="text-muted-foreground font-semibold select-none">{t('Relays')}</div>
|
||||
{favoriteRelays.map((relay) => (
|
||||
<RelayItem key={relay} relay={relay} />
|
||||
))}
|
||||
</div>
|
||||
<AddNewRelay />
|
||||
</div>
|
||||
</RelaySetsSettingComponentProvider>
|
||||
)
|
||||
}
|
||||
40
src/components/FavoriteRelaysSetting/provider.tsx
Normal file
40
src/components/FavoriteRelaysSetting/provider.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
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>>
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
return (
|
||||
<RelaySetsSettingComponentContext.Provider
|
||||
value={{
|
||||
renamingRelaySetId,
|
||||
setRenamingRelaySetId,
|
||||
expandedRelaySetId,
|
||||
setExpandedRelaySetId
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</RelaySetsSettingComponentContext.Provider>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue