mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Merge pull request #2285 from PR0M3TH3AN/ai/refactor-creator-profile-20250225-11746590299420068418
Refactor CreatorProfileController from js/app.js
This commit is contained in:
3 files changed
+645
-404
No files matched your search
@@ -163,6 +163,7 @@ import {
|
||||
import ApplicationBootstrap from "./ui/applicationBootstrap.js";
|
||||
import EngagementController from "./ui/engagementController.js";
|
||||
import SimilarContentController from "./ui/similarContentController.js";
|
||||
import CreatorProfileController from "./ui/creatorProfileController.js";
|
||||
import UrlHealthController from "./ui/urlHealthController.js";
|
||||
import VideoModalCommentController from "./ui/videoModalCommentController.js";
|
||||
import VideoModalController from "./ui/videoModalController.js";
|
||||
@@ -283,6 +284,26 @@ class Application {
|
||||
const { modalManager } = this.bootstrapper.initialize();
|
||||
this.modalManager = modalManager;
|
||||
|
||||
this.creatorProfileController = new CreatorProfileController({
|
||||
services: { nostrClient },
|
||||
ui: { zapController: this.zapController },
|
||||
callbacks: {
|
||||
getProfileCacheEntry: (pubkey) => this.getProfileCacheEntry(pubkey),
|
||||
setProfileCacheEntry: (pubkey, profile, opts) =>
|
||||
this.setProfileCacheEntry(pubkey, profile, opts),
|
||||
getCurrentVideo: () => this.currentVideo,
|
||||
getVideoModal: () => this.videoModal,
|
||||
},
|
||||
helpers: {
|
||||
fetchProfileMetadata,
|
||||
ensureProfileMetadataSubscription,
|
||||
safeEncodeNpub: (val) => this.safeEncodeNpub(val),
|
||||
formatShortNpub,
|
||||
sanitizeProfileMediaUrl,
|
||||
},
|
||||
logger: devLogger,
|
||||
});
|
||||
|
||||
this.videoModalController = new VideoModalController({
|
||||
getVideoModal: () => this.videoModal,
|
||||
callbacks: {
|
||||
@@ -4794,422 +4815,38 @@ class Application {
|
||||
}
|
||||
|
||||
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 "";
|
||||
return this.creatorProfileController
|
||||
? this.creatorProfileController.selectPreferredCreatorName(candidates)
|
||||
: "";
|
||||
}
|
||||
|
||||
selectPreferredCreatorPicture(candidates = []) {
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== "string") {
|
||||
continue;
|
||||
}
|
||||
const sanitized = sanitizeProfileMediaUrl(candidate);
|
||||
if (sanitized) {
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
return this.creatorProfileController
|
||||
? this.creatorProfileController.selectPreferredCreatorPicture(candidates)
|
||||
: "";
|
||||
}
|
||||
|
||||
resolveCreatorProfileFromSources({
|
||||
video,
|
||||
pubkey,
|
||||
cachedProfile = null,
|
||||
fetchedProfile = null,
|
||||
fallbackAvatar,
|
||||
} = {}) {
|
||||
const normalizedPubkey =
|
||||
typeof pubkey === "string" && pubkey.trim() ? pubkey.trim() : "";
|
||||
const fallbackAvatarCandidate =
|
||||
typeof fallbackAvatar === "string" && fallbackAvatar.trim()
|
||||
? fallbackAvatar.trim()
|
||||
: normalizedPubkey
|
||||
? `https://robohash.org/${normalizedPubkey}`
|
||||
: "assets/svg/default-profile.svg";
|
||||
const defaultAvatar =
|
||||
sanitizeProfileMediaUrl(fallbackAvatarCandidate) ||
|
||||
fallbackAvatarCandidate ||
|
||||
"assets/svg/default-profile.svg";
|
||||
|
||||
const nameCandidates = [];
|
||||
const pictureCandidates = [];
|
||||
|
||||
const collectFromSource = (source) => {
|
||||
if (!source || typeof source !== "object") {
|
||||
return;
|
||||
}
|
||||
const names = [
|
||||
source.display_name,
|
||||
source.displayName,
|
||||
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 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 resolvedPicture =
|
||||
this.selectPreferredCreatorPicture(pictureCandidates) || defaultAvatar;
|
||||
|
||||
return { name: resolvedName, picture: resolvedPicture };
|
||||
resolveCreatorProfileFromSources(options) {
|
||||
return this.creatorProfileController
|
||||
? this.creatorProfileController.resolveCreatorProfileFromSources(options)
|
||||
: { name: "Unknown", picture: "assets/svg/default-profile.svg" };
|
||||
}
|
||||
|
||||
resolveModalCreatorProfile({
|
||||
video,
|
||||
pubkey,
|
||||
cachedProfile = null,
|
||||
fetchedProfile = null,
|
||||
} = {}) {
|
||||
return this.resolveCreatorProfileFromSources({
|
||||
video,
|
||||
pubkey,
|
||||
cachedProfile,
|
||||
fetchedProfile,
|
||||
});
|
||||
resolveModalCreatorProfile(options) {
|
||||
return this.creatorProfileController
|
||||
? this.creatorProfileController.resolveModalCreatorProfile(options)
|
||||
: { name: "Unknown", picture: "assets/svg/default-profile.svg" };
|
||||
}
|
||||
|
||||
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;
|
||||
return this.creatorProfileController
|
||||
? this.creatorProfileController.decorateVideoCreatorIdentity(video)
|
||||
: video;
|
||||
}
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profileEntry = await fetchProfileMetadata(normalized, {
|
||||
nostr: nostrClient,
|
||||
relays: relayList,
|
||||
logger: devLogger,
|
||||
});
|
||||
|
||||
if (this.modalCreatorProfileRequestToken !== requestToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!profileEntry?.event) {
|
||||
if (this.modalCreatorProfileRequestToken === requestToken) {
|
||||
this.modalCreatorProfileRequestToken = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ensureProfileMetadataSubscription({
|
||||
pubkey: normalized,
|
||||
nostr: nostrClient,
|
||||
relays: relayList,
|
||||
onProfile: ({ profile }) => {
|
||||
if (typeof this.setProfileCacheEntry === "function" && profile) {
|
||||
this.setProfileCacheEntry(normalized, profile);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = profileEntry.event.content
|
||||
? JSON.parse(profileEntry.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 parsedLud16 =
|
||||
typeof parsed?.lud16 === "string" ? parsed.lud16.trim() : "";
|
||||
const parsedLud06 =
|
||||
typeof parsed?.lud06 === "string" ? parsed.lud06.trim() : "";
|
||||
const lightningAddressCandidate = (() => {
|
||||
const fields = [parsedLud16, parsedLud06];
|
||||
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 || "";
|
||||
const previousLightning =
|
||||
typeof this.currentVideo?.lightningAddress === "string"
|
||||
? 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,
|
||||
lightningAddress: nextLightning ? nextLightning : null,
|
||||
};
|
||||
} else {
|
||||
this.currentVideo.creator = {
|
||||
name: resolvedProfile.name,
|
||||
picture: resolvedProfile.picture,
|
||||
pubkey: normalized,
|
||||
lightningAddress: nextLightning ? nextLightning : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
]);
|
||||
|
||||
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,
|
||||
profileForCache,
|
||||
{ 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;
|
||||
async fetchModalCreatorProfile(options) {
|
||||
if (this.creatorProfileController) {
|
||||
return this.creatorProfileController.fetchModalCreatorProfile(options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
import { normalizeHexPubkey } from "../utils/nostrHelpers.js";
|
||||
|
||||
export default class CreatorProfileController {
|
||||
constructor({
|
||||
services: { nostrClient },
|
||||
ui: { zapController },
|
||||
callbacks: {
|
||||
getProfileCacheEntry,
|
||||
setProfileCacheEntry,
|
||||
getCurrentVideo,
|
||||
getVideoModal,
|
||||
},
|
||||
helpers: {
|
||||
fetchProfileMetadata,
|
||||
ensureProfileMetadataSubscription,
|
||||
safeEncodeNpub,
|
||||
formatShortNpub,
|
||||
sanitizeProfileMediaUrl,
|
||||
},
|
||||
logger,
|
||||
}) {
|
||||
this.nostrClient = nostrClient;
|
||||
this.zapController = zapController;
|
||||
this.getProfileCacheEntry = getProfileCacheEntry;
|
||||
this.setProfileCacheEntry = setProfileCacheEntry;
|
||||
this.getCurrentVideo = getCurrentVideo;
|
||||
this.getVideoModal = getVideoModal;
|
||||
this.fetchProfileMetadata = fetchProfileMetadata;
|
||||
this.ensureProfileMetadataSubscription = ensureProfileMetadataSubscription;
|
||||
this.safeEncodeNpub = safeEncodeNpub;
|
||||
this.formatShortNpub = formatShortNpub;
|
||||
this.sanitizeProfileMediaUrl = sanitizeProfileMediaUrl;
|
||||
this.logger = logger;
|
||||
|
||||
this.modalCreatorProfileRequestToken = null;
|
||||
}
|
||||
|
||||
get videoModal() {
|
||||
return this.getVideoModal ? this.getVideoModal() : null;
|
||||
}
|
||||
|
||||
get currentVideo() {
|
||||
return this.getCurrentVideo ? this.getCurrentVideo() : null;
|
||||
}
|
||||
|
||||
log(message, ...args) {
|
||||
if (this.logger && typeof this.logger.log === "function") {
|
||||
this.logger.log("[CreatorProfileController]", message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
warn(message, ...args) {
|
||||
if (this.logger && typeof this.logger.warn === "function") {
|
||||
this.logger.warn("[CreatorProfileController]", message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
error(message, ...args) {
|
||||
if (this.logger && typeof this.logger.error === "function") {
|
||||
this.logger.error("[CreatorProfileController]", message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
normalizeHexPubkey(pubkey) {
|
||||
return normalizeHexPubkey(pubkey);
|
||||
}
|
||||
|
||||
selectPreferredCreatorName(candidates = []) {
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
if (/^[0-9a-fA-F]{64}$/.test(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
selectPreferredCreatorPicture(candidates = []) {
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== "string") {
|
||||
continue;
|
||||
}
|
||||
const sanitized = this.sanitizeProfileMediaUrl(candidate);
|
||||
if (sanitized) {
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
resolveCreatorProfileFromSources({
|
||||
video,
|
||||
pubkey,
|
||||
cachedProfile = null,
|
||||
fetchedProfile = null,
|
||||
fallbackAvatar,
|
||||
} = {}) {
|
||||
const normalizedPubkey =
|
||||
typeof pubkey === "string" && pubkey.trim() ? pubkey.trim() : "";
|
||||
const fallbackAvatarCandidate =
|
||||
typeof fallbackAvatar === "string" && fallbackAvatar.trim()
|
||||
? fallbackAvatar.trim()
|
||||
: normalizedPubkey
|
||||
? `https://robohash.org/${normalizedPubkey}`
|
||||
: "assets/svg/default-profile.svg";
|
||||
const defaultAvatar =
|
||||
this.sanitizeProfileMediaUrl(fallbackAvatarCandidate) ||
|
||||
fallbackAvatarCandidate ||
|
||||
"assets/svg/default-profile.svg";
|
||||
|
||||
const nameCandidates = [];
|
||||
const pictureCandidates = [];
|
||||
|
||||
const collectFromSource = (source) => {
|
||||
if (!source || typeof source !== "object") {
|
||||
return;
|
||||
}
|
||||
const names = [
|
||||
source.display_name,
|
||||
source.displayName,
|
||||
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 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 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 = this.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 = "",
|
||||
cachedProfile = null,
|
||||
requestToken = null,
|
||||
} = {}) {
|
||||
const normalized = this.normalizeHexPubkey(pubkey);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.modalCreatorProfileRequestToken = requestToken;
|
||||
|
||||
const relayList =
|
||||
Array.isArray(this.nostrClient?.relays) && this.nostrClient.relays.length
|
||||
? this.nostrClient.relays
|
||||
: null;
|
||||
if (!relayList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profileEntry = await this.fetchProfileMetadata(normalized, {
|
||||
nostr: this.nostrClient,
|
||||
relays: relayList,
|
||||
logger: this.logger,
|
||||
});
|
||||
|
||||
if (this.modalCreatorProfileRequestToken !== requestToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!profileEntry?.event) {
|
||||
if (this.modalCreatorProfileRequestToken === requestToken) {
|
||||
this.modalCreatorProfileRequestToken = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.ensureProfileMetadataSubscription({
|
||||
pubkey: normalized,
|
||||
nostr: this.nostrClient,
|
||||
relays: relayList,
|
||||
onProfile: ({ profile }) => {
|
||||
if (typeof this.setProfileCacheEntry === "function" && profile) {
|
||||
this.setProfileCacheEntry(normalized, profile);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = profileEntry.event.content
|
||||
? JSON.parse(profileEntry.event.content)
|
||||
: null;
|
||||
} catch (error) {
|
||||
this.warn(
|
||||
`Failed to parse creator profile content for ${normalized}:`,
|
||||
error,
|
||||
);
|
||||
if (this.modalCreatorProfileRequestToken === requestToken) {
|
||||
this.modalCreatorProfileRequestToken = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.modalCreatorProfileRequestToken !== requestToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedLud16 =
|
||||
typeof parsed?.lud16 === "string" ? parsed.lud16.trim() : "";
|
||||
const parsedLud06 =
|
||||
typeof parsed?.lud06 === "string" ? parsed.lud06.trim() : "";
|
||||
const lightningAddressCandidate = (() => {
|
||||
const fields = [parsedLud16, parsedLud06];
|
||||
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 || "";
|
||||
const previousLightning =
|
||||
typeof this.currentVideo?.lightningAddress === "string"
|
||||
? 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,
|
||||
lightningAddress: nextLightning ? nextLightning : null,
|
||||
};
|
||||
} else {
|
||||
this.currentVideo.creator = {
|
||||
name: resolvedProfile.name,
|
||||
picture: resolvedProfile.picture,
|
||||
pubkey: normalized,
|
||||
lightningAddress: nextLightning ? nextLightning : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (this.videoModal) {
|
||||
this.videoModal.updateMetadata({
|
||||
creator: {
|
||||
name: resolvedProfile.name,
|
||||
avatarUrl: resolvedProfile.picture,
|
||||
npub: displayNpub,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.zapController?.setVisibility(Boolean(this.currentVideo?.lightningAddress));
|
||||
|
||||
const sanitizedFetchedPicture = this.sanitizeProfileMediaUrl(
|
||||
parsed?.picture || parsed?.image || parsed?.photo || "",
|
||||
);
|
||||
const fetchedNameCandidate = this.selectPreferredCreatorName([
|
||||
parsed?.display_name,
|
||||
parsed?.name,
|
||||
parsed?.username,
|
||||
]);
|
||||
|
||||
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,
|
||||
profileForCache,
|
||||
{ persist: false, reason: "modal-profile-fetch" },
|
||||
);
|
||||
} catch (error) {
|
||||
this.warn(
|
||||
`Failed to update profile cache for ${normalized}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.modalCreatorProfileRequestToken === requestToken) {
|
||||
this.modalCreatorProfileRequestToken = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import CreatorProfileController from "../../js/ui/creatorProfileController.js";
|
||||
|
||||
test("CreatorProfileController", async (t) => {
|
||||
await t.test("resolveCreatorProfileFromSources prioritizes cached profile", () => {
|
||||
const controller = new CreatorProfileController({
|
||||
services: { nostrClient: {} },
|
||||
ui: { zapController: {} },
|
||||
callbacks: {},
|
||||
helpers: {
|
||||
sanitizeProfileMediaUrl: (url) => url,
|
||||
},
|
||||
});
|
||||
|
||||
const result = controller.resolveCreatorProfileFromSources({
|
||||
pubkey: "pubkey1",
|
||||
cachedProfile: { name: "Cached Name", picture: "cached.jpg" },
|
||||
fetchedProfile: { name: "Fetched Name", picture: "fetched.jpg" },
|
||||
});
|
||||
|
||||
// logic prioritizes fetched > cached > video
|
||||
assert.strictEqual(result.name, "Fetched Name");
|
||||
assert.strictEqual(result.picture, "fetched.jpg");
|
||||
});
|
||||
|
||||
await t.test("resolveCreatorProfileFromSources falls back to fetched profile", () => {
|
||||
const controller = new CreatorProfileController({
|
||||
services: { nostrClient: {} },
|
||||
ui: { zapController: {} },
|
||||
callbacks: {},
|
||||
helpers: {
|
||||
sanitizeProfileMediaUrl: (url) => url,
|
||||
},
|
||||
});
|
||||
|
||||
const result = controller.resolveCreatorProfileFromSources({
|
||||
pubkey: "pubkey1",
|
||||
cachedProfile: null,
|
||||
fetchedProfile: { name: "Fetched Name", picture: "fetched.jpg" },
|
||||
});
|
||||
|
||||
assert.strictEqual(result.name, "Fetched Name");
|
||||
assert.strictEqual(result.picture, "fetched.jpg");
|
||||
});
|
||||
|
||||
await t.test("decorateVideoCreatorIdentity modifies video object", () => {
|
||||
const controller = new CreatorProfileController({
|
||||
services: { nostrClient: {} },
|
||||
ui: { zapController: {} },
|
||||
callbacks: {
|
||||
getProfileCacheEntry: () => ({ profile: { name: "Decorated Name" } }),
|
||||
},
|
||||
helpers: {
|
||||
safeEncodeNpub: () => "npub1...",
|
||||
formatShortNpub: (npub) => npub,
|
||||
sanitizeProfileMediaUrl: (url) => url,
|
||||
},
|
||||
});
|
||||
|
||||
const video = { pubkey: "abcdef" };
|
||||
const decorated = controller.decorateVideoCreatorIdentity(video);
|
||||
|
||||
assert.strictEqual(decorated.creatorName, "Decorated Name");
|
||||
assert.strictEqual(decorated.authorName, "Decorated Name");
|
||||
assert.strictEqual(decorated.creator.name, "Decorated Name");
|
||||
});
|
||||
|
||||
await t.test("fetchModalCreatorProfile fetches profile and updates modal", async () => {
|
||||
let modalUpdated = false;
|
||||
let zapVisibility = null;
|
||||
let profileCached = false;
|
||||
|
||||
const mockNostrClient = { relays: ["wss://relay.example.com"] };
|
||||
const mockVideoModal = {
|
||||
updateMetadata: () => { modalUpdated = true; }
|
||||
};
|
||||
const mockZapController = {
|
||||
setVisibility: (val) => { zapVisibility = val; }
|
||||
};
|
||||
|
||||
const validHex = "f".repeat(64);
|
||||
|
||||
const controller = new CreatorProfileController({
|
||||
services: { nostrClient: mockNostrClient },
|
||||
ui: { zapController: mockZapController },
|
||||
callbacks: {
|
||||
getProfileCacheEntry: () => null,
|
||||
setProfileCacheEntry: () => { profileCached = true; },
|
||||
getCurrentVideo: () => ({ pubkey: validHex }),
|
||||
getVideoModal: () => mockVideoModal,
|
||||
},
|
||||
helpers: {
|
||||
fetchProfileMetadata: async () => ({
|
||||
event: { content: JSON.stringify({ name: "Fetched" }) }
|
||||
}),
|
||||
ensureProfileMetadataSubscription: () => {},
|
||||
safeEncodeNpub: () => "npub1...",
|
||||
formatShortNpub: (val) => val,
|
||||
sanitizeProfileMediaUrl: (val) => val,
|
||||
},
|
||||
logger: { warn: () => {} }
|
||||
});
|
||||
|
||||
await controller.fetchModalCreatorProfile({
|
||||
pubkey: validHex,
|
||||
displayNpub: "npub1...",
|
||||
requestToken: Symbol("test")
|
||||
});
|
||||
|
||||
assert.strictEqual(modalUpdated, true, "Video modal should be updated");
|
||||
assert.strictEqual(zapVisibility, false, "Zap visibility should be false (no lightning address)");
|
||||
assert.strictEqual(profileCached, true, "Profile should be cached");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user