feat: favorite relays (#250)

This commit is contained in:
Cody Tseng 2025-04-05 15:31:34 +08:00 committed by GitHub
parent fab9ff88b5
commit c739d9d28c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 1081 additions and 982 deletions

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}