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. 234
      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 }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -2345,27 +2374,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
</div>
) : (
<>
{!!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 (
<div
key={item.index}
style={{
display: isLast ? 'block' : 'none'
}}
>
{item.component}
</div>
)
})}
{secondaryStack.length > 0 ? (
<TopSecondaryStackPane item={secondaryStack[secondaryStack.length - 1]!} />
) : null}
{secondaryStack.length === 0 ? (
<div className="block h-full min-h-0 min-w-0">
{renderActivePrimaryPageContent(primaryPages, currentPrimaryPage)}
@ -2457,20 +2468,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2457,20 +2468,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
{/* 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">
{secondaryStack.length > 0 ? (
secondaryStack.map((item, index) => {
const isLast = index === secondaryStack.length - 1
return (
<div
key={item.index}
className={cn(
'h-full min-h-0 min-w-0 flex-col',
isLast ? 'flex' : 'hidden'
)}
>
{item.component}
</div>
)
})
<TopSecondaryStackPane
item={secondaryStack[secondaryStack.length - 1]!}
className="flex h-full min-h-0 min-w-0 flex-col"
/>
) : (
<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>
@ -2637,6 +2638,21 @@ function secondaryPanelUrlsMatch(stackUrl: string, locationUrl: string): boolean @@ -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 (
<div key={item.index} className={className}>
{item.component}
</div>
)
}
function isPrimaryOnlyPathname(pathname: string): boolean {
const pathOnly = pathname.split('?')[0].split('#')[0]
const segments = pathOnly.split('/').filter(Boolean)

10
src/components/Relay/index.tsx

@ -5,7 +5,7 @@ import SearchInput from '@/components/SearchInput' @@ -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< @@ -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< @@ -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< @@ -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< @@ -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]
)

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

@ -1,9 +1,11 @@ @@ -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', () => { @@ -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', () => { @@ -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(

31
src/lib/url.ts

@ -118,6 +118,11 @@ export function normalizeAnyRelayUrl(url: string): string { @@ -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( @@ -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<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(
url: string,
configuredHttpIndexBases: readonly string[]

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

@ -3,12 +3,12 @@ import { RefreshButton } from '@/components/RefreshButton' @@ -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<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 feedRef = useRef<TNoteListRef>(null)

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

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

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

@ -135,9 +135,9 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -135,9 +135,9 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const [uploadingAvatar, setUploadingAvatar] = useState(false)
const [paymentInfoEvent, setPaymentInfoEvent] = useState<Event | null>(null)
const [paymentInfoEditOpen, setPaymentInfoEditOpen] = useState(false)
const [paymentInfoEditContent, setPaymentInfoEditContent] = useState('')
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 savingPaymentInfoRef = useRef(false)
const [profileEventJson, setProfileEventJson] = useState<string>('')
@ -174,11 +174,11 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -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) => { @@ -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) => { @@ -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) => { @@ -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) => { @@ -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) => { @@ -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<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 { 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) => { @@ -886,50 +838,16 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
</div>
</Item>
<Item>
<Label htmlFor="payment-info-content">{t('Additional content (JSON)')}</Label>
<Input
id="payment-info-content"
className="font-mono text-sm"
value={paymentInfoEditContent}
onChange={(e) => setPaymentInfoEditContent(e.target.value)}
placeholder='{}'
/>
</Item>
<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>
)}
<Label className="text-muted-foreground">{t('Event (JSON)')}</Label>
<p className="text-xs text-muted-foreground">
{t('paytoEditor.jsonPreviewHint', {
defaultValue:
'Live preview of the kind 10133 event that will be published. Payto tag order matches the list above.'
})}
</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">
{paymentInfoPreviewJson}
</pre>
</Item>
</div>
<DialogFooter>
@ -962,6 +880,80 @@ function tagRowsFromTags(tags: string[][]): EditorTagRow[] { @@ -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<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.
*

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

@ -3,14 +3,14 @@ import Relay from '@/components/Relay' @@ -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<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 bumpFeed = useCallback(() => {

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

@ -9,7 +9,7 @@ import { @@ -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 @@ -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) : []),

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

@ -31,7 +31,7 @@ import logger from '@/lib/logger' @@ -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 { @@ -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)))

10
src/services/client.service.ts

@ -159,7 +159,7 @@ import { @@ -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 { @@ -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 { @@ -2898,7 +2898,7 @@ class ClientService extends EventTarget {
let eosedAt: number | null = null
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 httpPollCursorUnix = 0
const clearHttpTimelinePoll = () => {
@ -3135,7 +3135,7 @@ class ClientService extends EventTarget { @@ -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 { @@ -3364,7 +3364,7 @@ class ClientService extends EventTarget {
} = {}
) {
const originalDedupedRelays = Array.from(new Set(urls))
const httpRelayBases = httpIndexRelayBasesInUrlBatch(
const httpRelayBases = httpIndexBasesForRelayQuery(
originalDedupedRelays,
this.viewerHttpIndexRelayBases
)

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

@ -1,4 +1,9 @@ @@ -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 { @@ -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' },

Loading…
Cancel
Save