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'
import { parseContent, PARSE_CONTENT_PARSERS_NOTE_TEXT } from '@/lib/content-parser' import { parseContent, PARSE_CONTENT_PARSERS_NOTE_TEXT } from '@/lib/content-parser'
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import { logContentSpacing, reprString } from '@/lib/content-spacing-debug' import { logContentSpacing, reprString } from '@/lib/content-spacing-debug'
import logger from '@/lib/logger'
import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji' import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { getHttpUrlFromITags } from '@/lib/event' import { getHttpUrlFromITags } from '@/lib/event'
@ -308,7 +307,8 @@ export default function Content({
return links return links
}, [event, contentLinks]) }, [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 imageMap = new Map<string, TImetaInfo>()
const mediaMap = new Map<string, TImetaInfo>() const mediaMap = new Map<string, TImetaInfo>()
extractedMedia.all.forEach((img: TImetaInfo) => { extractedMedia.all.forEach((img: TImetaInfo) => {
@ -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 (!nodes || nodes.length === 0) {
if ( if (
extractedMedia.images.length === 0 && extractedMedia.images.length === 0 &&
@ -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 mediaInContent = new Set<string>()
const imagesInContent: TImetaInfo[] = [] const imagesInContent: TImetaInfo[] = []
const videosInContent: TImetaInfo[] = []
const audioInContent: TImetaInfo[] = []
// Only process nodes if they exist and are not empty
if (nodes && nodes.length > 0) { if (nodes && nodes.length > 0) {
nodes.forEach((node) => { nodes.forEach((node) => {
if (node.type === 'image') { if (node.type === 'image') {
const cleanedUrl = cleanUrl(node.data) const cleanedUrl = cleanUrl(node.data)
mediaInContent.add(cleanedUrl) mediaInContent.add(cleanedUrl)
const imageInfo = imageMap.get(cleanedUrl) || { url: cleanedUrl, pubkey: event?.pubkey } const imageInfo = imageMap.get(cleanedUrl) || { url: cleanedUrl, pubkey }
if (!imagesInContent.find(img => img.url === cleanedUrl)) { if (!imagesInContent.find((img) => img.url === cleanedUrl)) {
imagesInContent.push(imageInfo) imagesInContent.push(imageInfo)
} }
} else if (node.type === 'images') { } else if (node.type === 'images') {
const urls = Array.isArray(node.data) ? node.data : [node.data] const urls = Array.isArray(node.data) ? node.data : [node.data]
urls.forEach(url => { urls.forEach((url) => {
const cleaned = cleanUrl(url) const cleaned = cleanUrl(url)
mediaInContent.add(cleaned) mediaInContent.add(cleaned)
const imageInfo = imageMap.get(cleaned) || { url: cleaned, pubkey: event?.pubkey } const imageInfo = imageMap.get(cleaned) || { url: cleaned, pubkey }
if (!imagesInContent.find(img => img.url === cleaned)) { if (!imagesInContent.find((img) => img.url === cleaned)) {
imagesInContent.push(imageInfo) imagesInContent.push(imageInfo)
} }
}) })
} else if (node.type === 'media') { } else if (node.type === 'media') {
const cleanedUrl = cleanUrl(node.data) const cleanedUrl = cleanUrl(node.data)
mediaInContent.add(cleanedUrl) 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') { } else if (node.type === 'url') {
const cleanedUrl = cleanUrl(node.data) const cleanedUrl = cleanUrl(node.data)
if (isImage(cleanedUrl)) { if (isImage(cleanedUrl)) {
mediaInContent.add(cleanedUrl) mediaInContent.add(cleanedUrl)
const imageInfo = imageMap.get(cleanedUrl) || { url: cleanedUrl, pubkey: event?.pubkey } const imageInfo = imageMap.get(cleanedUrl) || { url: cleanedUrl, pubkey }
if (!imagesInContent.find(img => img.url === cleanedUrl)) { if (!imagesInContent.find((img) => img.url === cleanedUrl)) {
imagesInContent.push(imageInfo) imagesInContent.push(imageInfo)
} }
} else if (isVideo(cleanedUrl) || isHlsPlaylistUrl(cleanedUrl)) { } else if (isVideo(cleanedUrl) || isHlsPlaylistUrl(cleanedUrl)) {
mediaInContent.add(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)) { } else if (isAudio(cleanedUrl)) {
mediaInContent.add(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)) { } else if (isMedia(cleanedUrl)) {
mediaInContent.add(cleanedUrl) mediaInContent.add(cleanedUrl)
} }
@ -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 carouselImages = extractedMedia.images.filter((img: TImetaInfo) => {
const cleaned = cleanUrl(img.url) const cleaned = cleanUrl(img.url)
return cleaned && !mediaInContent.has(cleaned) return cleaned && !mediaInContent.has(cleaned)
@ -425,16 +399,28 @@ export default function Content({
return cleaned && !mediaInContent.has(cleaned) return cleaned && !mediaInContent.has(cleaned)
}) })
logger.debug('[Content] Parsed content:', { return {
nodeCount: nodes?.length || 0, imageMap,
allMedia: extractedMedia.all.length, mediaMap,
images: extractedMedia.images.length, mediaInContent,
videos: extractedMedia.videos.length, imagesInContent,
audio: extractedMedia.audio.length, carouselImages,
imageMapSize: imageMap.size, videosFromTags,
mediaMapSize: mediaMap.size, audioFromTags
nodes: nodes?.map(n => ({ type: n.type, data: Array.isArray(n.data) ? n.data.length : n.data })) || [] }
}) }, [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 // Track which images/media have been rendered individually to prevent duplicates
const renderedUrls = new Set<string>() const renderedUrls = new Set<string>()

17
src/components/Profile/index.tsx

@ -19,7 +19,7 @@ import { showSimplePublishSuccess, toastPublishPromise } from '@/lib/publishing-
import { toProfileEditor } from '@/lib/link' import { toProfileEditor } from '@/lib/link'
import { encodeProfileInteractionsSpellId } from '@/pages/primary/SpellsPage/fauxSpellConfig' import { encodeProfileInteractionsSpellId } from '@/pages/primary/SpellsPage/fauxSpellConfig'
import { generateImageByPubkey } from '@/lib/pubkey' import { generateImageByPubkey } from '@/lib/pubkey'
import { isVideo } from '@/lib/url' import { isVideo, normalizeAnyRelayUrl } from '@/lib/url'
import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
@ -81,7 +81,7 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import { nip66Service } from '@/services/nip66.service' import { nip66Service } from '@/services/nip66.service'
import { normalizeAnyRelayUrl } from '@/lib/url' import { buildPaytoUri } from '@/lib/payto'
import type { TProfile } from '@/types' import type { TProfile } from '@/types'
/** /**
@ -163,6 +163,19 @@ function mergePaymentMethods(
if (addr) add('lightning', addr, `payto://lightning/${addr}`, 'Lightning Network') 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) // Then kind 10133 (payto tags and JSON content)
if (paymentInfo?.methods?.length) { if (paymentInfo?.methods?.length) {
paymentInfo.methods.forEach((m) => { paymentInfo.methods.forEach((m) => {

2
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. * 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 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). * 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'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { getProfileFromEvent } from '@/lib/event-metadata' import { getProfileFromEvent } from '@/lib/event-metadata'
import { getSeededProfileForNavigation } from '@/lib/profile-navigation-seed' 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 { useNostrOptional } from '@/providers/nostr-context'
import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext' import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext'
import { eventService, replaceableEventService } from '@/services/client.service' 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.length !== 64 || !/^[0-9a-f]{64}$/i.test(targetPk)) return
if (targetPk !== accPk.toLowerCase()) return if (targetPk !== accPk.toLowerCase()) return
const profilePk = profile?.pubkey?.trim()
const haveFullLocal = const haveFullLocal =
profile?.pubkey === targetPk && !profile.batchPlaceholder !!profilePk &&
/^[0-9a-f]{64}$/i.test(profilePk) &&
normalizeHexPubkey(profilePk) === targetPk &&
!profile?.batchPlaceholder
if (haveFullLocal) return if (haveFullLocal) return
setProfile(acc) setProfile(acc)

5
src/hooks/useProfileAuthorFeedSubRequests.ts

@ -106,7 +106,10 @@ export function useProfileAuthorFeedSubRequests({
return () => { return () => {
cancelled = true 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 const activeUrls = fullUrls?.length ? fullUrls : provisionalUrls

3
src/hooks/useProfilePins.tsx

@ -283,7 +283,8 @@ export function useProfilePins(pubkey: string | undefined) {
setLoadingPins(false) 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(() => { useEffect(() => {

2
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", "Type a topic or pick from the list": "Type a topic or pick from the list",
"Uploading to media server…": "Uploading to media server…", "Uploading to media server…": "Uploading to media server…",
profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint", profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint",
startupSessionHydrating: "startupSessionHydrating", startupSessionHydrating: "Syncing your relays and profile from the network…",
"AI / LLM prompt citation": "AI / LLM prompt citation", "AI / LLM prompt citation": "AI / LLM prompt citation",
"AsciiDoc wiki contribution": "AsciiDoc wiki contribution", "AsciiDoc wiki contribution": "AsciiDoc wiki contribution",
"Attach image, audio, or video": "Attach image, audio, or video", "Attach image, audio, or video": "Attach image, audio, or video",

2
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", "Type a topic or pick from the list": "Type a topic or pick from the list",
"Uploading to media server…": "Wird zum Medienserver hochgeladen…", "Uploading to media server…": "Wird zum Medienserver hochgeladen…",
profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint", profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint",
startupSessionHydrating: "startupSessionHydrating", startupSessionHydrating: "Relais und Profil werden aus dem Netz synchronisiert…",
"AI / LLM prompt citation": "AI / LLM prompt citation", "AI / LLM prompt citation": "AI / LLM prompt citation",
"AsciiDoc wiki contribution": "AsciiDoc wiki contribution", "AsciiDoc wiki contribution": "AsciiDoc wiki contribution",
"Attach image, audio, or video": "Attach image, audio, or video", "Attach image, audio, or video": "Attach image, audio, or video",

2
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", "Type a topic or pick from the list": "Type a topic or pick from the list",
"Uploading to media server…": "Uploading to media server…", "Uploading to media server…": "Uploading to media server…",
profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint", profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint",
startupSessionHydrating: "startupSessionHydrating", startupSessionHydrating: "Syncing your relays and profile from the network…",
"AI / LLM prompt citation": "AI / LLM prompt citation", "AI / LLM prompt citation": "AI / LLM prompt citation",
"AsciiDoc wiki contribution": "AsciiDoc wiki contribution", "AsciiDoc wiki contribution": "AsciiDoc wiki contribution",
"Attach image, audio, or video": "Attach image, audio, or video", "Attach image, audio, or video": "Attach image, audio, or video",

2
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", "Type a topic or pick from the list": "Type a topic or pick from the list",
"Uploading to media server…": "Uploading to media server…", "Uploading to media server…": "Uploading to media server…",
profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint", profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint",
startupSessionHydrating: "startupSessionHydrating", startupSessionHydrating: "Syncing your relays and profile from the network…",
"AI / LLM prompt citation": "AI / LLM prompt citation", "AI / LLM prompt citation": "AI / LLM prompt citation",
"AsciiDoc wiki contribution": "AsciiDoc wiki contribution", "AsciiDoc wiki contribution": "AsciiDoc wiki contribution",
"Attach image, audio, or video": "Attach image, audio, or video", "Attach image, audio, or video": "Attach image, audio, or video",

2
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", "Type a topic or pick from the list": "Type a topic or pick from the list",
"Uploading to media server…": "Uploading to media server…", "Uploading to media server…": "Uploading to media server…",
profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint", profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint",
startupSessionHydrating: "startupSessionHydrating", startupSessionHydrating: "Syncing your relays and profile from the network…",
"AI / LLM prompt citation": "AI / LLM prompt citation", "AI / LLM prompt citation": "AI / LLM prompt citation",
"AsciiDoc wiki contribution": "AsciiDoc wiki contribution", "AsciiDoc wiki contribution": "AsciiDoc wiki contribution",
"Attach image, audio, or video": "Attach image, audio, or video", "Attach image, audio, or video": "Attach image, audio, or video",

2
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", "Type a topic or pick from the list": "Type a topic or pick from the list",
"Uploading to media server…": "Uploading to media server…", "Uploading to media server…": "Uploading to media server…",
profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint", profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint",
startupSessionHydrating: "startupSessionHydrating", startupSessionHydrating: "Syncing your relays and profile from the network…",
"AI / LLM prompt citation": "AI / LLM prompt citation", "AI / LLM prompt citation": "AI / LLM prompt citation",
"AsciiDoc wiki contribution": "AsciiDoc wiki contribution", "AsciiDoc wiki contribution": "AsciiDoc wiki contribution",
"Attach image, audio, or video": "Attach image, audio, or video", "Attach image, audio, or video": "Attach image, audio, or video",

2
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", "Type a topic or pick from the list": "Type a topic or pick from the list",
"Uploading to media server…": "Uploading to media server…", "Uploading to media server…": "Uploading to media server…",
profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint", profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint",
startupSessionHydrating: "startupSessionHydrating", startupSessionHydrating: "Syncing your relays and profile from the network…",
"AI / LLM prompt citation": "AI / LLM prompt citation", "AI / LLM prompt citation": "AI / LLM prompt citation",
"AsciiDoc wiki contribution": "AsciiDoc wiki contribution", "AsciiDoc wiki contribution": "AsciiDoc wiki contribution",
"Attach image, audio, or video": "Attach image, audio, or video", "Attach image, audio, or video": "Attach image, audio, or video",

2
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", "Type a topic or pick from the list": "Type a topic or pick from the list",
"Uploading to media server…": "Uploading to media server…", "Uploading to media server…": "Uploading to media server…",
profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint", profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint",
startupSessionHydrating: "startupSessionHydrating", startupSessionHydrating: "Syncing your relays and profile from the network…",
"AI / LLM prompt citation": "AI / LLM prompt citation", "AI / LLM prompt citation": "AI / LLM prompt citation",
"AsciiDoc wiki contribution": "AsciiDoc wiki contribution", "AsciiDoc wiki contribution": "AsciiDoc wiki contribution",
"Attach image, audio, or video": "Attach image, audio, or video", "Attach image, audio, or video": "Attach image, audio, or video",

2
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", "Type a topic or pick from the list": "Type a topic or pick from the list",
"Uploading to media server…": "Uploading to media server…", "Uploading to media server…": "Uploading to media server…",
profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint", profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint",
startupSessionHydrating: "startupSessionHydrating", startupSessionHydrating: "Syncing your relays and profile from the network…",
"AI / LLM prompt citation": "AI / LLM prompt citation", "AI / LLM prompt citation": "AI / LLM prompt citation",
"AsciiDoc wiki contribution": "AsciiDoc wiki contribution", "AsciiDoc wiki contribution": "AsciiDoc wiki contribution",
"Attach image, audio, or video": "Attach image, audio, or video", "Attach image, audio, or video": "Attach image, audio, or video",

2
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", "Type a topic or pick from the list": "Type a topic or pick from the list",
"Uploading to media server…": "Uploading to media server…", "Uploading to media server…": "Uploading to media server…",
profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint", profileEditorRefreshCacheHint: "profileEditorRefreshCacheHint",
startupSessionHydrating: "startupSessionHydrating", startupSessionHydrating: "Syncing your relays and profile from the network…",
"AI / LLM prompt citation": "AI / LLM prompt citation", "AI / LLM prompt citation": "AI / LLM prompt citation",
"AsciiDoc wiki contribution": "AsciiDoc wiki contribution", "AsciiDoc wiki contribution": "AsciiDoc wiki contribution",
"Attach image, audio, or video": "Attach image, audio, or video", "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) {
const lud06Tags = event.tags.filter(tag => tag[0] === 'lud06' && 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]) 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 // Use first tag entry for single values, or fallback to JSON
const nip05 = const nip05 =
nip05Tags.length > 0 ? nip05Tags[0] : firstNip05StringFromJson(profileObj.nip05) nip05Tags.length > 0 ? nip05Tags[0] : firstNip05StringFromJson(profileObj.nip05)
@ -205,7 +216,7 @@ export function getProfileFromEvent(event: Event) {
// Build lightning address from FIRST tag or JSON (prefer first tag, fallback to JSON) // 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 // 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 lightningAddressFromJson = getLightningAddressFromProfile({ lud06: profileObj.lud06, lud16: profileObj.lud16 } as TProfile)
const lightningAddress = lightningAddressFromTags || lightningAddressFromJson const lightningAddress = lightningAddressFromTags || lightningAddressFromJson
@ -213,6 +224,7 @@ export function getProfileFromEvent(event: Event) {
const lightningAddressList = [...new Set([ const lightningAddressList = [...new Set([
...(lud16Tags.length > 0 ? lud16Tags : []), ...(lud16Tags.length > 0 ? lud16Tags : []),
...(lud06Tags.length > 0 ? lud06Tags : []), ...(lud06Tags.length > 0 ? lud06Tags : []),
...wLightningAddresses,
...(profileObj.lud16 ? [profileObj.lud16] : []), ...(profileObj.lud16 ? [profileObj.lud16] : []),
...(profileObj.lud06 ? [profileObj.lud06] : []), ...(profileObj.lud06 ? [profileObj.lud06] : []),
...(lightningAddressFromJson && !lightningAddressFromTags ? [lightningAddressFromJson] : []) ...(lightningAddressFromJson && !lightningAddressFromTags ? [lightningAddressFromJson] : [])
@ -257,6 +269,7 @@ export function getProfileFromEvent(event: Event) {
lud16, lud16,
lightningAddress, lightningAddress,
lightningAddressList: lightningAddressList.length > 0 ? lightningAddressList : undefined, lightningAddressList: lightningAddressList.length > 0 ? lightningAddressList : undefined,
wWalletTags: wWalletTags.length > 0 ? wWalletTags : undefined,
created_at: event.created_at created_at: event.created_at
} }
} }

2
src/lib/lightning.ts

@ -28,5 +28,5 @@ export function getLightningAddressFromProfile(profile: TProfile) {
lud06 = a 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'
import LoginDialog from '@/components/LoginDialog' import LoginDialog from '@/components/LoginDialog'
import NcryptsecPasswordPrompt from '@/components/NcryptsecPasswordPrompt' import NcryptsecPasswordPrompt from '@/components/NcryptsecPasswordPrompt'
import { import {
ACCOUNT_SESSION_HYDRATE_WALL_MS,
ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS, ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS,
DEFAULT_FAVORITE_RELAYS, DEFAULT_FAVORITE_RELAYS,
FAST_READ_RELAY_URLS, FAST_READ_RELAY_URLS,
@ -239,6 +240,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
hydrationGen: hydrationGenForThisRun hydrationGen: hydrationGenForThisRun
}) })
const controller = new AbortController() 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) const storedNsec = storage.getAccountNsec(account.pubkey)
if (storedNsec) { if (storedNsec) {
setNsec(storedNsec) setNsec(storedNsec)
@ -415,7 +423,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
kinds: [ExtendedKind.RSS_FEED_LIST], kinds: [ExtendedKind.RSS_FEED_LIST],
authors: [account.pubkey], authors: [account.pubkey],
limit: 1 limit: 1
}) }, hydrateFetchOpts)
.then((events) => { .then((events) => {
const latestEvent = getLatestEvent(events) const latestEvent = getLatestEvent(events)
if (latestEvent) { if (latestEvent) {
@ -457,16 +465,16 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
queryService.fetchEvents(FAST_READ_RELAY_URLS, { queryService.fetchEvents(FAST_READ_RELAY_URLS, {
kinds: [kinds.RelayList], kinds: [kinds.RelayList],
authors: [account.pubkey] authors: [account.pubkey]
}), }, hydrateFetchOpts),
queryService.fetchEvents(FAST_READ_RELAY_URLS, { queryService.fetchEvents(FAST_READ_RELAY_URLS, {
kinds: [ExtendedKind.CACHE_RELAYS], kinds: [ExtendedKind.CACHE_RELAYS],
authors: [account.pubkey] authors: [account.pubkey]
}), }, hydrateFetchOpts),
queryService.fetchEvents(FAST_READ_RELAY_URLS, { queryService.fetchEvents(FAST_READ_RELAY_URLS, {
kinds: [ExtendedKind.HTTP_RELAY_LIST], kinds: [ExtendedKind.HTTP_RELAY_LIST],
authors: [account.pubkey], authors: [account.pubkey],
limit: 1 limit: 1
}) }, hydrateFetchOpts)
]) ])
if (hydrationGenForThisRun !== accountHydrationGenerationRef.current) { if (hydrationGenForThisRun !== accountHydrationGenerationRef.current) {
return controller return controller
@ -523,7 +531,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
], ],
authors: [account.pubkey] authors: [account.pubkey]
} }
]) ], hydrateFetchOpts)
if (hydrationGenForThisRun !== accountHydrationGenerationRef.current) { if (hydrationGenForThisRun !== accountHydrationGenerationRef.current) {
return controller return controller
} }
@ -625,7 +633,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
authors: [account.pubkey], authors: [account.pubkey],
kinds: [kinds.Contacts], kinds: [kinds.Contacts],
limit: 1 limit: 1
}) }, hydrateFetchOpts)
.then((evts) => { .then((evts) => {
const evt = evts.sort((a, b) => b.created_at - a.created_at)[0] const evt = evts.sort((a, b) => b.created_at - a.created_at)[0]
if (evt && hydrationGenForThisRun === accountHydrationGenerationRef.current) { if (evt && hydrationGenForThisRun === accountHydrationGenerationRef.current) {
@ -677,7 +685,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
authors: [account.pubkey], authors: [account.pubkey],
kinds: [kinds.Mutelist], kinds: [kinds.Mutelist],
limit: 10 limit: 10
}) }, hydrateFetchOpts)
.then((evts) => { .then((evts) => {
const evt = getLatestEvent(evts) const evt = getLatestEvent(evts)
if (evt && hydrationGenForThisRun === accountHydrationGenerationRef.current) { if (evt && hydrationGenForThisRun === accountHydrationGenerationRef.current) {
@ -793,7 +801,18 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return controller return controller
} }
const promise = init() 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(() => { void promise.finally(() => {
window.clearTimeout(wallTimer)
const r = manualNetworkHydrateResolveRef.current const r = manualNetworkHydrateResolveRef.current
manualNetworkHydrateResolveRef.current = null manualNetworkHydrateResolveRef.current = null
r?.() r?.()
@ -811,6 +830,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
finishHydration() finishHydration()
}) })
return () => { return () => {
window.clearTimeout(wallTimer)
promise promise
.then((controller) => { .then((controller) => {
controller?.abort() controller?.abort()

79
src/services/lightning.service.ts

@ -344,34 +344,91 @@ class LightningService {
callback: string callback: string
lnurl: string lnurl: string
}> { }> {
try { const candidates = this.lightningAddressCandidates(profile)
let lnurl: string = '' for (const addr of candidates) {
const resolved = await this.fetchLnurlPayZapEndpoint(addr)
// Some clients have incorrectly filled in the positions for lud06 and lud16 if (resolved) return resolved
if (!profile.lightningAddress) { }
return null return null
} }
if (profile.lightningAddress.includes('@')) { /** Ordered lightning identifiers from kind 0 (lud16/lud06 + `w` lightning rows); de-duplicated. */
const [name, domain] = profile.lightningAddress.split('@') 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() lnurl = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString()
} else { } else {
const { words } = bech32.decode(profile.lightningAddress as any, 1000) const { words } = bech32.decode(lightningAddress as any, 1000)
const data = bech32.fromWords(words) const data = bech32.fromWords(words)
lnurl = utf8Decoder.decode(data) lnurl = utf8Decoder.decode(data)
} }
const res = await fetchWithTimeout(lnurl, { timeoutMs: 15_000 }) 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 { return {
callback: body.callback, callback: body.callback,
lnurl lnurl
} }
} }
} catch (err) { } 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 return null

5
src/types/index.d.ts vendored

@ -38,6 +38,11 @@ export type TProfile = {
lud16?: string lud16?: string
lightningAddress?: string lightningAddress?: string
lightningAddressList?: 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 created_at?: number
/** Kind 0: `bot` / `bot,true` tags without `bot,false` — see Nostr profile conventions. */ /** Kind 0: `bot` / `bot,true` tags without `bot,false` — see Nostr profile conventions. */
isBot?: boolean isBot?: boolean

Loading…
Cancel
Save