feat: support dnd to reorder relay sets

This commit is contained in:
codytseng
2025-08-17 18:04:08 +08:00
parent a7c4d1e450
commit 9bdee807ee
8 changed files with 163 additions and 46 deletions

View File

@@ -0,0 +1,17 @@
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useTranslation } from 'react-i18next'
import RelayItem from './RelayItem'
export default function FavoriteRelayList() {
const { t } = useTranslation()
const { favoriteRelays } = useFavoriteRelays()
return (
<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>
)
}

View File

@@ -10,12 +10,15 @@ import { Input } from '@/components/ui/input'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { TRelaySet } from '@/types'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import {
Check,
ChevronDown,
Edit,
EllipsisVertical,
FolderClosed,
GripVertical,
Link,
Trash2
} from 'lucide-react'
@@ -28,24 +31,44 @@ import { useRelaySetsSettingComponent } from './provider'
export default function RelaySet({ relaySet }: { relaySet: TRelaySet }) {
const { t } = useTranslation()
const { expandedRelaySetId } = useRelaySetsSettingComponent()
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: relaySet.id
})
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1
}
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 ref={setNodeRef} style={style} className="relative group">
<div className="w-full border rounded-lg px-2 py-2.5">
<div className="flex justify-between items-center">
<div className="flex items-center">
<div
className="cursor-grab active:cursor-grabbing p-2 hover:bg-muted rounded touch-none"
{...attributes}
{...listeners}
>
<GripVertical className="size-4 text-muted-foreground" />
</div>
<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>
<div className="flex gap-1">
<RelayUrlsExpandToggle relaySetId={relaySet.id}>
{t('n relays', { n: relaySet.relayUrls.length })}
</RelayUrlsExpandToggle>
<RelaySetOptions relaySet={relaySet} />
</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>
{expandedRelaySetId === relaySet.id && <RelayUrls relaySetId={relaySet.id} />}
</div>
{expandedRelaySetId === relaySet.id && <RelayUrls relaySetId={relaySet.id} />}
</div>
)
}

View File

@@ -0,0 +1,72 @@
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import {
closestCenter,
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors
} from '@dnd-kit/core'
import { restrictToParentElement, restrictToVerticalAxis } from '@dnd-kit/modifiers'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy
} from '@dnd-kit/sortable'
import { useTranslation } from 'react-i18next'
import PullRelaySetsButton from './PullRelaySetsButton'
import RelaySet from './RelaySet'
export default function RelaySetList() {
const { t } = useTranslation()
const { relaySets, reorderRelaySets } = useFavoriteRelays()
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates
})
)
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
const oldIndex = relaySets.findIndex((item) => item.id === active.id)
const newIndex = relaySets.findIndex((item) => item.id === over.id)
const reorderedSets = arrayMove(relaySets, oldIndex, newIndex)
reorderRelaySets(reorderedSets)
}
}
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-muted-foreground font-semibold select-none shrink-0">
{t('Relay sets')}
</div>
<PullRelaySetsButton />
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext
items={relaySets.map((set) => set.id)}
strategy={verticalListSortingStrategy}
>
<div className="grid gap-2">
{relaySets.map((relaySet) => (
<RelaySet key={relaySet.id} relaySet={relaySet} />
))}
</div>
</SortableContext>
</DndContext>
</div>
)
}

View File

@@ -1,39 +1,18 @@
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useTranslation } from 'react-i18next'
import AddNewRelay from './AddNewRelay'
import AddNewRelaySet from './AddNewRelaySet'
import FavoriteRelayList from './FavoriteRelayList'
import { RelaySetsSettingComponentProvider } from './provider'
import RelayItem from './RelayItem'
import RelaySet from './RelaySet'
import RelaySetList from './RelaySetList'
import TemporaryRelaySet from './TemporaryRelaySet'
import PullRelaySetsButton from './PullRelaySetsButton'
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="flex flex-wrap items-center justify-between gap-2">
<div className="text-muted-foreground font-semibold select-none shrink-0">
{t('Relay sets')}
</div>
<PullRelaySetsButton />
</div>
{relaySets.map((relaySet) => (
<RelaySet key={relaySet.id} relaySet={relaySet} />
))}
</div>
<RelaySetList />
<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>
<FavoriteRelayList />
<AddNewRelay />
</div>
</RelaySetsSettingComponentProvider>

View File

@@ -135,7 +135,7 @@ export async function createShortTextNoteDraftEvent(
}
// https://github.com/nostr-protocol/nips/blob/master/51.md
export function createRelaySetDraftEvent(relaySet: TRelaySet): TDraftEvent {
export function createRelaySetDraftEvent(relaySet: Omit<TRelaySet, 'aTag'>): TDraftEvent {
return {
kind: kinds.Relaysets,
content: '',
@@ -312,14 +312,18 @@ export function createProfileDraftEvent(content: string, tags: string[][] = []):
export function createFavoriteRelaysDraftEvent(
favoriteRelays: string[],
relaySetEvents: Event[]
relaySetEventsOrATags: Event[] | string[][]
): TDraftEvent {
const tags: string[][] = []
favoriteRelays.forEach((url) => {
tags.push(buildRelayTag(url))
})
relaySetEvents.forEach((event) => {
tags.push(buildATag(event))
relaySetEventsOrATags.forEach((eventOrATag) => {
if (Array.isArray(eventOrATag)) {
tags.push(eventOrATag)
} else {
tags.push(buildATag(eventOrATag))
}
})
return {
kind: ExtendedKind.FAVORITE_RELAYS,
@@ -579,7 +583,7 @@ function extractImagesFromContent(content: string) {
return content.match(/https?:\/\/[^\s"']+\.(jpg|jpeg|png|gif|webp|heic)/gi)
}
function buildATag(event: Event, upperCase: boolean = false) {
export function buildATag(event: Event, upperCase: boolean = false) {
const coordinate = getReplaceableCoordinateFromEvent(event)
const hint = client.getEventHint(event.id)
return trimTagEnd([upperCase ? 'A' : 'a', coordinate, hint])

View File

@@ -1,6 +1,7 @@
import { BIG_RELAY_URLS, POLL_TYPE } from '@/constants'
import { TPollType, TRelayList, TRelaySet } from '@/types'
import { Event, kinds } from 'nostr-tools'
import { buildATag } from './draft-event'
import { getReplaceableEventIdentifier } from './event'
import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning'
import { formatPubkey, pubkeyToNpub } from './pubkey'
@@ -91,7 +92,7 @@ export function getRelaySetFromEvent(event: Event): TRelaySet {
name = id
}
return { id, name, relayUrls }
return { id, name, relayUrls, aTag: buildATag(event) }
}
export function getZapInfoFromEvent(receiptEvent: Event) {

View File

@@ -21,6 +21,7 @@ type TFavoriteRelaysContext = {
addRelaySets: (newRelaySetEvents: Event[]) => Promise<void>
deleteRelaySet: (id: string) => Promise<void>
updateRelaySet: (newSet: TRelaySet) => Promise<void>
reorderRelaySets: (reorderedSets: TRelaySet[]) => Promise<void>
}
const FavoriteRelaysContext = createContext<TFavoriteRelaysContext | undefined>(undefined)
@@ -109,7 +110,15 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
relaySetEventMap.set(d, event)
}
})
const uniqueNewRelaySetEvents = Array.from(relaySetEventMap.values())
const uniqueNewRelaySetEvents = relaySetIds
.map((id, index) => {
const event = relaySetEventMap.get(id)
if (event) {
return event
}
return storedRelaySetEvents[index] || null
})
.filter(Boolean) as Event[]
setRelaySetEvents(uniqueNewRelaySetEvents)
await Promise.all(
uniqueNewRelaySetEvents.map((event) => {
@@ -210,6 +219,16 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
})
}
const reorderRelaySets = async (reorderedSets: TRelaySet[]) => {
setRelaySets(reorderedSets)
const draftEvent = createFavoriteRelaysDraftEvent(
favoriteRelays,
reorderedSets.map((set) => set.aTag)
)
const newFavoriteRelaysEvent = await publish(draftEvent)
updateFavoriteRelaysEvent(newFavoriteRelaysEvent)
}
return (
<FavoriteRelaysContext.Provider
value={{
@@ -220,7 +239,8 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
createRelaySet,
addRelaySets,
deleteRelaySet,
updateRelaySet
updateRelaySet,
reorderRelaySets
}}
>
{children}

View File

@@ -59,6 +59,7 @@ export type TWebMetadata = {
export type TRelaySet = {
id: string
aTag: string[]
name: string
relayUrls: string[]
}