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,