From af6f5f5bf0a4533e97529ab3974e4e6273f28800 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 22 May 2026 14:59:13 +0200 Subject: [PATCH] update json preview on profile and payto fix http relay page --- src/PageManager.tsx | 92 ++++--- src/components/Relay/index.tsx | 10 +- src/lib/relay-url-normalize.test.ts | 13 + src/lib/url.ts | 31 +++ src/pages/primary/RelayPage/index.tsx | 4 +- src/pages/secondary/NotFoundPage/index.tsx | 2 +- .../secondary/ProfileEditorPage/index.tsx | 234 +++++++++--------- src/pages/secondary/RelayPage/index.tsx | 4 +- .../secondary/RelayReviewsPage/index.tsx | 4 +- src/services/client-query.service.ts | 4 +- src/services/client.service.ts | 10 +- src/services/relay-info.service.ts | 11 +- 12 files changed, 239 insertions(+), 180 deletions(-) diff --git a/src/PageManager.tsx b/src/PageManager.tsx index b2db06fd..857d1ff1 100644 --- a/src/PageManager.tsx +++ b/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 // 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 (!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() if (closeModal) { ignorePopStateRef.current = true @@ -1718,6 +1729,14 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { if (state.index === currentIndex && currentItem) { 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 = 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)) } + /** UI-first back: sync stack / drawer immediately, then align browser history. */ const popSecondaryPage = () => { + navigationCounterRef.current += 1 + if (primaryNoteView) { + setPrimaryNoteView(null) + } + const stackLen = secondaryStackRef.current.length // 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) { const next = popOneSecondaryStackFrame() syncDrawerToSecondaryStackTop(next) + ignorePopStateRef.current = true window.history.back() } else { hardCloseSecondaryPanel() + const pathOnly = window.location.pathname.split('?')[0].split('#')[0] + if (!isPrimaryOnlyPathname(pathOnly)) { + ignorePopStateRef.current = true + window.history.back() + } } return } @@ -2184,9 +2215,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } } else if (stackLen > 1) { popOneSecondaryStackFrame() - // Must use real history navigation: replaceState + slice desyncs URL from the session stack - // (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). + ignorePopStateRef.current = true window.history.back() } else { // 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 }) { ) : ( <> - {!!secondaryStack.length && - secondaryStack.map((item, index) => { - const isLast = index === secondaryStack.length - 1 - logger.component('PageManager', 'Rendering secondary stack item', { - index, - isLast, - url: item.url, - hasComponent: !!item.component, - display: isLast ? 'block' : 'none' - }) - return ( -
- {item.component} -
- ) - })} + {secondaryStack.length > 0 ? ( + + ) : null} {secondaryStack.length === 0 ? (
{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 */}
{secondaryStack.length > 0 ? ( - secondaryStack.map((item, index) => { - const isLast = index === secondaryStack.length - 1 - return ( -
- {item.component} -
- ) - }) + ) : (

{t('doublePane.secondaryEmpty')}

@@ -2637,6 +2638,21 @@ function secondaryPanelUrlsMatch(stackUrl: string, locationUrl: string): boolean } /** `/`, `/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 ( +
+ {item.component} +
+ ) +} + function isPrimaryOnlyPathname(pathname: string): boolean { const pathOnly = pathname.split('?')[0].split('#')[0] const segments = pathOnly.split('/').filter(Boolean) diff --git a/src/components/Relay/index.tsx b/src/components/Relay/index.tsx index a3ac24bc..a1607614 100644 --- a/src/components/Relay/index.tsx +++ b/src/components/Relay/index.tsx @@ -5,7 +5,7 @@ import SearchInput from '@/components/SearchInput' import { useFetchRelayInfo } from '@/hooks' import type { TPrimaryPageName } from '@/PageManager' 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 { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import client from '@/services/client.service' @@ -33,7 +33,7 @@ const Relay = forwardRef< const { t } = useTranslation() const { addRelayUrls, removeRelayUrls } = useCurrentRelays() const { showKinds } = useKindFilterOrDefaults() - const normalizedUrl = useMemo(() => (url ? normalizeAnyRelayUrl(url) : undefined), [url]) + const normalizedUrl = useMemo(() => (url ? normalizeRelayUrlForPage(url) : undefined), [url]) const { relayInfo } = useFetchRelayInfo(normalizedUrl) const [searchInput, setSearchInput] = useState('') const [debouncedInput, setDebouncedInput] = useState(searchInput) @@ -65,7 +65,7 @@ const Relay = forwardRef< const handleRelayRefresh = (event: CustomEvent) => { const { relayUrl } = event.detail - if (normalizeAnyRelayUrl(relayUrl) === normalizedUrl) { + if (canonicalRelaySessionKey(relayUrl) === canonicalRelaySessionKey(normalizedUrl)) { if (noteListRef && typeof noteListRef !== 'function') { 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). */ const relaySeenMatchKey = useMemo( - () => (normalizedUrl ? (normalizeAnyRelayUrl(normalizedUrl) || normalizedUrl).toLowerCase() : ''), + () => (normalizedUrl ? canonicalRelaySessionKey(normalizedUrl) : ''), [normalizedUrl] ) const shouldHideEventNotFromThisRelay = useCallback( @@ -122,7 +122,7 @@ const Relay = forwardRef< if (normalizedUrl && isLocalNetworkUrl(normalizedUrl)) return false const seen = client.getSeenEventRelayUrls(ev.id) 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] ) diff --git a/src/lib/relay-url-normalize.test.ts b/src/lib/relay-url-normalize.test.ts index e88232d6..d05e2206 100644 --- a/src/lib/relay-url-normalize.test.ts +++ b/src/lib/relay-url-normalize.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest' import { canonicalRelaySessionKey, + httpIndexBasesForRelayQuery, httpIndexRelayBasesInUrlBatch, normalizeAnyRelayUrl, normalizeHttpRelayUrl, + normalizeRelayUrlForPage, normalizeUrl } from '@/lib/url' @@ -14,6 +16,9 @@ describe('relay URL normalization', () => { expect(normalizeHttpRelayUrl('https://mercury-relay.imwald.eu/')).toMatch( /^https:\/\/mercury-relay\.imwald\.eu\/?$/ ) + expect(normalizeRelayUrlForPage('https://mercury-relay.imwald.eu/')).toMatch( + /^https:\/\/mercury-relay\.imwald\.eu\/?$/ + ) }) it('keeps wss relays as wss', () => { @@ -40,6 +45,14 @@ describe('relay URL normalization', () => { 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', () => { expect(canonicalRelaySessionKey('wss://nostr.land/')).toMatch(/^wss:\/\/nostr\.land/) expect(canonicalRelaySessionKey('https://mercury-relay.imwald.eu/')).toMatch( diff --git a/src/lib/url.ts b/src/lib/url.ts index 64152df9..1a0ed4b2 100644 --- a/src/lib/url.ts +++ b/src/lib/url.ts @@ -118,6 +118,11 @@ export function normalizeAnyRelayUrl(url: string): string { 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). */ export function canonicalRelaySessionKey(url: string): string { const trimmed = url.trim() @@ -155,6 +160,32 @@ export function httpIndexRelayBasesInUrlBatch( 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() + 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( url: string, configuredHttpIndexBases: readonly string[] diff --git a/src/pages/primary/RelayPage/index.tsx b/src/pages/primary/RelayPage/index.tsx index aceb56e2..5a577310 100644 --- a/src/pages/primary/RelayPage/index.tsx +++ b/src/pages/primary/RelayPage/index.tsx @@ -3,12 +3,12 @@ import { RefreshButton } from '@/components/RefreshButton' import Relay from '@/components/Relay' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import { TPageRef } from '@/types' -import { normalizeAnyRelayUrl, simplifyUrl } from '@/lib/url' +import { normalizeRelayUrlForPage, simplifyUrl } from '@/lib/url' import { Server } from 'lucide-react' import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from 'react' const RelayPage = forwardRef(({ url }, ref) => { - const normalizedUrl = useMemo(() => (url ? normalizeAnyRelayUrl(url) : undefined), [url]) + const normalizedUrl = useMemo(() => (url ? normalizeRelayUrlForPage(url) : undefined), [url]) const layoutRef = useRef(null) const feedRef = useRef(null) diff --git a/src/pages/secondary/NotFoundPage/index.tsx b/src/pages/secondary/NotFoundPage/index.tsx index eb3a9d04..5f43e95a 100644 --- a/src/pages/secondary/NotFoundPage/index.tsx +++ b/src/pages/secondary/NotFoundPage/index.tsx @@ -7,7 +7,7 @@ const NotFoundPage = forwardRef(({ index }: { index?: number }, ref) => { const [contentKey, setContentKey] = useState(0) const bump = useCallback(() => setContentKey((k) => k + 1), []) return ( - }> + }>
diff --git a/src/pages/secondary/ProfileEditorPage/index.tsx b/src/pages/secondary/ProfileEditorPage/index.tsx index 000c518d..fbc7fa07 100644 --- a/src/pages/secondary/ProfileEditorPage/index.tsx +++ b/src/pages/secondary/ProfileEditorPage/index.tsx @@ -135,9 +135,9 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { const [uploadingAvatar, setUploadingAvatar] = useState(false) const [paymentInfoEvent, setPaymentInfoEvent] = useState(null) const [paymentInfoEditOpen, setPaymentInfoEditOpen] = useState(false) - const [paymentInfoEditContent, setPaymentInfoEditContent] = useState('') const [paymentInfoEditMethods, setPaymentInfoEditMethods] = useState([]) - 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 savingPaymentInfoRef = useRef(false) const [profileEventJson, setProfileEventJson] = useState('') @@ -174,11 +174,11 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { setProfileTagRows(tagRowsFromTags(buildTagListFromEvent(profileEvent ?? null))) }, [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(() => { - if (profileFormSyncLocked) return - setProfileEventJson(profileEvent ? JSON.stringify(profileEvent, null, 2) : '') - }, [profileEvent, profileFormSyncLocked]) + if (!profileEvent) return + setProfileEventJson(buildProfileEventJsonFromTagRows(profileEvent, profileTagRows)) + }, [profileTagRows, profileEvent]) // Fetch payment info (kind 10133). useEffect(() => { @@ -239,13 +239,25 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { // ─── Payment info ──────────────────────────────────────────────────────────── + const paymentInfoPreviewJson = useMemo( + () => + JSON.stringify( + createPaymentInfoDraftEvent( + paymentInfoDraftContentRef.current, + paymentMethodsToPaytoTags(paymentInfoEditMethods) + ), + null, + 2 + ), + [paymentInfoEditMethods, paymentInfoEditOpen] + ) + const openPaymentInfoEditor = useCallback(() => { if (paymentInfoEvent) { - setPaymentInfoEditContent( + paymentInfoDraftContentRef.current = typeof paymentInfoEvent.content === 'string' ? paymentInfoEvent.content : JSON.stringify(paymentInfoEvent.content ?? '', null, 2) - ) const paytoTags = (paymentInfoEvent.tags ?? []).filter( (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: '' }] ) } else { - setPaymentInfoEditContent('{}') + paymentInfoDraftContentRef.current = '{}' setPaymentInfoEditMethods([{ id: newEditorId(), type: 'lightning', authority: '' }]) } - setPaymentInfoShowFullJson(false) setPaymentInfoEditOpen(true) }, [paymentInfoEvent]) const savePaymentInfo = useCallback(async () => { if (savingPaymentInfoRef.current) return - const tags: string[][] = 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] - }) + const tags = paymentMethodsToPaytoTags(paymentInfoEditMethods) savingPaymentInfoRef.current = true setSavingPaymentInfo(true) try { - const contentStr = paymentInfoEditContent.trim() || '{}' - try { JSON.parse(contentStr) } catch { - toast.error(t('Invalid content JSON')) - return - } + const contentStr = paymentInfoDraftContentRef.current.trim() || '{}' const draft = createPaymentInfoDraftEvent(contentStr, tags) const published = await publish(draft) await client.updatePaymentInfoCache(published) @@ -299,7 +296,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { savingPaymentInfoRef.current = false setSavingPaymentInfo(false) } - }, [paymentInfoEditContent, paymentInfoEditMethods, publish, t]) + }, [paymentInfoEditMethods, publish, t]) // ─── Cache refresh ─────────────────────────────────────────────────────────── @@ -316,8 +313,9 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { ]) if (profileEvt) { await updateProfileEvent(profileEvt) - setProfileTagRows(tagRowsFromTags(buildTagListFromEvent(profileEvt))) - setProfileEventJson(JSON.stringify(profileEvt, null, 2)) + const refreshedRows = tagRowsFromTags(buildTagListFromEvent(profileEvt)) + setProfileTagRows(refreshedRows) + setProfileEventJson(buildProfileEventJsonFromTagRows(profileEvt, refreshedRows)) setHasChanged(false) } setPaymentInfoEvent(paymentEvt ?? null) @@ -426,54 +424,8 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { const savePromise = (async () => { try { - // Strip empty/incomplete rows, trim whitespace. - const validTags = profileTagRows - .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() - 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 = {} - const seenContent = new Set() - 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 { contentJson, orderedTags } = profileTagsToSavePayload(profileTagRows) + const draft = createProfileDraftEvent(contentJson, orderedTags) const published = await publish(draft) await updateProfileEvent(published) if (!mountedRef.current) return @@ -886,50 +838,16 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
- - setPaymentInfoEditContent(e.target.value)} - placeholder='{}' - /> - - - - {paymentInfoShowFullJson && ( -
-                  {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
-                  )}
-                
- )} + +

