diff --git a/js/services/authService.js b/js/services/authService.js index 3dc12d6c..592b9268 100644 --- a/js/services/authService.js +++ b/js/services/authService.js @@ -26,6 +26,7 @@ import { profileCache } from "../state/profileCache.js"; import getDefaultAuthProvider, { providers as defaultAuthProviders, } from "./authProviders/index.js"; +import { fetchProfileMetadata } from "./profileMetadataService.js"; class SimpleEventEmitter { constructor(logger = null) { @@ -1158,44 +1159,16 @@ export default class AuthService { } try { - const results = await Promise.all( - this.nostrClient.relays.map((relayUrl) => - this.nostrClient.pool.list([relayUrl], [ - { kinds: [0], authors: [normalized], limit: 1 }, - ]) - ) - ); + const result = await fetchProfileMetadata(normalized, { + nostr: this.nostrClient, + }); - const events = results.flat(); - let newest = null; - for (const event of events) { - if (!event || event.pubkey !== normalized || !event.content) { - continue; - } - if (!newest || event.created_at > newest.created_at) { - newest = event; - } + if (result?.event && typeof this.nostrClient?.handleEvent === "function") { + this.nostrClient.handleEvent(result.event); } - if (newest) { - if (this.nostrClient && typeof this.nostrClient.handleEvent === "function") { - this.nostrClient.handleEvent(newest); - } - if (newest.content) { - const data = JSON.parse(newest.content); - const profile = { - name: data.display_name || data.name || FALLBACK_PROFILE.name, - picture: data.picture || FALLBACK_PROFILE.picture, - about: typeof data.about === "string" ? data.about : FALLBACK_PROFILE.about, - website: - typeof data.website === "string" ? data.website : FALLBACK_PROFILE.website, - banner: - typeof data.banner === "string" ? data.banner : FALLBACK_PROFILE.banner, - lud16: typeof data.lud16 === "string" ? data.lud16 : FALLBACK_PROFILE.lud16, - lud06: typeof data.lud06 === "string" ? data.lud06 : FALLBACK_PROFILE.lud06, - }; - return profile; - } + if (result?.profile) { + return result.profile; } } catch (error) { this.log("[AuthService] Failed to fetch profile", error); diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index 0fdcef64..0cef2e22 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -241,6 +241,8 @@ class HashtagPreferencesService { this.backgroundLoading = false; this.preferencesVersion = DEFAULT_VERSION; this.decryptRetryTimeoutId = null; + this.loadPromise = null; + this.loadingPubkey = null; profileCache.subscribe((event, detail) => { if (event === "profileChanged") { @@ -273,6 +275,8 @@ class HashtagPreferencesService { clearTimeout(this.decryptRetryTimeoutId); this.decryptRetryTimeoutId = null; } + this.loadPromise = null; + this.loadingPubkey = null; this.emitChange("reset"); } @@ -452,6 +456,38 @@ class HashtagPreferencesService { async load(pubkey, options = {}) { const normalized = normalizeHexPubkey(pubkey); + + if (this.loadPromise && this.loadingPubkey === normalized) { + devLogger.log(`${LOG_PREFIX} Reusing in-flight preferences load.`, { + pubkey: normalized, + }); + return this.loadPromise; + } + + if (this.loadPromise && this.loadingPubkey && this.loadingPubkey !== normalized) { + devLogger.log(`${LOG_PREFIX} Waiting for existing preferences load to settle.`, { + previous: this.loadingPubkey, + next: normalized, + }); + await this.loadPromise.catch(() => {}); + } + + const loadPromise = this.loadInternal(normalized, options); + this.loadPromise = loadPromise; + this.loadingPubkey = normalized; + + try { + return await loadPromise; + } finally { + if (this.loadPromise === loadPromise) { + this.loadPromise = null; + this.loadingPubkey = null; + } + } + } + + async loadInternal(pubkey, options = {}) { + const normalized = normalizeHexPubkey(pubkey); const allowPermissionPrompt = options?.allowPermissionPrompt !== false; let wasLoadedForUser = this.activePubkey && diff --git a/js/services/nostrService.js b/js/services/nostrService.js index 5d7dfe7e..e2da7af6 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -1032,16 +1032,27 @@ export class NostrService { } async ensureDirectMessageSubscription({ actorPubkey, relays, ...handlers } = {}) { - if (this.dmSubscription) { - return this.dmSubscription; - } - const activeActor = actorPubkey || this.resolveActiveDmActor(); const normalizedActor = normalizeHexPubkey(activeActor); if (!normalizedActor) { return null; } + if (this.dmSubscription) { + if (this.dmActorPubkey === normalizedActor) { + devLogger.log( + "[nostrService] Reusing existing DM subscription for active actor.", + { actorPubkey: normalizedActor }, + ); + return this.dmSubscription; + } + devLogger.log( + "[nostrService] Replacing existing DM subscription for new actor.", + { previous: this.dmActorPubkey, next: normalizedActor }, + ); + this.stopDirectMessageSubscription(); + } + let resolvedRelays = Array.isArray(relays) ? relays : null; let relaySelection = null; @@ -1142,6 +1153,10 @@ export class NostrService { this.dmSubscription = subscription; this.dmActorPubkey = normalizedActor; + devLogger.log("[nostrService] DM subscription activated.", { + actorPubkey: normalizedActor, + relays: resolvedRelays, + }); this.emit("directMessages:subscribed", { subscription }); if (relaySelection?.warning === DM_RELAY_WARNING_FALLBACK) { this.emit("directMessages:relayWarning", { diff --git a/js/services/profileMetadataService.js b/js/services/profileMetadataService.js new file mode 100644 index 00000000..84513613 --- /dev/null +++ b/js/services/profileMetadataService.js @@ -0,0 +1,250 @@ +import { nostrClient } from "../nostrClientFacade.js"; +import { devLogger } from "../utils/logger.js"; + +const DEFAULT_PROFILE_IMAGE = "assets/svg/default-profile.svg"; +const HEX64_REGEX = /^[0-9a-f]{64}$/i; +const inFlight = new Map(); + +function normalizePubkey(pubkey) { + if (typeof pubkey !== "string") { + return ""; + } + + const trimmed = pubkey.trim(); + if (!trimmed || !HEX64_REGEX.test(trimmed)) { + return ""; + } + + return trimmed.toLowerCase(); +} + +function resolveRelays(relays, nostr) { + if (Array.isArray(relays) && relays.length) { + return relays.filter((relay) => typeof relay === "string" && relay.trim()); + } + + if (Array.isArray(nostr?.readRelays) && nostr.readRelays.length) { + return nostr.readRelays.filter((relay) => typeof relay === "string" && relay.trim()); + } + + if (Array.isArray(nostr?.relays) && nostr.relays.length) { + return nostr.relays.filter((relay) => typeof relay === "string" && relay.trim()); + } + + return []; +} + +function parseProfileEvent(event, defaultProfileImage) { + if (!event || typeof event !== "object" || !event.content) { + return null; + } + + try { + const data = JSON.parse(event.content); + const profile = { + name: data?.display_name || data?.name || "Unknown", + picture: data?.picture || defaultProfileImage, + }; + + const about = typeof data?.about === "string" ? data.about : ""; + if (about) { + profile.about = about; + } + + const website = typeof data?.website === "string" ? data.website : ""; + if (website) { + profile.website = website; + } + + const banner = typeof data?.banner === "string" ? data.banner : ""; + if (banner) { + profile.banner = banner; + } + + const lud16 = typeof data?.lud16 === "string" ? data.lud16 : ""; + if (lud16) { + profile.lud16 = lud16; + } + + const lud06 = typeof data?.lud06 === "string" ? data.lud06 : ""; + if (lud06) { + profile.lud06 = lud06; + } + + return profile; + } catch (error) { + devLogger.warn("[profileMetadata] Failed to parse profile event", error); + return null; + } +} + +async function performProfileFetch({ + pubkeys, + nostr, + relays, + logger, + defaultProfileImage, +} = {}) { + if (!nostr?.pool || typeof nostr.pool.list !== "function") { + logger.warn("[profileMetadata] Relay pool is not ready; skipping profile fetch."); + return new Map(); + } + + const relayUrls = resolveRelays(relays, nostr); + if (!relayUrls.length) { + logger.warn("[profileMetadata] No relays configured; skipping profile fetch."); + return new Map(); + } + + const filter = { + kinds: [0], + authors: pubkeys, + limit: pubkeys.length, + }; + + const newestProfiles = new Map(); + + const results = await Promise.allSettled( + relayUrls.map((relayUrl) => + nostr.pool + .list([relayUrl], [filter]) + .then((events) => ({ relayUrl, events })) + ) + ); + + results.forEach((result, index) => { + const relayUrl = + result.status === "fulfilled" + ? result.value.relayUrl + : relayUrls[index]; + + if (result.status !== "fulfilled") { + logger.warn( + `[profileMetadata] Failed to fetch profiles from relay ${relayUrl}:`, + result.reason, + ); + return; + } + + const events = Array.isArray(result.value.events) + ? result.value.events + : []; + if (!events.length) { + return; + } + + for (const event of events) { + const pubkey = normalizePubkey(event?.pubkey); + if (!pubkey) { + continue; + } + + const createdAt = Number.isFinite(event?.created_at) + ? event.created_at + : 0; + const previous = newestProfiles.get(pubkey); + if (previous && previous.createdAt >= createdAt) { + continue; + } + + const profile = parseProfileEvent(event, defaultProfileImage); + if (!profile) { + continue; + } + + newestProfiles.set(pubkey, { + createdAt, + profile, + event, + }); + } + }); + + return newestProfiles; +} + +export async function fetchProfileMetadataBatch({ + pubkeys, + nostr = nostrClient, + relays = null, + logger = devLogger, + defaultProfileImage = DEFAULT_PROFILE_IMAGE, +} = {}) { + const normalizedPubkeys = Array.isArray(pubkeys) + ? pubkeys.map(normalizePubkey).filter(Boolean) + : []; + + if (!normalizedPubkeys.length) { + return new Map(); + } + + const uniquePubkeys = Array.from(new Set(normalizedPubkeys)); + const results = new Map(); + const waiters = []; + const toFetch = []; + + uniquePubkeys.forEach((pubkey) => { + const existing = inFlight.get(pubkey); + if (existing) { + logger.debug?.("[profileMetadata] Reusing in-flight profile fetch", { + pubkey, + }); + waiters.push( + existing.then((entry) => { + if (entry) { + results.set(pubkey, entry); + } + }), + ); + return; + } + toFetch.push(pubkey); + }); + + let batchPromise = null; + if (toFetch.length) { + logger.debug?.("[profileMetadata] Fetching profile metadata", { + count: toFetch.length, + }); + const fetchPromise = performProfileFetch({ + pubkeys: toFetch, + nostr, + relays, + logger, + defaultProfileImage, + }); + + toFetch.forEach((pubkey) => { + const perPubkeyPromise = fetchPromise + .then((map) => map.get(pubkey) || null) + .finally(() => { + if (inFlight.get(pubkey) === perPubkeyPromise) { + inFlight.delete(pubkey); + } + }); + inFlight.set(pubkey, perPubkeyPromise); + }); + + batchPromise = fetchPromise.then((map) => { + map.forEach((value, pubkey) => results.set(pubkey, value)); + }); + } + + await Promise.all([...waiters, batchPromise].filter(Boolean)); + + return results; +} + +export async function fetchProfileMetadata(pubkey, options = {}) { + const normalized = normalizePubkey(pubkey); + if (!normalized) { + return null; + } + + const results = await fetchProfileMetadataBatch({ + pubkeys: [normalized], + ...options, + }); + + return results.get(normalized) || null; +} diff --git a/js/userBlocks.js b/js/userBlocks.js index f04e65a9..e0c28723 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -680,6 +680,8 @@ class UserBlockListManager { this.emitter = new TinyEventEmitter(); this.seedStateCache = new Map(); this.decryptRetryTimeoutId = null; + this.loadPromise = null; + this.loadingPubkey = null; profileCache.subscribe((event, detail) => { if (event === "profileChanged") { @@ -705,6 +707,8 @@ class UserBlockListManager { clearTimeout(this.decryptRetryTimeoutId); this.decryptRetryTimeoutId = null; } + this.loadPromise = null; + this.loadingPubkey = null; } on(eventName, handler) { @@ -767,6 +771,38 @@ class UserBlockListManager { async loadBlocks(userPubkey, options = {}) { const normalized = normalizeHex(userPubkey); + + if (this.loadPromise && this.loadingPubkey === normalized) { + devLogger.log("[UserBlockList] Reusing in-flight block list load.", { + pubkey: normalized, + }); + return this.loadPromise; + } + + if (this.loadPromise && this.loadingPubkey && this.loadingPubkey !== normalized) { + devLogger.log("[UserBlockList] Waiting for existing block list load to settle.", { + previous: this.loadingPubkey, + next: normalized, + }); + await this.loadPromise.catch(() => {}); + } + + const loadPromise = this.loadBlocksInternal(normalized, options); + this.loadPromise = loadPromise; + this.loadingPubkey = normalized; + + try { + return await loadPromise; + } finally { + if (this.loadPromise === loadPromise) { + this.loadPromise = null; + this.loadingPubkey = null; + } + } + } + + async loadBlocksInternal(userPubkey, options = {}) { + const normalized = normalizeHex(userPubkey); this.activePubkey = normalized; if (!normalized) { diff --git a/js/utils/profileBatchFetcher.js b/js/utils/profileBatchFetcher.js index 9a5111e6..365f0fd1 100644 --- a/js/utils/profileBatchFetcher.js +++ b/js/utils/profileBatchFetcher.js @@ -2,6 +2,7 @@ import { nostrClient } from "../nostrClientFacade.js"; import { devLogger } from "./logger.js"; +import { fetchProfileMetadataBatch } from "../services/profileMetadataService.js"; const DEFAULT_PROFILE_IMAGE = "assets/svg/default-profile.svg"; const HEX64_REGEX = /^[0-9a-f]{64}$/i; @@ -57,117 +58,18 @@ export async function batchFetchProfilesFromRelays({ return; } - if (!nostr?.pool || typeof nostr.pool.list !== "function") { - logger.warn( - "[batchFetchProfiles] Relay pool is not ready; skipping profile fetch.", - ); - return; - } + const profileResults = await fetchProfileMetadataBatch({ + pubkeys: toFetch, + nostr, + logger, + defaultProfileImage, + }); - const filter = { - kinds: [0], - authors: toFetch, - limit: toFetch.length, - }; - - const newestProfiles = new Map(); - - const applyProfileEvent = (evt, relayUrl) => { - if (!evt || typeof evt !== "object") { + profileResults.forEach((entry, pubkey) => { + if (!entry?.profile) { return; } - - const pubkey = - typeof evt.pubkey === "string" && hex64Regex.test(evt.pubkey) - ? evt.pubkey - : null; - if (!pubkey) { - return; - } - - const createdAt = Number.isFinite(evt.created_at) ? evt.created_at : 0; - const previous = newestProfiles.get(pubkey); - if (previous && previous.createdAt >= createdAt) { - return; - } - - try { - const data = JSON.parse(evt.content); - const profile = { - name: data?.name || data?.display_name || "Unknown", - picture: data?.picture || defaultProfileImage, - }; - - newestProfiles.set(pubkey, { createdAt, profile }); - setProfileCacheEntry(pubkey, profile); - updateProfileInDOM(pubkey, profile); - } catch (error) { - logger.warn( - `[batchFetchProfiles] Profile parse error for ${pubkey} from ${relayUrl}:`, - error, - ); - } - }; - - const relayPromises = (Array.isArray(nostr.relays) ? nostr.relays : []).map( - (relayUrl) => - nostr.pool - .list([relayUrl], [filter]) - .then((events) => ({ relayUrl, events })), - ); - - if (!relayPromises.length) { - logger.warn( - "[batchFetchProfiles] No relays configured; skipping profile fetch.", - ); - return; - } - - const results = await Promise.allSettled(relayPromises); - - results.forEach((result, index) => { - const relayUrl = - result.status === "fulfilled" - ? result.value.relayUrl - : nostr.relays[index]; - - if (result.status === "fulfilled") { - const events = Array.isArray(result.value.events) - ? result.value.events - : []; - if (!events.length) { - return; - } - - const newestByPubkey = new Map(); - for (const evt of events) { - if (!evt || typeof evt !== "object") { - continue; - } - const pubkey = - typeof evt.pubkey === "string" && hex64Regex.test(evt.pubkey) - ? evt.pubkey - : null; - if (!pubkey) { - continue; - } - const createdAt = Number.isFinite(evt.created_at) - ? evt.created_at - : 0; - const prior = newestByPubkey.get(pubkey); - if (!prior || createdAt > prior.createdAt) { - newestByPubkey.set(pubkey, { createdAt, event: evt }); - } - } - - newestByPubkey.forEach(({ event }) => { - applyProfileEvent(event, relayUrl); - }); - } else if (relayUrl) { - logger.warn( - `[batchFetchProfiles] Failed to fetch profiles from relay ${relayUrl}:`, - result.reason, - ); - } + setProfileCacheEntry(pubkey, entry.profile); + updateProfileInDOM(pubkey, entry.profile); }); }