diff --git a/js/app.js b/js/app.js index da6bcf1c..ed048602 100644 --- a/js/app.js +++ b/js/app.js @@ -291,11 +291,7 @@ class Application { this.profileAvatar = document.getElementById("profileAvatar") || null; // Profile modal controller state - this.activeProfilePane = null; - this.isWalletPaneBusy = false; - this.profileModalCachedSelection = null; - this.profileModalController = null; - this.profileSwitcherSelectionPubkey = null; + this.profileController = null; this.currentUserNpub = null; // Upload modal component @@ -388,6 +384,118 @@ class Application { this.boundRevertConfirmHandler ); + try { + const profileModalContainer = document.getElementById("modalContainer") || null; + if (profileModalContainer) { + const profileModalServices = { + normalizeHexPubkey: (value) => this.normalizeHexPubkey(value), + safeEncodeNpub: (pubkey) => this.safeEncodeNpub(pubkey), + safeDecodeNpub: (npub) => this.safeDecodeNpub(npub), + truncateMiddle: (value, maxLength) => truncateMiddle(value, maxLength), + getProfileCacheEntry: (pubkey) => this.getProfileCacheEntry(pubkey), + batchFetchProfiles: (authorSet) => this.batchFetchProfiles(authorSet), + switchProfile: (pubkey) => this.authService.switchProfile(pubkey), + removeSavedProfile: (pubkey) => + this.authService.removeSavedProfile(pubkey), + relayManager, + userBlocks, + nostrClient, + accessControl, + getCurrentUserNpub: () => this.getCurrentUserNpub(), + getActiveNwcSettings: () => this.getActiveNwcSettings(), + updateActiveNwcSettings: (partial) => + this.updateActiveNwcSettings(partial), + hydrateNwcSettingsForPubkey: (pubkey) => + this.hydrateNwcSettingsForPubkey(pubkey), + createDefaultNwcSettings: () => createDefaultNwcSettings(), + ensureWallet: (options) => this.ensureWallet(options), + loadVideos: (forceFetch, context) => + this.loadVideos(forceFetch, context), + onVideosShouldRefresh: (context) => + this.onVideosShouldRefresh(context), + describeAdminError: (code) => this.describeAdminError(code), + describeNotificationError: (code) => + this.describeNotificationError(code), + onAccessControlUpdated: () => this.onAccessControlUpdated(), + persistSavedProfiles: (options) => persistSavedProfiles(options), + watchHistoryService, + authService: this.authService, + log: (...args) => this.log(...args), + closeAllMoreMenus: () => this.closeAllMoreMenus(), + }; + + const profileModalState = { + getSavedProfiles: () => getSavedProfiles(), + setSavedProfiles: (profiles, options) => + setSavedProfiles(Array.isArray(profiles) ? profiles : [], options), + persistSavedProfiles: (options) => persistSavedProfiles(options), + getActivePubkey: () => getActiveProfilePubkey(), + setActivePubkey: (pubkey, options) => { + const normalized = + typeof pubkey === "string" && pubkey.trim() + ? pubkey.trim() + : null; + setStoredActiveProfilePubkey(normalized, options); + return getActiveProfilePubkey(); + }, + }; + + const profileModalCallbacks = { + onClose: () => this.handleProfileModalClosed(), + onLogout: async () => this.authService.logout(), + onChannelLink: (element) => this.handleProfileChannelLink(element), + onAddAccount: (controller) => this.handleAddProfile(controller), + onRequestSwitchProfile: (payload) => + this.handleProfileSwitchRequest(payload), + onRelayOperation: (payload) => + this.handleProfileRelayOperation(payload), + onRelayModeToggle: (payload) => + this.handleProfileRelayModeToggle(payload), + onRelayRestore: (payload) => + this.handleProfileRelayRestore(payload), + onBlocklistMutation: (payload) => + this.handleProfileBlocklistMutation(payload), + onWalletPersist: (payload) => + this.handleProfileWalletPersist(payload), + onWalletTestRequest: (payload) => + this.handleProfileWalletTest(payload), + onWalletDisconnectRequest: (payload) => + this.handleProfileWalletDisconnect(payload), + onAdminMutation: (payload) => + this.handleProfileAdminMutation(payload), + onAdminNotifyError: (payload) => + this.handleProfileAdminNotifyError(payload), + onHistoryReady: (payload) => + this.handleProfileHistoryEvent(payload), + }; + + this.profileController = new ProfileModalController({ + modalContainer: profileModalContainer, + removeTrackingScripts, + createWatchHistoryRenderer, + setGlobalModalState, + showError: (message) => this.showError(message), + showSuccess: (message) => this.showSuccess(message), + showStatus: (message) => this.showStatus(message), + constants: { + MAX_WALLET_DEFAULT_ZAP, + ADMIN_SUPER_NPUB, + ADMIN_DM_IMAGE_URL, + BITVID_WEBSITE_URL, + }, + services: profileModalServices, + state: profileModalState, + callbacks: profileModalCallbacks, + }); + } else { + console.warn( + "[Application] Profile modal controller disabled: modal container not found.", + ); + } + } catch (error) { + console.error("Failed to initialize profile modal controller:", error); + } + // Optional small inline player stats this.status = document.getElementById("status") || null; @@ -478,11 +586,11 @@ class Application { this.boundVideoModalZapCommentHandler ); this.boundVideoModalZapWalletHandler = () => { - if (!this.profileModalController) { + if (!this.profileController) { return; } - this.profileModalController + this.profileController .showWalletPane() .catch((error) => { console.error("Failed to open wallet pane:", error); @@ -894,7 +1002,34 @@ class Application { this.showError(`Failed to initialize edit modal: ${error.message}`); }); - const profileModalPromise = this.initProfileModal(); + const profileModalPromise = this.profileController + ? this.profileController + .load() + .then(() => { + try { + this.renderSavedProfiles(); + } catch (error) { + console.warn( + "[profileModal] Failed to render saved profiles after load:", + error, + ); + } + + try { + this.profileController.refreshWalletPaneState(); + } catch (error) { + console.warn( + "[profileModal] Failed to refresh wallet pane after load:", + error, + ); + } + return true; + }) + .catch((error) => { + console.error("Failed to load profile modal:", error); + return false; + }) + : Promise.resolve(false); const modalBootstrapPromise = Promise.all([ videoModalPromise, @@ -930,12 +1065,16 @@ class Application { ); }); - const adminPanePromise = this.refreshAdminPaneState().catch((error) => { - console.warn( - "Failed to update admin pane after connecting to Nostr:", - error - ); - }); + const adminPanePromise = this.profileController + ? Promise.resolve() + .then(() => this.profileController.refreshAdminPaneState()) + .catch((error) => { + console.warn( + "Failed to update admin pane after connecting to Nostr:", + error, + ); + }) + : Promise.resolve(null); await Promise.all([accessControlPromise, adminPanePromise]); @@ -1217,187 +1356,25 @@ class Application { } } - async initProfileModal() { - try { - const modalContainer = document.getElementById("modalContainer"); - if (!modalContainer) { - throw new Error("Modal container element not found!"); - } - - if (!this.profileModalController) { - const profileModalServices = { - normalizeHexPubkey: (value) => this.normalizeHexPubkey(value), - safeEncodeNpub: (pubkey) => this.safeEncodeNpub(pubkey), - safeDecodeNpub: (npub) => this.safeDecodeNpub(npub), - truncateMiddle: (value, maxLength) => truncateMiddle(value, maxLength), - getProfileCacheEntry: (pubkey) => this.getProfileCacheEntry(pubkey), - batchFetchProfiles: (authorSet) => this.batchFetchProfiles(authorSet), - switchProfile: (pubkey) => this.authService.switchProfile(pubkey), - removeSavedProfile: (pubkey) => - this.authService.removeSavedProfile(pubkey), - relayManager, - userBlocks, - nostrClient, - accessControl, - getCurrentUserNpub: () => this.getCurrentUserNpub(), - getActiveNwcSettings: () => this.getActiveNwcSettings(), - updateActiveNwcSettings: (partial) => - this.updateActiveNwcSettings(partial), - hydrateNwcSettingsForPubkey: (pubkey) => - this.hydrateNwcSettingsForPubkey(pubkey), - createDefaultNwcSettings: () => createDefaultNwcSettings(), - ensureWallet: (options) => this.ensureWallet(options), - loadVideos: (forceFetch) => this.loadVideos(forceFetch), - onVideosShouldRefresh: (context) => this.onVideosShouldRefresh(context), - describeAdminError: (code) => this.describeAdminError(code), - describeNotificationError: (code) => - this.describeNotificationError(code), - onAccessControlUpdated: () => this.onAccessControlUpdated(), - persistSavedProfiles: (options) => persistSavedProfiles(options), - }; - - const profileModalState = { - getSavedProfiles: () => getSavedProfiles(), - setSavedProfiles: (profiles, options) => - setSavedProfiles(Array.isArray(profiles) ? profiles : [], options), - persistSavedProfiles: (options) => persistSavedProfiles(options), - getActivePubkey: () => this.activeProfilePubkey, - setActivePubkey: (pubkey, options) => { - this.activeProfilePubkey = - typeof pubkey === "string" && pubkey.trim() - ? pubkey.trim() - : null; - setStoredActiveProfilePubkey(this.activeProfilePubkey, options); - return this.activeProfilePubkey; - }, - getCachedSelection: () => this.profileModalCachedSelection || null, - setCachedSelection: (value) => { - const normalized = - typeof value === "string" && value.trim() - ? value.trim() - : null; - this.profileModalCachedSelection = normalized; - return this.profileModalCachedSelection; - }, - getActivePane: () => { - const pane = - typeof this.activeProfilePane === "string" && this.activeProfilePane - ? this.activeProfilePane - : "account"; - return pane; - }, - setActivePane: (pane) => { - if (pane === null) { - this.activeProfilePane = null; - return this.activeProfilePane; - } - - const normalized = - typeof pane === "string" && pane.trim() - ? pane.trim().toLowerCase() - : "account"; - this.activeProfilePane = normalized; - return this.activeProfilePane; - }, - getWalletBusy: () => Boolean(this.isWalletPaneBusy), - setWalletBusy: (flag) => { - this.isWalletPaneBusy = Boolean(flag); - return this.isWalletPaneBusy; - }, - }; - - const profileModalCallbacks = { - onClose: () => this.handleProfileModalClosed(), - onLogout: async () => this.authService.logout(), - onChannelLink: (element) => this.handleProfileChannelLink(element), - onAddAccount: (controller) => this.handleAddProfile(controller), - onSelectPane: (pane) => { - this.activeProfilePane = pane; - }, - onPaneShown: (pane) => { - this.activeProfilePane = pane; - }, - onRequestSwitchProfile: (payload) => - this.handleProfileSwitchRequest(payload), - onRelayOperation: (payload) => - this.handleProfileRelayOperation(payload), - onRelayModeToggle: (payload) => - this.handleProfileRelayModeToggle(payload), - onRelayRestore: (payload) => - this.handleProfileRelayRestore(payload), - onBlocklistMutation: (payload) => - this.handleProfileBlocklistMutation(payload), - onWalletPersist: (payload) => - this.handleProfileWalletPersist(payload), - onWalletTestRequest: (payload) => - this.handleProfileWalletTest(payload), - onWalletDisconnectRequest: (payload) => - this.handleProfileWalletDisconnect(payload), - onAdminMutation: (payload) => - this.handleProfileAdminMutation(payload), - onAdminNotifyError: (payload) => - this.handleProfileAdminNotifyError(payload), - onHistoryReady: (payload) => - this.handleProfileHistoryEvent(payload), - }; - - this.profileModalController = new ProfileModalController({ - modalContainer, - removeTrackingScripts, - createWatchHistoryRenderer, - setGlobalModalState, - showError: (message) => this.showError(message), - showSuccess: (message) => this.showSuccess(message), - showStatus: (message) => this.showStatus(message), - constants: { - MAX_WALLET_DEFAULT_ZAP, - ADMIN_SUPER_NPUB, - ADMIN_DM_IMAGE_URL, - BITVID_WEBSITE_URL, - }, - services: profileModalServices, - state: profileModalState, - callbacks: profileModalCallbacks, - }); - } - - const alreadyLoaded = - this.profileModalController.profileModal instanceof HTMLElement; - if (!alreadyLoaded) { - const loaded = await this.profileModalController.load(); - if (!loaded) { - return false; - } - } else { - this.profileModalController.cacheDomReferences(); - } - - this.syncProfileModalState(); - this.renderSavedProfiles(); - return true; - } catch (error) { - console.error("initProfileModal failed:", error); - return false; - } - } - syncProfileModalState({ includeSavedProfiles = true, includeActivePubkey = true, } = {}) { - if (!this.profileModalController) { + if (!this.profileController) { return; } if (includeSavedProfiles) { try { - const entries = Array.isArray(this.savedProfiles) - ? this.savedProfiles.slice() - : []; - this.profileModalController.setSavedProfiles(entries, { - persist: false, - persistActive: false, - }); + if (typeof this.profileController.setSavedProfiles === "function") { + const entries = Array.isArray(this.savedProfiles) + ? this.savedProfiles.slice() + : []; + this.profileController.setSavedProfiles(entries, { + persist: false, + persistActive: false, + }); + } } catch (error) { console.warn( "[profileModal] Failed to synchronize saved profiles with controller:", @@ -1408,10 +1385,12 @@ class Application { if (includeActivePubkey) { try { - this.profileModalController.setActivePubkey( - this.activeProfilePubkey || null, - { persist: false }, - ); + if (typeof this.profileController.setActivePubkey === "function") { + this.profileController.setActivePubkey( + this.activeProfilePubkey || null, + { persist: false }, + ); + } } catch (error) { console.warn( "[profileModal] Failed to synchronize active profile with controller:", @@ -1422,44 +1401,22 @@ class Application { } renderSavedProfiles() { - if (!this.profileModalController) { + if (!this.profileController) { return; } this.syncProfileModalState(); - this.profileModalController.renderSavedProfiles(); - } - - selectProfilePane(name = "account") { - if (!this.profileModalController) { - return; + try { + this.profileController.renderSavedProfiles(); + } catch (error) { + console.warn( + "[profileModal] Failed to render saved profiles:", + error, + ); } - this.syncProfileModalState(); - this.profileModalController.selectPane(name); - } - - setWalletPaneBusy(isBusy = false) { - this.isWalletPaneBusy = Boolean(isBusy); - if (!this.profileModalController) { - return; - } - this.profileModalController.setWalletPaneBusy(this.isWalletPaneBusy); - } - - refreshWalletPaneState() { - if (!this.profileModalController) { - return; - } - this.syncProfileModalState({ includeSavedProfiles: false }); - this.profileModalController.refreshWalletPaneState(); - } - - updateWalletStatus(message, variant = "info") { - this.profileModalController?.updateWalletStatus(message, variant); } handleProfileModalClosed() { this.closeAllMoreMenus(); - this.activeProfilePane = null; } handleProfileChannelLink(element) { @@ -1471,8 +1428,8 @@ class Application { typeof element.dataset.targetNpub === "string" ? element.dataset.targetNpub : ""; - if (this.profileModalController) { - this.profileModalController.hide(); + if (this.profileController) { + this.profileController.hide(); } if (targetNpub) { window.location.hash = `#view=channel-profile&npub=${encodeURIComponent( @@ -1481,74 +1438,10 @@ class Application { } } - populateProfileRelays(relayEntries = null) { - if (!this.profileModalController) { - return; - } - this.syncProfileModalState({ includeSavedProfiles: false }); - this.profileModalController.populateProfileRelays(relayEntries); - } - - populateBlockedList(blocked = null) { - if (!this.profileModalController) { - return; - } - this.syncProfileModalState({ includeSavedProfiles: false }); - this.profileModalController.populateBlockedList(blocked); - } - - refreshAdminPaneState() { - if (!this.profileModalController) { - return null; - } - this.syncProfileModalState(); - return this.profileModalController.refreshAdminPaneState(); - } - - populateAdminLists() { - if (!this.profileModalController) { - return; - } - this.syncProfileModalState(); - this.profileModalController.populateAdminLists(); - } - - renderAdminList(...args) { - if (!this.profileModalController) { - return null; - } - this.syncProfileModalState(); - return this.profileModalController.renderAdminList(...args); - } - - handleAdminListMutation(...args) { - if (!this.profileModalController) { - return null; - } - this.syncProfileModalState(); - return this.profileModalController.handleAdminListMutation(...args); - } - - handleAddModerator(...args) { - if (!this.profileModalController) { - return null; - } - this.syncProfileModalState(); - return this.profileModalController.handleAddModerator(...args); - } - - handleRemoveModerator(...args) { - if (!this.profileModalController) { - return null; - } - this.syncProfileModalState(); - return this.profileModalController.handleRemoveModerator(...args); - } - async handleAddProfile(controller) { const button = (controller && controller.addAccountButton) || - this.profileModalController?.addAccountButton || + this.profileController?.addAccountButton || null; if (!(button instanceof HTMLElement)) { return; @@ -1725,10 +1618,12 @@ class Application { } async onAccessControlUpdated() { - try { - await this.refreshAdminPaneState(); - } catch (error) { - console.error("Failed to refresh admin pane after update:", error); + if (this.profileController) { + try { + await this.profileController.refreshAdminPaneState(); + } catch (error) { + console.error("Failed to refresh admin pane after update:", error); + } } this.loadVideos(true).catch((error) => { @@ -1765,11 +1660,11 @@ class Application { // 2) Profile button if (this.profileButton) { this.profileButton.addEventListener("click", () => { - if (!this.profileModalController) { + if (!this.profileController) { return; } - this.profileModalController + this.profileController .show() .catch((error) => { console.error("Failed to open profile modal:", error); @@ -2706,8 +2601,8 @@ class Application { } updateActiveProfileUI(pubkey, profile = {}) { - if (this.profileModalController) { - this.profileModalController.handleProfileUpdated({ + if (this.profileController) { + this.profileController.handleProfileUpdated({ pubkey, profile, }); @@ -2905,20 +2800,17 @@ class Application { await this.hydrateNwcSettingsForPubkey(normalizedActive); } - if (this.profileModalController) { - await this.profileModalController.handleAuthLogin(detail); + if (this.profileController) { + try { + await this.profileController.handleAuthLogin(detail); + } catch (error) { + console.error( + "Failed to process login within the profile controller:", + error, + ); + } } else { this.renderSavedProfiles(); - - try { - await this.refreshAdminPaneState(); - } catch (error) { - console.warn("Failed to refresh admin pane after login:", error); - } - - this.populateBlockedList(); - this.populateProfileRelays(); - this.refreshWalletPaneState(); } if (this.loginButton) { @@ -2978,20 +2870,17 @@ class Application { } this.nwcSettings.clear(); - if (this.profileModalController) { - await this.profileModalController.handleAuthLogout(detail); + if (this.profileController) { + try { + await this.profileController.handleAuthLogout(detail); + } catch (error) { + console.error( + "Failed to process logout within the profile controller:", + error, + ); + } } else { this.renderSavedProfiles(); - - try { - await this.refreshAdminPaneState(); - } catch (error) { - console.warn("Failed to refresh admin pane after logout:", error); - } - - this.populateBlockedList(); - this.populateProfileRelays(); - this.refreshWalletPaneState(); } if (this.loginButton) { @@ -3040,8 +2929,8 @@ class Application { } handleProfileUpdated(detail = {}) { - if (this.profileModalController) { - this.profileModalController.handleProfileUpdated(detail); + if (this.profileController) { + this.profileController.handleProfileUpdated(detail); } else if (Array.isArray(detail?.savedProfiles)) { this.renderSavedProfiles(); } @@ -3054,7 +2943,7 @@ class Application { if (normalizedPubkey && profile) { this.updateProfileInDOM(normalizedPubkey, profile); if ( - !this.profileModalController && + !this.profileController && this.normalizeHexPubkey(this.pubkey) === normalizedPubkey ) { this.updateActiveProfileUI(normalizedPubkey, profile); @@ -4190,8 +4079,8 @@ class Application { typeof settings?.nwcUri === "string" ? settings.nwcUri.trim() : ""; if (!normalizedUri) { this.showError("Connect a Lightning wallet to send zaps."); - if (this.profileModalController) { - this.profileModalController + if (this.profileController) { + this.profileController .showWalletPane() .catch((error) => { console.error("Failed to open wallet pane:", error); @@ -6211,7 +6100,16 @@ class Application { ); } - this.populateBlockedList(); + if (this.profileController) { + try { + this.profileController.populateBlockedList(); + } catch (error) { + console.warn( + "[profileModal] Failed to refresh blocked list after update:", + error, + ); + } + } await this.loadVideos(); subscriptions .refreshActiveFeed({ reason: "user-block-update" }) @@ -7038,6 +6936,9 @@ class Application { this.cancelPendingViewLogging(); let modalVideoEl = this.modalVideo; + if (!modalVideoEl && this.videoModal) { + modalVideoEl = this.videoModal.getVideoElement(); + } if (!modalVideoEl && this.videoModal) { const maybeLoader = this.videoModal.load; if (typeof maybeLoader === "function") { @@ -7052,6 +6953,9 @@ class Application { return { source: null, error }; } modalVideoEl = this.modalVideo; + if (!modalVideoEl && this.videoModal) { + modalVideoEl = this.videoModal.getVideoElement(); + } } } @@ -7948,6 +7852,20 @@ class Application { } } + if (this.profileController) { + if (typeof this.profileController.destroy === "function") { + try { + this.profileController.destroy(); + } catch (error) { + console.warn( + "[Application] Failed to destroy profile controller:", + error, + ); + } + } + this.profileController = null; + } + this.videoList = null; } diff --git a/js/channelProfile.js b/js/channelProfile.js index 3eef1de3..04f8b7b2 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -2,6 +2,7 @@ import { nostrClient, + DEFAULT_RELAY_URLS, convertEventToVideo as sharedConvertEventToVideo, } from "./nostr.js"; import { subscriptions } from "./subscriptions.js"; // <-- NEW import @@ -120,6 +121,13 @@ let pendingZapRetry = null; let zapInFlight = false; let zapControlsOpen = false; let currentVideoLoadToken = 0; +let currentProfileLoadToken = 0; + +const FALLBACK_CHANNEL_BANNER = "assets/jpg/bitvid.jpg"; +const FALLBACK_CHANNEL_AVATAR = "assets/svg/default-profile.svg"; +const PROFILE_EVENT_CACHE_TTL_MS = 5 * 60 * 1000; + +const channelProfileMetadataCache = new Map(); function getChannelZapButton() { if (cachedZapButton && !document.body.contains(cachedZapButton)) { @@ -1366,13 +1374,21 @@ export async function initChannelProfileView() { setupZapButton(); syncChannelShareButtonState(); - // 4) Load user’s profile (banner, avatar, etc.) - await loadUserProfile(hexPub); - await updateChannelMenuState(); - syncChannelShareButtonState(); + // 4) Load user’s profile (banner, avatar, etc.) and channel videos + const profilePromise = loadUserProfile(hexPub) + .then(() => updateChannelMenuState()) + .catch((error) => { + console.error("Failed to load channel profile:", error); + }) + .finally(() => { + syncChannelShareButtonState(); + }); - // 5) Load user’s videos (filtered + rendered like the home feed) - await loadUserVideos(hexPub); + const videosPromise = loadUserVideos(hexPub).catch((error) => { + console.error("Failed to load channel videos:", error); + }); + + await Promise.allSettled([profilePromise, videosPromise]); } function setupZapButton() { @@ -1476,17 +1492,521 @@ function renderSubscribeButton(channelHex) { } } -/** - * Fetches and displays the user's metadata (kind=0). - */ -async function loadUserProfile(pubkey) { +function trimProfileString(value) { + if (typeof value !== "string") { + return ""; + } + + return value.trim(); +} + +function normalizeChannelProfileMetadata(raw = {}) { + const name = + trimProfileString(raw.display_name) || + trimProfileString(raw.name) || + "Unknown User"; + const picture = + trimProfileString(raw.picture) || trimProfileString(raw.image) || FALLBACK_CHANNEL_AVATAR; + const about = trimProfileString(raw.about || raw.bio); + const website = trimProfileString(raw.website || raw.url); + const banner = + trimProfileString(raw.banner || raw.header || raw.background) || ""; + const lud16 = trimProfileString(raw.lud16); + const lud06 = trimProfileString(raw.lud06); + const lightningAddress = + trimProfileString(raw.lightningAddress) || lud16 || lud06 || ""; + + return { + name, + picture, + about, + website, + banner, + lud16, + lud06, + lightningAddress, + }; +} + +function rememberChannelProfile(pubkey, { profile = {}, event = null } = {}) { + if (typeof pubkey !== "string" || !pubkey) { + return; + } + + channelProfileMetadataCache.set(pubkey, { + timestamp: Date.now(), + profile: normalizeChannelProfileMetadata(profile), + event: event ? { ...event } : null, + }); +} + +function getCachedChannelProfile(pubkey) { + const entry = channelProfileMetadataCache.get(pubkey); + if (!entry) { + return null; + } + + if ( + typeof entry.timestamp !== "number" || + Date.now() - entry.timestamp > PROFILE_EVENT_CACHE_TTL_MS + ) { + channelProfileMetadataCache.delete(pubkey); + return null; + } + + return { + profile: entry.profile, + event: entry.event ? { ...entry.event } : null, + }; +} + +function isValidRelayUrl(url) { + if (typeof url !== "string") { + return false; + } + + const trimmed = url.trim(); + if (!trimmed) { + return false; + } + + if (!/^wss?:\/\//i.test(trimmed)) { + return false; + } + + if (/\s/.test(trimmed)) { + return false; + } + + try { + const parsed = new URL(trimmed); + return Boolean(parsed.hostname); + } catch (error) { + return false; + } +} + +function getCachedChannelVideoEvents(pubkey) { + if (!nostrClient?.allEvents || typeof nostrClient.allEvents.forEach !== "function") { + return []; + } + + const normalized = typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + if (!normalized) { + return []; + } + + const results = []; + nostrClient.allEvents.forEach((event) => { + if ( + event && + event.kind === 30078 && + typeof event.pubkey === "string" && + event.pubkey.trim().toLowerCase() === normalized + ) { + results.push(event); + } + }); + + return results; +} + +function buildRenderableChannelVideos({ events = [], app } = {}) { + if (!Array.isArray(events) || events.length === 0) { + return []; + } + + const convertedVideos = events + .map((evt) => sharedConvertEventToVideo(evt)) + .filter((vid) => vid && !vid.invalid); + + if (!convertedVideos.length) { + return []; + } + + const dedupedById = new Map(); + convertedVideos.forEach((video) => { + if (video?.id && !dedupedById.has(video.id)) { + dedupedById.set(video.id, video); + } + }); + + const uniqueVideos = Array.from(dedupedById.values()); + + const newestByRoot = + app?.dedupeVideosByRoot?.(uniqueVideos) ?? dedupeToNewestByRoot(uniqueVideos); + + let videos = newestByRoot.filter((video) => !video.deleted); + videos = videos.filter((video) => { + if (!video || !video.id) { + return false; + } + + if (app?.blacklistedEventIds?.has?.(video.id)) { + return false; + } + + if (!accessControl.canAccess(video)) { + return false; + } + + return true; + }); + + videos.sort((a, b) => b.created_at - a.created_at); + return videos; +} + +function renderChannelVideosFromList({ + videos = [], + container, + app, + loadToken, + allowEmptyMessage = true, +} = {}) { + if (!container || loadToken !== currentVideoLoadToken) { + return false; + } + + if (!Array.isArray(videos) || videos.length === 0) { + if (allowEmptyMessage) { + container.dataset.hasChannelVideos = "false"; + container.innerHTML = `
No videos to display.
`; + return true; + } + return false; + } + + container.dataset.hasChannelVideos = "true"; + container.innerHTML = ""; + + const fragment = document.createDocumentFragment(); + const allKnownEventsArray = Array.from(nostrClient.allEvents.values()); + const loadedThumbnails = + app?.loadedThumbnails instanceof Map ? app.loadedThumbnails : null; + const unsupportedBtihMessage = + typeof app?.getUnsupportedBtihMessage === "function" + ? app.getUnsupportedBtihMessage() + : "This magnet link is missing a compatible BitTorrent v1 info hash."; + + const extractPlaybackDetail = (trigger, video) => { + if ( + app?.videoListView && + typeof app.videoListView.extractPlaybackDetail === "function" + ) { + return app.videoListView.extractPlaybackDetail(trigger, video); + } + + const element = trigger instanceof HTMLElement ? trigger : null; + const target = element?.closest("[data-play-url],[data-play-magnet]") || element; + + const rawUrlValue = + (target?.dataset && typeof target.dataset.playUrl === "string" + ? target.dataset.playUrl + : null) ?? target?.getAttribute?.("data-play-url") ?? ""; + const rawMagnetValue = + (target?.dataset && typeof target.dataset.playMagnet === "string" + ? target.dataset.playMagnet + : null) ?? target?.getAttribute?.("data-play-magnet") ?? ""; + + let url = ""; + if (rawUrlValue) { + try { + url = decodeURIComponent(rawUrlValue); + } catch (error) { + url = rawUrlValue; + } + } + + const magnet = typeof rawMagnetValue === "string" ? rawMagnetValue : ""; + const videoId = + target?.dataset?.videoId || + target?.getAttribute?.("data-video-id") || + video?.id || + ""; + + return { videoId, url, magnet, video }; + }; + + const playbackHandler = + typeof app?.videoListViewPlaybackHandler === "function" + ? app.videoListViewPlaybackHandler.bind(app) + : null; + + const startPlayback = (detail) => { + if (playbackHandler) { + playbackHandler(detail); + return; + } + + if (detail.videoId && typeof app?.playVideoByEventId === "function") { + Promise.resolve( + app.playVideoByEventId(detail.videoId, { + url: detail.url, + magnet: detail.magnet, + title: detail.video?.title, + description: detail.video?.description, + }) + ).catch((error) => { + console.error("Failed to play channel video via event id:", error); + if (typeof app?.playVideoWithFallback === "function") { + app.playVideoWithFallback({ + url: detail.url, + magnet: detail.magnet, + }); + } + }); + return; + } + + if (typeof app?.playVideoWithFallback === "function") { + Promise.resolve( + app.playVideoWithFallback({ url: detail.url, magnet: detail.magnet }) + ).catch((error) => { + console.error("Failed to start fallback playback for channel video:", error); + }); + } + }; + + videos.forEach((video, index) => { + if ( + video.isPrivate && + video.pubkey === nostrClient.pubkey && + !video.alreadyDecrypted + ) { + video.magnet = fakeDecrypt(video.magnet); + video.alreadyDecrypted = true; + } + + app?.videosMap?.set(video.id, video); + + const canEdit = video.pubkey === app?.pubkey; + let hasOlder = false; + if (canEdit && video.videoRootId) { + hasOlder = app?.hasOlderVersion?.(video, allKnownEventsArray) || false; + } + + const pointerInfo = + typeof app?.deriveVideoPointerInfo === "function" + ? app.deriveVideoPointerInfo(video) + : null; + if (pointerInfo && typeof app?.persistWatchHistoryMetadataForVideo === "function") { + app.persistWatchHistoryMetadataForVideo(video, pointerInfo); + } + + const shareUrl = + typeof app?.buildShareUrlFromEventId === "function" + ? app.buildShareUrlFromEventId(video.id) + : "#"; + const timeAgo = + typeof app?.formatTimeAgo === "function" + ? app.formatTimeAgo(video.created_at) + : new Date(video.created_at * 1000).toLocaleString(); + + const videoCard = new VideoCard({ + document, + video, + index, + shareUrl, + pointerInfo, + timeAgo, + capabilities: { + canEdit, + canDelete: canEdit, + canRevert: hasOlder, + canManageBlacklist: + typeof app?.canCurrentUserManageBlacklist === "function" + ? app.canCurrentUserManageBlacklist() + : false, + }, + helpers: { + escapeHtml: (value) => escapeHTML(value), + isMagnetSupported: (magnet) => app?.isMagnetUriSupported?.(magnet), + toLocaleString: (value) => + typeof value === "number" ? value.toLocaleString() : value, + }, + assets: { + fallbackThumbnailSrc: "assets/jpg/video-thumbnail-fallback.jpg", + unsupportedBtihMessage, + }, + state: { loadedThumbnails }, + ensureGlobalMoreMenuHandlers: () => app?.ensureGlobalMoreMenuHandlers?.(), + onRequestCloseAllMenus: () => app?.closeAllMoreMenus?.(), + formatters: { + formatTimeAgo: (ts) => + typeof app?.formatTimeAgo === "function" + ? app.formatTimeAgo(ts) + : new Date(ts * 1000).toLocaleString(), + }, + }); + + videoCard.onPlay = ({ event: domEvent, video: cardVideo }) => { + const trigger = domEvent?.currentTarget || domEvent?.target; + const detail = extractPlaybackDetail(trigger, cardVideo || video); + detail.video = detail.video || video; + startPlayback(detail); + }; + + videoCard.onEdit = ({ video: editVideo, index: editIndex }) => { + app?.handleEditVideo?.({ + eventId: editVideo?.id || "", + index: Number.isFinite(editIndex) ? editIndex : null, + }); + }; + + videoCard.onRevert = ({ video: revertVideo, index: revertIndex }) => { + app?.handleRevertVideo?.({ + eventId: revertVideo?.id || "", + index: Number.isFinite(revertIndex) ? revertIndex : null, + }); + }; + + videoCard.onDelete = ({ video: deleteVideo, index: deleteIndex }) => { + app?.handleFullDeleteVideo?.({ + eventId: deleteVideo?.id || "", + index: Number.isFinite(deleteIndex) ? deleteIndex : null, + }); + }; + + videoCard.onMoreAction = ({ dataset = {} }) => { + const action = dataset.action || ""; + const detail = { + ...dataset, + eventId: dataset.eventId || video.id || "", + context: dataset.context || "channel-grid", + }; + app?.handleMoreMenuAction?.(action || "copy-link", detail); + }; + + videoCard.onAuthorNavigate = ({ pubkey }) => { + const targetPubkey = pubkey || video.pubkey || ""; + if (targetPubkey && typeof app?.goToProfile === "function") { + app.goToProfile(targetPubkey); + } + }; + + const cardEl = videoCard.getRoot(); + if (cardEl) { + fragment.appendChild(cardEl); + } + }); + + container.appendChild(fragment); + + attachHealthBadges(container); + attachUrlHealthBadges(container, ({ badgeEl, url, eventId }) => { + const video = app?.videosMap?.get(eventId) || { id: eventId }; + app?.handleUrlHealthBadge?.({ video, url, badgeEl }); + }); + + app?.mountVideoListView?.(); + + const lazyEls = container.querySelectorAll("[data-lazy]"); + lazyEls.forEach((el) => app?.mediaLoader?.observe?.(el)); + + app?.attachMoreMenuHandlers?.(container); + + return true; +} + +function applyChannelProfileMetadata({ + profile = {}, + event = null, + pubkey = "", + npub = "", + loadToken = null, +} = {}) { + if (loadToken !== null && loadToken !== currentProfileLoadToken) { + return; + } + + const normalized = normalizeChannelProfileMetadata(profile); + + const bannerEl = document.getElementById("channelBanner"); + if (bannerEl) { + bannerEl.src = normalized.banner || FALLBACK_CHANNEL_BANNER; + } + + const avatarEl = document.getElementById("channelAvatar"); + if (avatarEl) { + avatarEl.src = normalized.picture || FALLBACK_CHANNEL_AVATAR; + } + + const nameEl = document.getElementById("channelName"); + if (nameEl) { + nameEl.textContent = normalized.name || "Unknown User"; + } + + const channelNpubEl = document.getElementById("channelNpub"); + if (channelNpubEl) { + if (npub) { + channelNpubEl.textContent = npub; + } else if (pubkey) { + try { + channelNpubEl.textContent = window.NostrTools.nip19.npubEncode(pubkey); + } catch (error) { + channelNpubEl.textContent = ""; + } + } + } + + const aboutEl = document.getElementById("channelAbout"); + if (aboutEl) { + aboutEl.textContent = normalized.about || ""; + } + + const websiteEl = document.getElementById("channelWebsite"); + if (websiteEl) { + if (normalized.website) { + websiteEl.href = normalized.website; + websiteEl.textContent = normalized.website; + } else { + websiteEl.textContent = ""; + websiteEl.removeAttribute("href"); + } + } + + const previousLightning = currentChannelLightningAddress; + const lightningAddress = normalized.lightningAddress || ""; + currentChannelLightningAddress = lightningAddress; + + const lnEl = document.getElementById("channelLightning"); + if (lnEl) { + lnEl.textContent = + lightningAddress || "No lightning address found."; + } + + if (event) { + currentChannelProfileEvent = { ...event }; + } else if (loadToken === currentProfileLoadToken && !event) { + currentChannelProfileEvent = null; + } + + if (lightningAddress) { + if (lightningAddress !== previousLightning) { + fetchLightningMetadata(lightningAddress) + .then(() => updateZapSplitSummary()) + .catch(() => {}); + } + } else { + updateZapSplitSummary(); + } + + setChannelZapVisibility(Boolean(lightningAddress)); +} + +async function fetchChannelProfileFromRelays(pubkey) { + if (!nostrClient?.pool || !Array.isArray(nostrClient?.relays)) { + return { event: null, profile: {} }; + } + try { const events = await nostrClient.pool.list(nostrClient.relays, [ { kinds: [0], authors: [pubkey], limit: 1 }, ]); let newestEvent = null; - for (const event of events) { + for (const event of Array.isArray(events) ? events : []) { if (!event || !event.content) { continue; } @@ -1496,87 +2016,108 @@ async function loadUserProfile(pubkey) { } if (newestEvent?.content) { - currentChannelProfileEvent = { ...newestEvent }; - const meta = JSON.parse(newestEvent.content); + try { + const meta = JSON.parse(newestEvent.content); + return { event: newestEvent, profile: meta }; + } catch (error) { + console.warn("Failed to parse channel metadata payload:", error); + return { event: newestEvent, profile: {} }; + } + } - // Banner - const bannerEl = document.getElementById("channelBanner"); - if (bannerEl) { - bannerEl.src = meta.banner || "assets/jpg/default-banner.jpg"; - } + return { event: newestEvent, profile: {} }; + } catch (error) { + throw error; + } +} - // Avatar - const avatarEl = document.getElementById("channelAvatar"); - if (avatarEl) { - avatarEl.src = meta.picture || "assets/svg/default-profile.svg"; - } +/** + * Fetches and displays the user's metadata (kind=0). + */ +async function loadUserProfile(pubkey) { + const app = getApp(); + const loadToken = ++currentProfileLoadToken; - // Channel Name - const nameEl = document.getElementById("channelName"); - if (nameEl) { - nameEl.textContent = meta.display_name || meta.name || "Unknown User"; - } + setChannelZapVisibility(false); - // Channel npub - const channelNpubEl = document.getElementById("channelNpub"); - if (channelNpubEl) { - const userNpub = window.NostrTools.nip19.npubEncode(pubkey); - channelNpubEl.textContent = userNpub; - } - - // About/Description - const aboutEl = document.getElementById("channelAbout"); - if (aboutEl) { - aboutEl.textContent = meta.about || ""; - } - - // Website - const websiteEl = document.getElementById("channelWebsite"); - if (websiteEl) { - if (meta.website) { - websiteEl.href = meta.website; - websiteEl.textContent = meta.website; - } else { - websiteEl.textContent = ""; - websiteEl.removeAttribute("href"); - } - } - - // Lightning Address - const lnEl = document.getElementById("channelLightning"); - const lightningAddress = (meta.lud16 || meta.lud06 || "").trim(); - currentChannelLightningAddress = lightningAddress; - if (lnEl) { - lnEl.textContent = - lightningAddress || "No lightning address found."; - } - if (lightningAddress) { - fetchLightningMetadata(lightningAddress) - .then(() => updateZapSplitSummary()) - .catch(() => {}); - } else { - updateZapSplitSummary(); - } - setChannelZapVisibility(!!lightningAddress); + const cachedEntry = getCachedChannelProfile(pubkey); + if (cachedEntry) { + applyChannelProfileMetadata({ + profile: cachedEntry.profile, + event: cachedEntry.event, + pubkey, + npub: currentChannelNpub, + loadToken, + }); + } else { + const stateEntry = + typeof app?.getProfileCacheEntry === "function" + ? app.getProfileCacheEntry(pubkey) + : null; + if (stateEntry?.profile) { + applyChannelProfileMetadata({ + profile: stateEntry.profile, + event: null, + pubkey, + npub: currentChannelNpub, + loadToken, + }); } else { - console.warn("No metadata found for this user."); - setChannelZapVisibility(false); + applyChannelProfileMetadata({ + profile: {}, + event: null, + pubkey, + npub: currentChannelNpub, + loadToken, + }); + } + } + + try { + const result = await fetchChannelProfileFromRelays(pubkey); + if (loadToken !== currentProfileLoadToken) { + return; + } + + rememberChannelProfile(pubkey, result); + + if (typeof app?.setProfileCacheEntry === "function") { + try { + app.setProfileCacheEntry(pubkey, { + ...result.profile, + lightningAddress: + result.profile?.lightningAddress || + result.profile?.lud16 || + result.profile?.lud06 || + "", + }); + } catch (error) { + console.warn( + "Failed to persist channel profile metadata to cache:", + error + ); + } + } + + applyChannelProfileMetadata({ + profile: result.profile, + event: result.event, + pubkey, + npub: currentChannelNpub, + loadToken, + }); + } catch (error) { + if (loadToken === currentProfileLoadToken) { + console.error("Failed to fetch user profile data:", error); const lnEl = document.getElementById("channelLightning"); - if (lnEl) { + if (lnEl && !currentChannelLightningAddress) { lnEl.textContent = "No lightning address found."; } - currentChannelLightningAddress = ""; - currentChannelProfileEvent = null; + if (!currentChannelLightningAddress) { + updateZapSplitSummary(); + setChannelZapVisibility(false); + } } - } catch (err) { - console.error("Failed to fetch user profile data:", err); - setChannelZapVisibility(false); - const lnEl = document.getElementById("channelLightning"); - if (lnEl) { - lnEl.textContent = "No lightning address found."; - } - currentChannelLightningAddress = ""; - currentChannelProfileEvent = null; } } @@ -1602,6 +2143,27 @@ async function loadUserVideos(pubkey) { } } + let renderedFromCache = false; + if (container) { + const cachedEvents = getCachedChannelVideoEvents(pubkey); + if (cachedEvents.length) { + const cachedVideos = buildRenderableChannelVideos({ + events: cachedEvents, + app, + }); + if (cachedVideos.length) { + renderedFromCache = + renderChannelVideosFromList({ + videos: cachedVideos, + container, + app, + loadToken, + allowEmptyMessage: false, + }) || renderedFromCache; + } + } + } + const ensureAccessPromise = accessControl .ensureReady() .catch((error) => { @@ -1622,14 +2184,15 @@ async function loadUserVideos(pubkey) { // 2) Collect raw events from all relays const events = []; - const relayList = Array.isArray(nostrClient.relays) + const knownRelays = Array.isArray(nostrClient.relays) ? nostrClient.relays : Array.from(nostrClient.relays || []); + const relayList = knownRelays.filter((url) => isValidRelayUrl(url)); if (relayList.length === 0) { try { const fallbackEvents = await nostrClient.pool.list( - nostrClient.relays, + Array.from(DEFAULT_RELAY_URLS), [filter] ); if (Array.isArray(fallbackEvents)) { @@ -1648,7 +2211,9 @@ async function loadUserVideos(pubkey) { const relayUrl = relayList[index]; if (result.status === "fulfilled") { - const relayEvents = Array.isArray(result.value) ? result.value : []; + const relayEvents = Array.isArray(result.value) + ? result.value + : []; events.push(...relayEvents); return; } @@ -1670,272 +2235,18 @@ async function loadUserVideos(pubkey) { return; } - // 3) Convert to "video" objects and keep everything (including tombstones) - const convertedVideos = events - .map((evt) => sharedConvertEventToVideo(evt)) - .filter((vid) => !vid.invalid); - - // 4) Deduplicate older overshadowed versions => newest only - const newestByRoot = - app?.dedupeVideosByRoot?.(convertedVideos) ?? - dedupeToNewestByRoot(convertedVideos); - - // 5) Filter out tombstones, blacklisted IDs / authors - let videos = newestByRoot.filter((video) => !video.deleted); - videos = videos.filter((video) => { - // Event-level blacklisting - if (app.blacklistedEventIds.has(video.id)) return false; - - // Author-level - if (!accessControl.canAccess(video)) return false; - return true; + const videos = buildRenderableChannelVideos({ events, app }); + const rendered = renderChannelVideosFromList({ + videos, + container, + app, + loadToken, + allowEmptyMessage: true, }); - // 6) Sort newest first - videos.sort((a, b) => b.created_at - a.created_at); - - // 7) Render them - container.innerHTML = ""; - if (!videos.length) { - container.dataset.hasChannelVideos = "false"; - container.innerHTML = `No videos to display.
`; - return; - } - - container.dataset.hasChannelVideos = "true"; - const fragment = document.createDocumentFragment(); - const allKnownEventsArray = Array.from(nostrClient.allEvents.values()); - const loadedThumbnails = - app?.loadedThumbnails instanceof Map ? app.loadedThumbnails : null; - const unsupportedBtihMessage = - typeof app?.getUnsupportedBtihMessage === "function" - ? app.getUnsupportedBtihMessage() - : "This magnet link is missing a compatible BitTorrent v1 info hash."; - - const extractPlaybackDetail = (trigger, video) => { - if ( - app?.videoListView && - typeof app.videoListView.extractPlaybackDetail === "function" - ) { - return app.videoListView.extractPlaybackDetail(trigger, video); - } - - const element = trigger instanceof HTMLElement ? trigger : null; - const target = - element?.closest("[data-play-url],[data-play-magnet]") || element; - - const rawUrlValue = - (target?.dataset && typeof target.dataset.playUrl === "string" - ? target.dataset.playUrl - : null) ?? target?.getAttribute?.("data-play-url") ?? ""; - const rawMagnetValue = - (target?.dataset && typeof target.dataset.playMagnet === "string" - ? target.dataset.playMagnet - : null) ?? target?.getAttribute?.("data-play-magnet") ?? ""; - - let url = ""; - if (rawUrlValue) { - try { - url = decodeURIComponent(rawUrlValue); - } catch (error) { - url = rawUrlValue; - } - } - - const magnet = typeof rawMagnetValue === "string" ? rawMagnetValue : ""; - const videoId = - target?.dataset?.videoId || - target?.getAttribute?.("data-video-id") || - video?.id || - ""; - - return { videoId, url, magnet, video }; - }; - - const playbackHandler = - typeof app?.videoListViewPlaybackHandler === "function" - ? app.videoListViewPlaybackHandler.bind(app) - : null; - - const startPlayback = (detail) => { - if (playbackHandler) { - playbackHandler(detail); - return; - } - - if (detail.videoId && typeof app?.playVideoByEventId === "function") { - Promise.resolve( - app.playVideoByEventId(detail.videoId, { - url: detail.url, - magnet: detail.magnet, - title: detail.video?.title, - description: detail.video?.description, - }) - ).catch((error) => { - console.error("Failed to play channel video via event id:", error); - if (typeof app?.playVideoWithFallback === "function") { - app.playVideoWithFallback({ - url: detail.url, - magnet: detail.magnet, - }); - } - }); - return; - } - - if (typeof app?.playVideoWithFallback === "function") { - Promise.resolve( - app.playVideoWithFallback({ url: detail.url, magnet: detail.magnet }) - ).catch((error) => { - console.error( - "Failed to start fallback playback for channel video:", - error - ); - }); - } - }; - - videos.forEach((video, index) => { - if ( - video.isPrivate && - video.pubkey === nostrClient.pubkey && - !video.alreadyDecrypted - ) { - video.magnet = fakeDecrypt(video.magnet); - video.alreadyDecrypted = true; - } - - app?.videosMap?.set(video.id, video); - - const canEdit = video.pubkey === app.pubkey; - let hasOlder = false; - if (canEdit && video.videoRootId) { - hasOlder = app.hasOlderVersion(video, allKnownEventsArray); - } - - const pointerInfo = - typeof app?.deriveVideoPointerInfo === "function" - ? app.deriveVideoPointerInfo(video) - : null; - if (pointerInfo && typeof app?.persistWatchHistoryMetadataForVideo === "function") { - app.persistWatchHistoryMetadataForVideo(video, pointerInfo); - } - - const shareUrl = - typeof app?.buildShareUrlFromEventId === "function" - ? app.buildShareUrlFromEventId(video.id) - : "#"; - const timeAgo = - typeof app?.formatTimeAgo === "function" - ? app.formatTimeAgo(video.created_at) - : new Date(video.created_at * 1000).toLocaleString(); - - const videoCard = new VideoCard({ - document, - video, - index, - shareUrl, - pointerInfo, - timeAgo, - capabilities: { - canEdit, - canDelete: canEdit, - canRevert: hasOlder, - canManageBlacklist: - typeof app?.canCurrentUserManageBlacklist === "function" - ? app.canCurrentUserManageBlacklist() - : false, - }, - helpers: { - escapeHtml: (value) => escapeHTML(value), - isMagnetSupported: (magnet) => app.isMagnetUriSupported(magnet), - toLocaleString: (value) => - typeof value === "number" ? value.toLocaleString() : value, - }, - assets: { - fallbackThumbnailSrc: "assets/jpg/video-thumbnail-fallback.jpg", - unsupportedBtihMessage, - }, - state: { loadedThumbnails }, - ensureGlobalMoreMenuHandlers: () => - app?.ensureGlobalMoreMenuHandlers?.(), - onRequestCloseAllMenus: () => app?.closeAllMoreMenus?.(), - formatters: { - formatTimeAgo: (ts) => - typeof app?.formatTimeAgo === "function" - ? app.formatTimeAgo(ts) - : new Date(ts * 1000).toLocaleString(), - }, - }); - - videoCard.onPlay = ({ event: domEvent, video: cardVideo }) => { - const trigger = domEvent?.currentTarget || domEvent?.target; - const detail = extractPlaybackDetail(trigger, cardVideo || video); - detail.video = detail.video || video; - startPlayback(detail); - }; - - videoCard.onEdit = ({ video: editVideo, index: editIndex }) => { - app?.handleEditVideo?.({ - eventId: editVideo?.id || "", - index: Number.isFinite(editIndex) ? editIndex : null, - }); - }; - - videoCard.onRevert = ({ video: revertVideo, index: revertIndex }) => { - app?.handleRevertVideo?.({ - eventId: revertVideo?.id || "", - index: Number.isFinite(revertIndex) ? revertIndex : null, - }); - }; - - videoCard.onDelete = ({ video: deleteVideo, index: deleteIndex }) => { - app?.handleFullDeleteVideo?.({ - eventId: deleteVideo?.id || "", - index: Number.isFinite(deleteIndex) ? deleteIndex : null, - }); - }; - - videoCard.onMoreAction = ({ dataset = {} }) => { - const action = dataset.action || ""; - const detail = { - ...dataset, - eventId: dataset.eventId || video.id || "", - context: dataset.context || "channel-grid", - }; - app?.handleMoreMenuAction?.(action || "copy-link", detail); - }; - - videoCard.onAuthorNavigate = ({ pubkey }) => { - const targetPubkey = pubkey || video.pubkey || ""; - if (targetPubkey && typeof app?.goToProfile === "function") { - app.goToProfile(targetPubkey); - } - }; - - const cardEl = videoCard.getRoot(); - if (cardEl) { - fragment.appendChild(cardEl); - } - }); - - container.appendChild(fragment); - - attachHealthBadges(container); - attachUrlHealthBadges(container, ({ badgeEl, url, eventId }) => { - const video = app?.videosMap?.get(eventId) || { id: eventId }; - app?.handleUrlHealthBadge?.({ video, url, badgeEl }); - }); - - app?.mountVideoListView?.(); - - const lazyEls = container.querySelectorAll("[data-lazy]"); - lazyEls.forEach((el) => app?.mediaLoader?.observe?.(el)); - - app?.attachMoreMenuHandlers?.(container); - + renderedFromCache = rendered || renderedFromCache; } catch (err) { - if (loadToken === currentVideoLoadToken && container) { + if (loadToken === currentVideoLoadToken && container && !renderedFromCache) { container.dataset.hasChannelVideos = "false"; container.innerHTML = `Failed to load videos. Please try again.
diff --git a/js/nostr.js b/js/nostr.js index 0dcae21a..ad4386a5 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -800,7 +800,22 @@ function sanitizeRelayList(list) { if (!/^wss?:\/\//i.test(trimmed)) { return; } - const normalized = trimmed.replace(/\/+$/, ""); + if (/\s/.test(trimmed)) { + return; + } + + let normalized = trimmed.replace(/\/+$/, ""); + try { + const parsed = new URL(trimmed); + if (!parsed.hostname) { + return; + } + const pathname = parsed.pathname.replace(/\/+$/, ""); + normalized = `${parsed.protocol}//${parsed.host}${pathname}${parsed.search || ""}`; + } catch (error) { + // Ignore URL parsing failures and fall back to the trimmed string. + } + if (seen.has(normalized)) { return; } diff --git a/js/services/authService.js b/js/services/authService.js index a185b10a..a289cdc4 100644 --- a/js/services/authService.js +++ b/js/services/authService.js @@ -65,6 +65,11 @@ const HEX64_REGEX = /^[0-9a-f]{64}$/i; const FALLBACK_PROFILE = { name: "Unknown", picture: "assets/svg/default-profile.svg", + about: "", + website: "", + banner: "", + lud16: "", + lud06: "", }; export default class AuthService { @@ -556,8 +561,15 @@ export default class AuthService { try { const data = JSON.parse(events[0].content); profile = { - name: data.name || data.display_name || FALLBACK_PROFILE.name, + name: data.display_name || data.name || FALLBACK_PROFILE.name, picture: data.picture || FALLBACK_PROFILE.picture, + about: typeof data.about === "string" ? data.about : FALLBACK_PROFILE.about, + website: + typeof data.website === "string" ? data.website : FALLBACK_PROFILE.website, + banner: + typeof data.banner === "string" ? data.banner : FALLBACK_PROFILE.banner, + lud16: typeof data.lud16 === "string" ? data.lud16 : FALLBACK_PROFILE.lud16, + lud06: typeof data.lud06 === "string" ? data.lud06 : FALLBACK_PROFILE.lud06, }; } catch (error) { this.log("[AuthService] Failed to parse profile metadata", error); @@ -618,8 +630,15 @@ export default class AuthService { if (newest?.content) { const data = JSON.parse(newest.content); const profile = { - name: data.name || data.display_name || FALLBACK_PROFILE.name, + name: data.display_name || data.name || FALLBACK_PROFILE.name, picture: data.picture || FALLBACK_PROFILE.picture, + about: typeof data.about === "string" ? data.about : FALLBACK_PROFILE.about, + website: + typeof data.website === "string" ? data.website : FALLBACK_PROFILE.website, + banner: + typeof data.banner === "string" ? data.banner : FALLBACK_PROFILE.banner, + lud16: typeof data.lud16 === "string" ? data.lud16 : FALLBACK_PROFILE.lud16, + lud06: typeof data.lud06 === "string" ? data.lud06 : FALLBACK_PROFILE.lud06, }; this.setProfileCacheEntry(normalized, profile, { persist: true, diff --git a/js/state/cache.js b/js/state/cache.js index 785da11b..385083a7 100644 --- a/js/state/cache.js +++ b/js/state/cache.js @@ -18,6 +18,15 @@ const URL_PROBE_TIMEOUT_RETRY_MS = 15 * 1000; // 15 seconds const HEX64_REGEX = /^[0-9a-f]{64}$/i; +function sanitizeProfileString(value) { + if (typeof value !== "string") { + return ""; + } + + const trimmed = value.trim(); + return trimmed; +} + let savedProfiles = []; let activeProfilePubkey = null; const profileCache = new Map(); @@ -444,6 +453,40 @@ export function loadProfileCacheFromStorage() { picture: profile.picture || "assets/svg/default-profile.svg", }; + const about = sanitizeProfileString(profile.about || profile.aboutMe); + if (about) { + normalized.about = about; + } + + const website = sanitizeProfileString(profile.website || profile.url); + if (website) { + normalized.website = website; + } + + const banner = sanitizeProfileString(profile.banner || profile.header); + if (banner) { + normalized.banner = banner; + } + + const lud16 = sanitizeProfileString(profile.lud16); + if (lud16) { + normalized.lud16 = lud16; + } + + const lud06 = sanitizeProfileString(profile.lud06); + if (lud06) { + normalized.lud06 = lud06; + } + + const lightningCandidates = [ + sanitizeProfileString(profile.lightningAddress), + lud16, + lud06, + ].filter(Boolean); + if (lightningCandidates.length) { + normalized.lightningAddress = lightningCandidates[0]; + } + profileCache.set(pubkey, { profile: normalized, timestamp, @@ -536,6 +579,40 @@ export function setProfileCacheEntry(pubkey, profile, { persist = true } = {}) { picture: profile.picture || "assets/svg/default-profile.svg", }; + const about = sanitizeProfileString(profile.about || profile.aboutMe); + if (about) { + normalized.about = about; + } + + const website = sanitizeProfileString(profile.website || profile.url); + if (website) { + normalized.website = website; + } + + const banner = sanitizeProfileString(profile.banner || profile.header); + if (banner) { + normalized.banner = banner; + } + + const lud16 = sanitizeProfileString(profile.lud16); + if (lud16) { + normalized.lud16 = lud16; + } + + const lud06 = sanitizeProfileString(profile.lud06); + if (lud06) { + normalized.lud06 = lud06; + } + + const lightningCandidates = [ + sanitizeProfileString(profile.lightningAddress), + lud16, + lud06, + ].filter(Boolean); + if (lightningCandidates.length) { + normalized.lightningAddress = lightningCandidates[0]; + } + const entry = { profile: normalized, timestamp: Date.now(), diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index 4993d2fd..19223c41 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -151,9 +151,14 @@ export class VideoModal { const wrapper = this.document.createElement("div"); wrapper.innerHTML = html; this.removeTrackingScripts(wrapper); - container.appendChild(wrapper); - const playerModal = wrapper.querySelector("#playerModal"); + const fragment = this.document.createDocumentFragment(); + while (wrapper.firstChild) { + fragment.appendChild(wrapper.firstChild); + } + container.appendChild(fragment); + + const playerModal = container.querySelector("#playerModal"); if (!playerModal) { throw new Error("Player modal root not found in markup."); }