feat: favorite relays (#250)
This commit is contained in:
parent
fab9ff88b5
commit
c739d9d28c
63 changed files with 1081 additions and 982 deletions
25
src/components/DrawerMenuItem/index.tsx
Normal file
25
src/components/DrawerMenuItem/index.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { DrawerClose } from '@/components/ui/drawer'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function DrawerMenuItem({
|
||||
children,
|
||||
className,
|
||||
onClick
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
onClick?: (e: React.MouseEvent) => void
|
||||
}) {
|
||||
return (
|
||||
<DrawerClose className="w-full">
|
||||
<Button
|
||||
onClick={onClick}
|
||||
className={cn('w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5', className)}
|
||||
variant="ghost"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -6,29 +7,35 @@ import {
|
|||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useRelaySets } from '@/providers/RelaySetsProvider'
|
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { TRelaySet } from '@/types'
|
||||
import { Check, ChevronDown, Circle, CircleCheck, EllipsisVertical } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
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, selectedRelaySetIds } = useRelaySetsSettingComponent()
|
||||
const isSelected = useMemo(
|
||||
() => selectedRelaySetIds.includes(relaySet.id),
|
||||
[selectedRelaySetIds, relaySet.id]
|
||||
)
|
||||
const { expandedRelaySetId } = useRelaySetsSettingComponent()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full border rounded-lg p-4 ${isSelected ? 'border-highlight bg-highlight/5' : ''}`}
|
||||
>
|
||||
<div className="w-full border rounded-lg pl-4 pr-2 py-2.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex space-x-2 items-center">
|
||||
<RelaySetActiveToggle relaySetId={relaySet.id} />
|
||||
<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">
|
||||
|
|
@ -43,37 +50,10 @@ export default function RelaySet({ relaySet }: { relaySet: TRelaySet }) {
|
|||
)
|
||||
}
|
||||
|
||||
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 { updateRelaySet } = useFavoriteRelays()
|
||||
const { renamingRelaySetId, setRenamingRelaySetId } = useRelaySetsSettingComponent()
|
||||
|
||||
const saveNewRelaySetName = () => {
|
||||
if (relaySet.name === newSetName) {
|
||||
|
|
@ -108,12 +88,7 @@ function RelaySetName({ relaySet }: { relaySet: TRelaySet }) {
|
|||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="h-8 font-semibold flex items-center cursor-pointer select-none"
|
||||
onClick={() => toggleSelectedRelaySetId(relaySet.id)}
|
||||
>
|
||||
{relaySet.name}
|
||||
</div>
|
||||
<div className="h-8 font-semibold flex items-center select-none">{relaySet.name}</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -141,33 +116,70 @@ function RelayUrlsExpandToggle({
|
|||
|
||||
function RelaySetOptions({ relaySet }: { relaySet: TRelaySet }) {
|
||||
const { t } = useTranslation()
|
||||
const { deleteRelaySet } = useRelaySets()
|
||||
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>
|
||||
<Button variant="ghost" size="icon">
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => setRenamingRelaySetId(relaySet.id)}>
|
||||
<DropdownMenuItem onClick={rename}>
|
||||
<Edit />
|
||||
{t('Rename')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`https://jumble.social/?${relaySet.relayUrls.map((url) => 'r=' + url).join('&')}`
|
||||
)
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useFetchRelayInfo } from '@/hooks'
|
||||
import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
|
||||
import { useRelaySets } from '@/providers/RelaySetsProvider'
|
||||
import { CircleX, SearchCheck } from 'lucide-react'
|
||||
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 } = useRelaySets()
|
||||
const { relaySets, updateRelaySet } = useFavoriteRelays()
|
||||
const [newRelayUrl, setNewRelayUrl] = useState('')
|
||||
const [newRelayUrlError, setNewRelayUrlError] = useState<string | null>(null)
|
||||
const relaySet = useMemo(
|
||||
|
|
@ -79,20 +79,13 @@ export default function RelayUrls({ relaySetId }: { relaySetId: string }) {
|
|||
}
|
||||
|
||||
function RelayUrl({ url, onRemove }: { url: string; onRemove: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const { relayInfo } = useFetchRelayInfo(url)
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="text-muted-foreground text-sm">{url}</div>
|
||||
{relayInfo?.supported_nips?.includes(50) && (
|
||||
<div title={t('supports search')} className="text-highlight">
|
||||
<SearchCheck size={14} />
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
<div className="shrink-0">
|
||||
<CircleX
|
||||
size={16}
|
||||
onClick={onRemove}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -5,8 +5,6 @@ type TRelaySetsSettingComponentContext = {
|
|||
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<
|
||||
|
|
@ -26,7 +24,6 @@ export const useRelaySetsSettingComponent = () => {
|
|||
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
|
||||
|
|
@ -34,16 +31,7 @@ export function RelaySetsSettingComponentProvider({ children }: { children: Reac
|
|||
renamingRelaySetId,
|
||||
setRenamingRelaySetId,
|
||||
expandedRelaySetId,
|
||||
setExpandedRelaySetId,
|
||||
selectedRelaySetIds,
|
||||
toggleSelectedRelaySetId: (relaySetId) => {
|
||||
setSelectedRelaySetIds((pre) => {
|
||||
if (pre.includes(relaySetId)) {
|
||||
return pre.filter((id) => id !== relaySetId)
|
||||
}
|
||||
return [...pre, relaySetId]
|
||||
})
|
||||
}
|
||||
setExpandedRelaySetId
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
@ -1,67 +1,71 @@
|
|||
import { toRelaySettings } from '@/lib/link'
|
||||
import { simplifyUrl } from '@/lib/url'
|
||||
import { SecondaryPageLink } from '@/PageManager'
|
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
|
||||
import { useFeed } from '@/providers/FeedProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useRelaySets } from '@/providers/RelaySetsProvider'
|
||||
import { Circle, CircleCheck } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RelayIcon from '../RelayIcon'
|
||||
import RelaySetCard from '../RelaySetCard'
|
||||
import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu'
|
||||
import { UsersRound } from 'lucide-react'
|
||||
|
||||
export default function FeedSwitcher({ close }: { close?: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const { feedType, switchFeed, activeRelaySetId, temporaryRelayUrls } = useFeed()
|
||||
const { pubkey } = useNostr()
|
||||
const { relaySets } = useRelaySets()
|
||||
const { relaySets, favoriteRelays } = useFavoriteRelays()
|
||||
const { feedInfo, switchFeed, temporaryRelayUrls } = useFeed()
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{pubkey && (
|
||||
<FeedSwitcherItem
|
||||
itemName={t('Following')}
|
||||
isActive={feedType === 'following'}
|
||||
isActive={feedInfo.feedType === 'following'}
|
||||
onClick={() => {
|
||||
if (!pubkey) return
|
||||
switchFeed('following', { pubkey })
|
||||
close?.()
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex justify-center items-center w-6 h-6 shrink-0">
|
||||
<UsersRound className="size-4" />
|
||||
</div>
|
||||
<div>{t('Following')}</div>
|
||||
</div>
|
||||
</FeedSwitcherItem>
|
||||
)}
|
||||
{temporaryRelayUrls.length > 0 && (
|
||||
<FeedSwitcherItem
|
||||
key="temporary"
|
||||
isActive={feedInfo.feedType === 'temporary'}
|
||||
temporary
|
||||
onClick={() => {
|
||||
switchFeed('temporary')
|
||||
close?.()
|
||||
}}
|
||||
controls={<SaveRelayDropdownMenu urls={temporaryRelayUrls} />}
|
||||
>
|
||||
{temporaryRelayUrls.length === 1 ? simplifyUrl(temporaryRelayUrls[0]) : t('Temporary')}
|
||||
</FeedSwitcherItem>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between px-2">
|
||||
<div className="text-muted-foreground text-sm font-semibold">{t('relay sets')}</div>
|
||||
<div className="flex justify-end items-center text-sm">
|
||||
<SecondaryPageLink
|
||||
to={toRelaySettings()}
|
||||
className="text-highlight text-sm font-semibold"
|
||||
className="text-highlight font-semibold"
|
||||
onClick={() => close?.()}
|
||||
>
|
||||
{t('edit')}
|
||||
</SecondaryPageLink>
|
||||
</div>
|
||||
{temporaryRelayUrls.length > 0 && (
|
||||
<FeedSwitcherItem
|
||||
key="temporary"
|
||||
itemName={
|
||||
temporaryRelayUrls.length === 1 ? simplifyUrl(temporaryRelayUrls[0]) : t('Temporary')
|
||||
}
|
||||
isActive={feedType === 'temporary'}
|
||||
temporary
|
||||
onClick={() => {
|
||||
switchFeed('temporary')
|
||||
close?.()
|
||||
}}
|
||||
controls={<SaveRelayDropdownMenu urls={temporaryRelayUrls} />}
|
||||
/>
|
||||
)}
|
||||
{relaySets
|
||||
.filter((set) => set.relayUrls.length > 0)
|
||||
.map((set) => (
|
||||
<RelaySetCard
|
||||
key={set.id}
|
||||
relaySet={set}
|
||||
select={feedType === 'relays' && set.id === activeRelaySetId}
|
||||
showConnectionStatus={feedType === 'relays' && set.id === activeRelaySetId}
|
||||
select={feedInfo.feedType === 'relays' && set.id === feedInfo.id}
|
||||
onSelectChange={(select) => {
|
||||
if (!select) return
|
||||
switchFeed('relays', { activeRelaySetId: set.id })
|
||||
|
|
@ -69,19 +73,34 @@ export default function FeedSwitcher({ close }: { close?: () => void }) {
|
|||
}}
|
||||
/>
|
||||
))}
|
||||
{favoriteRelays.map((relay) => (
|
||||
<FeedSwitcherItem
|
||||
key={relay}
|
||||
isActive={feedInfo.feedType === 'relay' && feedInfo.id === relay}
|
||||
onClick={() => {
|
||||
switchFeed('relay', { relay })
|
||||
close?.()
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-2 items-center w-full">
|
||||
<RelayIcon url={relay} />
|
||||
<div className="flex-1 w-0 truncate">{simplifyUrl(relay)}</div>
|
||||
</div>
|
||||
</FeedSwitcherItem>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedSwitcherItem({
|
||||
itemName,
|
||||
children,
|
||||
isActive,
|
||||
temporary = false,
|
||||
onClick,
|
||||
controls
|
||||
}: {
|
||||
itemName: string
|
||||
children: React.ReactNode
|
||||
isActive: boolean
|
||||
temporary?: boolean
|
||||
onClick: () => void
|
||||
|
|
@ -93,20 +112,9 @@ function FeedSwitcherItem({
|
|||
onClick={onClick}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2 items-center">
|
||||
<FeedToggle isActive={isActive} />
|
||||
<div className="font-semibold">{itemName}</div>
|
||||
</div>
|
||||
<div className="font-semibold flex-1">{children}</div>
|
||||
{controls}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedToggle({ isActive }: { isActive: boolean }) {
|
||||
return isActive ? (
|
||||
<CircleCheck size={18} className="text-highlight shrink-0" />
|
||||
) : (
|
||||
<Circle size={18} className="text-muted-foreground shrink-0" />
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Separator } from '@/components/ui/separator'
|
||||
import { BIG_RELAY_URLS, COMMENT_EVENT_KIND } from '@/constants'
|
||||
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
||||
import { isCommentEvent, isProtectedEvent } from '@/lib/event'
|
||||
import { tagNameEquals } from '@/lib/tag'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -71,7 +71,7 @@ export default function Nip22ReplyNoteList({
|
|||
relayUrls.slice(0, 4),
|
||||
{
|
||||
'#E': [event.id],
|
||||
kinds: [COMMENT_EVENT_KIND],
|
||||
kinds: [ExtendedKind.COMMENT],
|
||||
limit: LIMIT
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { GROUP_METADATA_EVENT_KIND } from '@/constants'
|
||||
import { ExtendedKind } from '@/constants'
|
||||
import { isSupportedKind } from '@/lib/event'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
|
|
@ -58,7 +58,7 @@ export default function GenericNoteCard({
|
|||
<LiveEventCard event={event} className={className} reposter={reposter} embedded={embedded} />
|
||||
)
|
||||
}
|
||||
if (event.kind === GROUP_METADATA_EVENT_KIND) {
|
||||
if (event.kind === ExtendedKind.GROUP_METADATA) {
|
||||
return (
|
||||
<GroupMetadataCard
|
||||
className={className}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { PICTURE_EVENT_KIND } from '@/constants'
|
||||
import { ExtendedKind } from '@/constants'
|
||||
import { isReplyNoteEvent } from '@/lib/event'
|
||||
import { checkAlgoRelay } from '@/lib/relay'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -53,7 +53,7 @@ export default function NoteList({
|
|||
const isPictures = useMemo(() => listMode === 'pictures', [listMode])
|
||||
const noteFilter = useMemo(() => {
|
||||
return {
|
||||
kinds: isPictures ? [PICTURE_EVENT_KIND] : [kinds.ShortTextNote, kinds.Repost],
|
||||
kinds: isPictures ? [ExtendedKind.PICTURE] : [kinds.ShortTextNote, kinds.Repost],
|
||||
...filter
|
||||
}
|
||||
}, [JSON.stringify(filter), isPictures])
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export default function NoteOptions({ event, className }: { event: Event; classN
|
|||
<div className={className} onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent collisionPadding={8} className="min-w-52">
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(getSharableEventId(event))}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ export default function RepostButton({ event }: { event: Event }) {
|
|||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="min-w-44">
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export default function SeenOnButton({ event }: { event: Event }) {
|
|||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent collisionPadding={8}>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>{t('Seen on')}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{relays.map((relay) => (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { PICTURE_EVENT_KIND } from '@/constants'
|
||||
import { ExtendedKind } from '@/constants'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { tagNameEquals } from '@/lib/tag'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -24,7 +24,7 @@ export function CommentNotification({
|
|||
!rootEventId ||
|
||||
!rootPubkey ||
|
||||
!rootKind ||
|
||||
![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(parseInt(rootKind))
|
||||
![kinds.ShortTextNote, ExtendedKind.PICTURE].includes(parseInt(rootKind))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import Image from '@/components/Image'
|
||||
import { PICTURE_EVENT_KIND } from '@/constants'
|
||||
import { ExtendedKind } from '@/constants'
|
||||
import { useFetchEvent } from '@/hooks'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { extractEmojiFromEventTags, tagNameEquals } from '@/lib/tag'
|
||||
|
|
@ -53,7 +53,7 @@ export function ReactionNotification({
|
|||
return notification.content
|
||||
}, [notification])
|
||||
|
||||
if (!event || !eventId || ![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(event.kind)) {
|
||||
if (!event || !eventId || ![kinds.ShortTextNote, ExtendedKind.PICTURE].includes(event.kind)) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { COMMENT_EVENT_KIND } from '@/constants'
|
||||
import { ExtendedKind } from '@/constants'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { CommentNotification } from './CommentNotification'
|
||||
|
|
@ -30,7 +30,7 @@ export function NotificationItem({
|
|||
if (notification.kind === kinds.Zap) {
|
||||
return <ZapNotification notification={notification} isNew={isNew} />
|
||||
}
|
||||
if (notification.kind === COMMENT_EVENT_KIND) {
|
||||
if (notification.kind === ExtendedKind.COMMENT) {
|
||||
return <CommentNotification notification={notification} isNew={isNew} />
|
||||
}
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Separator } from '@/components/ui/separator'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { BIG_RELAY_URLS, COMMENT_EVENT_KIND } from '@/constants'
|
||||
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
|
|
@ -44,13 +44,13 @@ const NotificationList = forwardRef((_, ref) => {
|
|||
const filterKinds = useMemo(() => {
|
||||
switch (notificationType) {
|
||||
case 'mentions':
|
||||
return [kinds.ShortTextNote, COMMENT_EVENT_KIND]
|
||||
return [kinds.ShortTextNote, ExtendedKind.COMMENT]
|
||||
case 'reactions':
|
||||
return [kinds.Reaction, kinds.Repost]
|
||||
case 'zaps':
|
||||
return [kinds.Zap]
|
||||
default:
|
||||
return [kinds.ShortTextNote, kinds.Repost, kinds.Reaction, kinds.Zap, COMMENT_EVENT_KIND]
|
||||
return [kinds.ShortTextNote, kinds.Repost, kinds.Reaction, kinds.Zap, ExtendedKind.COMMENT]
|
||||
}
|
||||
}, [notificationType])
|
||||
useImperativeHandle(
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export default function ProfileOptions({ pubkey }: { pubkey: string }) {
|
|||
<Ellipsis />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent collisionPadding={8}>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText('nostr:' + pubkeyToNpub(pubkey))}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { useFetchRelayInfo } from '@/hooks'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Server } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
export default function RelayIcon({
|
||||
url,
|
||||
className = 'w-6 h-6',
|
||||
className,
|
||||
iconSize = 14
|
||||
}: {
|
||||
url?: string
|
||||
|
|
@ -23,7 +24,7 @@ export default function RelayIcon({
|
|||
}, [url, relayInfo])
|
||||
|
||||
return (
|
||||
<Avatar className={className}>
|
||||
<Avatar className={cn('w-6 h-6', className)}>
|
||||
<AvatarImage src={iconUrl} className="object-cover object-center" />
|
||||
<AvatarFallback>
|
||||
<Server size={iconSize} />
|
||||
|
|
|
|||
|
|
@ -1,33 +1,31 @@
|
|||
import client from '@/services/client.service'
|
||||
import { TRelaySet } from '@/types'
|
||||
import { ChevronDown, Circle, CircleCheck } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ChevronDown, FolderClosed } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RelayIcon from '../RelayIcon'
|
||||
|
||||
export default function RelaySetCard({
|
||||
relaySet,
|
||||
select,
|
||||
onSelectChange,
|
||||
showConnectionStatus = false
|
||||
onSelectChange
|
||||
}: {
|
||||
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' : ''}`}
|
||||
className={`w-full border rounded-lg p-4 ${select ? 'border-highlight bg-highlight/5' : 'clickable'}`}
|
||||
onClick={() => onSelectChange(!select)}
|
||||
>
|
||||
<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="flex space-x-2 items-center cursor-pointer">
|
||||
<div className="flex justify-center items-center w-6 h-6 shrink-0">
|
||||
<FolderClosed className="size-4" />
|
||||
</div>
|
||||
<div className="h-8 font-semibold flex items-center select-none">{relaySet.name}</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
|
|
@ -36,21 +34,11 @@ export default function RelaySetCard({
|
|||
</RelayUrlsExpandToggle>
|
||||
</div>
|
||||
</div>
|
||||
{expand && (
|
||||
<RelayUrls urls={relaySet.relayUrls} showConnectionStatus={showConnectionStatus} />
|
||||
)}
|
||||
{expand && <RelayUrls urls={relaySet.relayUrls} />}
|
||||
</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,
|
||||
|
|
@ -63,7 +51,10 @@ function RelayUrlsExpandToggle({
|
|||
return (
|
||||
<div
|
||||
className="text-sm text-muted-foreground flex items-center gap-1 cursor-pointer hover:text-foreground"
|
||||
onClick={() => onExpandChange(!expand)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onExpandChange(!expand)
|
||||
}}
|
||||
>
|
||||
<div className="select-none">{children}</div>
|
||||
<ChevronDown
|
||||
|
|
@ -74,49 +65,15 @@ function RelayUrlsExpandToggle({
|
|||
)
|
||||
}
|
||||
|
||||
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])
|
||||
|
||||
function RelayUrls({ urls }: { urls: string[] }) {
|
||||
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 className="pl-8 space-y-1">
|
||||
{urls.map((url) => (
|
||||
<div key={url} className="flex items-center gap-2">
|
||||
<RelayIcon url={url} className="w-4 h-4" iconSize={10} />
|
||||
<div className="text-muted-foreground text-sm truncate">{url}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,174 +0,0 @@
|
|||
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 { useRelaySets } from '@/providers/RelaySetsProvider'
|
||||
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 { useTranslation } from 'react-i18next'
|
||||
import RelaySetCard from '../RelaySetCard'
|
||||
|
||||
export default function PullFromRelaysButton() {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey } = useNostr()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const trigger = (
|
||||
<Button variant="secondary" className="w-full" disabled={!pubkey}>
|
||||
<CloudDownload />
|
||||
{t('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>{t('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 className="max-h-[90vh] overflow-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('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 { t } = useTranslation()
|
||||
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
|
||||
}
|
||||
)
|
||||
events.sort((a, b) => b.created_at - a.created_at)
|
||||
|
||||
const relaySetIds = new Set<string>()
|
||||
const relaySets: TRelaySet[] = []
|
||||
events.forEach((evt) => {
|
||||
const id = evt.tags.find(tagNameEquals('d'))?.[1]
|
||||
if (!id || relaySetIds.has(id)) return
|
||||
|
||||
relaySetIds.add(id)
|
||||
const relayUrls = evt.tags
|
||||
.filter(tagNameEquals('relay'))
|
||||
.map((tag) => tag[1])
|
||||
.filter((url) => url && isWebsocketUrl(url))
|
||||
if (!relayUrls.length) return
|
||||
|
||||
let title = evt.tags.find(tagNameEquals('title'))?.[1]
|
||||
if (!title) {
|
||||
title = relayUrls.length === 1 ? simplifyUrl(relayUrls[0]) : id
|
||||
}
|
||||
relaySets.push({ id, name: title, relayUrls })
|
||||
})
|
||||
|
||||
setRelaySets(relaySets)
|
||||
setInitialed(true)
|
||||
}
|
||||
init()
|
||||
}, [pubkey])
|
||||
|
||||
if (!pubkey) return null
|
||||
if (!initialed) return <div className="text-center text-muted-foreground">{t('loading...')}</div>
|
||||
if (!relaySets.length) {
|
||||
return <div className="text-center text-muted-foreground">{t('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))}
|
||||
>
|
||||
{t('Select 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
|
||||
? t('Pull n relay sets', { n: selectedRelaySetIds.length })
|
||||
: t('Pull')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
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 { useTranslation } from 'react-i18next'
|
||||
import { useRelaySetsSettingComponent } from './provider'
|
||||
|
||||
export default function PushToRelaysButton() {
|
||||
const { t } = useTranslation()
|
||||
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: t('Push Successful'),
|
||||
description: t('Successfully pushed relay sets to relays')
|
||||
})
|
||||
setPushing(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
disabled={!pubkey || pushing || selectedRelaySetIds.length === 0}
|
||||
onClick={push}
|
||||
>
|
||||
<CloudUpload />
|
||||
{t('Push to relays')}
|
||||
{pushing && <Loader className="animate-spin" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import { useFetchRelayInfos } from '@/hooks'
|
||||
import { useFeed } from '@/providers/FeedProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { SearchCheck } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu'
|
||||
|
||||
export default function TemporaryRelaySet() {
|
||||
const { t } = useTranslation()
|
||||
const { temporaryRelayUrls } = useFeed()
|
||||
const [relays, setRelays] = useState<
|
||||
{
|
||||
url: string
|
||||
isConnected: boolean
|
||||
}[]
|
||||
>(temporaryRelayUrls.map((url) => ({ url, isConnected: false })))
|
||||
const { relayInfos } = useFetchRelayInfos(relays.map((relay) => relay.url))
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full border border-dashed rounded-lg p-4 border-highlight bg-highlight/5 flex gap-4 justify-between">
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="h-8 font-semibold">Temporary</div>
|
||||
</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>
|
||||
{relayInfos[index]?.supported_nips?.includes(50) && (
|
||||
<div title={t('supports search')} className="text-highlight">
|
||||
<SearchCheck size={14} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<SaveRelayDropdownMenu urls={temporaryRelayUrls} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
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)
|
||||
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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,11 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
DrawerTitle
|
||||
} from '@/components/ui/drawer'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -7,12 +14,15 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { normalizeUrl } from '@/lib/url'
|
||||
import { useRelaySets } from '@/providers/RelaySetsProvider'
|
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { TRelaySet } from '@/types'
|
||||
import { Check, FolderPlus, Plus, Star } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DrawerMenuItem from '../DrawerMenuItem'
|
||||
|
||||
export default function SaveRelayDropdownMenu({
|
||||
urls,
|
||||
|
|
@ -22,29 +32,66 @@ export default function SaveRelayDropdownMenu({
|
|||
atTitlebar?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { relaySets } = useRelaySets()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { favoriteRelays, relaySets } = useFavoriteRelays()
|
||||
const normalizedUrls = useMemo(() => urls.map((url) => normalizeUrl(url)).filter(Boolean), [urls])
|
||||
const alreadySaved = useMemo(
|
||||
() => relaySets.some((set) => normalizedUrls.every((url) => set.relayUrls.includes(url))),
|
||||
[relaySets, normalizedUrls]
|
||||
const alreadySaved = useMemo(() => {
|
||||
return (
|
||||
normalizedUrls.every((url) => favoriteRelays.includes(url)) ||
|
||||
relaySets.some((set) => normalizedUrls.every((url) => set.relayUrls.includes(url)))
|
||||
)
|
||||
}, [relaySets, normalizedUrls])
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||
|
||||
const trigger = atTitlebar ? (
|
||||
<Button variant="ghost" size="titlebar-icon" onClick={() => setIsDrawerOpen(true)}>
|
||||
<Star className={alreadySaved ? 'fill-primary stroke-primary' : ''} />
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
className="enabled:hover:text-primary [&_svg]:size-5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsDrawerOpen(true)
|
||||
}}
|
||||
>
|
||||
<Star className={alreadySaved ? 'fill-primary stroke-primary' : ''} />
|
||||
</button>
|
||||
)
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||||
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
|
||||
<DrawerContent hideOverlay>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{t('Save to')} ...</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="py-2">
|
||||
<RelayItem urls={normalizedUrls} />
|
||||
{relaySets.map((set) => (
|
||||
<RelaySetItem key={set.id} set={set} urls={normalizedUrls} />
|
||||
))}
|
||||
<Separator />
|
||||
<SaveToNewSet urls={normalizedUrls} />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{atTitlebar ? (
|
||||
<Button variant="ghost" size="titlebar-icon">
|
||||
<Star className={alreadySaved ? 'fill-primary stroke-primary' : ''} />
|
||||
</Button>
|
||||
) : (
|
||||
<button className="enabled:hover:text-primary [&_svg]:size-5">
|
||||
<Star className={alreadySaved ? 'fill-primary stroke-primary' : ''} />
|
||||
</button>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuLabel>{t('Save to')} ...</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<RelayItem urls={normalizedUrls} />
|
||||
{relaySets.map((set) => (
|
||||
<RelaySetItem key={set.id} set={set} urls={normalizedUrls} />
|
||||
))}
|
||||
|
|
@ -55,8 +102,43 @@ export default function SaveRelayDropdownMenu({
|
|||
)
|
||||
}
|
||||
|
||||
function RelayItem({ urls }: { urls: string[] }) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { favoriteRelays, addFavoriteRelays, deleteFavoriteRelays } = useFavoriteRelays()
|
||||
const saved = useMemo(
|
||||
() => urls.every((url) => favoriteRelays.includes(url)),
|
||||
[favoriteRelays, urls]
|
||||
)
|
||||
|
||||
const handleClick = async () => {
|
||||
if (saved) {
|
||||
await deleteFavoriteRelays(urls)
|
||||
} else {
|
||||
await addFavoriteRelays(urls)
|
||||
}
|
||||
}
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<DrawerMenuItem onClick={handleClick}>
|
||||
{saved ? <Check /> : <Plus />}
|
||||
{t('Favorite')}
|
||||
</DrawerMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem className="flex gap-2" onClick={handleClick}>
|
||||
{saved ? <Check /> : <Plus />}
|
||||
{t('Favorite')}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
function RelaySetItem({ set, urls }: { set: TRelaySet; urls: string[] }) {
|
||||
const { updateRelaySet } = useRelaySets()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { updateRelaySet } = useFavoriteRelays()
|
||||
const saved = urls.every((url) => set.relayUrls.includes(url))
|
||||
|
||||
const handleClick = () => {
|
||||
|
|
@ -73,6 +155,15 @@ function RelaySetItem({ set, urls }: { set: TRelaySet; urls: string[] }) {
|
|||
}
|
||||
}
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<DrawerMenuItem onClick={handleClick}>
|
||||
{saved ? <Check /> : <Plus />}
|
||||
{set.name}
|
||||
</DrawerMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem key={set.id} className="flex gap-2" onClick={handleClick}>
|
||||
{saved ? <Check /> : <Plus />}
|
||||
|
|
@ -83,7 +174,8 @@ function RelaySetItem({ set, urls }: { set: TRelaySet; urls: string[] }) {
|
|||
|
||||
function SaveToNewSet({ urls }: { urls: string[] }) {
|
||||
const { t } = useTranslation()
|
||||
const { addRelaySet } = useRelaySets()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { addRelaySet } = useFavoriteRelays()
|
||||
|
||||
const handleSave = () => {
|
||||
const newSetName = prompt(t('Enter a name for the new relay set'))
|
||||
|
|
@ -92,6 +184,15 @@ function SaveToNewSet({ urls }: { urls: string[] }) {
|
|||
}
|
||||
}
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<DrawerMenuItem onClick={handleSave}>
|
||||
<FolderPlus />
|
||||
{t('Save to a new relay set')}
|
||||
</DrawerMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem onClick={handleSave}>
|
||||
<FolderPlus />
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ function ProfileButton() {
|
|||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" side="top">
|
||||
<DropdownMenuContent side="top">
|
||||
<DropdownMenuItem onClick={() => push(toProfile(pubkey))}>
|
||||
<UserRound />
|
||||
{t('Profile')}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ const DrawerContent = React.forwardRef<
|
|||
style={{
|
||||
paddingBottom: 'env(safe-area-inset-bottom)'
|
||||
}}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ const DropdownMenuContent = React.forwardRef<
|
|||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
'z-50 min-w-52 overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
collisionPadding={10}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const PopoverContent = React.forwardRef<
|
|||
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue