Browse Source

fix double-pane mode

make spells page vertically-aligned
imwald
Silberengel 1 month ago
parent
commit
f492532fee
  1. 58
      src/PageManager.tsx
  2. 58
      src/components/CacheRelaysSetting/index.tsx
  3. 11
      src/components/MailboxSetting/NewMailboxRelayInput.tsx
  4. 75
      src/i18n/locales/de.ts
  5. 23
      src/i18n/locales/en.ts
  6. 12
      src/layouts/PrimaryPageLayout/index.tsx
  7. 4
      src/layouts/SecondaryPageLayout/index.tsx
  8. 19
      src/lib/draft-event.ts
  9. 242
      src/pages/primary/SpellsPage/CreateSpellDialog.tsx
  10. 184
      src/pages/primary/SpellsPage/index.tsx
  11. 36
      src/services/spell.service.ts

58
src/PageManager.tsx

@ -31,8 +31,10 @@ import { @@ -31,8 +31,10 @@ import {
cloneElement,
createContext,
createRef,
lazy,
ReactNode,
RefObject,
Suspense,
useCallback,
useContext,
useEffect,
@ -40,6 +42,7 @@ import { @@ -40,6 +42,7 @@ import {
useState
} from 'react'
import BottomNavigationBar from './components/BottomNavigationBar'
import { useTranslation } from 'react-i18next'
import TooManyRelaysAlertDialog from './components/TooManyRelaysAlertDialog'
import { normalizeUrl } from './lib/url'
import ExplorePage from './pages/primary/ExplorePage'
@ -49,8 +52,10 @@ import ProfilePage from './pages/primary/ProfilePage' @@ -49,8 +52,10 @@ import ProfilePage from './pages/primary/ProfilePage'
import RelayPage from './pages/primary/RelayPage'
import SearchPage from './pages/primary/SearchPage'
import DiscussionsPage from './pages/primary/DiscussionsPage'
import SpellsPage from './pages/primary/SpellsPage'
import { useScreenSize } from './providers/ScreenSizeProvider'
/** Lazy-loaded so PageManager does not synchronously import SpellsPage (avoids HMR cycle: SpellsPage → PrimaryPageLayout → PageManager → SpellsPage). */
const SpellsPageLazy = lazy(() => import('./pages/primary/SpellsPage'))
import { routes } from './routes'
import modalManager from './services/modal-manager.service'
import CreateWalletGuideToast from './components/CreateWalletGuideToast'
@ -100,7 +105,17 @@ const getPrimaryPageMap = () => ({ @@ -100,7 +105,17 @@ const getPrimaryPageMap = () => ({
relay: <RelayPage ref={PRIMARY_PAGE_REF_MAP.relay} />,
search: <SearchPage ref={PRIMARY_PAGE_REF_MAP.search} />,
discussions: <DiscussionsPage ref={PRIMARY_PAGE_REF_MAP.discussions} />,
spells: <SpellsPage ref={PRIMARY_PAGE_REF_MAP.spells} />
spells: (
<Suspense
fallback={
<div className="flex flex-1 items-center justify-center p-8 text-sm text-muted-foreground">
Loading
</div>
}
>
<SpellsPageLazy ref={PRIMARY_PAGE_REF_MAP.spells} />
</Suspense>
)
})
// Type for primary page names - use the return type of getPrimaryPageMap
@ -493,13 +508,13 @@ function MainContentArea({ @@ -493,13 +508,13 @@ function MainContentArea({
primaryNoteView: !!primaryNoteView
})
// Always use single column layout since double-panel is disabled. flex + min-h-0 so primary page ScrollArea gets a height and can scroll.
// flex + min-h-0 + min-w-0 so primary pages get a real height in flex parents and can shrink horizontally (double-pane).
return (
<div className="flex-1 flex flex-col min-h-0 w-full pr-2 py-2">
<div className="flex-1 flex flex-col min-h-0 rounded-lg shadow-lg bg-background overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-col w-full pr-2 py-2">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-lg bg-background shadow-lg">
{primaryNoteView ? (
// Show note view with back button
<div className="flex flex-col h-full w-full">
<div className="flex h-full min-h-0 min-w-0 w-full flex-col">
<div className="flex justify-center py-1 border-b">
<span className="text-green-600 dark:text-green-500 font-semibold text-sm">
Imwald
@ -539,10 +554,10 @@ function MainContentArea({ @@ -539,10 +554,10 @@ function MainContentArea({
return (
<div
key={name}
className="flex flex-col h-full min-h-0 w-full"
style={{
display: isCurrentPage ? 'block' : 'none'
}}
className={cn(
'flex h-full min-h-0 w-full min-w-0 flex-col',
isCurrentPage ? 'flex' : 'hidden'
)}
>
{(() => {
try {
@ -564,6 +579,7 @@ function MainContentArea({ @@ -564,6 +579,7 @@ function MainContentArea({
}
export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
// DEPRECATED: showRecommendedRelaysPanel removed - double-panel functionality disabled
const [currentPrimaryPage, setCurrentPrimaryPage] = useState<TPrimaryPageName>('home')
@ -1574,9 +1590,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1574,9 +1590,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
if (panelMode === 'double') {
// Double-pane mode: show feed on left (flexible, maintains width), secondary stack on right (1042px, same as drawer)
return (
<div className="flex-1 flex overflow-hidden">
{/* Left panel: Feed (flexible, takes remaining space after 1042px) */}
<div className="flex-1 min-w-0 overflow-auto border-r">
<div className="flex min-h-0 min-w-0 flex-1 overflow-hidden">
{/* Left: primary column — must be a flex column so MainContentArea flex-1 gets height */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden border-r">
<MainContentArea
primaryPages={primaryPages}
currentPrimaryPage={currentPrimaryPage}
@ -1585,25 +1601,27 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1585,25 +1601,27 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
goBack={goBack}
/>
</div>
{/* Right panel: Secondary stack (1042px fixed width, same as drawer) */}
<div className="w-[1042px] shrink-0 overflow-auto">
{/* Right: secondary stack — max width so left pane keeps space on small desktops */}
<div className="flex h-full min-h-0 w-[min(1042px,50vw)] shrink-0 flex-col overflow-hidden border-l border-border/60 bg-muted/20">
{secondaryStack.length > 0 ? (
secondaryStack.map((item, index) => {
const isLast = index === secondaryStack.length - 1
return (
<div
key={item.index}
style={{
display: isLast ? 'block' : 'none'
}}
className={cn(
'h-full min-h-0 min-w-0 flex-col',
isLast ? 'flex' : 'hidden'
)}
>
{item.component}
</div>
)
})
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
{/* Empty state - no secondary content */}
<div className="flex h-full min-h-0 flex-col items-center justify-center gap-2 p-4 text-center text-sm text-muted-foreground">
<p>{t('doublePane.secondaryEmpty')}</p>
<p className="text-xs opacity-80">{t('doublePane.secondaryEmptyHint')}</p>
</div>
)}
</div>

58
src/components/CacheRelaysSetting/index.tsx

@ -848,45 +848,25 @@ export default function CacheRelaysSetting() { @@ -848,45 +848,25 @@ export default function CacheRelaysSetting() {
<div className="text-xs text-muted-foreground space-y-1">
<div>{t('Clear cached data stored in your browser, including IndexedDB events, localStorage settings, and service worker caches.')}</div>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<Button
variant="outline"
className="flex-1 w-full sm:w-auto"
onClick={handleClearCache}
>
<Trash2 className="h-4 w-4 mr-2" />
<div className="flex min-w-0 flex-wrap gap-2">
<Button variant="outline" className="shrink-0" onClick={handleClearCache}>
<Trash2 className="mr-2 h-4 w-4" />
{t('Clear Cache')}
</Button>
<Button
variant="outline"
className="flex-1 w-full sm:w-auto"
onClick={handleRefreshCache}
>
<RefreshCw className="h-4 w-4 mr-2" />
<Button variant="outline" className="shrink-0" onClick={handleRefreshCache}>
<RefreshCw className="mr-2 h-4 w-4" />
{t('Refresh Cache')}
</Button>
<Button
variant="outline"
className="flex-1 w-full sm:w-auto"
onClick={handleBrowseCache}
>
<Database className="h-4 w-4 mr-2" />
<Button variant="outline" className="shrink-0" onClick={handleBrowseCache}>
<Database className="mr-2 h-4 w-4" />
{t('Browse Cache')}
</Button>
<Button
variant="outline"
className="flex-1 w-full sm:w-auto"
onClick={handleClearServiceWorker}
>
<XCircle className="h-4 w-4 mr-2" />
<Button variant="outline" className="shrink-0" onClick={handleClearServiceWorker}>
<XCircle className="mr-2 h-4 w-4" />
{t('Clear Service Worker')}
</Button>
<Button
variant="outline"
className="flex-1 w-full sm:w-auto"
onClick={handleShowConsoleLogs}
>
<Terminal className="h-4 w-4 mr-2" />
<Button variant="outline" className="shrink-0" onClick={handleShowConsoleLogs}>
<Terminal className="mr-2 h-4 w-4" />
{t('View Console Logs')} ({consoleLogRef.current.length})
</Button>
</div>
@ -1285,15 +1265,15 @@ export default function CacheRelaysSetting() { @@ -1285,15 +1265,15 @@ export default function CacheRelaysSetting() {
</div>
</div>
</DrawerHeader>
<div className="px-4 pb-2 space-y-2">
<div className="flex flex-col sm:flex-row gap-2">
<div className="space-y-2 px-4 pb-2">
<div className="flex min-w-0 flex-wrap gap-2">
<Input
placeholder={t('Search logs...')}
value={consoleLogSearch}
onChange={(e) => setConsoleLogSearch(e.target.value)}
className="flex-1"
className="min-w-0 flex-1 basis-[min(100%,12rem)]"
/>
<div className="flex gap-1 shrink-0">
<div className="flex shrink-0 gap-1">
<Button
type="button"
variant={consoleLogLevel === 'errors-warnings' ? 'secondary' : 'outline'}
@ -1408,15 +1388,15 @@ export default function CacheRelaysSetting() { @@ -1408,15 +1388,15 @@ export default function CacheRelaysSetting() {
</div>
</div>
</DialogHeader>
<div className="px-6 pb-4 space-y-2">
<div className="flex flex-col sm:flex-row gap-2">
<div className="space-y-2 px-6 pb-4">
<div className="flex min-w-0 flex-wrap gap-2">
<Input
placeholder={t('Search logs...')}
value={consoleLogSearch}
onChange={(e) => setConsoleLogSearch(e.target.value)}
className="flex-1"
className="min-w-0 flex-1 basis-[min(100%,12rem)]"
/>
<div className="flex gap-1 shrink-0">
<div className="flex shrink-0 gap-1">
<Button
type="button"
variant={consoleLogLevel === 'errors-warnings' ? 'secondary' : 'outline'}

11
src/components/MailboxSetting/NewMailboxRelayInput.tsx

@ -34,17 +34,20 @@ export default function NewMailboxRelayInput({ @@ -34,17 +34,20 @@ export default function NewMailboxRelayInput({
}
return (
<div>
<div className="flex flex-col sm:flex-row gap-4">
<div className="min-w-0">
{/* flex-wrap: narrow panes (e.g. double-pane) use viewport breakpoints, not container width */}
<div className="flex flex-wrap gap-2">
<Input
className={newRelayUrlError ? 'border-destructive' : ''}
className={`min-w-0 flex-1 basis-[min(100%,16rem)] ${newRelayUrlError ? 'border-destructive' : ''}`}
placeholder={t('Add a new relay')}
value={newRelayUrl}
onKeyDown={handleRelayUrlInputKeyDown}
onChange={handleRelayUrlInputChange}
onBlur={save}
/>
<Button className="w-full sm:w-auto" onClick={save}>{t('Add')}</Button>
<Button className="shrink-0" onClick={save}>
{t('Add')}
</Button>
</div>
{newRelayUrlError && <div className="text-destructive text-xs mt-1">{newRelayUrlError}</div>}
</div>

75
src/i18n/locales/de.ts

@ -618,6 +618,79 @@ export default { @@ -618,6 +618,79 @@ export default {
'shortcuts.activate': 'Schaltflächen und viele Steuerelemente auslösen',
'shortcuts.closeOverlays': 'Dialoge, Menüs und Such-Dropdown schließen',
'shortcuts.scrollWhenFocused': 'Den fokussierten scrollbaren Bereich scrollen',
'shortcuts.browserBack': 'Zurück im Browser (Verlauf)'
'shortcuts.browserBack': 'Zurück im Browser (Verlauf)',
Spells: 'Zaubersprüche',
Tags: 'Tags',
Close: 'Schließen',
'Back to spell list': 'Zurück zur Zauberspruch-Liste',
'Create a Spell': 'Zauberspruch anlegen',
'No spells yet. Create one with the button above.':
'Noch keine Zaubersprüche. Lege mit dem Button oben einen an.',
'Select a spell…': 'Zauberspruch wählen…',
'View definition': 'Definition anzeigen',
'Add to favorites': 'Zu Favoriten hinzufügen',
'Remove from favorites': 'Aus Favoriten entfernen',
'COUNT spells show a number, not a feed.':
'COUNT-Zaubersprüche zeigen eine Zahl, keinen Feed.',
'Log in to run this spell (it uses $me or $contacts).':
'Zum Ausführen anmelden (verwendet $me oder $contacts).',
'Could not run this spell. Check that it has a valid REQ/COUNT command, or add read relays in settings.':
'Zauberspruch konnte nicht ausgeführt werden. Prüfe REQ/COUNT oder füge Lese-Relays in den Einstellungen hinzu.',
'Select a spell to view its feed.': 'Wähle einen Zauberspruch, um den Feed zu sehen.',
'Add another row': 'Weitere Zeile hinzufügen',
'Remove this row': 'Diese Zeile entfernen',
'One kind number per row (e.g. 1 for notes).':
'Eine Kind-Nummer pro Zeile (z. B. 1 für Notizen).',
'One author per row: $me, $contacts, or hex pubkey / npub.':
'Ein Autor pro Zeile: $me, $contacts oder Hex-Pubkey / npub.',
'One hex event id per row.': 'Eine Hex-Event-ID pro Zeile.',
'One wss:// URL per row. Leave empty to use your read relays.':
'Eine wss://-URL pro Zeile. Alle leer lassen für deine Lese-Relays.',
'One topic per row.': 'Ein Thema pro Zeile.',
topic: 'Thema',
'Spell form fields': 'Zauberspruch-Formularfelder',
'Spell definition': 'Zauberspruch-Definition',
'Spell published': 'Zauberspruch veröffentlicht',
'Spells are saved relay filters (NIP-A7). Fill in the filter fields below. Use $me for your pubkey and $contacts for your follow list when executing.':
'Zaubersprüche sind gespeicherte Relay-Filter (NIP-A7). Fülle die Felder unten. $me = dein Pubkey, $contacts = deine Follow-Liste bei der Ausführung.',
Command: 'Befehl',
'REQ returns a feed; COUNT returns a number.':
'REQ liefert einen Feed; COUNT nur eine Zahl.',
Name: 'Name',
'Human-readable spell name': 'Lesbarer Name des Zauberspruchs',
'Description (content)': 'Beschreibung (Inhalt)',
'Plain text description of the query': 'Klartext-Beschreibung der Abfrage',
Kinds: 'Kinds',
'Comma-separated kind numbers (e.g. 1 for notes).':
'Kind-Nummern, durch Komma getrennt (z. B. 1 für Notizen).',
Authors: 'Autoren',
'$me = your pubkey, $contacts = your follow list. Comma-separated.':
'$me = dein Pubkey, $contacts = deine Follow-Liste. Komma-getrennt.',
'Event IDs (ids)': 'Event-IDs (ids)',
'Comma-separated event ids': 'Event-IDs, komma-getrennt',
Limit: 'Limit',
Since: 'Seit',
'Relative: 7d, 24h, 1w, 1mo, 1y. Or Unix timestamp.':
'Relativ: 7d, 24h, 1w, 1mo, 1y. Oder Unix-Zeitstempel.',
Until: 'Bis',
Optional: 'Optional',
'Search (NIP-50)': 'Suche (NIP-50)',
'Full-text search query': 'Volltextsuchanfrage',
'Leave empty to use your read relays.':
'Leer lassen, um deine Lese-Relays zu verwenden.',
'Topics (t tags for categorization)': 'Themen (t-Tags zur Kategorisierung)',
'Comma-separated topics': 'Themen, komma-getrennt',
Mode: 'Modus',
Feed: 'Feed',
Fetch: 'Abrufen',
'Fetch once, then stop.': 'Einmal abrufen, dann stoppen.',
'Live feed; keeps updating.': 'Live-Feed; wird fortgesetzt aktualisiert.',
'Saving…': 'Speichern…',
Clear: 'Leeren',
'doublePane.secondaryEmpty':
'Öffne eine Notiz, ein Profil oder Einstellungen, um sie hier anzuzeigen.',
'doublePane.secondaryEmptyHint': 'Feed und Hauptseiten bleiben links.'
}
}

23
src/i18n/locales/en.ts

@ -699,6 +699,27 @@ export default { @@ -699,6 +699,27 @@ export default {
'shortcuts.activate': 'Activate buttons and many controls',
'shortcuts.closeOverlays': 'Close dialogs, menus, and the search dropdown',
'shortcuts.scrollWhenFocused': 'Scroll the focused scrollable area',
'shortcuts.browserBack': 'Browser back (history)'
'shortcuts.browserBack': 'Browser back (history)',
'No spells yet. Create one with the button above.':
'No spells yet. Create one with the button above.',
'Select a spell…': 'Select a spell…',
'Select a spell to view its feed.': 'Select a spell to view its feed.',
'Add another row': 'Add another row',
'Remove this row': 'Remove this row',
'One kind number per row (e.g. 1 for notes).': 'One kind number per row (e.g. 1 for notes).',
'One author per row: $me, $contacts, or hex pubkey / npub.':
'One author per row: $me, $contacts, or hex pubkey / npub.',
'One hex event id per row.': 'One hex event id per row.',
'One wss:// URL per row. Leave empty to use your read relays.':
'One wss:// URL per row. Leave empty to use your read relays.',
'One topic per row.': 'One topic per row.',
topic: 'topic',
'Spell form fields': 'Spell form fields',
Spells: 'Spells',
'doublePane.secondaryEmpty': 'Open a note, profile, or settings item to show it here.',
'doublePane.secondaryEmptyHint': 'Your feed and primary pages stay on the left.'
}
}

12
src/layouts/PrimaryPageLayout/index.tsx

@ -117,15 +117,21 @@ const PrimaryPageLayout = forwardRef( @@ -117,15 +117,21 @@ const PrimaryPageLayout = forwardRef(
return (
<DeepBrowsingProvider active={current === pageName && display} scrollAreaRef={scrollAreaRef}>
<div className="relative h-full min-h-0 flex flex-col">
<div className="relative flex h-full min-h-0 min-w-0 flex-col">
<PrimaryPageTitlebar hideBottomBorder={hideTitlebarBottomBorder}>
{titlebar}
</PrimaryPageTitlebar>
{subHeader && <div className="shrink-0 bg-background">{subHeader}</div>}
{subHeader && (
<div className="min-w-0 shrink-0 bg-background">{subHeader}</div>
)}
<div
ref={scrollAreaRef}
tabIndex={-1}
className={subHeader ? 'flex-1 min-h-0 overflow-y-auto overflow-x-hidden' : 'absolute top-12 left-0 right-0 bottom-0 overflow-y-auto overflow-x-hidden'}
className={
subHeader
? 'min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-auto'
: 'absolute bottom-0 left-0 right-0 top-12 min-w-0 overflow-y-auto overflow-x-auto'
}
>
{children}
<div className="h-4" />

4
src/layouts/SecondaryPageLayout/index.tsx

@ -113,7 +113,7 @@ const SecondaryPageLayout = forwardRef( @@ -113,7 +113,7 @@ const SecondaryPageLayout = forwardRef(
return (
<DeepBrowsingProvider active={currentIndex === index} scrollAreaRef={scrollAreaRef}>
<div className="h-full flex flex-col">
<div className="flex h-full min-h-0 min-w-0 flex-col">
{title && (
<>
<div className="flex justify-center py-1 border-b">
@ -133,7 +133,7 @@ const SecondaryPageLayout = forwardRef( @@ -133,7 +133,7 @@ const SecondaryPageLayout = forwardRef(
<div
ref={scrollAreaRef}
tabIndex={-1}
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden"
className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-auto"
>
{children}
<div className="h-4" />

19
src/lib/draft-event.ts

@ -612,9 +612,14 @@ export function createSpellDraftEvent(params: TSpellDraftParams): TDraftEvent { @@ -612,9 +612,14 @@ export function createSpellDraftEvent(params: TSpellDraftParams): TDraftEvent {
const tags: string[][] = [['cmd', params.cmd]]
if (params.name?.trim()) tags.push(['name', params.name.trim()])
if (params.alt?.trim()) tags.push(['alt', params.alt.trim()])
params.kinds.filter((k) => k.trim()).forEach((k) => tags.push(['k', k.trim()]))
if (params.authors.length) tags.push(['authors', ...params.authors])
if (params.ids.length) tags.push(['ids', ...params.ids])
params.kinds
.map((k) => k.trim())
.filter(Boolean)
.forEach((k) => tags.push(['k', k]))
const authors = params.authors.map((a) => a.trim()).filter(Boolean)
if (authors.length) tags.push(['authors', ...authors])
const ids = params.ids.map((id) => id.trim()).filter(Boolean)
if (ids.length) tags.push(['ids', ...ids])
params.tagFilters.forEach(({ letter, values }) => {
if (letter?.trim() && values.some((v) => v?.trim())) {
tags.push(['tag', letter.trim(), ...values.map((v) => v.trim()).filter(Boolean)])
@ -627,8 +632,12 @@ export function createSpellDraftEvent(params: TSpellDraftParams): TDraftEvent { @@ -627,8 +632,12 @@ export function createSpellDraftEvent(params: TSpellDraftParams): TDraftEvent {
if (params.since.trim()) tags.push(['since', params.since.trim()])
if (params.until.trim()) tags.push(['until', params.until.trim()])
if (params.search.trim()) tags.push(['search', params.search.trim()])
if (params.relays.length) tags.push(['relays', ...params.relays])
params.topics.filter((t) => t?.trim()).forEach((t) => tags.push(['t', t.trim()]))
const relays = params.relays.map((r) => r.trim()).filter(Boolean)
if (relays.length) tags.push(['relays', ...relays])
params.topics
.map((t) => t.trim())
.filter(Boolean)
.forEach((t) => tags.push(['t', t]))
if (params.closeOnEose) tags.push(['close-on-eose'])
return {
kind: ExtendedKind.SPELL,

242
src/pages/primary/SpellsPage/CreateSpellDialog.tsx

@ -12,11 +12,25 @@ import { createSpellDraftEvent, type TSpellDraftParams } from '@/lib/draft-event @@ -12,11 +12,25 @@ import { createSpellDraftEvent, type TSpellDraftParams } from '@/lib/draft-event
import { useNostr } from '@/providers/NostrProvider'
import { showPublishingError, showSimplePublishSuccess } from '@/lib/publishing-feedback'
import indexedDb from '@/services/indexed-db.service'
import { X } from 'lucide-react'
import { Minus, Plus, X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import logger from '@/lib/logger'
/** Arrow keys should control the control, not the dialog scroll */
function keyboardTargetUsesArrowKeys(target: EventTarget | null): boolean {
if (!target || !(target instanceof HTMLElement)) return false
const tag = target.tagName
if (tag === 'TEXTAREA' || tag === 'SELECT') return true
if (tag === 'INPUT') {
const type = (target as HTMLInputElement).type
if (type === 'number' || type === 'range' || type === 'date' || type === 'time') return true
}
return target.isContentEditable
}
const SCROLL_STEP_PX = 48
const DEFAULT_PARAMS: TSpellDraftParams = {
cmd: 'REQ',
content: '',
@ -35,6 +49,82 @@ const DEFAULT_PARAMS: TSpellDraftParams = { @@ -35,6 +49,82 @@ const DEFAULT_PARAMS: TSpellDraftParams = {
closeOnEose: false
}
/** One input per list item; add/remove rows. */
function DynamicStringListField({
label,
hint,
values,
onChange,
placeholder,
inputType = 'text'
}: {
label: string
hint?: string
values: string[]
onChange: (next: string[]) => void
placeholder?: string
inputType?: 'text' | 'number'
}) {
const { t } = useTranslation()
const rows = values.length > 0 ? values : ['']
const updateAt = (i: number, v: string) => {
const base = values.length > 0 ? [...values] : ['']
base[i] = v
onChange(base)
}
const removeAt = (i: number) => {
const base = values.length > 0 ? [...values] : ['']
if (base.length <= 1) {
onChange([''])
return
}
base.splice(i, 1)
onChange(base)
}
const addRow = () => {
const base = values.length > 0 ? [...values] : ['']
onChange([...base, ''])
}
return (
<div className="grid gap-2">
<Label>{label}</Label>
<div className="flex flex-col gap-2">
{rows.map((v, i) => (
<div key={i} className="flex gap-2">
<Input
type={inputType}
value={v}
onChange={(e) => updateAt(i, e.target.value)}
placeholder={placeholder}
className="min-w-0 flex-1"
/>
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => removeAt(i)}
title={t('Remove this row')}
aria-label={t('Remove this row')}
>
<Minus className="size-4" />
</Button>
</div>
))}
</div>
<Button type="button" variant="outline" size="sm" className="h-9 w-fit gap-1" onClick={addRow}>
<Plus className="size-4" />
{t('Add another row')}
</Button>
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
</div>
)
}
export default function CreateSpellDialog({
open,
onOpenChange,
@ -48,6 +138,31 @@ export default function CreateSpellDialog({ @@ -48,6 +138,31 @@ export default function CreateSpellDialog({
const { pubkey, publish, checkLogin } = useNostr()
const [form, setForm] = useState<TSpellDraftParams>(DEFAULT_PARAMS)
const [saving, setSaving] = useState(false)
const scrollBodyRef = useRef<HTMLDivElement>(null)
const handleScrollBodyKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
const el = scrollBodyRef.current
if (!el) return
if (e.key === 'PageDown' || e.key === 'PageUp') {
e.preventDefault()
const page = el.clientHeight * 0.85
el.scrollBy({ top: e.key === 'PageDown' ? page : -page, behavior: 'smooth' })
return
}
if (keyboardTargetUsesArrowKeys(e.target)) return
if (e.key === 'ArrowDown') {
e.preventDefault()
el.scrollBy({ top: SCROLL_STEP_PX, behavior: 'smooth' })
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
el.scrollBy({ top: -SCROLL_STEP_PX, behavior: 'smooth' })
}
}, [])
const handleClear = () => setForm({ ...DEFAULT_PARAMS })
const handleCancel = () => {
@ -77,42 +192,43 @@ export default function CreateSpellDialog({ @@ -77,42 +192,43 @@ export default function CreateSpellDialog({
}
}
const kindsStr = form.kinds.length ? form.kinds.join(', ') : ''
const setKindsStr = (s: string) =>
setForm((f) => ({ ...f, kinds: s.split(/[\s,]+/).filter(Boolean) }))
const authorsStr = form.authors.join(', ')
const setAuthorsStr = (s: string) =>
setForm((f) => ({ ...f, authors: s.split(/[\s,]+/).filter(Boolean) }))
const idsStr = form.ids.join(', ')
const setIdsStr = (s: string) =>
setForm((f) => ({ ...f, ids: s.split(/[\s,]+/).filter(Boolean) }))
const relaysStr = form.relays.join(', ')
const setRelaysStr = (s: string) =>
setForm((f) => ({ ...f, relays: s.split(/[\s,]+/).filter(Boolean) }))
const topicsStr = form.topics.join(', ')
const setTopicsStr = (s: string) =>
setForm((f) => ({ ...f, topics: s.split(/[\s,]+/).filter(Boolean) }))
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto max-w-2xl" withoutClose>
<DialogHeader className="flex flex-row items-center justify-between gap-2 pr-8">
<DialogTitle>{t('Create a Spell')}</DialogTitle>
<DialogContent
className="flex max-h-[90vh] max-w-2xl flex-col gap-0 overflow-hidden p-0"
withoutClose
>
{/* Fixed top: not inside overflow-y-auto so title/intro never scroll away or clip */}
<div className="relative shrink-0 border-b px-6 pb-4 pt-6">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
className="absolute right-4 top-4 z-10 h-8 w-8 shrink-0"
onClick={() => onOpenChange(false)}
aria-label={t('Close')}
>
<X className="size-4" />
</Button>
<DialogHeader className="space-y-1.5 pr-10 text-left sm:text-left">
<DialogTitle>{t('Create a Spell')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{t('Spells are saved relay filters (NIP-A7). Fill in the filter fields below. Use $me for your pubkey and $contacts for your follow list when executing.')}
<p className="mt-2 text-sm text-muted-foreground">
{t(
'Spells are saved relay filters (NIP-A7). Fill in the filter fields below. Use $me for your pubkey and $contacts for your follow list when executing.'
)}
</p>
</div>
<div className="grid gap-4 py-2">
<div
ref={scrollBodyRef}
tabIndex={0}
role="region"
aria-label={t('Spell form fields')}
className="min-h-0 flex-1 overflow-y-auto px-6 py-4 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ring-offset-background"
onKeyDown={handleScrollBodyKeyDown}
>
<div className="grid gap-4">
<div className="grid gap-2">
<Label>{t('Command')}</Label>
<select
@ -145,34 +261,30 @@ export default function CreateSpellDialog({ @@ -145,34 +261,30 @@ export default function CreateSpellDialog({
/>
</div>
<div className="grid gap-2">
<Label>{t('Kinds')}</Label>
<Input
value={kindsStr}
onChange={(e) => setKindsStr(e.target.value)}
placeholder="e.g. 1, 6, 7"
<DynamicStringListField
label={t('Kinds')}
hint={t('One kind number per row (e.g. 1 for notes).')}
placeholder="1"
inputType="number"
values={form.kinds}
onChange={(kinds) => setForm((f) => ({ ...f, kinds }))}
/>
<p className="text-xs text-muted-foreground">{t('Comma-separated kind numbers (e.g. 1 for notes).')}</p>
</div>
<div className="grid gap-2">
<Label>{t('Authors')}</Label>
<Input
value={authorsStr}
onChange={(e) => setAuthorsStr(e.target.value)}
placeholder="$me, $contacts, or npub1..."
<DynamicStringListField
label={t('Authors')}
hint={t('One author per row: $me, $contacts, or hex pubkey / npub.')}
placeholder="$me"
values={form.authors}
onChange={(authors) => setForm((f) => ({ ...f, authors }))}
/>
<p className="text-xs text-muted-foreground">{t('$me = your pubkey, $contacts = your follow list. Comma-separated.')}</p>
</div>
<div className="grid gap-2">
<Label>{t('Event IDs (ids)')}</Label>
<Input
value={idsStr}
onChange={(e) => setIdsStr(e.target.value)}
placeholder={t('Comma-separated event ids')}
<DynamicStringListField
label={t('Event IDs (ids)')}
hint={t('One hex event id per row.')}
placeholder="hex id…"
values={form.ids}
onChange={(ids) => setForm((f) => ({ ...f, ids }))}
/>
</div>
<div className="grid gap-2">
<Label>{t('Limit')}</Label>
@ -191,7 +303,9 @@ export default function CreateSpellDialog({ @@ -191,7 +303,9 @@ export default function CreateSpellDialog({
onChange={(e) => setForm((f) => ({ ...f, since: e.target.value }))}
placeholder="7d or 1704067200 or now"
/>
<p className="text-xs text-muted-foreground">{t('Relative: 7d, 24h, 1w, 1mo, 1y. Or Unix timestamp.')}</p>
<p className="text-xs text-muted-foreground">
{t('Relative: 7d, 24h, 1w, 1mo, 1y. Or Unix timestamp.')}
</p>
</div>
<div className="grid gap-2">
@ -212,24 +326,21 @@ export default function CreateSpellDialog({ @@ -212,24 +326,21 @@ export default function CreateSpellDialog({
/>
</div>
<div className="grid gap-2">
<Label>{t('Relays')}</Label>
<Input
value={relaysStr}
onChange={(e) => setRelaysStr(e.target.value)}
placeholder="wss://relay.example.com, ..."
<DynamicStringListField
label={t('Relays')}
hint={t('One wss:// URL per row. Leave empty to use your read relays.')}
placeholder="wss://…"
values={form.relays}
onChange={(relays) => setForm((f) => ({ ...f, relays }))}
/>
<p className="text-xs text-muted-foreground">{t('Leave empty to use your read relays.')}</p>
</div>
<div className="grid gap-2">
<Label>{t('Topics (t tags for categorization)')}</Label>
<Input
value={topicsStr}
onChange={(e) => setTopicsStr(e.target.value)}
placeholder={t('Comma-separated topics')}
<DynamicStringListField
label={t('Topics (t tags for categorization)')}
hint={t('One topic per row.')}
placeholder={t('topic')}
values={form.topics}
onChange={(topics) => setForm((f) => ({ ...f, topics }))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('Mode')}</Label>
@ -254,8 +365,9 @@ export default function CreateSpellDialog({ @@ -254,8 +365,9 @@ export default function CreateSpellDialog({
</p>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2 justify-end pt-2 border-t">
<div className="flex shrink-0 flex-wrap justify-end gap-2 border-t px-6 py-4">
<Button variant="outline" onClick={handleClear}>
{t('Clear')}
</Button>

184
src/pages/primary/SpellsPage/index.tsx

@ -12,8 +12,14 @@ import { @@ -12,8 +12,14 @@ import {
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
@ -25,17 +31,19 @@ import { @@ -25,17 +31,19 @@ import {
spellIsCount
} from '@/services/spell.service'
import { TFeedSubRequest } from '@/types'
import { ChevronLeft, FileText, MoreVertical, Plus, Trash2, Wand2 } from 'lucide-react'
import { FileText, MoreVertical, Plus, Star, Trash2, Wand2 } from 'lucide-react'
import type { Event } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CreateSpellDialog from './CreateSpellDialog'
import type { TPageRef } from '@/types'
/** Sentinel value for Radix Select when no spell is selected */
const SPELL_SELECT_NONE = '__spell_none__'
const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
const { t } = useTranslation()
const { pubkey, relayList } = useNostr()
const { isSmallScreen } = useScreenSize()
const [spells, setSpells] = useState<Event[]>([])
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set())
const [selectedSpell, setSelectedSpell] = useState<Event | null>(null)
@ -127,23 +135,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -127,23 +135,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
ref={ref}
pageName="spells"
titlebar={
isSmallScreen ? (
<div className="flex items-center justify-between w-full gap-2">
{selectedSpell ? (
<Button
variant="ghost"
size="titlebar-icon"
onClick={() => setSelectedSpell(null)}
title={t('Back to spell list')}
>
<ChevronLeft className="size-5" />
</Button>
) : (
<div className="w-10 shrink-0" />
)}
<div className="font-semibold flex-1 text-center min-w-0 truncate">
{selectedSpell ? getSpellName(selectedSpell) : t('Spells')}
</div>
<div className="flex w-full items-center justify-between gap-2">
<div className="font-semibold">{t('Spells')}</div>
<Button
variant="ghost"
size="titlebar-icon"
@ -153,133 +146,162 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -153,133 +146,162 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
<Plus className="size-5" />
</Button>
</div>
) : (
<div className="font-semibold">{t('Spells')}</div>
)
}
displayScrollToTopButton
>
<div className="flex flex-col md:flex-row min-h-0 flex-1 gap-4 p-4">
{/* Left (desktop) / Top (mobile) pane: spell list */}
<div className={`flex flex-col gap-2 shrink-0 ${isSmallScreen ? 'order-1 border-b border-border pb-4' : 'w-64 border-r border-border pr-4'}`}>
<div className="flex min-h-0 flex-1 flex-col gap-4 p-4">
{/* Spell picker + actions above the feed */}
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<Select
value={selectedSpell?.id ?? SPELL_SELECT_NONE}
onValueChange={(v) => {
if (v === SPELL_SELECT_NONE) setSelectedSpell(null)
else setSelectedSpell(orderedSpells.find((s) => s.id === v) ?? null)
}}
disabled={orderedSpells.length === 0}
>
<SelectTrigger className="min-w-0 flex-1 sm:max-w-md">
<SelectValue placeholder={t('Select a spell…')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={SPELL_SELECT_NONE}>{t('Select a spell…')}</SelectItem>
{orderedSpells.map((spell) => (
<SelectItem key={spell.id} value={spell.id}>
{favoriteIds.has(spell.id) ? `${getSpellName(spell)}` : getSpellName(spell)}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<Button
className="w-full justify-start gap-2"
className="justify-start gap-2"
variant="outline"
onClick={() => setCreateOpen(true)}
>
<Wand2 className="size-4" />
{t('Create a Spell')}
</Button>
<ul className="space-y-1 overflow-y-auto min-h-0">
{orderedSpells.length === 0 && (
<li className="text-sm text-muted-foreground py-2">{t('No spells yet. Create one above.')}</li>
)}
{orderedSpells.map((spell) => (
<li key={spell.id} className="flex items-center gap-1">
<button
type="button"
className={`flex-1 text-left text-sm px-2 py-1.5 rounded truncate min-w-0 ${selectedSpell?.id === spell.id ? 'bg-primary/10 text-primary font-medium' : 'hover:bg-muted'}`}
onClick={() => setSelectedSpell(spell)}
>
{getSpellName(spell)}
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{selectedSpell && (
<>
<Button
variant="ghost"
variant="outline"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
className="shrink-0"
title={
favoriteIds.has(selectedSpell.id)
? t('Remove from favorites')
: t('Add to favorites')
}
onClick={() => toggleFavorite(selectedSpell.id)}
>
<Star
className={`size-4 ${favoriteIds.has(selectedSpell.id) ? 'fill-amber-400 text-amber-500' : ''}`}
/>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="shrink-0" title={t('More options')}>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setDefinitionSpell(spell)}>
<DropdownMenuItem onClick={() => setDefinitionSpell(selectedSpell)}>
<FileText className="size-4" />
{t('View definition')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => handleDeleteSpell(spell)}
onClick={() => handleDeleteSpell(selectedSpell)}
>
<Trash2 className="size-4" />
{t('Delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
type="button"
className="shrink-0 p-1 text-muted-foreground hover:text-foreground"
onClick={() => toggleFavorite(spell.id)}
title={favoriteIds.has(spell.id) ? t('Remove from favorites') : t('Add to favorites')}
>
{favoriteIds.has(spell.id) ? '★' : '☆'}
</button>
</li>
))}
</ul>
</>
)}
</div>
</div>
{/* Right (desktop) / Bottom (mobile) pane: feed */}
<div className="flex-1 min-w-0 flex flex-col">
{orderedSpells.length === 0 && (
<p className="text-sm text-muted-foreground">{t('No spells yet. Create one with the button above.')}</p>
)}
{/* Feed */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{selectedSpell ? (
subRequests.length > 0 ? (
<NoteList
subRequests={subRequests}
showKinds={selectedSpell.tags.filter((t) => t[0] === 'k').map((t) => parseInt(t[1], 10)).filter((n) => !Number.isNaN(n)) || [1]}
showKinds={(() => {
const kinds = selectedSpell.tags
.filter((tag) => tag[0] === 'k')
.map((tag) => parseInt(tag[1], 10))
.filter((n) => !Number.isNaN(n))
// `[] || [1]` is wrong ([] is truthy); default to kind 1 for notes
return kinds.length ? kinds : [1]
})()}
useFilterAsIs
/>
) : spellIsCount(selectedSpell) ? (
<div className="text-muted-foreground py-8 text-center">{t('COUNT spells show a number, not a feed.')}</div>
) : !pubkey && (selectedSpell.tags.some((t) => t[0] === 'authors' && (t.includes('$me') || t.includes('$contacts')))) ? (
<div className="text-muted-foreground py-8 text-center">{t('Log in to run this spell (it uses $me or $contacts).')}</div>
<div className="py-8 text-center text-muted-foreground">
{t('COUNT spells show a number, not a feed.')}
</div>
) : !pubkey &&
selectedSpell.tags.some(
(tag) => tag[0] === 'authors' && (tag.includes('$me') || tag.includes('$contacts'))
) ? (
<div className="py-8 text-center text-muted-foreground">
{t('Log in to run this spell (it uses $me or $contacts).')}
</div>
) : (
<div className="text-muted-foreground py-8 text-center">
{t('Could not run this spell. Check that it has a valid REQ/COUNT command, or add read relays in settings.')}
<div className="py-8 text-center text-muted-foreground">
{t(
'Could not run this spell. Check that it has a valid REQ/COUNT command, or add read relays in settings.'
)}
</div>
)
) : (
<div className="text-muted-foreground py-8 text-center">{t('Select a spell from the list to view its feed.')}</div>
<div className="py-8 text-center text-muted-foreground">
{t('Select a spell to view its feed.')}
</div>
)}
</div>
</div>
<CreateSpellDialog
open={createOpen}
onOpenChange={setCreateOpen}
onSaved={loadSpells}
/>
<CreateSpellDialog open={createOpen} onOpenChange={setCreateOpen} onSaved={loadSpells} />
<Dialog open={!!definitionSpell} onOpenChange={(open) => !open && setDefinitionSpell(null)}>
<DialogContent className="max-h-[85vh] overflow-y-auto max-w-lg">
<DialogContent className="max-h-[85vh] max-w-lg overflow-y-auto">
<DialogHeader>
<DialogTitle>{definitionSpell ? getSpellName(definitionSpell) : t('Spell definition')}</DialogTitle>
<DialogTitle>
{definitionSpell ? getSpellName(definitionSpell) : t('Spell definition')}
</DialogTitle>
</DialogHeader>
{definitionSpell && (
<div className="space-y-4 text-sm">
{definitionSpell.content?.trim() && (
<div>
<div className="font-medium text-muted-foreground mb-1">{t('Description')}</div>
<div className="mb-1 font-medium text-muted-foreground">{t('Description')}</div>
<p className="whitespace-pre-wrap break-words">{definitionSpell.content.trim()}</p>
</div>
)}
<div>
<div className="font-medium text-muted-foreground mb-2">{t('Tags')}</div>
<div className="mb-2 font-medium text-muted-foreground">{t('Tags')}</div>
<dl className="space-y-1.5 font-mono text-xs">
{definitionSpell.tags.map((tag, i) => (
<div key={i} className="flex flex-wrap gap-x-2 gap-y-0.5">
<dt className="text-muted-foreground shrink-0">{tag[0]}:</dt>
<dd className="break-all min-w-0">
<dt className="shrink-0 text-muted-foreground">{tag[0]}:</dt>
<dd className="min-w-0 break-all">
{tag.length > 1 ? tag.slice(1).join(', ') : '—'}
</dd>
</div>
))}
</dl>
</div>
<div className="text-muted-foreground text-xs break-words overflow-wrap-anywhere">
<span className="font-medium">id:</span>{' '}
<span className="break-all">{definitionSpell.id}</span>
<div className="overflow-wrap-anywhere break-words text-xs text-muted-foreground">
<span className="font-medium">id:</span> <span className="break-all">{definitionSpell.id}</span>
</div>
</div>
)}

36
src/services/spell.service.ts

@ -2,12 +2,12 @@ @@ -2,12 +2,12 @@
* NIP-A7 Spells: parse and execute kind 777 events as portable relay query filters.
*/
import { ExtendedKind } from '@/constants'
import { ExtendedKind, FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import { tagNameEquals } from '@/lib/tag'
import logger from '@/lib/logger'
import { normalizeUrl } from '@/lib/url'
import type { Event } from 'nostr-tools'
import type { Filter } from 'nostr-tools'
import { FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
const RELATIVE_UNIT_SECONDS: Record<string, number> = {
s: 1,
@ -48,17 +48,41 @@ export type SpellExecutionContext = { @@ -48,17 +48,41 @@ export type SpellExecutionContext = {
relayListRead: string[]
}
/** Default read relays for spells (deduped); merged after spell/user lists so outages on one relay still leave alternatives. */
function defaultSpellReadFallbackRelays(): string[] {
return dedupeRelayUrls([...FAST_READ_RELAY_URLS, ...SEARCHABLE_RELAY_URLS])
}
function dedupeRelayUrls(urls: string[]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const u of urls) {
const key = normalizeUrl(u) || u
if (!key || seen.has(key)) continue
seen.add(key)
out.push(key)
}
return out
}
/**
* Get relay URLs for executing a spell: from spell's `relays` tag or fallback to context relay list / fast-read.
* Get relay URLs for executing a spell: from spell's `relays` tag or context read list, always merged with
* app default read relays so a single down relay (503, etc.) does not block the feed.
*/
export function getRelaysForSpell(spell: Event, context: { relayListRead: string[] }): string[] {
let primary: string[] = []
const relayTag = spell.tags.find(tagNameEquals('relays'))
if (relayTag && relayTag.length > 1) {
const urls = relayTag.slice(1).filter((u): u is string => typeof u === 'string' && (u.startsWith('wss://') || u.startsWith('ws://')))
if (urls.length) return urls
if (urls.length) primary = urls
}
if (!primary.length && context.relayListRead.length) {
primary = [...context.relayListRead]
}
if (!primary.length) {
return defaultSpellReadFallbackRelays()
}
if (context.relayListRead.length) return context.relayListRead
return [...new Set([...FAST_READ_RELAY_URLS, ...SEARCHABLE_RELAY_URLS])]
return dedupeRelayUrls([...primary, ...FAST_READ_RELAY_URLS, ...SEARCHABLE_RELAY_URLS])
}
/**

Loading…
Cancel
Save