From 5f16f40714e4a22946a90298e453891e9fe94ea3 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 14 May 2026 20:41:22 +0200 Subject: [PATCH] bug-fixes --- src/components/Content/index.tsx | 232 +++++++++---------- src/components/Profile/index.tsx | 17 +- src/constants.ts | 2 + src/hooks/useFetchProfile.tsx | 8 +- src/hooks/useProfileAuthorFeedSubRequests.ts | 5 +- src/hooks/useProfilePins.tsx | 3 +- src/i18n/locales/cs.ts | 2 +- src/i18n/locales/de.ts | 2 +- src/i18n/locales/en.ts | 2 +- src/i18n/locales/es.ts | 2 +- src/i18n/locales/fr.ts | 2 +- src/i18n/locales/nl.ts | 2 +- src/i18n/locales/pl.ts | 2 +- src/i18n/locales/ru.ts | 2 +- src/i18n/locales/tr.ts | 2 +- src/i18n/locales/zh.ts | 2 +- src/lib/event-metadata.ts | 15 +- src/lib/lightning.ts | 2 +- src/providers/NostrProvider/index.tsx | 34 ++- src/services/lightning.service.ts | 81 ++++++- src/types/index.d.ts | 5 + 21 files changed, 264 insertions(+), 160 deletions(-) diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index 39fcf74b..5e42154c 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -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,134 +307,121 @@ export default function Content({ return links }, [event, contentLinks]) - // Create maps for quick lookup of images/media by cleaned URL - const imageMap = new Map() - const mediaMap = new Map() - extractedMedia.all.forEach((img: TImetaInfo) => { - const cleaned = cleanUrl(img.url) - if (!cleaned) return - if (img.m?.startsWith('image/')) { - imageMap.set(cleaned, img) - } else if ( - img.m?.startsWith('video/') || - img.m?.startsWith('audio/') || - img.m === 'media/*' || - isHlsPlaylistUrl(cleaned) - ) { - mediaMap.set(cleaned, img) - } else if (isImage(cleaned)) { - imageMap.set(cleaned, img) - } else if (isMedia(cleaned)) { - mediaMap.set(cleaned, img) - } - }) - - // 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 && - extractedMedia.videos.length === 0 && - extractedMedia.audio.length === 0 && - !iArticleUrl - ) { - return null - } - } + /** 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() + const mediaMap = new Map() + extractedMedia.all.forEach((img: TImetaInfo) => { + const cleaned = cleanUrl(img.url) + if (!cleaned) return + if (img.m?.startsWith('image/')) { + imageMap.set(cleaned, img) + } else if ( + img.m?.startsWith('video/') || + img.m?.startsWith('audio/') || + img.m === 'media/*' || + isHlsPlaylistUrl(cleaned) + ) { + mediaMap.set(cleaned, img) + } else if (isImage(cleaned)) { + imageMap.set(cleaned, img) + } else if (isMedia(cleaned)) { + mediaMap.set(cleaned, img) + } + }) - // First pass: find which media appears in content (will be rendered in carousels or inline) - const mediaInContent = new Set() - 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)) { - imagesInContent.push(imageInfo) + if (!nodes || nodes.length === 0) { + if ( + extractedMedia.images.length === 0 && + extractedMedia.videos.length === 0 && + extractedMedia.audio.length === 0 && + !iArticleUrl + ) { + return null } - } else if (node.type === 'images') { - const urls = Array.isArray(node.data) ? node.data : [node.data] - 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)) { - imagesInContent.push(imageInfo) + } + + const pubkey = event?.pubkey + const mediaInContent = new Set() + const imagesInContent: TImetaInfo[] = [] + + 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 } + 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) => { + const cleaned = cleanUrl(url) + mediaInContent.add(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) + } else if (node.type === 'url') { + const cleanedUrl = cleanUrl(node.data) + if (isImage(cleanedUrl)) { + mediaInContent.add(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) + } else if (isAudio(cleanedUrl)) { + mediaInContent.add(cleanedUrl) + } else if (isMedia(cleanedUrl)) { + mediaInContent.add(cleanedUrl) + } } }) - } 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)) { - 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) - } } + + const carouselImages = extractedMedia.images.filter((img: TImetaInfo) => { + const cleaned = cleanUrl(img.url) + return cleaned && !mediaInContent.has(cleaned) }) - } - - // 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) - }) - const videosFromTags = extractedMedia.videos.filter((video: TImetaInfo) => { - const cleaned = cleanUrl(video.url) - return cleaned && !mediaInContent.has(cleaned) - }) - const audioFromTags = extractedMedia.audio.filter((audio: TImetaInfo) => { - const cleaned = cleanUrl(audio.url) - 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 })) || [] - }) - + const videosFromTags = extractedMedia.videos.filter((video: TImetaInfo) => { + const cleaned = cleanUrl(video.url) + return cleaned && !mediaInContent.has(cleaned) + }) + const audioFromTags = extractedMedia.audio.filter((audio: TImetaInfo) => { + const cleaned = cleanUrl(audio.url) + return cleaned && !mediaInContent.has(cleaned) + }) + + 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() diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 12a59ce2..28640ac4 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -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' 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( 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) => { diff --git a/src/constants.ts b/src/constants.ts index 559bec11..fead0dd1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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). diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx index 6265db12..26e52d19 100644 --- a/src/hooks/useFetchProfile.tsx +++ b/src/hooks/useFetchProfile.tsx @@ -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) { 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) diff --git a/src/hooks/useProfileAuthorFeedSubRequests.ts b/src/hooks/useProfileAuthorFeedSubRequests.ts index cdaddb7c..fa92bd7f 100644 --- a/src/hooks/useProfileAuthorFeedSubRequests.ts +++ b/src/hooks/useProfileAuthorFeedSubRequests.ts @@ -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 diff --git a/src/hooks/useProfilePins.tsx b/src/hooks/useProfilePins.tsx index 9cf39f12..18ba0b41 100644 --- a/src/hooks/useProfilePins.tsx +++ b/src/hooks/useProfilePins.tsx @@ -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(() => { diff --git a/src/i18n/locales/cs.ts b/src/i18n/locales/cs.ts index 8bf5ad10..659df9a5 100644 --- a/src/i18n/locales/cs.ts +++ b/src/i18n/locales/cs.ts @@ -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", diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 8a14e567..4826284d 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -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", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index fb6ea2fd..e1a4dd5c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 50897598..c3f7e782 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -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", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 35b67a02..c2993687 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -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", diff --git a/src/i18n/locales/nl.ts b/src/i18n/locales/nl.ts index 95d79d9c..055f6626 100644 --- a/src/i18n/locales/nl.ts +++ b/src/i18n/locales/nl.ts @@ -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", diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index b97d0581..4bf5a99b 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -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", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index bcd2edeb..7ed7fc7e 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -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", diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts index 830cb35d..adee87d9 100644 --- a/src/i18n/locales/tr.ts +++ b/src/i18n/locales/tr.ts @@ -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", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index cb106ec5..098636bd 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -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", diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index dfe8e76b..868e7592 100644 --- a/src/lib/event-metadata.ts +++ b/src/lib/event-metadata.ts @@ -185,6 +185,17 @@ export function getProfileFromEvent(event: Event) { const websiteTags = event.tags.filter(tag => tag[0] === 'website' && tag[1]).map(tag => tag[1]) 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 = @@ -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) { 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) { lud16, lightningAddress, lightningAddressList: lightningAddressList.length > 0 ? lightningAddressList : undefined, + wWalletTags: wWalletTags.length > 0 ? wWalletTags : undefined, created_at: event.created_at } } diff --git a/src/lib/lightning.ts b/src/lib/lightning.ts index 94b5d95e..0012f92f 100644 --- a/src/lib/lightning.ts +++ b/src/lib/lightning.ts @@ -28,5 +28,5 @@ export function getLightningAddressFromProfile(profile: TProfile) { lud06 = a } - return lud16 || lud06 || undefined + return lud16 || lud06 || profile.lightningAddress || undefined } diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 5ac7d72a..5bb5cd1a 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -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 }) { 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 }) { 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 }) { 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 }) { ], authors: [account.pubkey] } - ]) + ], hydrateFetchOpts) if (hydrationGenForThisRun !== accountHydrationGenerationRef.current) { return controller } @@ -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 }) { 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 }) { 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 }) { finishHydration() }) return () => { + window.clearTimeout(wallTimer) promise .then((controller) => { controller?.abort() diff --git a/src/services/lightning.service.ts b/src/services/lightning.service.ts index af9e9654..733cbb36 100644 --- a/src/services/lightning.service.ts +++ b/src/services/lightning.service.ts @@ -344,34 +344,91 @@ class LightningService { callback: string lnurl: string }> { - try { - let lnurl: string = '' + const candidates = this.lightningAddressCandidates(profile) + for (const addr of candidates) { + const resolved = await this.fetchLnurlPayZapEndpoint(addr) + if (resolved) return resolved + } + return null + } - // Some clients have incorrectly filled in the positions for lud06 and lud16 - if (!profile.lightningAddress) { - return null - } + /** 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() + 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 + } - if (profile.lightningAddress.includes('@')) { - const [name, domain] = profile.lightningAddress.split('@') + private async fetchLnurlPayZapEndpoint(lightningAddress: string): Promise { + 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 diff --git a/src/types/index.d.ts b/src/types/index.d.ts index a907bd41..f909d1bb 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -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