+ {t('paytoEditor.jsonPreviewHint', { + defaultValue: + 'Live preview of the kind 10133 event that will be published. Payto tag order matches the list above.' + })} +

+
+                {paymentInfoPreviewJson}
+              
@@ -962,6 +880,80 @@ function tagRowsFromTags(tags: string[][]): EditorTagRow[] { 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() + 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 = {} + const seenContent = new Set() + 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. * diff --git a/src/pages/secondary/RelayPage/index.tsx b/src/pages/secondary/RelayPage/index.tsx index 9c4a7db6..879fcfe4 100644 --- a/src/pages/secondary/RelayPage/index.tsx +++ b/src/pages/secondary/RelayPage/index.tsx @@ -3,14 +3,14 @@ import Relay from '@/components/Relay' import { RefreshButton } from '@/components/RefreshButton' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' 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 NotFoundPage from '../NotFoundPage' const RelayPage = forwardRef(({ url, index, hideTitlebar = false }: { url?: string; index?: number; hideTitlebar?: boolean }, ref) => { const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const feedRef = useRef(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 bumpFeed = useCallback(() => { diff --git a/src/pages/secondary/RelayReviewsPage/index.tsx b/src/pages/secondary/RelayReviewsPage/index.tsx index a19e60a3..bb20c1d8 100644 --- a/src/pages/secondary/RelayReviewsPage/index.tsx +++ b/src/pages/secondary/RelayReviewsPage/index.tsx @@ -9,7 +9,7 @@ import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' 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 { useNostr } from '@/providers/NostrProvider' import type { TFeedSubRequest } from '@/types' @@ -34,7 +34,7 @@ const RelayReviewsPage = forwardRef(({ url, index, hideTitlebar = false }: { url return () => registerPrimaryPanelRefresh(null) }, [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. */ const relayReviewDTags = useMemo( () => (url ? relayReviewDTagsForRelayUrl(url) : []), diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index 52a8aa96..f4f090b5 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -31,7 +31,7 @@ import logger from '@/lib/logger' import { getViewerNostrLandAggrSearchRelayUrls } from '@/lib/nostr-land-relay-eligibility' import { canonicalRelaySessionKey, - httpIndexRelayBasesInUrlBatch, + httpIndexBasesForRelayQuery, normalizeAnyRelayUrl, normalizeHttpRelayUrl, normalizeUrl @@ -479,7 +479,7 @@ export class QueryService { ? FIRST_RELAY_RESULT_GRACE_MS : null - const httpRelayBases = httpIndexRelayBasesInUrlBatch(urls, options?.httpIndexRelayBases ?? []).filter( + const httpRelayBases = httpIndexBasesForRelayQuery(urls, options?.httpIndexRelayBases ?? []).filter( (u) => !relaySessionStrikes.isReadHttpSkipped(u) ) const httpKeys = new Set(httpRelayBases.map((u) => canonicalRelaySessionKey(u))) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 5079a16a..7950bb31 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -159,7 +159,7 @@ import { } from '@/lib/nostr-land-relay-eligibility' import { canonicalRelaySessionKey, - httpIndexRelayBasesInUrlBatch, + httpIndexBasesForRelayQuery, isKind10243HttpRelayTagUrl, isLocalNetworkUrl, isWebsocketUrl, @@ -2449,7 +2449,7 @@ class ClientService extends EventTarget { ) { const originalDedupedRelays = Array.from(new Set(urls)) const httpKeys = new Set( - httpIndexRelayBasesInUrlBatch(originalDedupedRelays, this.viewerHttpIndexRelayBases).map((u) => + httpIndexBasesForRelayQuery(originalDedupedRelays, this.viewerHttpIndexRelayBases).map((u) => canonicalRelaySessionKey(u) ) ) @@ -2898,7 +2898,7 @@ class ClientService extends EventTarget { let eosedAt: number | null = null let eventIds = new Set() - const httpTimelinePollBases = httpIndexRelayBasesInUrlBatch(relays, this.viewerHttpIndexRelayBases) + const httpTimelinePollBases = httpIndexBasesForRelayQuery(relays, this.viewerHttpIndexRelayBases) let httpPollIntervalId: ReturnType | null = null let httpPollCursorUnix = 0 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. const httpPollKeys = new Set( - httpIndexRelayBasesInUrlBatch(relays, this.viewerHttpIndexRelayBases).map((u) => + httpIndexBasesForRelayQuery(relays, this.viewerHttpIndexRelayBases).map((u) => canonicalRelaySessionKey(u) ) ) @@ -3364,7 +3364,7 @@ class ClientService extends EventTarget { } = {} ) { const originalDedupedRelays = Array.from(new Set(urls)) - const httpRelayBases = httpIndexRelayBasesInUrlBatch( + const httpRelayBases = httpIndexBasesForRelayQuery( originalDedupedRelays, this.viewerHttpIndexRelayBases ) diff --git a/src/services/relay-info.service.ts b/src/services/relay-info.service.ts index a267cd76..1b09a5c6 100644 --- a/src/services/relay-info.service.ts +++ b/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 { TAwesomeRelayCollection, TRelayInfo } from '@/types' 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). // HTTP index relay URLs do use the proxy to avoid CORS. 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 }) const res = await fetchWithTimeout(fetchUrl, { headers: { Accept: 'application/nostr+json' },