Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
5f16f40714
  1. 78
      src/components/Content/index.tsx
  2. 17
      src/components/Profile/index.tsx
  3. 2
      src/constants.ts
  4. 8
      src/hooks/useFetchProfile.tsx
  5. 5
      src/hooks/useProfileAuthorFeedSubRequests.ts
  6. 3
      src/hooks/useProfilePins.tsx
  7. 2
      src/i18n/locales/cs.ts
  8. 2
      src/i18n/locales/de.ts
  9. 2
      src/i18n/locales/en.ts
  10. 2
      src/i18n/locales/es.ts
  11. 2
      src/i18n/locales/fr.ts
  12. 2
      src/i18n/locales/nl.ts
  13. 2
      src/i18n/locales/pl.ts
  14. 2
      src/i18n/locales/ru.ts
  15. 2
      src/i18n/locales/tr.ts
  16. 2
      src/i18n/locales/zh.ts
  17. 15
      src/lib/event-metadata.ts
  18. 2
      src/lib/lightning.ts
  19. 34
      src/providers/NostrProvider/index.tsx
  20. 79
      src/services/lightning.service.ts
  21. 5
      src/types/index.d.ts

78
src/components/Content/index.tsx

@ -2,7 +2,6 @@ import { useEmojiInfosForEvent, useMediaExtraction } from '@/hooks' @@ -2,7 +2,6 @@ import { useEmojiInfosForEvent, useMediaExtraction } from '@/hooks'
import { parseContent, PARSE_CONTENT_PARSERS_NOTE_TEXT } from '@/lib/content-parser'
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import { logContentSpacing, reprString } from '@/lib/content-spacing-debug'
import logger from '@/lib/logger'
import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
import { cn } from '@/lib/utils'
import { getHttpUrlFromITags } from '@/lib/event'
@ -308,7 +307,8 @@ export default function Content({ @@ -308,7 +307,8 @@ export default function Content({
return links
}, [event, contentLinks])
// Create maps for quick lookup of images/media by cleaned URL
/** Maps, node scan, and tag-vs-content splits — memoized so feed/provider re-renders do not redo O(n²) work per note. */
const contentMediaLayout = useMemo(() => {
const imageMap = new Map<string, TImetaInfo>()
const mediaMap = new Map<string, TImetaInfo>()
extractedMedia.all.forEach((img: TImetaInfo) => {
@ -330,7 +330,6 @@ export default function Content({ @@ -330,7 +330,6 @@ export default function Content({
}
})
// If no nodes but we have media from tags, still render the media (or i-tag article preview)
if (!nodes || nodes.length === 0) {
if (
extractedMedia.images.length === 0 &&
@ -342,67 +341,44 @@ export default function Content({ @@ -342,67 +341,44 @@ export default function Content({
}
}
// First pass: find which media appears in content (will be rendered in carousels or inline)
const pubkey = event?.pubkey
const mediaInContent = new Set<string>()
const imagesInContent: TImetaInfo[] = []
const videosInContent: TImetaInfo[] = []
const audioInContent: TImetaInfo[] = []
// Only process nodes if they exist and are not empty
if (nodes && nodes.length > 0) {
nodes.forEach((node) => {
if (node.type === 'image') {
const cleanedUrl = cleanUrl(node.data)
mediaInContent.add(cleanedUrl)
const imageInfo = imageMap.get(cleanedUrl) || { url: cleanedUrl, pubkey: event?.pubkey }
if (!imagesInContent.find(img => img.url === cleanedUrl)) {
const imageInfo = imageMap.get(cleanedUrl) || { url: cleanedUrl, pubkey }
if (!imagesInContent.find((img) => img.url === cleanedUrl)) {
imagesInContent.push(imageInfo)
}
} else if (node.type === 'images') {
const urls = Array.isArray(node.data) ? node.data : [node.data]
urls.forEach(url => {
urls.forEach((url) => {
const cleaned = cleanUrl(url)
mediaInContent.add(cleaned)
const imageInfo = imageMap.get(cleaned) || { url: cleaned, pubkey: event?.pubkey }
if (!imagesInContent.find(img => img.url === cleaned)) {
const imageInfo = imageMap.get(cleaned) || { url: cleaned, pubkey }
if (!imagesInContent.find((img) => img.url === cleaned)) {
imagesInContent.push(imageInfo)
}
})
} else if (node.type === 'media') {
const cleanedUrl = cleanUrl(node.data)
mediaInContent.add(cleanedUrl)
const mediaInfo = mediaMap.get(cleanedUrl)
if (isVideo(cleanedUrl) || isHlsPlaylistUrl(cleanedUrl) || mediaInfo?.m?.startsWith('video/')) {
const row = mediaInfo || { url: cleanedUrl, pubkey: event?.pubkey, m: 'video/*' }
if (!videosInContent.find((v) => v.url === cleanedUrl)) {
videosInContent.push(row)
}
} else if (isAudio(cleanedUrl) || mediaInfo?.m?.startsWith('audio/')) {
const row = mediaInfo || { url: cleanedUrl, pubkey: event?.pubkey, m: 'audio/*' }
if (!audioInContent.find((a) => a.url === cleanedUrl)) {
audioInContent.push(row)
}
}
} else if (node.type === 'url') {
const cleanedUrl = cleanUrl(node.data)
if (isImage(cleanedUrl)) {
mediaInContent.add(cleanedUrl)
const imageInfo = imageMap.get(cleanedUrl) || { url: cleanedUrl, pubkey: event?.pubkey }
if (!imagesInContent.find(img => img.url === cleanedUrl)) {
const imageInfo = imageMap.get(cleanedUrl) || { url: cleanedUrl, pubkey }
if (!imagesInContent.find((img) => img.url === cleanedUrl)) {
imagesInContent.push(imageInfo)
}
} else if (isVideo(cleanedUrl) || isHlsPlaylistUrl(cleanedUrl)) {
mediaInContent.add(cleanedUrl)
const videoInfo = mediaMap.get(cleanedUrl) || { url: cleanedUrl, pubkey: event?.pubkey, m: 'video/*' }
if (!videosInContent.find(v => v.url === cleanedUrl)) {
videosInContent.push(videoInfo)
}
} else if (isAudio(cleanedUrl)) {
mediaInContent.add(cleanedUrl)
const audioInfo = mediaMap.get(cleanedUrl) || { url: cleanedUrl, pubkey: event?.pubkey, m: 'audio/*' }
if (!audioInContent.find(a => a.url === cleanedUrl)) {
audioInContent.push(audioInfo)
}
} else if (isMedia(cleanedUrl)) {
mediaInContent.add(cleanedUrl)
}
@ -410,8 +386,6 @@ export default function Content({ @@ -410,8 +386,6 @@ export default function Content({
})
}
// Filter: only show media that DON'T appear in content (from tags)
// Use cleaned URLs for comparison to ensure consistency
const carouselImages = extractedMedia.images.filter((img: TImetaInfo) => {
const cleaned = cleanUrl(img.url)
return cleaned && !mediaInContent.has(cleaned)
@ -425,16 +399,28 @@ export default function Content({ @@ -425,16 +399,28 @@ export default function Content({
return cleaned && !mediaInContent.has(cleaned)
})
logger.debug('[Content] Parsed content:', {
nodeCount: nodes?.length || 0,
allMedia: extractedMedia.all.length,
images: extractedMedia.images.length,
videos: extractedMedia.videos.length,
audio: extractedMedia.audio.length,
imageMapSize: imageMap.size,
mediaMapSize: mediaMap.size,
nodes: nodes?.map(n => ({ type: n.type, data: Array.isArray(n.data) ? n.data.length : n.data })) || []
})
return {
imageMap,
mediaMap,
mediaInContent,
imagesInContent,
carouselImages,
videosFromTags,
audioFromTags
}
}, [nodes, extractedMedia, event?.pubkey, iArticleUrl])
if (!contentMediaLayout) return null
const {
imageMap,
mediaMap,
mediaInContent,
imagesInContent,
carouselImages,
videosFromTags,
audioFromTags
} = contentMediaLayout
// Track which images/media have been rendered individually to prevent duplicates
const renderedUrls = new Set<string>()

17
src/components/Profile/index.tsx

@ -19,7 +19,7 @@ import { showSimplePublishSuccess, toastPublishPromise } from '@/lib/publishing- @@ -19,7 +19,7 @@ import { showSimplePublishSuccess, toastPublishPromise } from '@/lib/publishing-
import { toProfileEditor } from '@/lib/link'
import { encodeProfileInteractionsSpellId } from '@/pages/primary/SpellsPage/fauxSpellConfig'
import { generateImageByPubkey } from '@/lib/pubkey'
import { isVideo } from '@/lib/url'
import { isVideo, normalizeAnyRelayUrl } from '@/lib/url'
import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
@ -81,7 +81,7 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -81,7 +81,7 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import { nip66Service } from '@/services/nip66.service'
import { normalizeAnyRelayUrl } from '@/lib/url'
import { buildPaytoUri } from '@/lib/payto'
import type { TProfile } from '@/types'
/**
@ -163,6 +163,19 @@ function mergePaymentMethods( @@ -163,6 +163,19 @@ function mergePaymentMethods(
if (addr) add('lightning', addr, `payto://lightning/${addr}`, 'Lightning Network')
})
// Kind-0 `w` tags: on-chain / liquid (lightning rows are already in lightningAddressList)
profile?.wWalletTags?.forEach((w) => {
const net = w.network.toLowerCase()
if (net === 'lightning') return
const addr = w.address?.trim()
if (!addr) return
if (net === 'bitcoin') {
add('bitcoin', addr, buildPaytoUri('bitcoin', addr), 'Bitcoin', { currency: w.currency })
} else if (net === 'liquid') {
add('liquid', addr, buildPaytoUri('liquid', addr), 'Liquid', { currency: w.currency })
}
})
// Then kind 10133 (payto tags and JSON content)
if (paymentInfo?.methods?.length) {
paymentInfo.methods.forEach((m) => {

2
src/constants.ts

@ -227,6 +227,8 @@ export const SINGLE_RELAY_KINDLESS_EOSE_TIMEOUT_MS = 6000 @@ -227,6 +227,8 @@ export const SINGLE_RELAY_KINDLESS_EOSE_TIMEOUT_MS = 6000
* IndexedDB cache still applies on every load; this only skips redundant network merges after a recent run.
*/
export const ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000
/** Failsafe: clear {@link NostrProvider} `isAccountSessionHydrating` if the hydrate promise never settles (hung relays, etc.). */
export const ACCOUNT_SESSION_HYDRATE_WALL_MS = 60_000
/**
* Batched kind-0 queries (ReplaceableEventService) over many relays (inbox, favorites, cache, defaults).

8
src/hooks/useFetchProfile.tsx

@ -2,7 +2,7 @@ import { PROFILE_FETCH_PROMISE_TIMEOUT_MS } from '@/constants' @@ -2,7 +2,7 @@ import { PROFILE_FETCH_PROMISE_TIMEOUT_MS } from '@/constants'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { getProfileFromEvent } from '@/lib/event-metadata'
import { getSeededProfileForNavigation } from '@/lib/profile-navigation-seed'
import { userIdToPubkey } from '@/lib/pubkey'
import { normalizeHexPubkey, userIdToPubkey } from '@/lib/pubkey'
import { useNostrOptional } from '@/providers/nostr-context'
import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext'
import { eventService, replaceableEventService } from '@/services/client.service'
@ -610,8 +610,12 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -610,8 +610,12 @@ export function useFetchProfile(id?: string, skipCache = false) {
if (targetPk.length !== 64 || !/^[0-9a-f]{64}$/i.test(targetPk)) return
if (targetPk !== accPk.toLowerCase()) return
const profilePk = profile?.pubkey?.trim()
const haveFullLocal =
profile?.pubkey === targetPk && !profile.batchPlaceholder
!!profilePk &&
/^[0-9a-f]{64}$/i.test(profilePk) &&
normalizeHexPubkey(profilePk) === targetPk &&
!profile?.batchPlaceholder
if (haveFullLocal) return
setProfile(acc)

5
src/hooks/useProfileAuthorFeedSubRequests.ts

@ -106,7 +106,10 @@ export function useProfileAuthorFeedSubRequests({ @@ -106,7 +106,10 @@ export function useProfileAuthorFeedSubRequests({
return () => {
cancelled = true
}
}, [pubkey, relayListsKey, kindsKey, kinds, refreshToken, favoriteRelays, blockedRelays, includeAuthorLocalRelays])
// `relayListsKey` already fingerprints `favoriteRelays` + `blockedRelays` by sorted URL content.
// Do not list those arrays here: the provider often hands new `[]` references each render and would
// retrigger this effect forever (setState → re-render → new refs → effect → …).
}, [pubkey, relayListsKey, kindsKey, kinds, refreshToken, includeAuthorLocalRelays])
const activeUrls = fullUrls?.length ? fullUrls : provisionalUrls

3
src/hooks/useProfilePins.tsx

@ -283,7 +283,8 @@ export function useProfilePins(pubkey: string | undefined) { @@ -283,7 +283,8 @@ export function useProfilePins(pubkey: string | undefined) {
setLoadingPins(false)
}
},
[pubkey, blockedKey, blockedRelays, includeAuthorLocalRelays]
// `blockedKey` fingerprints `blockedRelays`; omit the array so new [] references do not recreate loadPins every render.
[pubkey, blockedKey, includeAuthorLocalRelays]
)
useEffect(() => {

2
src/i18n/locales/cs.ts

@ -1988,7 +1988,7 @@ export default { @@ -1988,7 +1988,7 @@ export default {
"Type a topic or pick from the list": "Type a topic or pick from the list",
"Uploading to media server…": "Uploading to media server…",
profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint",
startupSessionHydrating: "startupSessionHydrating",
startupSessionHydrating: "Syncing your relays and profile from the network…",
"AI / LLM prompt citation": "AI / LLM prompt citation",
"AsciiDoc wiki contribution": "AsciiDoc wiki contribution",
"Attach image, audio, or video": "Attach image, audio, or video",

2
src/i18n/locales/de.ts

@ -2009,7 +2009,7 @@ export default { @@ -2009,7 +2009,7 @@ export default {
"Type a topic or pick from the list": "Type a topic or pick from the list",
"Uploading to media server…": "Wird zum Medienserver hochgeladen…",
profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint",
startupSessionHydrating: "startupSessionHydrating",
startupSessionHydrating: "Relais und Profil werden aus dem Netz synchronisiert…",
"AI / LLM prompt citation": "AI / LLM prompt citation",
"AsciiDoc wiki contribution": "AsciiDoc wiki contribution",
"Attach image, audio, or video": "Attach image, audio, or video",

2
src/i18n/locales/en.ts

@ -2054,7 +2054,7 @@ export default { @@ -2054,7 +2054,7 @@ export default {
"Type a topic or pick from the list": "Type a topic or pick from the list",
"Uploading to media server…": "Uploading to media server…",
profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint",
startupSessionHydrating: "startupSessionHydrating",
startupSessionHydrating: "Syncing your relays and profile from the network…",
"AI / LLM prompt citation": "AI / LLM prompt citation",
"AsciiDoc wiki contribution": "AsciiDoc wiki contribution",
"Attach image, audio, or video": "Attach image, audio, or video",

2
src/i18n/locales/es.ts

@ -1988,7 +1988,7 @@ export default { @@ -1988,7 +1988,7 @@ export default {
"Type a topic or pick from the list": "Type a topic or pick from the list",
"Uploading to media server…": "Uploading to media server…",
profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint",
startupSessionHydrating: "startupSessionHydrating",
startupSessionHydrating: "Syncing your relays and profile from the network…",
"AI / LLM prompt citation": "AI / LLM prompt citation",
"AsciiDoc wiki contribution": "AsciiDoc wiki contribution",
"Attach image, audio, or video": "Attach image, audio, or video",

2
src/i18n/locales/fr.ts

@ -1988,7 +1988,7 @@ export default { @@ -1988,7 +1988,7 @@ export default {
"Type a topic or pick from the list": "Type a topic or pick from the list",
"Uploading to media server…": "Uploading to media server…",
profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint",
startupSessionHydrating: "startupSessionHydrating",
startupSessionHydrating: "Syncing your relays and profile from the network…",
"AI / LLM prompt citation": "AI / LLM prompt citation",
"AsciiDoc wiki contribution": "AsciiDoc wiki contribution",
"Attach image, audio, or video": "Attach image, audio, or video",

2
src/i18n/locales/nl.ts

@ -1988,7 +1988,7 @@ export default { @@ -1988,7 +1988,7 @@ export default {
"Type a topic or pick from the list": "Type a topic or pick from the list",
"Uploading to media server…": "Uploading to media server…",
profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint",
startupSessionHydrating: "startupSessionHydrating",
startupSessionHydrating: "Syncing your relays and profile from the network…",
"AI / LLM prompt citation": "AI / LLM prompt citation",
"AsciiDoc wiki contribution": "AsciiDoc wiki contribution",
"Attach image, audio, or video": "Attach image, audio, or video",

2
src/i18n/locales/pl.ts

@ -1988,7 +1988,7 @@ export default { @@ -1988,7 +1988,7 @@ export default {
"Type a topic or pick from the list": "Type a topic or pick from the list",
"Uploading to media server…": "Uploading to media server…",
profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint",
startupSessionHydrating: "startupSessionHydrating",
startupSessionHydrating: "Syncing your relays and profile from the network…",
"AI / LLM prompt citation": "AI / LLM prompt citation",
"AsciiDoc wiki contribution": "AsciiDoc wiki contribution",
"Attach image, audio, or video": "Attach image, audio, or video",

2
src/i18n/locales/ru.ts

@ -1988,7 +1988,7 @@ export default { @@ -1988,7 +1988,7 @@ export default {
"Type a topic or pick from the list": "Type a topic or pick from the list",
"Uploading to media server…": "Uploading to media server…",
profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint",
startupSessionHydrating: "startupSessionHydrating",
startupSessionHydrating: "Syncing your relays and profile from the network…",
"AI / LLM prompt citation": "AI / LLM prompt citation",
"AsciiDoc wiki contribution": "AsciiDoc wiki contribution",
"Attach image, audio, or video": "Attach image, audio, or video",

2
src/i18n/locales/tr.ts

@ -1988,7 +1988,7 @@ export default { @@ -1988,7 +1988,7 @@ export default {
"Type a topic or pick from the list": "Type a topic or pick from the list",
"Uploading to media server…": "Uploading to media server…",
profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint",
startupSessionHydrating: "startupSessionHydrating",
startupSessionHydrating: "Syncing your relays and profile from the network…",
"AI / LLM prompt citation": "AI / LLM prompt citation",
"AsciiDoc wiki contribution": "AsciiDoc wiki contribution",
"Attach image, audio, or video": "Attach image, audio, or video",

2
src/i18n/locales/zh.ts

@ -1988,7 +1988,7 @@ export default { @@ -1988,7 +1988,7 @@ export default {
"Type a topic or pick from the list": "Type a topic or pick from the list",
"Uploading to media server…": "Uploading to media server…",
profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint",
startupSessionHydrating: "startupSessionHydrating",
startupSessionHydrating: "Syncing your relays and profile from the network…",
"AI / LLM prompt citation": "AI / LLM prompt citation",
"AsciiDoc wiki contribution": "AsciiDoc wiki contribution",
"Attach image, audio, or video": "Attach image, audio, or video",

15
src/lib/event-metadata.ts

@ -186,6 +186,17 @@ export function getProfileFromEvent(event: Event) { @@ -186,6 +186,17 @@ export function getProfileFromEvent(event: Event) {
const lud06Tags = event.tags.filter(tag => tag[0] === 'lud06' && tag[1]).map(tag => tag[1])
const lud16Tags = event.tags.filter(tag => tag[0] === 'lud16' && tag[1]).map(tag => tag[1])
/** `["w", currency, address, network]` — multi-wallet hints on kind 0 */
const wWalletTags = event.tags
.filter((tag): tag is string[] => tag[0] === 'w' && !!tag[1] && !!tag[2] && !!tag[3])
.map((tag) => ({
currency: String(tag[1]).trim(),
address: String(tag[2]).trim(),
network: String(tag[3]).trim().toLowerCase()
}))
.filter((w) => w.address && w.network)
const wLightningAddresses = wWalletTags.filter((w) => w.network === 'lightning').map((w) => w.address)
// Use first tag entry for single values, or fallback to JSON
const nip05 =
nip05Tags.length > 0 ? nip05Tags[0] : firstNip05StringFromJson(profileObj.nip05)
@ -205,7 +216,7 @@ export function getProfileFromEvent(event: Event) { @@ -205,7 +216,7 @@ export function getProfileFromEvent(event: Event) {
// Build lightning address from FIRST tag or JSON (prefer first tag, fallback to JSON)
// This is used by the zap button and should only come from kind 0, not kind 10133 payto
const lightningAddressFromTags = lud16 || lud06
const lightningAddressFromTags = lud16 || lud06 || wLightningAddresses[0]
const lightningAddressFromJson = getLightningAddressFromProfile({ lud06: profileObj.lud06, lud16: profileObj.lud16 } as TProfile)
const lightningAddress = lightningAddressFromTags || lightningAddressFromJson
@ -213,6 +224,7 @@ export function getProfileFromEvent(event: Event) { @@ -213,6 +224,7 @@ export function getProfileFromEvent(event: Event) {
const lightningAddressList = [...new Set([
...(lud16Tags.length > 0 ? lud16Tags : []),
...(lud06Tags.length > 0 ? lud06Tags : []),
...wLightningAddresses,
...(profileObj.lud16 ? [profileObj.lud16] : []),
...(profileObj.lud06 ? [profileObj.lud06] : []),
...(lightningAddressFromJson && !lightningAddressFromTags ? [lightningAddressFromJson] : [])
@ -257,6 +269,7 @@ export function getProfileFromEvent(event: Event) { @@ -257,6 +269,7 @@ export function getProfileFromEvent(event: Event) {
lud16,
lightningAddress,
lightningAddressList: lightningAddressList.length > 0 ? lightningAddressList : undefined,
wWalletTags: wWalletTags.length > 0 ? wWalletTags : undefined,
created_at: event.created_at
}
}

2
src/lib/lightning.ts

@ -28,5 +28,5 @@ export function getLightningAddressFromProfile(profile: TProfile) { @@ -28,5 +28,5 @@ export function getLightningAddressFromProfile(profile: TProfile) {
lud06 = a
}
return lud16 || lud06 || undefined
return lud16 || lud06 || profile.lightningAddress || undefined
}

34
src/providers/NostrProvider/index.tsx

@ -2,6 +2,7 @@ import storage from '@/services/local-storage.service' @@ -2,6 +2,7 @@ import storage from '@/services/local-storage.service'
import LoginDialog from '@/components/LoginDialog'
import NcryptsecPasswordPrompt from '@/components/NcryptsecPasswordPrompt'
import {
ACCOUNT_SESSION_HYDRATE_WALL_MS,
ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS,
DEFAULT_FAVORITE_RELAYS,
FAST_READ_RELAY_URLS,
@ -239,6 +240,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -239,6 +240,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
hydrationGen: hydrationGenForThisRun
})
const controller = new AbortController()
/** Abort + bounded time on hydrate REQs so tab close / account switch does not leave hung subs. */
const hydrateFetchOpts = {
signal: controller.signal,
globalTimeout: 28_000,
foreground: true as const,
firstRelayResultGraceMs: false as const
}
const storedNsec = storage.getAccountNsec(account.pubkey)
if (storedNsec) {
setNsec(storedNsec)
@ -415,7 +423,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -415,7 +423,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
kinds: [ExtendedKind.RSS_FEED_LIST],
authors: [account.pubkey],
limit: 1
})
}, hydrateFetchOpts)
.then((events) => {
const latestEvent = getLatestEvent(events)
if (latestEvent) {
@ -457,16 +465,16 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -457,16 +465,16 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
queryService.fetchEvents(FAST_READ_RELAY_URLS, {
kinds: [kinds.RelayList],
authors: [account.pubkey]
}),
}, hydrateFetchOpts),
queryService.fetchEvents(FAST_READ_RELAY_URLS, {
kinds: [ExtendedKind.CACHE_RELAYS],
authors: [account.pubkey]
}),
}, hydrateFetchOpts),
queryService.fetchEvents(FAST_READ_RELAY_URLS, {
kinds: [ExtendedKind.HTTP_RELAY_LIST],
authors: [account.pubkey],
limit: 1
})
}, hydrateFetchOpts)
])
if (hydrationGenForThisRun !== accountHydrationGenerationRef.current) {
return controller
@ -523,7 +531,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -523,7 +531,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
],
authors: [account.pubkey]
}
])
], hydrateFetchOpts)
if (hydrationGenForThisRun !== accountHydrationGenerationRef.current) {
return controller
}
@ -625,7 +633,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -625,7 +633,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
authors: [account.pubkey],
kinds: [kinds.Contacts],
limit: 1
})
}, hydrateFetchOpts)
.then((evts) => {
const evt = evts.sort((a, b) => b.created_at - a.created_at)[0]
if (evt && hydrationGenForThisRun === accountHydrationGenerationRef.current) {
@ -677,7 +685,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -677,7 +685,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
authors: [account.pubkey],
kinds: [kinds.Mutelist],
limit: 10
})
}, hydrateFetchOpts)
.then((evts) => {
const evt = getLatestEvent(evts)
if (evt && hydrationGenForThisRun === accountHydrationGenerationRef.current) {
@ -793,7 +801,18 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -793,7 +801,18 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return controller
}
const promise = init()
const wallTimer = window.setTimeout(() => {
if (accountHydrationGenerationRef.current === hydrationGenForThisRun) {
logger.warn('[NostrProvider] Account session hydrate exceeded wall time; clearing spinner', {
pubkeySlice: account?.pubkey?.slice(0, 12),
hydrationGen: hydrationGenForThisRun,
wallMs: ACCOUNT_SESSION_HYDRATE_WALL_MS
})
setIsAccountSessionHydrating(false)
}
}, ACCOUNT_SESSION_HYDRATE_WALL_MS)
void promise.finally(() => {
window.clearTimeout(wallTimer)
const r = manualNetworkHydrateResolveRef.current
manualNetworkHydrateResolveRef.current = null
r?.()
@ -811,6 +830,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -811,6 +830,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
finishHydration()
})
return () => {
window.clearTimeout(wallTimer)
promise
.then((controller) => {
controller?.abort()

79
src/services/lightning.service.ts

@ -344,34 +344,91 @@ class LightningService { @@ -344,34 +344,91 @@ class LightningService {
callback: string
lnurl: string
}> {
try {
let lnurl: string = ''
// Some clients have incorrectly filled in the positions for lud06 and lud16
if (!profile.lightningAddress) {
const candidates = this.lightningAddressCandidates(profile)
for (const addr of candidates) {
const resolved = await this.fetchLnurlPayZapEndpoint(addr)
if (resolved) return resolved
}
return null
}
if (profile.lightningAddress.includes('@')) {
const [name, domain] = profile.lightningAddress.split('@')
/** Ordered lightning identifiers from kind 0 (lud16/lud06 + `w` lightning rows); de-duplicated. */
private lightningAddressCandidates(profile: TProfile): string[] {
const raw =
profile.lightningAddressList?.length && profile.lightningAddressList.length > 0
? profile.lightningAddressList
: profile.lightningAddress
? [profile.lightningAddress]
: []
const out: string[] = []
const seen = new Set<string>()
for (const a of raw) {
const t = a?.trim()
if (!t) continue
const k = t.toLowerCase()
if (seen.has(k)) continue
seen.add(k)
out.push(t)
}
return out
}
private async fetchLnurlPayZapEndpoint(lightningAddress: string): Promise<null | {
callback: string
lnurl: string
}> {
try {
let lnurl = ''
if (lightningAddress.includes('@')) {
const [name, domain] = lightningAddress.split('@')
if (!name?.trim() || !domain?.trim()) return null
lnurl = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString()
} else {
const { words } = bech32.decode(profile.lightningAddress as any, 1000)
const { words } = bech32.decode(lightningAddress as any, 1000)
const data = bech32.fromWords(words)
lnurl = utf8Decoder.decode(data)
}
const res = await fetchWithTimeout(lnurl, { timeoutMs: 15_000 })
const body = await res.json()
if (!res.ok) {
logger.warn('LNURL-pay metadata HTTP error', {
status: res.status,
statusText: res.statusText,
lnurl,
lightningAddress
})
return null
}
if (body.allowsNostr && body.nostrPubkey) {
const text = await res.text()
let body: { allowsNostr?: unknown; nostrPubkey?: unknown; callback?: unknown }
try {
body = JSON.parse(text) as { allowsNostr?: unknown; nostrPubkey?: unknown; callback?: unknown }
} catch {
logger.warn('LNURL-pay metadata was not valid JSON (HTML error page or empty redirect target?)', {
lnurl,
lightningAddress,
preview: text.slice(0, 160)
})
return null
}
if (body.allowsNostr && body.nostrPubkey && typeof body.callback === 'string') {
return {
callback: body.callback,
lnurl
}
}
} catch (err) {
logger.error('Failed to resolve LNURL from profile', { error: err, profile })
const failedFetch =
err instanceof TypeError || (err instanceof Error && err.message === 'Failed to fetch')
logger.error('Failed to resolve LNURL from profile', {
error: err,
lightningAddress,
/** Browser blocks reading cross-origin LNURL without `Access-Control-Allow-Origin`. */
hint: typeof window !== 'undefined' && failedFetch ? 'possible_missing_cors_on_lnurl_host' : undefined
})
}
return null

5
src/types/index.d.ts vendored

@ -38,6 +38,11 @@ export type TProfile = { @@ -38,6 +38,11 @@ export type TProfile = {
lud16?: string
lightningAddress?: string
lightningAddressList?: string[]
/**
* Kind-0 `w` tags: `["w", currency, address, network]` (bitcoin / liquid / lightning, etc.).
* Lightning rows are merged into {@link lightningAddressList}; this keeps on-chain / liquid for payto UI.
*/
wWalletTags?: Array<{ currency: string; address: string; network: string }>
created_at?: number
/** Kind 0: `bot` / `bot,true` tags without `bot,false` — see Nostr profile conventions. */
isBot?: boolean

Loading…
Cancel
Save