Browse Source

update json preview on profile and payto

fix http relay page
imwald
Silberengel 3 weeks ago
parent
commit
af6f5f5bf0
  1. 92
      src/PageManager.tsx
  2. 10
      src/components/Relay/index.tsx
  3. 13
      src/lib/relay-url-normalize.test.ts
  4. 31
      src/lib/url.ts
  5. 4
      src/pages/primary/RelayPage/index.tsx
  6. 2
      src/pages/secondary/NotFoundPage/index.tsx
  7. 232
      src/pages/secondary/ProfileEditorPage/index.tsx
  8. 4
      src/pages/secondary/RelayPage/index.tsx
  9. 4
      src/pages/secondary/RelayReviewsPage/index.tsx
  10. 4
      src/services/client-query.service.ts
  11. 10
      src/services/client.service.ts
  12. 11
      src/services/relay-info.service.ts

92
src/PageManager.tsx

@ -1597,7 +1597,18 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// If the side panel has frames, this popstate is almost certainly stack navigation — do not let // If the side panel has frames, this popstate is almost certainly stack navigation — do not let
// modalManager steal it (history.forward + return), which leaves the URL changed and the panel stale. // modalManager steal it (history.forward + return), which leaves the URL changed and the panel stale.
const browserPathOnlyEarly = window.location.pathname.split('?')[0].split('#')[0]
if (secondaryStackRef.current.length === 0) { if (secondaryStackRef.current.length === 0) {
if (!isPrimaryOnlyPathname(browserPathOnlyEarly)) {
const locUrl =
window.location.pathname + window.location.search + window.location.hash
const synced = syncSecondaryStackWhenPopStateStateIsNull([], locUrl)
if (synced.length > 0) {
secondaryStackRef.current = synced
setSecondaryStack(synced)
return
}
}
const closeModal = modalManager.pop() const closeModal = modalManager.pop()
if (closeModal) { if (closeModal) {
ignorePopStateRef.current = true ignorePopStateRef.current = true
@ -1718,6 +1729,14 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
if (state.index === currentIndex && currentItem) { if (state.index === currentIndex && currentItem) {
const historyState = state const historyState = state
const browserLoc =
window.location.pathname + window.location.search + window.location.hash
if (
!secondaryPanelUrlsMatch(currentItem.url, browserLoc) &&
!secondaryPanelUrlsMatch(currentItem.url, historyState.url)
) {
return syncSecondaryStackWhenPopStateStateIsNull(pre, browserLoc)
}
const urlMatches = const urlMatches =
currentItem.url === historyState.url || currentItem.url === historyState.url ||
secondaryPanelUrlsMatch(currentItem.url, historyState.url) secondaryPanelUrlsMatch(currentItem.url, historyState.url)
@ -2145,7 +2164,13 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
openDrawer(noteId, navigationEventStore.peekEvent(noteId)) openDrawer(noteId, navigationEventStore.peekEvent(noteId))
} }
/** UI-first back: sync stack / drawer immediately, then align browser history. */
const popSecondaryPage = () => { const popSecondaryPage = () => {
navigationCounterRef.current += 1
if (primaryNoteView) {
setPrimaryNoteView(null)
}
const stackLen = secondaryStackRef.current.length const stackLen = secondaryStackRef.current.length
// Mobile / single-pane: one code path — drawer + stack share the same close behavior // Mobile / single-pane: one code path — drawer + stack share the same close behavior
@ -2153,9 +2178,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
if (stackLen > 1) { if (stackLen > 1) {
const next = popOneSecondaryStackFrame() const next = popOneSecondaryStackFrame()
syncDrawerToSecondaryStackTop(next) syncDrawerToSecondaryStackTop(next)
ignorePopStateRef.current = true
window.history.back() window.history.back()
} else { } else {
hardCloseSecondaryPanel() hardCloseSecondaryPanel()
const pathOnly = window.location.pathname.split('?')[0].split('#')[0]
if (!isPrimaryOnlyPathname(pathOnly)) {
ignorePopStateRef.current = true
window.history.back()
}
} }
return return
} }
@ -2184,9 +2215,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
} }
} else if (stackLen > 1) { } else if (stackLen > 1) {
popOneSecondaryStackFrame() popOneSecondaryStackFrame()
// Must use real history navigation: replaceState + slice desyncs URL from the session stack ignorePopStateRef.current = true
// (e.g. note → highlight → Back: bar shows the article but the panel still shows the highlight).
// Eager stack pop above keeps the panel in sync even when popstate returns early (index === currentIndex).
window.history.back() window.history.back()
} else { } else {
// Stack empty but user hit back/close: align URL to primary without history.go(-1), which // Stack empty but user hit back/close: align URL to primary without history.go(-1), which
@ -2345,27 +2374,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
</div> </div>
) : ( ) : (
<> <>
{!!secondaryStack.length && {secondaryStack.length > 0 ? (
secondaryStack.map((item, index) => { <TopSecondaryStackPane item={secondaryStack[secondaryStack.length - 1]!} />
const isLast = index === secondaryStack.length - 1 ) : null}
logger.component('PageManager', 'Rendering secondary stack item', {
index,
isLast,
url: item.url,
hasComponent: !!item.component,
display: isLast ? 'block' : 'none'
})
return (
<div
key={item.index}
style={{
display: isLast ? 'block' : 'none'
}}
>
{item.component}
</div>
)
})}
{secondaryStack.length === 0 ? ( {secondaryStack.length === 0 ? (
<div className="block h-full min-h-0 min-w-0"> <div className="block h-full min-h-0 min-w-0">
{renderActivePrimaryPageContent(primaryPages, currentPrimaryPage)} {renderActivePrimaryPageContent(primaryPages, currentPrimaryPage)}
@ -2457,20 +2468,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
{/* Right: secondary stack — max width so left pane keeps space on small desktops */} {/* 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 bg-muted/25"> <div className="flex h-full min-h-0 w-[min(1042px,50vw)] shrink-0 flex-col overflow-hidden border-l border-border bg-muted/25">
{secondaryStack.length > 0 ? ( {secondaryStack.length > 0 ? (
secondaryStack.map((item, index) => { <TopSecondaryStackPane
const isLast = index === secondaryStack.length - 1 item={secondaryStack[secondaryStack.length - 1]!}
return ( className="flex h-full min-h-0 min-w-0 flex-col"
<div />
key={item.index}
className={cn(
'h-full min-h-0 min-w-0 flex-col',
isLast ? 'flex' : 'hidden'
)}
>
{item.component}
</div>
)
})
) : ( ) : (
<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"> <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>{t('doublePane.secondaryEmpty')}</p>
@ -2637,6 +2638,21 @@ function secondaryPanelUrlsMatch(stackUrl: string, locationUrl: string): boolean
} }
/** `/`, `/feed`, `/explore`, etc. — not `/notes/…`, `/feed/notes/…`, `/relays/…`. */ /** `/`, `/feed`, `/explore`, etc. — not `/notes/…`, `/feed/notes/…`, `/relays/…`. */
/** Mount only the top secondary frame so Back unmounts feeds/relays under the previous page. */
function TopSecondaryStackPane({
item,
className = 'block h-full min-h-0 min-w-0'
}: {
item: TStackItem
className?: string
}) {
return (
<div key={item.index} className={className}>
{item.component}
</div>
)
}
function isPrimaryOnlyPathname(pathname: string): boolean { function isPrimaryOnlyPathname(pathname: string): boolean {
const pathOnly = pathname.split('?')[0].split('#')[0] const pathOnly = pathname.split('?')[0].split('#')[0]
const segments = pathOnly.split('/').filter(Boolean) const segments = pathOnly.split('/').filter(Boolean)

10
src/components/Relay/index.tsx

@ -5,7 +5,7 @@ import SearchInput from '@/components/SearchInput'
import { useFetchRelayInfo } from '@/hooks' import { useFetchRelayInfo } from '@/hooks'
import type { TPrimaryPageName } from '@/PageManager' import type { TPrimaryPageName } from '@/PageManager'
import { SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants' import { SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants'
import { isLocalNetworkUrl, normalizeAnyRelayUrl } from '@/lib/url' import { canonicalRelaySessionKey, isLocalNetworkUrl, normalizeRelayUrlForPage } from '@/lib/url'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -33,7 +33,7 @@ const Relay = forwardRef<
const { t } = useTranslation() const { t } = useTranslation()
const { addRelayUrls, removeRelayUrls } = useCurrentRelays() const { addRelayUrls, removeRelayUrls } = useCurrentRelays()
const { showKinds } = useKindFilterOrDefaults() const { showKinds } = useKindFilterOrDefaults()
const normalizedUrl = useMemo(() => (url ? normalizeAnyRelayUrl(url) : undefined), [url]) const normalizedUrl = useMemo(() => (url ? normalizeRelayUrlForPage(url) : undefined), [url])
const { relayInfo } = useFetchRelayInfo(normalizedUrl) const { relayInfo } = useFetchRelayInfo(normalizedUrl)
const [searchInput, setSearchInput] = useState('') const [searchInput, setSearchInput] = useState('')
const [debouncedInput, setDebouncedInput] = useState(searchInput) const [debouncedInput, setDebouncedInput] = useState(searchInput)
@ -65,7 +65,7 @@ const Relay = forwardRef<
const handleRelayRefresh = (event: CustomEvent) => { const handleRelayRefresh = (event: CustomEvent) => {
const { relayUrl } = event.detail const { relayUrl } = event.detail
if (normalizeAnyRelayUrl(relayUrl) === normalizedUrl) { if (canonicalRelaySessionKey(relayUrl) === canonicalRelaySessionKey(normalizedUrl)) {
if (noteListRef && typeof noteListRef !== 'function') { if (noteListRef && typeof noteListRef !== 'function') {
noteListRef.current?.refresh() noteListRef.current?.refresh()
} }
@ -108,7 +108,7 @@ const Relay = forwardRef<
/** When we know delivery relays, drop rows that never arrived from this feed’s relay (stale cache / mis-tagged). */ /** When we know delivery relays, drop rows that never arrived from this feed’s relay (stale cache / mis-tagged). */
const relaySeenMatchKey = useMemo( const relaySeenMatchKey = useMemo(
() => (normalizedUrl ? (normalizeAnyRelayUrl(normalizedUrl) || normalizedUrl).toLowerCase() : ''), () => (normalizedUrl ? canonicalRelaySessionKey(normalizedUrl) : ''),
[normalizedUrl] [normalizedUrl]
) )
const shouldHideEventNotFromThisRelay = useCallback( const shouldHideEventNotFromThisRelay = useCallback(
@ -122,7 +122,7 @@ const Relay = forwardRef<
if (normalizedUrl && isLocalNetworkUrl(normalizedUrl)) return false if (normalizedUrl && isLocalNetworkUrl(normalizedUrl)) return false
const seen = client.getSeenEventRelayUrls(ev.id) const seen = client.getSeenEventRelayUrls(ev.id)
if (seen.length === 0) return false if (seen.length === 0) return false
return !seen.some((u) => (normalizeAnyRelayUrl(u) || u).toLowerCase() === relaySeenMatchKey) return !seen.some((u) => canonicalRelaySessionKey(u) === relaySeenMatchKey)
}, },
[relaySeenMatchKey, normalizedUrl, hostPrimaryPageName, allowKindlessRelayExplore] [relaySeenMatchKey, normalizedUrl, hostPrimaryPageName, allowKindlessRelayExplore]
) )

13
src/lib/relay-url-normalize.test.ts

@ -1,9 +1,11 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
canonicalRelaySessionKey, canonicalRelaySessionKey,
httpIndexBasesForRelayQuery,
httpIndexRelayBasesInUrlBatch, httpIndexRelayBasesInUrlBatch,
normalizeAnyRelayUrl, normalizeAnyRelayUrl,
normalizeHttpRelayUrl, normalizeHttpRelayUrl,
normalizeRelayUrlForPage,
normalizeUrl normalizeUrl
} from '@/lib/url' } from '@/lib/url'
@ -14,6 +16,9 @@ describe('relay URL normalization', () => {
expect(normalizeHttpRelayUrl('https://mercury-relay.imwald.eu/')).toMatch( expect(normalizeHttpRelayUrl('https://mercury-relay.imwald.eu/')).toMatch(
/^https:\/\/mercury-relay\.imwald\.eu\/?$/ /^https:\/\/mercury-relay\.imwald\.eu\/?$/
) )
expect(normalizeRelayUrlForPage('https://mercury-relay.imwald.eu/')).toMatch(
/^https:\/\/mercury-relay\.imwald\.eu\/?$/
)
}) })
it('keeps wss relays as wss', () => { it('keeps wss relays as wss', () => {
@ -40,6 +45,14 @@ describe('relay URL normalization', () => {
expect(httpIndexRelayBasesInUrlBatch(batch, [])).toEqual([]) expect(httpIndexRelayBasesInUrlBatch(batch, [])).toEqual([])
}) })
it('httpIndexBasesForRelayQuery polls explicit https relays without kind-10243 config', () => {
const batch = ['https://mercury-relay.imwald.eu/']
expect(httpIndexBasesForRelayQuery(batch, [])).toEqual(['https://mercury-relay.imwald.eu/'])
expect(httpIndexBasesForRelayQuery(batch, ['https://other.example/'])).toEqual([
'https://mercury-relay.imwald.eu/'
])
})
it('canonicalRelaySessionKey routes by scheme without cross-normalizing', () => { it('canonicalRelaySessionKey routes by scheme without cross-normalizing', () => {
expect(canonicalRelaySessionKey('wss://nostr.land/')).toMatch(/^wss:\/\/nostr\.land/) expect(canonicalRelaySessionKey('wss://nostr.land/')).toMatch(/^wss:\/\/nostr\.land/)
expect(canonicalRelaySessionKey('https://mercury-relay.imwald.eu/')).toMatch( expect(canonicalRelaySessionKey('https://mercury-relay.imwald.eu/')).toMatch(

31
src/lib/url.ts

@ -118,6 +118,11 @@ export function normalizeAnyRelayUrl(url: string): string {
return normalizeUrl(url) return normalizeUrl(url)
} }
/** Relay explore/detail routes accept WebSocket relays or kind-10243 HTTP index bases. */
export function normalizeRelayUrlForPage(url: string): string {
return normalizeAnyRelayUrl(url) || normalizeHttpRelayUrl(url)
}
/** Stable key for per-relay session stats (scheme preserved; no https→wss aliasing). */ /** Stable key for per-relay session stats (scheme preserved; no https→wss aliasing). */
export function canonicalRelaySessionKey(url: string): string { export function canonicalRelaySessionKey(url: string): string {
const trimmed = url.trim() const trimmed = url.trim()
@ -155,6 +160,32 @@ export function httpIndexRelayBasesInUrlBatch(
return [...out] return [...out]
} }
/**
* HTTP index bases to poll for a REQ batch: explicit http(s) relay URLs in `urls`, plus any that
* match the viewer's kind-10243 list. Unlike {@link httpIndexRelayBasesInUrlBatch} alone, does not
* require configuration when the batch already names an HTTP index relay (e.g. relay detail page).
*/
export function httpIndexBasesForRelayQuery(
urls: readonly string[],
configuredHttpIndexBases: readonly string[] = []
): string[] {
const seen = new Set<string>()
const out: string[] = []
const add = (raw: string) => {
const n = normalizeHttpRelayUrl(raw)
if (!n || !isKind10243HttpRelayTagUrl(n)) return
const key = n.toLowerCase()
if (seen.has(key)) return
seen.add(key)
out.push(n)
}
for (const raw of urls) {
if (isHttpOrHttpsScheme(raw.trim())) add(raw)
}
for (const base of httpIndexRelayBasesInUrlBatch(urls, configuredHttpIndexBases)) add(base)
return out
}
export function urlMatchesConfiguredHttpIndexRelay( export function urlMatchesConfiguredHttpIndexRelay(
url: string, url: string,
configuredHttpIndexBases: readonly string[] configuredHttpIndexBases: readonly string[]

4
src/pages/primary/RelayPage/index.tsx

@ -3,12 +3,12 @@ import { RefreshButton } from '@/components/RefreshButton'
import Relay from '@/components/Relay' import Relay from '@/components/Relay'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { TPageRef } from '@/types' import { TPageRef } from '@/types'
import { normalizeAnyRelayUrl, simplifyUrl } from '@/lib/url' import { normalizeRelayUrlForPage, simplifyUrl } from '@/lib/url'
import { Server } from 'lucide-react' import { Server } from 'lucide-react'
import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from 'react' import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from 'react'
const RelayPage = forwardRef<TPageRef, { url?: string }>(({ url }, ref) => { const RelayPage = forwardRef<TPageRef, { url?: string }>(({ url }, ref) => {
const normalizedUrl = useMemo(() => (url ? normalizeAnyRelayUrl(url) : undefined), [url]) const normalizedUrl = useMemo(() => (url ? normalizeRelayUrlForPage(url) : undefined), [url])
const layoutRef = useRef<TPageRef>(null) const layoutRef = useRef<TPageRef>(null)
const feedRef = useRef<TNoteListRef>(null) const feedRef = useRef<TNoteListRef>(null)

2
src/pages/secondary/NotFoundPage/index.tsx

@ -7,7 +7,7 @@ const NotFoundPage = forwardRef(({ index }: { index?: number }, ref) => {
const [contentKey, setContentKey] = useState(0) const [contentKey, setContentKey] = useState(0)
const bump = useCallback(() => setContentKey((k) => k + 1), []) const bump = useCallback(() => setContentKey((k) => k + 1), [])
return ( return (
<SecondaryPageLayout ref={ref} index={index} hideBackButton controls={<RefreshButton onClick={bump} />}> <SecondaryPageLayout ref={ref} index={index} controls={<RefreshButton onClick={bump} />}>
<div key={contentKey}> <div key={contentKey}>
<NotFound /> <NotFound />
</div> </div>

232
src/pages/secondary/ProfileEditorPage/index.tsx

@ -135,9 +135,9 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const [uploadingAvatar, setUploadingAvatar] = useState(false) const [uploadingAvatar, setUploadingAvatar] = useState(false)
const [paymentInfoEvent, setPaymentInfoEvent] = useState<Event | null>(null) const [paymentInfoEvent, setPaymentInfoEvent] = useState<Event | null>(null)
const [paymentInfoEditOpen, setPaymentInfoEditOpen] = useState(false) const [paymentInfoEditOpen, setPaymentInfoEditOpen] = useState(false)
const [paymentInfoEditContent, setPaymentInfoEditContent] = useState('')
const [paymentInfoEditMethods, setPaymentInfoEditMethods] = useState<EditorPaymentMethodRow[]>([]) const [paymentInfoEditMethods, setPaymentInfoEditMethods] = useState<EditorPaymentMethodRow[]>([])
const [paymentInfoShowFullJson, setPaymentInfoShowFullJson] = useState(false) /** Kind 10133 `content` preserved from the opened event; not edited in the UI (payto tags only). */
const paymentInfoDraftContentRef = useRef('{}')
const [savingPaymentInfo, setSavingPaymentInfo] = useState(false) const [savingPaymentInfo, setSavingPaymentInfo] = useState(false)
const savingPaymentInfoRef = useRef(false) const savingPaymentInfoRef = useRef(false)
const [profileEventJson, setProfileEventJson] = useState<string>('') const [profileEventJson, setProfileEventJson] = useState<string>('')
@ -174,11 +174,11 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
setProfileTagRows(tagRowsFromTags(buildTagListFromEvent(profileEvent ?? null))) setProfileTagRows(tagRowsFromTags(buildTagListFromEvent(profileEvent ?? null)))
}, [profileEvent, profileFormSyncLocked]) }, [profileEvent, profileFormSyncLocked])
// Sync full-event JSON editor (same guard as tag list). // Live full-event JSON preview from the current tag list (reorder, edit, add, remove).
useEffect(() => { useEffect(() => {
if (profileFormSyncLocked) return if (!profileEvent) return
setProfileEventJson(profileEvent ? JSON.stringify(profileEvent, null, 2) : '') setProfileEventJson(buildProfileEventJsonFromTagRows(profileEvent, profileTagRows))
}, [profileEvent, profileFormSyncLocked]) }, [profileTagRows, profileEvent])
// Fetch payment info (kind 10133). // Fetch payment info (kind 10133).
useEffect(() => { useEffect(() => {
@ -239,13 +239,25 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
// ─── Payment info ──────────────────────────────────────────────────────────── // ─── Payment info ────────────────────────────────────────────────────────────
const paymentInfoPreviewJson = useMemo(
() =>
JSON.stringify(
createPaymentInfoDraftEvent(
paymentInfoDraftContentRef.current,
paymentMethodsToPaytoTags(paymentInfoEditMethods)
),
null,
2
),
[paymentInfoEditMethods, paymentInfoEditOpen]
)
const openPaymentInfoEditor = useCallback(() => { const openPaymentInfoEditor = useCallback(() => {
if (paymentInfoEvent) { if (paymentInfoEvent) {
setPaymentInfoEditContent( paymentInfoDraftContentRef.current =
typeof paymentInfoEvent.content === 'string' typeof paymentInfoEvent.content === 'string'
? paymentInfoEvent.content ? paymentInfoEvent.content
: JSON.stringify(paymentInfoEvent.content ?? '', null, 2) : JSON.stringify(paymentInfoEvent.content ?? '', null, 2)
)
const paytoTags = (paymentInfoEvent.tags ?? []).filter( const paytoTags = (paymentInfoEvent.tags ?? []).filter(
(tag) => Array.isArray(tag) && tag[0] === 'payto' && tag[1] != null (tag) => Array.isArray(tag) && tag[0] === 'payto' && tag[1] != null
) )
@ -259,34 +271,19 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
: [{ id: newEditorId(), type: 'lightning', authority: '' }] : [{ id: newEditorId(), type: 'lightning', authority: '' }]
) )
} else { } else {
setPaymentInfoEditContent('{}') paymentInfoDraftContentRef.current = '{}'
setPaymentInfoEditMethods([{ id: newEditorId(), type: 'lightning', authority: '' }]) setPaymentInfoEditMethods([{ id: newEditorId(), type: 'lightning', authority: '' }])
} }
setPaymentInfoShowFullJson(false)
setPaymentInfoEditOpen(true) setPaymentInfoEditOpen(true)
}, [paymentInfoEvent]) }, [paymentInfoEvent])
const savePaymentInfo = useCallback(async () => { const savePaymentInfo = useCallback(async () => {
if (savingPaymentInfoRef.current) return if (savingPaymentInfoRef.current) return
const tags: string[][] = paymentInfoEditMethods const tags = paymentMethodsToPaytoTags(paymentInfoEditMethods)
.filter((m) => {
const type = m.type.trim()
return m.authority.trim() && type && type !== PAYTO_EDITOR_OTHER_OPTION
})
.map((m) => {
const type = m.type.trim().toLowerCase()
const authority =
type === 'paypal' ? normalizePaypalAuthority(m.authority) : m.authority.trim()
return ['payto', type, authority]
})
savingPaymentInfoRef.current = true savingPaymentInfoRef.current = true
setSavingPaymentInfo(true) setSavingPaymentInfo(true)
try { try {
const contentStr = paymentInfoEditContent.trim() || '{}' const contentStr = paymentInfoDraftContentRef.current.trim() || '{}'
try { JSON.parse(contentStr) } catch {
toast.error(t('Invalid content JSON'))
return
}
const draft = createPaymentInfoDraftEvent(contentStr, tags) const draft = createPaymentInfoDraftEvent(contentStr, tags)
const published = await publish(draft) const published = await publish(draft)
await client.updatePaymentInfoCache(published) await client.updatePaymentInfoCache(published)
@ -299,7 +296,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
savingPaymentInfoRef.current = false savingPaymentInfoRef.current = false
setSavingPaymentInfo(false) setSavingPaymentInfo(false)
} }
}, [paymentInfoEditContent, paymentInfoEditMethods, publish, t]) }, [paymentInfoEditMethods, publish, t])
// ─── Cache refresh ─────────────────────────────────────────────────────────── // ─── Cache refresh ───────────────────────────────────────────────────────────
@ -316,8 +313,9 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
]) ])
if (profileEvt) { if (profileEvt) {
await updateProfileEvent(profileEvt) await updateProfileEvent(profileEvt)
setProfileTagRows(tagRowsFromTags(buildTagListFromEvent(profileEvt))) const refreshedRows = tagRowsFromTags(buildTagListFromEvent(profileEvt))
setProfileEventJson(JSON.stringify(profileEvt, null, 2)) setProfileTagRows(refreshedRows)
setProfileEventJson(buildProfileEventJsonFromTagRows(profileEvt, refreshedRows))
setHasChanged(false) setHasChanged(false)
} }
setPaymentInfoEvent(paymentEvt ?? null) setPaymentInfoEvent(paymentEvt ?? null)
@ -426,54 +424,8 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const savePromise = (async () => { const savePromise = (async () => {
try { try {
// Strip empty/incomplete rows, trim whitespace. const { contentJson, orderedTags } = profileTagsToSavePayload(profileTagRows)
const validTags = profileTagRows const draft = createProfileDraftEvent(contentJson, orderedTags)
.map((row) => row.tag)
.filter((t) => {
if (!Array.isArray(t) || !(t[0] ?? '').trim()) return false
const name = (t[0] ?? '').trim()
if (name === 'bot') return true
return t.length >= 2 && (t[1] ?? '').trim()
})
.map((t) => {
const name = (t[0] ?? '').trim()
const v1 = (t[1] ?? '').trim()
if (name === 'bot') {
if (t.length === 1 || !v1) return ['bot']
const low = v1.toLowerCase()
if (low === 'false') return ['bot', 'false']
if (low === 'true') return ['bot', 'true']
return ['bot', v1]
}
return [name, v1, ...t.slice(2)]
})
const orderedTags = validTags
// Enforce at-most-one uniqueness: keep only the first occurrence.
.filter((() => {
const seen = new Set<string>()
return (t: string[]) => {
if (!AT_MOST_ONE_NAMES.includes(t[0])) return true
if (seen.has(t[0])) return false
seen.add(t[0])
return true
}
})())
const content: Record<string, string> = {}
const seenContent = new Set<string>()
for (const tag of orderedTags) {
const name = tag[0]
if (name === 'bot') continue
if (DISPLAY_ORDER.includes(name) && !seenContent.has(name)) {
content[name] = tag[1]
seenContent.add(name)
}
}
// Keep displayName alias for backward compatibility.
if (content['display_name']) content['displayName'] = content['display_name']
const draft = createProfileDraftEvent(JSON.stringify(content), orderedTags)
const published = await publish(draft) const published = await publish(draft)
await updateProfileEvent(published) await updateProfileEvent(published)
if (!mountedRef.current) return if (!mountedRef.current) return
@ -886,50 +838,16 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
</div> </div>
</Item> </Item>
<Item> <Item>
<Label htmlFor="payment-info-content">{t('Additional content (JSON)')}</Label> <Label className="text-muted-foreground">{t('Event (JSON)')}</Label>
<Input <p className="text-xs text-muted-foreground">
id="payment-info-content" {t('paytoEditor.jsonPreviewHint', {
className="font-mono text-sm" defaultValue:
value={paymentInfoEditContent} 'Live preview of the kind 10133 event that will be published. Payto tag order matches the list above.'
onChange={(e) => setPaymentInfoEditContent(e.target.value)} })}
placeholder='{}' </p>
/> <pre className="mt-2 p-3 rounded-md bg-muted text-xs overflow-auto max-h-64 break-all whitespace-pre-wrap border font-mono">
</Item> {paymentInfoPreviewJson}
<Item>
<Button
type="button"
variant="outline"
size="sm"
className="gap-1"
onClick={() => setPaymentInfoShowFullJson((v) => !v)}
>
<ChevronDown
className={`h-4 w-4 transition-transform ${paymentInfoShowFullJson ? 'rotate-180' : ''}`}
/>
{t('Show full event JSON')}
</Button>
{paymentInfoShowFullJson && (
<pre className="mt-2 p-3 rounded-md bg-muted text-xs overflow-auto max-h-48 break-all whitespace-pre-wrap border">
{JSON.stringify(
createPaymentInfoDraftEvent(
paymentInfoEditContent.trim() || '{}',
paymentInfoEditMethods
.filter((m) => {
const type = m.type.trim()
return m.authority.trim() && type && type !== PAYTO_EDITOR_OTHER_OPTION
})
.map((m) => {
const type = m.type.trim().toLowerCase()
const authority =
type === 'paypal' ? normalizePaypalAuthority(m.authority) : m.authority.trim()
return ['payto', type, authority]
})
),
null,
2
)}
</pre> </pre>
)}
</Item> </Item>
</div> </div>
<DialogFooter> <DialogFooter>
@ -962,6 +880,80 @@ function tagRowsFromTags(tags: string[][]): EditorTagRow[] {
return tags.map((tag) => ({ id: newEditorId(), tag })) return tags.map((tag) => ({ id: newEditorId(), tag }))
} }
/** Valid tags + content JSON from editor rows (tag list order is preserved). */
function profileTagsToSavePayload(rows: EditorTagRow[]): {
contentJson: string
orderedTags: string[][]
} {
const validTags = rows
.map((row) => row.tag)
.filter((t) => {
if (!Array.isArray(t) || !(t[0] ?? '').trim()) return false
const name = (t[0] ?? '').trim()
if (name === 'bot') return true
return t.length >= 2 && (t[1] ?? '').trim()
})
.map((t) => {
const name = (t[0] ?? '').trim()
const v1 = (t[1] ?? '').trim()
if (name === 'bot') {
if (t.length === 1 || !v1) return ['bot']
const low = v1.toLowerCase()
if (low === 'false') return ['bot', 'false']
if (low === 'true') return ['bot', 'true']
return ['bot', v1]
}
return [name, v1, ...t.slice(2)]
})
const orderedTags = validTags.filter((() => {
const seen = new Set<string>()
return (t: string[]) => {
if (!AT_MOST_ONE_NAMES.includes(t[0])) return true
if (seen.has(t[0])) return false
seen.add(t[0])
return true
}
})())
const content: Record<string, string> = {}
const seenContent = new Set<string>()
for (const tag of orderedTags) {
const name = tag[0]
if (name === 'bot') continue
if (DISPLAY_ORDER.includes(name) && !seenContent.has(name)) {
content[name] = tag[1]
seenContent.add(name)
}
}
if (content['display_name']) content['displayName'] = content['display_name']
return { contentJson: JSON.stringify(content), orderedTags }
}
function buildProfileEventJsonFromTagRows(baseEvent: Event, rows: EditorTagRow[]): string {
const { contentJson, orderedTags } = profileTagsToSavePayload(rows)
return JSON.stringify(
{ ...baseEvent, content: contentJson, tags: orderedTags },
null,
2
)
}
function paymentMethodsToPaytoTags(methods: EditorPaymentMethodRow[]): string[][] {
return methods
.filter((m) => {
const type = m.type.trim()
return m.authority.trim() && type && type !== PAYTO_EDITOR_OTHER_OPTION
})
.map((m) => {
const type = m.type.trim().toLowerCase()
const authority =
type === 'paypal' ? normalizePaypalAuthority(m.authority) : m.authority.trim()
return ['payto', type, authority]
})
}
/** /**
* Build the unified tag list from a stored profile event. * Build the unified tag list from a stored profile event.
* *

4
src/pages/secondary/RelayPage/index.tsx

@ -3,14 +3,14 @@ import Relay from '@/components/Relay'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { normalizeAnyRelayUrl, simplifyUrl } from '@/lib/url' import { normalizeRelayUrlForPage, simplifyUrl } from '@/lib/url'
import { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react'
import NotFoundPage from '../NotFoundPage' import NotFoundPage from '../NotFoundPage'
const RelayPage = forwardRef(({ url, index, hideTitlebar = false }: { url?: string; index?: number; hideTitlebar?: boolean }, ref) => { const RelayPage = forwardRef(({ url, index, hideTitlebar = false }: { url?: string; index?: number; hideTitlebar?: boolean }, ref) => {
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const feedRef = useRef<TNoteListRef>(null) const feedRef = useRef<TNoteListRef>(null)
const normalizedUrl = useMemo(() => (url ? normalizeAnyRelayUrl(url) : undefined), [url]) const normalizedUrl = useMemo(() => (url ? normalizeRelayUrlForPage(url) : undefined), [url])
const title = useMemo(() => (url ? simplifyUrl(url) : undefined), [url]) const title = useMemo(() => (url ? simplifyUrl(url) : undefined), [url])
const bumpFeed = useCallback(() => { const bumpFeed = useCallback(() => {

4
src/pages/secondary/RelayReviewsPage/index.tsx

@ -9,7 +9,7 @@ import {
userReadRelaysWithHttp userReadRelaysWithHttp
} from '@/lib/favorites-feed-relays' } from '@/lib/favorites-feed-relays'
import { relayReviewDTagsForRelayUrl, relayReviewsFeedSnapshotKey } from '@/lib/relay-review-feed' import { relayReviewDTagsForRelayUrl, relayReviewsFeedSnapshotKey } from '@/lib/relay-review-feed'
import { normalizeAnyRelayUrl, simplifyUrl } from '@/lib/url' import { normalizeRelayUrlForPage, simplifyUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import type { TFeedSubRequest } from '@/types' import type { TFeedSubRequest } from '@/types'
@ -34,7 +34,7 @@ const RelayReviewsPage = forwardRef(({ url, index, hideTitlebar = false }: { url
return () => registerPrimaryPanelRefresh(null) return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, bumpFeed]) }, [hideTitlebar, registerPrimaryPanelRefresh, bumpFeed])
const normalizedUrl = useMemo(() => (url ? normalizeAnyRelayUrl(url) : undefined), [url]) const normalizedUrl = useMemo(() => (url ? normalizeRelayUrlForPage(url) : undefined), [url])
/** `d` tag values vary by client (raw vs normalized URL); REQ must OR-match every variant. */ /** `d` tag values vary by client (raw vs normalized URL); REQ must OR-match every variant. */
const relayReviewDTags = useMemo( const relayReviewDTags = useMemo(
() => (url ? relayReviewDTagsForRelayUrl(url) : []), () => (url ? relayReviewDTagsForRelayUrl(url) : []),

4
src/services/client-query.service.ts

@ -31,7 +31,7 @@ import logger from '@/lib/logger'
import { getViewerNostrLandAggrSearchRelayUrls } from '@/lib/nostr-land-relay-eligibility' import { getViewerNostrLandAggrSearchRelayUrls } from '@/lib/nostr-land-relay-eligibility'
import { import {
canonicalRelaySessionKey, canonicalRelaySessionKey,
httpIndexRelayBasesInUrlBatch, httpIndexBasesForRelayQuery,
normalizeAnyRelayUrl, normalizeAnyRelayUrl,
normalizeHttpRelayUrl, normalizeHttpRelayUrl,
normalizeUrl normalizeUrl
@ -479,7 +479,7 @@ export class QueryService {
? FIRST_RELAY_RESULT_GRACE_MS ? FIRST_RELAY_RESULT_GRACE_MS
: null : null
const httpRelayBases = httpIndexRelayBasesInUrlBatch(urls, options?.httpIndexRelayBases ?? []).filter( const httpRelayBases = httpIndexBasesForRelayQuery(urls, options?.httpIndexRelayBases ?? []).filter(
(u) => !relaySessionStrikes.isReadHttpSkipped(u) (u) => !relaySessionStrikes.isReadHttpSkipped(u)
) )
const httpKeys = new Set(httpRelayBases.map((u) => canonicalRelaySessionKey(u))) const httpKeys = new Set(httpRelayBases.map((u) => canonicalRelaySessionKey(u)))

10
src/services/client.service.ts

@ -159,7 +159,7 @@ import {
} from '@/lib/nostr-land-relay-eligibility' } from '@/lib/nostr-land-relay-eligibility'
import { import {
canonicalRelaySessionKey, canonicalRelaySessionKey,
httpIndexRelayBasesInUrlBatch, httpIndexBasesForRelayQuery,
isKind10243HttpRelayTagUrl, isKind10243HttpRelayTagUrl,
isLocalNetworkUrl, isLocalNetworkUrl,
isWebsocketUrl, isWebsocketUrl,
@ -2449,7 +2449,7 @@ class ClientService extends EventTarget {
) { ) {
const originalDedupedRelays = Array.from(new Set(urls)) const originalDedupedRelays = Array.from(new Set(urls))
const httpKeys = new Set( const httpKeys = new Set(
httpIndexRelayBasesInUrlBatch(originalDedupedRelays, this.viewerHttpIndexRelayBases).map((u) => httpIndexBasesForRelayQuery(originalDedupedRelays, this.viewerHttpIndexRelayBases).map((u) =>
canonicalRelaySessionKey(u) canonicalRelaySessionKey(u)
) )
) )
@ -2898,7 +2898,7 @@ class ClientService extends EventTarget {
let eosedAt: number | null = null let eosedAt: number | null = null
let eventIds = new Set<string>() let eventIds = new Set<string>()
const httpTimelinePollBases = httpIndexRelayBasesInUrlBatch(relays, this.viewerHttpIndexRelayBases) const httpTimelinePollBases = httpIndexBasesForRelayQuery(relays, this.viewerHttpIndexRelayBases)
let httpPollIntervalId: ReturnType<typeof setInterval> | null = null let httpPollIntervalId: ReturnType<typeof setInterval> | null = null
let httpPollCursorUnix = 0 let httpPollCursorUnix = 0
const clearHttpTimelinePoll = () => { const clearHttpTimelinePoll = () => {
@ -3135,7 +3135,7 @@ class ClientService extends EventTarget {
// HTTP index relays are handled via httpTimelinePollBases above — never pass them to the WS subscribe path. // HTTP index relays are handled via httpTimelinePollBases above — never pass them to the WS subscribe path.
const httpPollKeys = new Set( const httpPollKeys = new Set(
httpIndexRelayBasesInUrlBatch(relays, this.viewerHttpIndexRelayBases).map((u) => httpIndexBasesForRelayQuery(relays, this.viewerHttpIndexRelayBases).map((u) =>
canonicalRelaySessionKey(u) canonicalRelaySessionKey(u)
) )
) )
@ -3364,7 +3364,7 @@ class ClientService extends EventTarget {
} = {} } = {}
) { ) {
const originalDedupedRelays = Array.from(new Set(urls)) const originalDedupedRelays = Array.from(new Set(urls))
const httpRelayBases = httpIndexRelayBasesInUrlBatch( const httpRelayBases = httpIndexBasesForRelayQuery(
originalDedupedRelays, originalDedupedRelays,
this.viewerHttpIndexRelayBases this.viewerHttpIndexRelayBases
) )

11
src/services/relay-info.service.ts

@ -1,4 +1,9 @@
import { devProxyLoopbackHttpRelayBase, normalizeHttpRelayUrl, simplifyUrl } from '@/lib/url' import {
devProxyCorsProblematicHttpsIndexRelayBase,
devProxyLoopbackHttpRelayBase,
normalizeHttpRelayUrl,
simplifyUrl
} from '@/lib/url'
import indexDb from '@/services/indexed-db.service' import indexDb from '@/services/indexed-db.service'
import { TAwesomeRelayCollection, TRelayInfo } from '@/types' import { TAwesomeRelayCollection, TRelayInfo } from '@/types'
import DataLoader from 'dataloader' import DataLoader from 'dataloader'
@ -167,7 +172,9 @@ class RelayInfoService {
// port and would return that relay's NIP-11 for any localhost WS relay (wrong data). // port and would return that relay's NIP-11 for any localhost WS relay (wrong data).
// HTTP index relay URLs do use the proxy to avoid CORS. // HTTP index relay URLs do use the proxy to avoid CORS.
const isWsRelay = /^wss?:\/\//i.test(url.trim()) const isWsRelay = /^wss?:\/\//i.test(url.trim())
const fetchUrl = isWsRelay ? httpBase : devProxyLoopbackHttpRelayBase(httpBase) const fetchUrl = isWsRelay
? httpBase
: devProxyCorsProblematicHttpsIndexRelayBase(devProxyLoopbackHttpRelayBase(httpBase))
logger.debug('[RelayInfo] Fetching NIP-11', { url, fetchUrl }) logger.debug('[RelayInfo] Fetching NIP-11', { url, fetchUrl })
const res = await fetchWithTimeout(fetchUrl, { const res = await fetchWithTimeout(fetchUrl, {
headers: { Accept: 'application/nostr+json' }, headers: { Accept: 'application/nostr+json' },

Loading…
Cancel
Save