From 9e8afa8f6d057cec4ada9451735d15a1d23af79f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 27 Oct 2025 18:37:59 -0400 Subject: [PATCH 1/7] Improve video modal creator profile hydration --- js/app.js | 361 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 329 insertions(+), 32 deletions(-) diff --git a/js/app.js b/js/app.js index e747d190..732e444c 100644 --- a/js/app.js +++ b/js/app.js @@ -27,6 +27,7 @@ import { attachHealthBadges } from "./gridHealth.js"; import { attachUrlHealthBadges } from "./urlHealthObserver.js"; import { updateVideoCardSourceVisibility } from "./utils/cardSourceVisibility.js"; import { collectVideoTags } from "./utils/videoTags.js"; +import { sanitizeProfileMediaUrl } from "./utils/profileMedia.js"; import { ADMIN_INITIAL_EVENT_BLACKLIST } from "./lists.js"; import { userBlocks } from "./userBlocks.js"; import { relayManager } from "./relayManager.js"; @@ -215,6 +216,8 @@ class Application { const { modalManager } = this.bootstrapper.initialize(); this.modalManager = modalManager; + this.modalCreatorProfileRequestToken = null; + this.commentController = null; this.initializeCommentController(); @@ -8286,6 +8289,22 @@ class Application { ? Math.floor(video.created_at) : null; + const normalizedCreatorPubkey = + this.normalizeHexPubkey(video.pubkey) || video.pubkey; + const cachedCreatorProfileEntry = + normalizedCreatorPubkey && typeof this.getProfileCacheEntry === "function" + ? this.getProfileCacheEntry(normalizedCreatorPubkey) + : null; + const cachedCreatorProfile = + cachedCreatorProfileEntry && + typeof cachedCreatorProfileEntry === "object" + ? cachedCreatorProfileEntry.profile || null + : null; + const initialLightningAddress = + typeof video.lightningAddress === "string" + ? video.lightningAddress.trim() + : ""; + this.currentVideo = { ...video, url: trimmedUrl, @@ -8294,7 +8313,7 @@ class Application { magnetCandidate || fallbackMagnetForCandidate || legacyInfoHash || "", torrentSupported: magnetSupported, legacyInfoHash: video.legacyInfoHash || legacyInfoHash, - lightningAddress: null, + lightningAddress: initialLightningAddress || null, lastEditedAt: normalizedEditedAt, }; @@ -8302,6 +8321,30 @@ class Application { const modalTags = collectVideoTags(this.currentVideo); this.currentVideo.displayTags = modalTags; + const creatorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey; + const displayNpub = formatShortNpub(creatorNpub) || creatorNpub; + const initialCreatorProfile = this.resolveModalCreatorProfile({ + video: this.currentVideo, + pubkey: normalizedCreatorPubkey, + cachedProfile: cachedCreatorProfile, + }); + this.currentVideo.creatorName = initialCreatorProfile.name; + this.currentVideo.creatorPicture = initialCreatorProfile.picture; + this.currentVideo.creatorNpub = displayNpub; + if (this.currentVideo.creator && typeof this.currentVideo.creator === "object") { + this.currentVideo.creator = { + ...this.currentVideo.creator, + name: initialCreatorProfile.name, + picture: initialCreatorProfile.picture, + pubkey: normalizedCreatorPubkey, + }; + } else { + this.currentVideo.creator = { + name: initialCreatorProfile.name, + picture: initialCreatorProfile.picture, + pubkey: normalizedCreatorPubkey, + }; + } this.updateModalSimilarContent({ activeVideo: this.currentVideo }); if (Number.isFinite(knownPostedAt)) { @@ -8352,8 +8395,8 @@ class Application { )}`; window.history.pushState({}, "", pushUrl); - this.zapController?.setVisibility(false); this.zapController?.resetState(); + this.zapController?.setVisibility(Boolean(this.currentVideo.lightningAddress)); const magnetInput = sanitizedMagnet || @@ -8388,52 +8431,38 @@ class Application { playbackPromise = this.playVideoWithFallback(playbackOptions); } - let lightningAddress = ""; - let creatorProfile = { - name: "Unknown", - picture: `https://robohash.org/${video.pubkey}`, - }; - try { - const userEvents = await nostrClient.pool.list(nostrClient.relays, [ - { kinds: [0], authors: [video.pubkey], limit: 1 }, - ]); - if (userEvents.length > 0 && userEvents[0]?.content) { - const data = JSON.parse(userEvents[0].content); - lightningAddress = (data.lud16 || data.lud06 || "").trim(); - creatorProfile = { - name: data.name || data.display_name || "Unknown", - picture: data.picture || `https://robohash.org/${video.pubkey}`, - }; - } - } catch (error) { - this.log("Error fetching creator profile:", error); - } - - this.zapController?.setVisibility(!!lightningAddress); - if (this.currentVideo) { - this.currentVideo.lightningAddress = lightningAddress || null; - } - - const creatorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey; if (this.videoModal) { const timestampPayload = this.buildModalTimestampPayload({ postedAt: this.currentVideo?.rootCreatedAt ?? null, editedAt: normalizedEditedAt, }); - const displayNpub = formatShortNpub(creatorNpub); this.videoModal.updateMetadata({ title: video.title || "Untitled", description: video.description || "No description available.", timestamps: timestampPayload, tags: modalTags, creator: { - name: creatorProfile.name, - avatarUrl: creatorProfile.picture, + name: initialCreatorProfile.name, + avatarUrl: initialCreatorProfile.picture, npub: displayNpub, }, }); } + const profileRequestToken = Symbol("modal-profile-request"); + this.modalCreatorProfileRequestToken = profileRequestToken; + this.fetchModalCreatorProfile({ + pubkey: normalizedCreatorPubkey, + displayNpub, + cachedProfile: cachedCreatorProfile, + requestToken: profileRequestToken, + }).catch((error) => { + devLogger.error( + "[Application] Failed to fetch creator profile for modal:", + error, + ); + }); + this.ensureModalPostedTimestamp(this.currentVideo); const playbackResult = @@ -8789,6 +8818,274 @@ class Application { return null; } + selectPreferredCreatorName(candidates = []) { + for (const candidate of candidates) { + if (typeof candidate !== "string") { + continue; + } + const trimmed = candidate.trim(); + if (!trimmed) { + continue; + } + if (HEX64_REGEX.test(trimmed)) { + continue; + } + return trimmed; + } + return ""; + } + + selectPreferredCreatorPicture(candidates = []) { + for (const candidate of candidates) { + if (typeof candidate !== "string") { + continue; + } + const sanitized = sanitizeProfileMediaUrl(candidate); + if (sanitized) { + return sanitized; + } + } + return ""; + } + + resolveModalCreatorProfile({ + video, + pubkey, + cachedProfile = null, + fetchedProfile = null, + } = {}) { + const normalizedPubkey = + typeof pubkey === "string" && pubkey.trim() ? pubkey.trim() : ""; + const fallbackAvatarCandidate = normalizedPubkey + ? `https://robohash.org/${normalizedPubkey}` + : "assets/svg/default-profile.svg"; + const defaultAvatar = + sanitizeProfileMediaUrl(fallbackAvatarCandidate) || fallbackAvatarCandidate; + + const nameCandidates = []; + const pictureCandidates = []; + + const collectFromSource = (source) => { + if (!source || typeof source !== "object") { + return; + } + const names = [source.display_name, source.name, source.username]; + names.forEach((value) => { + if (typeof value === "string") { + nameCandidates.push(value); + } + }); + const pictures = [source.picture, source.image, source.photo]; + pictures.forEach((value) => { + if (typeof value === "string") { + pictureCandidates.push(value); + } + }); + }; + + collectFromSource(fetchedProfile); + collectFromSource(cachedProfile); + + if (video && typeof video === "object") { + collectFromSource(video.creator); + if (typeof video.creatorName === "string") { + nameCandidates.push(video.creatorName); + } + if (typeof video.creatorPicture === "string") { + pictureCandidates.push(video.creatorPicture); + } + collectFromSource(video.author); + if (typeof video.authorName === "string") { + nameCandidates.push(video.authorName); + } + if (typeof video.authorPicture === "string") { + pictureCandidates.push(video.authorPicture); + } + collectFromSource(video.profile); + } + + const resolvedName = this.selectPreferredCreatorName(nameCandidates) || "Unknown"; + const resolvedPicture = + this.selectPreferredCreatorPicture(pictureCandidates) || defaultAvatar; + + return { name: resolvedName, picture: resolvedPicture }; + } + + async fetchModalCreatorProfile({ + pubkey, + displayNpub = "", + cachedProfile = null, + requestToken = null, + } = {}) { + const normalized = this.normalizeHexPubkey(pubkey); + if (!normalized) { + return; + } + + const relayList = + Array.isArray(nostrClient?.relays) && nostrClient.relays.length + ? nostrClient.relays + : null; + if (!relayList || !nostrClient?.pool || typeof nostrClient.pool.list !== "function") { + return; + } + + const events = await nostrClient.pool.list(relayList, [ + { kinds: [0], authors: [normalized], limit: 1 }, + ]); + + if (this.modalCreatorProfileRequestToken !== requestToken) { + return; + } + + const newest = Array.isArray(events) + ? events.reduce((latest, event) => { + if (!event || typeof event !== "object") { + return latest; + } + const eventPubkey = this.normalizeHexPubkey(event.pubkey); + if (eventPubkey && eventPubkey !== normalized) { + return latest; + } + const createdAt = Number.isFinite(event.created_at) ? event.created_at : 0; + if (!latest || createdAt > latest.createdAt) { + return { createdAt, event }; + } + return latest; + }, null) + : null; + + if (!newest || !newest.event) { + if (this.modalCreatorProfileRequestToken === requestToken) { + this.modalCreatorProfileRequestToken = null; + } + return; + } + + let parsed = null; + try { + parsed = newest.event.content ? JSON.parse(newest.event.content) : null; + } catch (error) { + devLogger.warn( + `[Application] Failed to parse creator profile content for ${normalized}:`, + error, + ); + if (this.modalCreatorProfileRequestToken === requestToken) { + this.modalCreatorProfileRequestToken = null; + } + return; + } + + if (this.modalCreatorProfileRequestToken !== requestToken) { + return; + } + + const lightningAddressCandidate = (() => { + const fields = [parsed?.lud16, parsed?.lud06]; + for (const field of fields) { + if (typeof field !== "string") { + continue; + } + const trimmed = field.trim(); + if (trimmed) { + return trimmed; + } + } + return ""; + })(); + + const fetchedProfile = { + display_name: parsed?.display_name, + name: parsed?.name, + username: parsed?.username, + picture: parsed?.picture, + image: parsed?.image, + photo: parsed?.photo, + }; + + const resolvedProfile = this.resolveModalCreatorProfile({ + video: this.currentVideo, + pubkey: normalized, + cachedProfile, + fetchedProfile, + }); + + if (this.modalCreatorProfileRequestToken !== requestToken) { + return; + } + + const activeVideoPubkey = this.normalizeHexPubkey(this.currentVideo?.pubkey); + if (activeVideoPubkey && activeVideoPubkey !== normalized) { + return; + } + + const nextLightning = lightningAddressCandidate || this.currentVideo?.lightningAddress || ""; + + if (this.currentVideo) { + this.currentVideo.lightningAddress = nextLightning ? nextLightning : null; + this.currentVideo.creatorName = resolvedProfile.name; + this.currentVideo.creatorPicture = resolvedProfile.picture; + this.currentVideo.creatorNpub = displayNpub; + if (this.currentVideo.creator && typeof this.currentVideo.creator === "object") { + this.currentVideo.creator = { + ...this.currentVideo.creator, + name: resolvedProfile.name, + picture: resolvedProfile.picture, + pubkey: normalized, + }; + } else { + this.currentVideo.creator = { + name: resolvedProfile.name, + picture: resolvedProfile.picture, + pubkey: normalized, + }; + } + } + + if (this.videoModal) { + this.videoModal.updateMetadata({ + creator: { + name: resolvedProfile.name, + avatarUrl: resolvedProfile.picture, + npub: displayNpub, + }, + }); + } + + this.zapController?.setVisibility(Boolean(this.currentVideo?.lightningAddress)); + + const sanitizedFetchedPicture = sanitizeProfileMediaUrl( + parsed?.picture || parsed?.image || parsed?.photo || "", + ); + const fetchedNameCandidate = this.selectPreferredCreatorName([ + parsed?.display_name, + parsed?.name, + parsed?.username, + ]); + + if (fetchedNameCandidate || sanitizedFetchedPicture) { + try { + this.setProfileCacheEntry( + normalized, + { + name: fetchedNameCandidate || resolvedProfile.name, + picture: sanitizedFetchedPicture || resolvedProfile.picture, + }, + { persist: false, reason: "modal-profile-fetch" }, + ); + } catch (error) { + devLogger.warn( + `[Application] Failed to update profile cache for ${normalized}:`, + error, + ); + } + } + + if (this.modalCreatorProfileRequestToken === requestToken) { + this.modalCreatorProfileRequestToken = null; + } + } + normalizeReactionCount(value) { if (Number.isFinite(value)) { return Math.max(0, Math.round(Number(value))); From 3ef44a665160f12387bc1256b05efc57da35bcdb Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 27 Oct 2025 19:03:31 -0400 Subject: [PATCH 2/7] Hydrate video cards with cached creator identity --- js/app.js | 267 ++++++++++++++++++++++++++++++---- js/channelProfile.js | 49 ++++++- js/ui/components/VideoCard.js | 136 +++++++++++++++++ js/ui/views/VideoListView.js | 29 ++++ 4 files changed, 450 insertions(+), 31 deletions(-) diff --git a/js/app.js b/js/app.js index 732e444c..f1595857 100644 --- a/js/app.js +++ b/js/app.js @@ -840,11 +840,12 @@ class Application { if (!video || typeof video !== "object") { return video; } + let decoratedVideo = video; if (typeof this.decorateVideoModeration === "function") { try { const decorated = this.decorateVideoModeration(video); if (decorated && typeof decorated === "object") { - return decorated; + decoratedVideo = decorated; } } catch (error) { devLogger.warn( @@ -853,7 +854,22 @@ class Application { ); } } - return video; + if (typeof this.decorateVideoCreatorIdentity === "function") { + try { + const identityDecorated = this.decorateVideoCreatorIdentity( + decoratedVideo, + ); + if (identityDecorated && typeof identityDecorated === "object") { + decoratedVideo = identityDecorated; + } + } catch (error) { + devLogger.warn( + "[Application] Failed to decorate similar content identity", + error, + ); + } + } + return decoratedVideo; }; const target = activeVideo && typeof activeVideo === "object" ? decorateCandidate(activeVideo) : null; @@ -2742,12 +2758,50 @@ class Application { pubkey, profile, }); + } else { + devLogger.warn( + "[Application] ProfileIdentityController missing; profile identity was not refreshed in the DOM.", + ); + } + + const normalizedPubkey = + this.normalizeHexPubkey(pubkey) || + (typeof pubkey === "string" ? pubkey.trim() : ""); + if (!normalizedPubkey) { return; } - devLogger.warn( - "[Application] ProfileIdentityController missing; profile identity was not refreshed in the DOM.", - ); + const maybeDecorateVideo = (video) => { + if (!video || typeof video !== "object") { + return; + } + const videoPubkey = + this.normalizeHexPubkey(video.pubkey) || + (typeof video.pubkey === "string" ? video.pubkey.trim() : ""); + if (videoPubkey !== normalizedPubkey) { + return; + } + this.decorateVideoCreatorIdentity(video); + }; + + if (this.videosMap instanceof Map) { + for (const video of this.videosMap.values()) { + maybeDecorateVideo(video); + } + } + + if ( + this.videoListView && + Array.isArray(this.videoListView.currentVideos) + ) { + this.videoListView.currentVideos.forEach((video) => { + maybeDecorateVideo(video); + }); + } + + if (this.currentVideo) { + maybeDecorateVideo(this.currentVideo); + } } async publishVideoNote(payload, { onSuccess, suppressModalClose } = {}) { @@ -5478,7 +5532,15 @@ class Application { } const decoratedVideos = Array.isArray(videos) - ? videos.map((video) => this.decorateVideoModeration(video)) + ? videos.map((video) => { + const moderated = this.decorateVideoModeration(video); + const targetVideo = + moderated && typeof moderated === "object" ? moderated : video; + const withIdentity = this.decorateVideoCreatorIdentity(targetVideo); + return withIdentity && typeof withIdentity === "object" + ? withIdentity + : targetVideo; + }) : []; this.videoListView.render(decoratedVideos, metadata); @@ -7150,11 +7212,32 @@ class Application { ? index : null; + const ensureCreatorIdentity = (video) => { + if ( + !video || + typeof video !== "object" || + typeof this.decorateVideoCreatorIdentity !== "function" + ) { + return video; + } + try { + const decorated = this.decorateVideoCreatorIdentity(video); + return decorated && typeof decorated === "object" ? decorated : video; + } catch (error) { + devLogger.warn( + "[Application] Failed to decorate video identity for action target:", + error, + ); + return video; + } + }; + const initialVideo = providedVideo && typeof providedVideo === "object" ? providedVideo : null; + const preparedInitialVideo = ensureCreatorIdentity(initialVideo); const providedId = - initialVideo && typeof initialVideo.id === "string" - ? initialVideo.id.trim() + preparedInitialVideo && typeof preparedInitialVideo.id === "string" + ? preparedInitialVideo.id.trim() : ""; const targetEventId = trimmedEventId || providedId; @@ -7163,9 +7246,9 @@ class Application { : []; if (providedId) { - this.videosMap.set(providedId, initialVideo); + this.videosMap.set(providedId, preparedInitialVideo); if (!trimmedEventId || providedId === trimmedEventId) { - return initialVideo; + return preparedInitialVideo; } } @@ -7173,8 +7256,9 @@ class Application { if (targetEventId) { const match = list.find((video) => video?.id === targetEventId); if (match) { - this.videosMap.set(match.id, match); - return match; + const preparedMatch = ensureCreatorIdentity(match); + this.videosMap.set(preparedMatch.id, preparedMatch); + return preparedMatch; } } if ( @@ -7184,8 +7268,9 @@ class Application { ) { const match = list[normalizedIndex]; if (match) { - this.videosMap.set(match.id, match); - return match; + const preparedMatch = ensureCreatorIdentity(match); + this.videosMap.set(preparedMatch.id, preparedMatch); + return preparedMatch; } } } @@ -7199,20 +7284,23 @@ class Application { const activeVideos = nostrClient.getActiveVideos(); const fromActive = activeVideos.find((video) => video.id === targetEventId); if (fromActive) { - this.videosMap.set(fromActive.id, fromActive); - return fromActive; + const preparedActive = ensureCreatorIdentity(fromActive); + this.videosMap.set(preparedActive.id, preparedActive); + return preparedActive; } const fromAll = nostrClient.allEvents.get(targetEventId); if (fromAll) { - this.videosMap.set(fromAll.id, fromAll); - return fromAll; + const preparedFromAll = ensureCreatorIdentity(fromAll); + this.videosMap.set(preparedFromAll.id, preparedFromAll); + return preparedFromAll; } const fetched = await nostrClient.getEventById(targetEventId); if (fetched) { - this.videosMap.set(fetched.id, fetched); - return fetched; + const preparedFetched = ensureCreatorIdentity(fetched); + this.videosMap.set(preparedFetched.id, preparedFetched); + return preparedFetched; } } @@ -7221,8 +7309,9 @@ class Application { if (normalizedIndex >= 0 && normalizedIndex < activeVideos.length) { const candidate = activeVideos[normalizedIndex]; if (candidate) { - this.videosMap.set(candidate.id, candidate); - return candidate; + const preparedCandidate = ensureCreatorIdentity(candidate); + this.videosMap.set(preparedCandidate.id, preparedCandidate); + return preparedCandidate; } } } @@ -8848,19 +8937,25 @@ class Application { return ""; } - resolveModalCreatorProfile({ + resolveCreatorProfileFromSources({ video, pubkey, cachedProfile = null, fetchedProfile = null, + fallbackAvatar, } = {}) { const normalizedPubkey = typeof pubkey === "string" && pubkey.trim() ? pubkey.trim() : ""; - const fallbackAvatarCandidate = normalizedPubkey - ? `https://robohash.org/${normalizedPubkey}` - : "assets/svg/default-profile.svg"; + const fallbackAvatarCandidate = + typeof fallbackAvatar === "string" && fallbackAvatar.trim() + ? fallbackAvatar.trim() + : normalizedPubkey + ? `https://robohash.org/${normalizedPubkey}` + : "assets/svg/default-profile.svg"; const defaultAvatar = - sanitizeProfileMediaUrl(fallbackAvatarCandidate) || fallbackAvatarCandidate; + sanitizeProfileMediaUrl(fallbackAvatarCandidate) || + fallbackAvatarCandidate || + "assets/svg/default-profile.svg"; const nameCandidates = []; const pictureCandidates = []; @@ -8869,7 +8964,12 @@ class Application { if (!source || typeof source !== "object") { return; } - const names = [source.display_name, source.name, source.username]; + const names = [ + source.display_name, + source.displayName, + source.name, + source.username, + ]; names.forEach((value) => { if (typeof value === "string") { nameCandidates.push(value); @@ -8902,15 +9002,124 @@ class Application { pictureCandidates.push(video.authorPicture); } collectFromSource(video.profile); + const extraNames = [ + video.shortNpub, + video.creatorNpub, + video.npub, + video.authorNpub, + ]; + extraNames.forEach((value) => { + if (typeof value === "string") { + nameCandidates.push(value); + } + }); } - const resolvedName = this.selectPreferredCreatorName(nameCandidates) || "Unknown"; + const resolvedName = + this.selectPreferredCreatorName(nameCandidates) || "Unknown"; const resolvedPicture = this.selectPreferredCreatorPicture(pictureCandidates) || defaultAvatar; return { name: resolvedName, picture: resolvedPicture }; } + resolveModalCreatorProfile({ + video, + pubkey, + cachedProfile = null, + fetchedProfile = null, + } = {}) { + return this.resolveCreatorProfileFromSources({ + video, + pubkey, + cachedProfile, + fetchedProfile, + }); + } + + decorateVideoCreatorIdentity(video) { + if (!video || typeof video !== "object") { + return video; + } + + const normalizedPubkey = + this.normalizeHexPubkey(video.pubkey) || + (typeof video.pubkey === "string" ? video.pubkey.trim() : ""); + if (!normalizedPubkey) { + return video; + } + + let cachedProfile = null; + if (typeof this.getProfileCacheEntry === "function") { + const cacheEntry = this.getProfileCacheEntry(normalizedPubkey); + if (cacheEntry && typeof cacheEntry === "object") { + cachedProfile = cacheEntry.profile || null; + } + } + + const resolvedProfile = this.resolveCreatorProfileFromSources({ + video, + pubkey: normalizedPubkey, + cachedProfile, + }); + + if (!video.creator || typeof video.creator !== "object") { + video.creator = {}; + } + + if (!video.creator.pubkey) { + video.creator.pubkey = normalizedPubkey; + } + + if (resolvedProfile.name) { + video.creator.name = resolvedProfile.name; + if ( + typeof video.creatorName !== "string" || + !video.creatorName.trim() || + video.creatorName === "Unknown" + ) { + video.creatorName = resolvedProfile.name; + } + if ( + typeof video.authorName !== "string" || + !video.authorName.trim() || + video.authorName === "Unknown" + ) { + video.authorName = resolvedProfile.name; + } + } + + if (resolvedProfile.picture) { + video.creator.picture = resolvedProfile.picture; + video.creatorPicture = resolvedProfile.picture; + if ( + typeof video.authorPicture !== "string" || + !video.authorPicture.trim() + ) { + video.authorPicture = resolvedProfile.picture; + } + } + + const encodedNpub = this.safeEncodeNpub(normalizedPubkey); + if (encodedNpub) { + const shortNpub = formatShortNpub(encodedNpub) || encodedNpub; + if (typeof video.npub !== "string" || !video.npub.trim()) { + video.npub = encodedNpub; + } + if (typeof video.shortNpub !== "string" || !video.shortNpub.trim()) { + video.shortNpub = shortNpub; + } + if ( + typeof video.creatorNpub !== "string" || + !video.creatorNpub.trim() + ) { + video.creatorNpub = shortNpub; + } + } + + return video; + } + async fetchModalCreatorProfile({ pubkey, displayNpub = "", diff --git a/js/channelProfile.js b/js/channelProfile.js index 3c8623cf..ab390dc3 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -110,11 +110,13 @@ function decorateChannelVideo(video, app = getApp()) { return null; } + let decoratedVideo = video; + if (typeof app?.decorateVideoModeration === "function") { try { const decorated = app.decorateVideoModeration(video); if (decorated && typeof decorated === "object") { - return decorated; + decoratedVideo = decorated; } } catch (error) { devLogger.warn( @@ -124,7 +126,21 @@ function decorateChannelVideo(video, app = getApp()) { } } - return video; + if (typeof app?.decorateVideoCreatorIdentity === "function") { + try { + const identityDecorated = app.decorateVideoCreatorIdentity(decoratedVideo); + if (identityDecorated && typeof identityDecorated === "object") { + decoratedVideo = identityDecorated; + } + } catch (error) { + devLogger.warn( + "[ChannelProfile] Failed to decorate channel video identity", + error, + ); + } + } + + return decoratedVideo; } function collectChannelVideos(pubkey, app = getApp()) { @@ -4357,6 +4373,34 @@ export async function renderChannelVideosFromList({ cardState = "critical"; } + const identity = { + name: + typeof video.creatorName === "string" && video.creatorName + ? video.creatorName + : typeof video.authorName === "string" + ? video.authorName + : "", + picture: + typeof video.creatorPicture === "string" && video.creatorPicture + ? video.creatorPicture + : typeof video.authorPicture === "string" + ? video.authorPicture + : "", + pubkey: typeof video.pubkey === "string" ? video.pubkey : "", + npub: + typeof video.npub === "string" && video.npub + ? video.npub + : typeof video.authorNpub === "string" + ? video.authorNpub + : "", + shortNpub: + typeof video.shortNpub === "string" && video.shortNpub + ? video.shortNpub + : typeof video.creatorNpub === "string" + ? video.creatorNpub + : "", + }; + const videoCard = new VideoCard({ document, video, @@ -4365,6 +4409,7 @@ export async function renderChannelVideosFromList({ pointerInfo, timeAgo, cardState, + identity, capabilities: { canEdit, canDelete: canEdit, diff --git a/js/ui/components/VideoCard.js b/js/ui/components/VideoCard.js index 6c51c50a..efd19df1 100644 --- a/js/ui/components/VideoCard.js +++ b/js/ui/components/VideoCard.js @@ -1,5 +1,6 @@ import { normalizeDesignSystemContext } from "../../designSystem.js"; import { updateVideoCardSourceVisibility } from "../../utils/cardSourceVisibility.js"; +import { sanitizeProfileMediaUrl } from "../../utils/profileMedia.js"; import { userLogger } from "../../utils/logger.js"; import { applyModerationContextDatasets, @@ -9,6 +10,8 @@ import { import { buildModerationBadgeText } from "../moderationCopy.js"; const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; +const DEFAULT_PROFILE_AVATAR = "assets/svg/default-profile.svg"; +const HEX64_REGEX = /^[0-9a-f]{64}$/i; export class VideoCard { constructor({ @@ -27,6 +30,7 @@ export class VideoCard { assets = {}, state = {}, variant = "default", + identity = null, ensureGlobalMoreMenuHandlers, onRequestCloseAllMenus, nsfwContext = null, @@ -91,6 +95,7 @@ export class VideoCard { : null; this.designSystem = normalizeDesignSystemContext(designSystem); + this.identity = this.normalizeIdentity(identity); const normalizedVariant = typeof variant === "string" && variant.trim().toLowerCase() === "compact" @@ -461,6 +466,8 @@ export class VideoCard { header.appendChild(controls); } + this.applyIdentityToAuthorElements(); + content.appendChild(title); content.appendChild(header); @@ -501,6 +508,135 @@ export class VideoCard { this.refreshModerationUi(); } + normalizeIdentity(nextIdentity = {}, fallback = null) { + const baseline = fallback && typeof fallback === "object" ? fallback : {}; + const candidate = + nextIdentity && typeof nextIdentity === "object" ? nextIdentity : {}; + const video = this.video && typeof this.video === "object" ? this.video : {}; + + const pickFirstString = ( + entries = [], + { allowHex = true, sanitizer = (value) => value } = {}, + ) => { + for (const entry of entries) { + if (typeof entry !== "string") { + continue; + } + const trimmed = entry.trim(); + if (!trimmed) { + continue; + } + if (!allowHex && HEX64_REGEX.test(trimmed)) { + continue; + } + const sanitized = sanitizer(trimmed); + if (!sanitized) { + continue; + } + return sanitized; + } + return ""; + }; + + const pubkey = pickFirstString([ + candidate.pubkey, + baseline.pubkey, + video.pubkey, + video.author?.pubkey, + video.creator?.pubkey, + video.profile?.pubkey, + ]); + + const npub = pickFirstString([ + candidate.npub, + baseline.npub, + video.npub, + video.authorNpub, + video.profile?.npub, + ]); + + const shortNpub = pickFirstString([ + candidate.shortNpub, + baseline.shortNpub, + video.shortNpub, + video.creatorNpub, + ]); + + const name = pickFirstString( + [ + candidate.name, + candidate.display_name, + candidate.displayName, + candidate.username, + baseline.name, + baseline.display_name, + baseline.displayName, + baseline.username, + video.creatorName, + video.authorName, + video.creator?.name, + video.author?.name, + video.profile?.display_name, + video.profile?.name, + video.profile?.username, + ], + { allowHex: false }, + ); + + const picture = pickFirstString( + [ + candidate.picture, + candidate.image, + candidate.photo, + baseline.picture, + baseline.image, + baseline.photo, + video.creatorPicture, + video.authorPicture, + video.creator?.picture, + video.author?.picture, + video.profile?.picture, + video.profile?.image, + video.profile?.photo, + ], + { sanitizer: (value) => sanitizeProfileMediaUrl(value) }, + ); + + const resolvedName = name || shortNpub || npub || pubkey || ""; + const resolvedPicture = picture || DEFAULT_PROFILE_AVATAR; + + return { + name: resolvedName, + npub, + shortNpub, + pubkey, + picture: resolvedPicture, + }; + } + + updateIdentity(nextIdentity = {}) { + this.identity = this.normalizeIdentity(nextIdentity, this.identity); + this.applyIdentityToAuthorElements(); + } + + applyIdentityToAuthorElements() { + const nameLabel = this.identity?.name || "Unknown"; + if (this.authorNameEl) { + this.authorNameEl.textContent = nameLabel || "Unknown"; + } + + if (this.authorPicEl) { + const picture = this.identity?.picture || DEFAULT_PROFILE_AVATAR; + if (this.authorPicEl.getAttribute("src") !== picture) { + this.authorPicEl.src = picture; + } + + const altFallback = + nameLabel || this.identity?.shortNpub || this.identity?.npub || "Channel"; + this.authorPicEl.alt = `${altFallback}'s avatar`; + } + } + setCardBackdropImage(src) { if (!this.root || !this.root.style) { return; diff --git a/js/ui/views/VideoListView.js b/js/ui/views/VideoListView.js index 09be79bb..dc33716f 100644 --- a/js/ui/views/VideoListView.js +++ b/js/ui/views/VideoListView.js @@ -556,6 +556,34 @@ export class VideoListView { this.utils.persistWatchHistoryMetadata(video, pointerInfo); } + const identity = { + name: + typeof video.creatorName === "string" && video.creatorName + ? video.creatorName + : typeof video.authorName === "string" + ? video.authorName + : "", + picture: + typeof video.creatorPicture === "string" && video.creatorPicture + ? video.creatorPicture + : typeof video.authorPicture === "string" + ? video.authorPicture + : "", + pubkey: typeof video.pubkey === "string" ? video.pubkey : "", + npub: + typeof video.npub === "string" && video.npub + ? video.npub + : typeof video.authorNpub === "string" + ? video.authorNpub + : "", + shortNpub: + typeof video.shortNpub === "string" && video.shortNpub + ? video.shortNpub + : typeof video.creatorNpub === "string" + ? video.creatorNpub + : "", + }; + const videoCard = new VideoCard({ document: this.document, video, @@ -566,6 +594,7 @@ export class VideoListView { postedAt: normalizedPostedAt, cardState, motionState, + identity, nsfwContext: { isNsfw: video?.isNsfw === true, allowNsfw: this.allowNsfw, From 9ef040f0ec32db6993c0a31db2842bf115db0e4b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 27 Oct 2025 19:12:07 -0400 Subject: [PATCH 3/7] Sanitize similar content avatars --- js/ui/components/SimilarContentCard.js | 20 ++++++++++++++++---- js/ui/components/VideoModal.js | 22 ++++++++++++++++++++-- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/js/ui/components/SimilarContentCard.js b/js/ui/components/SimilarContentCard.js index 904d761a..8cdb6be8 100644 --- a/js/ui/components/SimilarContentCard.js +++ b/js/ui/components/SimilarContentCard.js @@ -1,5 +1,6 @@ import { normalizeDesignSystemContext } from "../../designSystem.js"; import { formatShortNpub } from "../../utils/formatters.js"; +import { sanitizeProfileMediaUrl } from "../../utils/profileMedia.js"; const DEFAULT_PROFILE_AVATAR = "assets/svg/default-profile.svg"; @@ -213,15 +214,26 @@ export class SimilarContentCard { continue; } const trimmed = entry.trim(); - if (trimmed) { - return trimmed; + if (!trimmed) { + continue; + } + const sanitized = sanitizeProfileMediaUrl(trimmed); + if (sanitized) { + return sanitized; } } - return ""; + const fallback = + typeof baseline.picture === "string" && baseline.picture.trim() + ? sanitizeProfileMediaUrl(baseline.picture) || baseline.picture.trim() + : ""; + + return fallback; })(); - return { name, npub, shortNpub, pubkey, picture }; + const resolvedPicture = picture || DEFAULT_PROFILE_AVATAR; + + return { name, npub, shortNpub, pubkey, picture: resolvedPicture }; } applyIdentityToElements() { diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index b0f702ea..2683cd40 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -5063,12 +5063,30 @@ export class VideoModal { continue; } const trimmed = candidate.trim(); - if (trimmed) { - picture = trimmed; + if (!trimmed) { + continue; + } + const sanitized = sanitizeProfileMediaUrl(trimmed); + if (sanitized) { + picture = sanitized; break; } } + if (!picture && pubkey) { + const robohash = `https://robohash.org/${pubkey}`; + picture = sanitizeProfileMediaUrl(robohash) || robohash; + } + + if (!picture) { + const fallbackAvatar = + typeof this.DEFAULT_PROFILE_AVATAR === "string" && + this.DEFAULT_PROFILE_AVATAR.trim() + ? this.DEFAULT_PROFILE_AVATAR.trim() + : "assets/svg/default-profile.svg"; + picture = sanitizeProfileMediaUrl(fallbackAvatar) || fallbackAvatar; + } + return { name, npub, From 57c44e76e743ac49778f8106f6a2729c5dfa68a7 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 27 Oct 2025 19:19:26 -0400 Subject: [PATCH 4/7] Stop retaining stale lightning address on modal profile refresh --- js/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/app.js b/js/app.js index f1595857..04abbbc9 100644 --- a/js/app.js +++ b/js/app.js @@ -9228,7 +9228,7 @@ class Application { return; } - const nextLightning = lightningAddressCandidate || this.currentVideo?.lightningAddress || ""; + const nextLightning = lightningAddressCandidate || ""; if (this.currentVideo) { this.currentVideo.lightningAddress = nextLightning ? nextLightning : null; From af192952a78ccd29a7518f387a487607d210245c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 27 Oct 2025 19:30:51 -0400 Subject: [PATCH 5/7] Fix modal profile lightning cache updates --- js/app.js | 46 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/js/app.js b/js/app.js index 04abbbc9..089d8e82 100644 --- a/js/app.js +++ b/js/app.js @@ -9189,8 +9189,12 @@ class Application { return; } + const parsedLud16 = + typeof parsed?.lud16 === "string" ? parsed.lud16.trim() : ""; + const parsedLud06 = + typeof parsed?.lud06 === "string" ? parsed.lud06.trim() : ""; const lightningAddressCandidate = (() => { - const fields = [parsed?.lud16, parsed?.lud06]; + const fields = [parsedLud16, parsedLud06]; for (const field of fields) { if (typeof field !== "string") { continue; @@ -9229,6 +9233,10 @@ class Application { } const nextLightning = lightningAddressCandidate || ""; + const previousLightning = + typeof this.currentVideo?.lightningAddress === "string" + ? this.currentVideo.lightningAddress + : ""; if (this.currentVideo) { this.currentVideo.lightningAddress = nextLightning ? nextLightning : null; @@ -9241,12 +9249,14 @@ class Application { name: resolvedProfile.name, picture: resolvedProfile.picture, pubkey: normalized, + lightningAddress: nextLightning ? nextLightning : null, }; } else { this.currentVideo.creator = { name: resolvedProfile.name, picture: resolvedProfile.picture, pubkey: normalized, + lightningAddress: nextLightning ? nextLightning : null, }; } } @@ -9272,14 +9282,38 @@ class Application { parsed?.username, ]); - if (fetchedNameCandidate || sanitizedFetchedPicture) { + const cachedLightning = + typeof cachedProfile?.lightningAddress === "string" + ? cachedProfile.lightningAddress.trim() + : ""; + const shouldUpdateCache = + Boolean(fetchedNameCandidate) || + Boolean(sanitizedFetchedPicture) || + cachedLightning !== nextLightning || + previousLightning !== nextLightning; + + if (shouldUpdateCache) { try { + const profileForCache = { + name: fetchedNameCandidate || resolvedProfile.name, + picture: sanitizedFetchedPicture || resolvedProfile.picture, + }; + + if (parsedLud16) { + profileForCache.lud16 = parsedLud16; + } + + if (parsedLud06) { + profileForCache.lud06 = parsedLud06; + } + + if (nextLightning) { + profileForCache.lightningAddress = nextLightning; + } + this.setProfileCacheEntry( normalized, - { - name: fetchedNameCandidate || resolvedProfile.name, - picture: sanitizedFetchedPicture || resolvedProfile.picture, - }, + profileForCache, { persist: false, reason: "modal-profile-fetch" }, ); } catch (error) { From be6162f5cdca3715fcc696970a8a0986441422db Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 27 Oct 2025 19:56:18 -0400 Subject: [PATCH 6/7] Reset wallet client after NWC timeouts --- js/channelProfile.js | 16 +++++++++++++- js/ui/zapController.js | 47 +++++++++++++++++++++++++----------------- 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/js/channelProfile.js b/js/channelProfile.js index ab390dc3..1f64d855 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -54,7 +54,8 @@ import { getPlatformLightningAddress } from "./payments/platformAddress.js"; import { devLogger, userLogger } from "./utils/logger.js"; import { ensureWallet, - sendPayment as sendWalletPayment + sendPayment as sendWalletPayment, + resetWalletClient } from "./payments/nwcClient.js"; import { prepareStaticModal, @@ -2578,6 +2579,19 @@ function createZapDependencies({ error }); } + if ( + typeof error?.message === "string" && + error.message.toLowerCase().includes("timed out") + ) { + try { + resetWalletClient(); + } catch (resetError) { + devLogger?.warn?.( + "[zap] Failed to reset wallet client after timeout", + resetError + ); + } + } logZapError( "wallet.sendPayment", { diff --git a/js/ui/zapController.js b/js/ui/zapController.js index 4587cf90..b490e2a9 100644 --- a/js/ui/zapController.js +++ b/js/ui/zapController.js @@ -23,6 +23,7 @@ import { devLogger, userLogger } from "../utils/logger.js"; import { ensureWallet as ensureWalletDefault, sendPayment as sendPaymentDefault, + resetWalletClient as resetWalletClientDefault, } from "../payments/nwcClient.js"; const DEFAULT_SPLIT_SUMMARY = "Enter an amount to view the split."; @@ -474,6 +475,10 @@ export default class ZapController { typeof this.payments?.sendPayment === "function" ? (bolt11, params) => this.payments.sendPayment(bolt11, params) : (bolt11, params) => sendPaymentDefault(bolt11, params); + const resetWalletClientFn = + typeof this.payments?.resetWalletClient === "function" + ? () => this.payments.resetWalletClient() + : () => resetWalletClientDefault(); let activeShare = null; @@ -543,18 +548,18 @@ export default class ZapController { } }, sendPayment: async (bolt11, params) => { + const shareType = activeShare || "unknown"; + const amount = + shareType === "platform" + ? shares.platformShare + : shares.creatorShare; + const address = + shareType === "platform" + ? platformEntry?.address || getCachedPlatformLightningAddress() + : creatorEntry?.address || lightningAddress; try { const payment = await sendPaymentFn(bolt11, params); if (Array.isArray(shareTracker)) { - const shareType = activeShare || "unknown"; - const amount = - shareType === "platform" - ? shares.platformShare - : shares.creatorShare; - const address = - shareType === "platform" - ? platformEntry?.address || getCachedPlatformLightningAddress() - : creatorEntry?.address || lightningAddress; shareTracker.push({ type: shareType, status: "success", @@ -566,15 +571,6 @@ export default class ZapController { return payment; } catch (error) { if (Array.isArray(shareTracker)) { - const shareType = activeShare || "unknown"; - const amount = - shareType === "platform" - ? shares.platformShare - : shares.creatorShare; - const address = - shareType === "platform" - ? platformEntry?.address || getCachedPlatformLightningAddress() - : creatorEntry?.address || lightningAddress; shareTracker.push({ type: shareType, status: "error", @@ -583,10 +579,23 @@ export default class ZapController { error, }); } + if ( + typeof error?.message === "string" && + error.message.toLowerCase().includes("timed out") + ) { + try { + resetWalletClientFn(); + } catch (resetError) { + devLogger.warn( + "[zap] Failed to reset wallet client after timeout", + resetError + ); + } + } logZapError( "wallet.sendPayment", { - shareType: activeShare || "unknown", + shareType, amount, address, tracker: shareTracker, From 18ffde214740b0404e86c091fbc34e7f59445bd4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 27 Oct 2025 20:14:28 -0400 Subject: [PATCH 7/7] Include LNURL hint in NWC zap payments --- js/payments/nwcClient.js | 16 +++++++++++++--- js/payments/zapSplit.js | 1 + tests/nwc-client.test.mjs | 2 ++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/js/payments/nwcClient.js b/js/payments/nwcClient.js index 435c317c..1df36acc 100644 --- a/js/payments/nwcClient.js +++ b/js/payments/nwcClient.js @@ -1083,7 +1083,7 @@ function sanitizeAmount(amount) { return Math.max(0, rounded); } -function buildPayInvoiceParams({ invoice, amountSats, zapRequest }) { +function buildPayInvoiceParams({ invoice, amountSats, zapRequest, lnurl }) { const params = { invoice }; const amount = sanitizeAmount(amountSats); @@ -1095,13 +1095,23 @@ function buildPayInvoiceParams({ invoice, amountSats, zapRequest }) { params.zap_request = zapRequest; } + if (typeof lnurl === "string") { + const trimmed = lnurl.trim(); + if (trimmed) { + params.lnurl = trimmed; + } + } + return params; } -export async function sendPayment(bolt11, { settings, amountSats, zapRequest, timeoutMs } = {}) { +export async function sendPayment( + bolt11, + { settings, amountSats, zapRequest, lnurl, timeoutMs } = {} +) { const context = await ensureWallet({ settings }); const invoice = sanitizeInvoice(bolt11); - const params = buildPayInvoiceParams({ invoice, amountSats, zapRequest }); + const params = buildPayInvoiceParams({ invoice, amountSats, zapRequest, lnurl }); const payload = { id: `req-${Date.now()}-${++requestCounter}`, method: "pay_invoice", diff --git a/js/payments/zapSplit.js b/js/payments/zapSplit.js index 21e7bddb..302d42ed 100644 --- a/js/payments/zapSplit.js +++ b/js/payments/zapSplit.js @@ -370,6 +370,7 @@ async function processShare({ const payment = await deps.wallet.sendPayment(invoice.invoice, { amountSats, zapRequest, + lnurl: resolved.url, }); return { diff --git a/tests/nwc-client.test.mjs b/tests/nwc-client.test.mjs index 3e7cec80..a4c5ef50 100644 --- a/tests/nwc-client.test.mjs +++ b/tests/nwc-client.test.mjs @@ -13,11 +13,13 @@ await (async () => { invoice: "bolt11-invoice", amountSats: 123, zapRequest: "zap-request-json", + lnurl: " https://pay.example/lnurlp/test ", }); assert.equal(params.invoice, "bolt11-invoice"); assert.equal(params.amount, 123_000); assert.equal(params.zap_request, "zap-request-json"); + assert.equal(params.lnurl, "https://pay.example/lnurlp/test"); })(); await (async () => {