Merge branch 'unstable' into chore/code-health/remove-unused-promises-app-init-18262315909809877684

This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-02-07 20:07:22 -05:00
commit ae5174600d
46 files changed
+4751 -1541

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
// benchmarks/render_loop_overhead_bench.js
class WatchHistoryTelemetry {
persistMetadataForVideo(video, pointerInfo) {
// Deprecated - effectively a no-op in real code
}
}
class ModalCoordinator {
constructor() {
this.watchHistoryTelemetry = new WatchHistoryTelemetry();
}
persistWatchHistoryMetadataForVideo(video, pointerInfo) {
if (this.watchHistoryTelemetry) {
this.watchHistoryTelemetry.persistMetadataForVideo(video, pointerInfo);
return;
}
}
}
class App {
constructor() {
this._modal = new ModalCoordinator();
this._coordinatorsReady = true; // Simulating initialized state
}
_initCoordinators() {
if (this._coordinatorsReady) return;
this._coordinatorsReady = true;
}
persistWatchHistoryMetadataForVideo(...args) {
this._initCoordinators();
return this._modal.persistWatchHistoryMetadataForVideo(...args);
}
}
const app = new App();
const video = { id: "test", title: "test" };
const pointerInfo = { key: "test" };
const ITERATIONS = 100_000_000;
console.log(`Benchmarking overhead for ${ITERATIONS.toLocaleString()} iterations...`);
const startWithCall = performance.now();
for (let i = 0; i < ITERATIONS; i++) {
if (
pointerInfo &&
typeof app?.persistWatchHistoryMetadataForVideo === "function"
) {
app.persistWatchHistoryMetadataForVideo(video, pointerInfo);
}
}
const endWithCall = performance.now();
const durationWithCall = endWithCall - startWithCall;
const startWithoutCall = performance.now();
for (let i = 0; i < ITERATIONS; i++) {
// Simulating the removal of the call
if (false) { // Condition becomes false or code is removed
// Removed
}
}
const endWithoutCall = performance.now();
const durationWithoutCall = endWithoutCall - startWithoutCall;
console.log(`With call: ${durationWithCall.toFixed(2)}ms`);
console.log(`Without call: ${durationWithoutCall.toFixed(2)}ms`);
console.log(`Improvement: ${(durationWithCall - durationWithoutCall).toFixed(2)}ms`);
console.log(`Relative improvement: ${(durationWithCall / durationWithoutCall).toFixed(2)}x`);
+436 -374
View File
@@ -22,6 +22,7 @@ import {
DEFAULT_BLUR_THRESHOLD,
DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
FEED_TYPES,
} from "./constants.js";
import { attachHealthBadges } from "./gridHealth.js";
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
@@ -138,22 +139,10 @@ import {
import { getDesignSystemMode as getCanonicalDesignSystemMode } from "./designSystem.js";
import { getHashViewName, setHashView } from "./hashView.js";
import {
getPubkey as getStoredPubkey,
setPubkey as setStoredPubkey,
getCurrentUserNpub as getStoredCurrentUserNpub,
setCurrentUserNpub as setStoredCurrentUserNpub,
getCurrentVideo as getStoredCurrentVideo,
setCurrentVideo as setStoredCurrentVideo,
setModalState as setGlobalModalState,
subscribeToAppStateKey,
} from "./state/appState.js";
import {
getSavedProfiles,
getActiveProfilePubkey,
setActiveProfilePubkey as setStoredActiveProfilePubkey,
setSavedProfiles,
persistSavedProfiles,
getProfileCacheMap,
getCachedUrlHealth as readCachedUrlHealth,
storeUrlHealth as persistUrlHealth,
setInFlightUrlProbe,
@@ -211,35 +200,43 @@ const RELAY_UI_BATCH_DELAY_MS = 250;
*/
class Application {
get pubkey() {
return getStoredPubkey();
return this.authService.pubkey;
}
set pubkey(value) {
setStoredPubkey(value ?? null);
this.authService.pubkey = value;
}
get currentUserNpub() {
return getStoredCurrentUserNpub();
return this.authService.currentUserNpub;
}
set currentUserNpub(value) {
setStoredCurrentUserNpub(value ?? null);
this.authService.currentUserNpub = value;
}
get currentVideo() {
return getStoredCurrentVideo();
return this.playbackService.currentVideo;
}
set currentVideo(value) {
setStoredCurrentVideo(value ?? null);
this.playbackService.currentVideo = value;
}
get activeProfilePubkey() {
return getActiveProfilePubkey();
return this.authService.activeProfilePubkey;
}
set activeProfilePubkey(value) {
this.authService.setActiveProfilePubkey(value, { persist: false });
}
get savedProfiles() {
return getSavedProfiles();
return this.authService.savedProfiles;
}
set savedProfiles(value) {
this.authService.setSavedProfiles(value, { persist: false, persistActive: false });
}
constructor({ services = {}, ui = {}, helpers = {}, loadView: viewLoader } = {}) {
@@ -603,7 +600,7 @@ class Application {
queueSignEvent,
bootstrapTrustedSeeds,
getModerationSettings,
getActiveProfilePubkey,
getActiveProfilePubkey: () => this.authService.activeProfilePubkey,
})),
writable: true,
configurable: true,
@@ -688,7 +685,7 @@ class Application {
}
persistActiveProfileSelection(pubkey, { persist = true } = {}) {
setStoredActiveProfilePubkey(pubkey, { persist });
this.authService.setActiveProfilePubkey(pubkey, { persist });
this.renderSavedProfiles();
}
@@ -718,9 +715,7 @@ class Application {
const uniqueAuthors = new Set(activeVideos.map((v) => v.pubkey));
// 3) For each author, fetchAndRenderProfile with forceRefresh = true
for (const authorPubkey of uniqueAuthors) {
this.authService.fetchAndRenderProfile(authorPubkey, true);
}
this.batchFetchProfiles(uniqueAuthors, { forceRefresh: true });
}
getCurrentUserNpub() {
@@ -756,237 +751,333 @@ class Application {
return false;
}
async init() {
try {
if (typeof this.loadView !== "function") {
const module = await import("./viewManager.js");
this.loadView = module?.loadView || null;
}
async _initViewManager() {
if (typeof this.loadView !== "function") {
const module = await import("./viewManager.js");
this.loadView = module?.loadView || null;
}
}
if (nostrClient && typeof nostrClient.warmupExtension === "function") {
nostrClient.warmupExtension();
}
_warmupNostrExtension() {
if (nostrClient && typeof nostrClient.warmupExtension === "function") {
nostrClient.warmupExtension();
}
}
// Force update of any registered service workers to ensure latest code is used.
if ("serviceWorker" in navigator) {
navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach((registration) => registration.update());
});
}
_initServiceWorker() {
// Force update of any registered service workers to ensure latest code is used.
if ("serviceWorker" in navigator) {
navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach((registration) => registration.update());
});
}
}
this.authService.hydrateFromStorage();
this.renderSavedProfiles();
_initAuthAndModeration() {
this.authService.hydrateFromStorage();
this.renderSavedProfiles();
if (
this.nostrService &&
typeof this.nostrService.setDmBlockChecker === "function"
) {
this.nostrService.setDmBlockChecker((pubkey) =>
this.isAuthorBlocked(pubkey),
);
}
loadModerationOverridesFromStorage();
loadModerationSettingsFromStorage();
loadDmPrivacySettingsFromStorage();
this.moderationSettings = this.normalizeModerationSettings(
getModerationSettings(),
if (
this.nostrService &&
typeof this.nostrService.setDmBlockChecker === "function"
) {
this.nostrService.setDmBlockChecker((pubkey) =>
this.isAuthorBlocked(pubkey),
);
}
const videoModalPromise = this.videoModal.load().then(() => {
const modalRoot = this.videoModal.getRoot();
if (modalRoot) {
this.attachMoreMenuHandlers(modalRoot);
loadModerationOverridesFromStorage();
loadModerationSettingsFromStorage();
loadDmPrivacySettingsFromStorage();
this.moderationSettings = this.normalizeModerationSettings(
getModerationSettings(),
);
}
_bindVideoModalEvents() {
const modalRoot = this.videoModal.getRoot();
if (modalRoot) {
this.attachMoreMenuHandlers(modalRoot);
}
if (
this.videoModal &&
typeof this.videoModal.addEventListener === "function"
) {
this.videoModal.addEventListener("video:share-nostr", (event) => {
this.openShareNostrModal({
video: event?.detail?.video || null,
triggerElement: event?.detail?.trigger || null,
});
});
this.videoModal.addEventListener("video:copy-cdn", (event) => {
const video = event?.detail?.video || this.currentVideo;
const url = video?.url || "";
if (!url) {
this.showError("No CDN link available to copy.");
return;
}
if (
this.videoModal &&
typeof this.videoModal.addEventListener === "function"
) {
this.videoModal.addEventListener("video:share-nostr", (event) => {
this.openShareNostrModal({
video: event?.detail?.video || null,
triggerElement: event?.detail?.trigger || null,
});
});
navigator.clipboard
.writeText(url)
.then(() => this.showSuccess("CDN link copied to clipboard!"))
.catch(() => this.showError("Failed to copy CDN link."));
});
this.videoModal.addEventListener("video:copy-cdn", (event) => {
const video = event?.detail?.video || this.currentVideo;
const url = video?.url || "";
if (!url) {
this.showError("No CDN link available to copy.");
return;
}
navigator.clipboard
.writeText(url)
.then(() => this.showSuccess("CDN link copied to clipboard!"))
.catch(() => this.showError("Failed to copy CDN link."));
});
this.videoModal.addEventListener("video:copy-magnet", () => {
this.handleCopyMagnet();
});
this.videoModal.addEventListener("video:copy-magnet", () => {
this.handleCopyMagnet();
});
this.videoModal.addEventListener("playback:switch-source", (event) => {
const detail = event?.detail || {};
const { source } = detail;
if (!source) {
return;
}
const modalVideo = detail?.video || null;
const fallbackVideo = this.currentVideo || null;
const video = {
...(fallbackVideo || {}),
...(modalVideo || {}),
};
const urlCandidate =
typeof video.url === "string" ? video.url.trim() : "";
const magnetCandidate =
typeof video.magnet === "string" ? video.magnet.trim() : "";
this.videoModal.addEventListener("playback:switch-source", (event) => {
const detail = event?.detail || {};
const { source } = detail;
if (!source) {
return;
}
const modalVideo = detail?.video || null;
const fallbackVideo = this.currentVideo || null;
const video = {
...(fallbackVideo || {}),
...(modalVideo || {}),
};
const urlCandidate =
typeof video.url === "string" ? video.url.trim() : "";
const magnetCandidate =
typeof video.magnet === "string" ? video.magnet.trim() : "";
if (!modalVideo && !fallbackVideo) {
devLogger.warn("[app] Playback source switch missing video data.");
return;
}
if (!modalVideo && !fallbackVideo) {
devLogger.warn("[app] Playback source switch missing video data.");
return;
}
const magnetAvailable = Boolean(magnetCandidate);
const cachedStreamHealth =
video?.id && this.streamHealthSnapshots instanceof Map
? this.streamHealthSnapshots.get(video.id)
: null;
const cachedPeers = Number.isFinite(cachedStreamHealth?.peers)
? cachedStreamHealth.peers
: null;
const hasActivePeers =
cachedPeers === null ? null : cachedPeers > 0;
const cachedUrlHealth =
video?.id && urlCandidate
? this.getCachedUrlHealth(video.id, urlCandidate)
: null;
const cdnUnavailable =
!urlCandidate ||
["offline", "timeout"].includes(cachedUrlHealth?.status);
const magnetAvailable = Boolean(magnetCandidate);
const cachedStreamHealth =
video?.id && this.streamHealthSnapshots instanceof Map
? this.streamHealthSnapshots.get(video.id)
: null;
const cachedPeers = Number.isFinite(cachedStreamHealth?.peers)
? cachedStreamHealth.peers
: null;
const hasActivePeers =
cachedPeers === null ? null : cachedPeers > 0;
const cachedUrlHealth =
video?.id && urlCandidate
? this.getCachedUrlHealth(video.id, urlCandidate)
: null;
const cdnUnavailable =
!urlCandidate ||
["offline", "timeout"].includes(cachedUrlHealth?.status);
if (source === "torrent" && !magnetAvailable) {
userLogger.warn(
"[app] Unable to switch to torrent playback: missing magnet.",
);
this.showError(
"Torrent playback is unavailable for this video. No magnet was provided.",
);
return;
}
if (source === "torrent" && !magnetAvailable) {
userLogger.warn(
"[app] Unable to switch to torrent playback: missing magnet.",
);
this.showError(
"Torrent playback is unavailable for this video. No magnet was provided.",
);
return;
}
if (source === "torrent" && hasActivePeers === false) {
userLogger.warn(
"[app] Switching to torrent playback despite 0 active peers detected.",
);
this.showStatus(
"Warning: No peers detected. Playback may fail or stall.",
{ autoHideMs: 5000 }
);
// Proceed anyway
}
if (source === "torrent" && hasActivePeers === false) {
userLogger.warn(
"[app] Switching to torrent playback despite 0 active peers detected.",
);
this.showStatus(
"Warning: No peers detected. Playback may fail or stall.",
{ autoHideMs: 5000 }
);
// Proceed anyway
}
if (source === "url" && cdnUnavailable) {
userLogger.warn(
"[app] Unable to switch to CDN playback: URL unavailable.",
);
this.showError(
"CDN playback is unavailable right now, staying on the torrent stream.",
);
return;
}
if (source === "url" && cdnUnavailable) {
userLogger.warn(
"[app] Unable to switch to CDN playback: URL unavailable.",
);
this.showError(
"CDN playback is unavailable right now, staying on the torrent stream.",
);
return;
}
if (this.playSource && source === this.playSource) {
return;
}
if (this.playSource && source === this.playSource) {
return;
}
this.playVideoWithFallback({
url: urlCandidate,
magnet: magnetCandidate,
forcedSource: source,
}).catch((error) => {
devLogger.warn(
"[app] Failed to switch playback source:",
error,
);
});
});
}
}
this.playVideoWithFallback({
url: urlCandidate,
magnet: magnetCandidate,
forcedSource: source,
}).catch((error) => {
_initModals() {
const videoModalPromise = this.videoModal.load().then(() => {
this._bindVideoModalEvents();
});
const uploadModalPromise = this.uploadModal.load().catch((error) => {
devLogger.error("initUploadModal failed:", error);
this.showError(`Failed to initialize upload modal: ${error.message}`);
});
const editModalPromise = this.editModal.load().catch((error) => {
devLogger.error("Failed to load edit modal:", error);
this.showError(`Failed to initialize edit modal: ${error.message}`);
});
const profileModalPromise = this.profileController
? this.profileController
.load()
.then(() => {
try {
this.renderSavedProfiles();
} catch (error) {
devLogger.warn(
"[app] Failed to switch playback source:",
"[profileModal] Failed to render saved profiles after load:",
error,
);
});
});
}
try {
this.profileController.refreshWalletPaneState();
} catch (error) {
devLogger.warn(
"[profileModal] Failed to refresh wallet pane after load:",
error,
);
}
return true;
})
.catch((error) => {
devLogger.error("Failed to load profile modal:", error);
return false;
})
: Promise.resolve(false);
return Promise.all([
videoModalPromise,
uploadModalPromise,
editModalPromise,
profileModalPromise,
]);
}
async _initTrustedSeeds() {
try {
await bootstrapTrustedSeeds();
} catch (error) {
devLogger.warn("[app.init()] Trusted seed bootstrap failed:", error);
}
}
_initViewCounter() {
try {
initViewCounter({ nostrClient });
} catch (error) {
devLogger.warn("Failed to initialize view counter:", error);
}
}
_initAccessControl() {
accessControl
.refresh()
.then(() => {
if (
accessControl.lastError &&
accessControl.lastError?.code === "nostr-unavailable"
) {
devLogger.warn(
"[app.init()] Access control refresh should not run before nostrClient.init()",
accessControl.lastError
);
}
})
.catch((error) => {
devLogger.warn(
"Failed to refresh admin lists after connecting to Nostr:",
error
);
});
const uploadModalPromise = this.uploadModal.load().catch((error) => {
devLogger.error("initUploadModal failed:", error);
this.showError(`Failed to initialize upload modal: ${error.message}`);
});
if (this.profileController) {
Promise.resolve()
.then(() => this.profileController.refreshAdminPaneState())
.catch((error) => {
devLogger.warn(
"Failed to update admin pane after connecting to Nostr:",
error,
);
});
}
}
const editModalPromise = this.editModal.load().catch((error) => {
devLogger.error("Failed to load edit modal:", error);
this.showError(`Failed to initialize edit modal: ${error.message}`);
});
async _syncSessionActorBlacklist(trigger) {
if (this.pubkey) {
return;
}
const profileModalPromise = this.profileController
? this.profileController
.load()
.then(() => {
try {
this.renderSavedProfiles();
} catch (error) {
devLogger.warn(
"[profileModal] Failed to render saved profiles after load:",
error,
);
}
const sessionActorPubkey = nostrClient.sessionActor?.pubkey;
if (!sessionActorPubkey) {
return;
}
try {
this.profileController.refreshWalletPaneState();
} catch (error) {
devLogger.warn(
"[profileModal] Failed to refresh wallet pane after load:",
error,
);
}
return true;
})
.catch((error) => {
devLogger.error("Failed to load profile modal:", error);
return false;
})
: Promise.resolve(false);
const blacklist = accessControl.getBlacklist();
try {
await userBlocks.seedBaselineDelta(
sessionActorPubkey,
Array.from(blacklist || []),
);
} catch (error) {
devLogger.warn(
`[app.init()] Failed to sync session actor blacklist${
trigger ? ` (${trigger})` : ""
}:`,
error,
);
}
}
const modalBootstrapPromise = Promise.all([
videoModalPromise,
uploadModalPromise,
editModalPromise,
profileModalPromise,
]);
async _handleSessionActorReady({ pubkey, reason } = {}) {
if (this.pubkey) {
return;
}
// Initialize the pool early to unblock bootstrapTrustedSeeds,
// but do NOT await the full connection process here.
const normalizedPubkey = this.normalizeHexPubkey(pubkey);
if (!normalizedPubkey) {
return;
}
const triggerLabel = reason ? `session-actor-${reason}` : "session-actor";
await this._syncSessionActorBlacklist(triggerLabel);
const refreshReason = "session-actor-ready";
if (typeof this.refreshVisibleModerationUi === "function") {
try {
await nostrClient.ensurePool();
} catch (poolError) {
devLogger.warn("[app.init()] Pool ensure failed:", poolError);
}
// Kick off relay connection in the background.
const nostrInitPromise = nostrClient.init().catch((err) => {
devLogger.warn("[app.init()] Background nostrClient.init failed:", err);
});
await modalBootstrapPromise;
try {
await bootstrapTrustedSeeds();
this.refreshVisibleModerationUi({ reason: refreshReason });
} catch (error) {
devLogger.warn("[app.init()] Trusted seed bootstrap failed:", error);
}
try {
initViewCounter({ nostrClient });
} catch (error) {
devLogger.warn("Failed to initialize view counter:", error);
devLogger.warn(
"[app.init()] Failed to refresh moderation UI after session actor:",
error,
);
}
} else {
this.refreshAllVideoGrids({
reason: refreshReason,
forceMainReload: true,
}).catch((error) => {
devLogger.warn(
"[app.init()] Failed to refresh video grids after session actor:",
error,
);
});
}
}
accessControl
.refresh()
@@ -1003,10 +1094,12 @@ class Application {
})
.catch((error) => {
devLogger.warn(
"Failed to refresh admin lists after connecting to Nostr:",
error
"[app.init()] Failed to process session actor change:",
error,
);
});
});
}
if (this.profileController) {
Promise.resolve()
@@ -1019,176 +1112,139 @@ class Application {
});
}
const syncSessionActorBlacklist = async (trigger) => {
if (this.pubkey) {
// Kick off relay connection in the background.
nostrClient.init().catch((err) => {
devLogger.warn("[app.init()] Background nostrClient.init failed:", err);
});
}
_initAccessControlListeners() {
if (typeof accessControl.onBlacklistChange === "function") {
accessControl.onBlacklistChange(() => {
this._syncSessionActorBlacklist("blacklist-change");
if (this.isUserLoggedIn()) {
return;
}
const sessionActorPubkey = nostrClient.sessionActor?.pubkey;
if (!sessionActorPubkey) {
return;
}
const blacklist = accessControl.getBlacklist();
try {
await userBlocks.seedBaselineDelta(
sessionActorPubkey,
Array.from(blacklist || []),
);
} catch (error) {
const refreshReason = "admin-blacklist-change";
this.refreshAllVideoGrids({
reason: refreshReason,
forceMainReload: true,
}).catch((error) => {
devLogger.warn(
`[app.init()] Failed to sync session actor blacklist${
trigger ? ` (${trigger})` : ""
}:`,
"[app.init()] Failed to refresh video grids after admin blacklist change:",
error,
);
}
};
});
const handleSessionActorReady = async ({ pubkey, reason } = {}) => {
if (this.pubkey) {
return;
}
const normalizedPubkey = this.normalizeHexPubkey(pubkey);
if (!normalizedPubkey) {
return;
}
const triggerLabel = reason ? `session-actor-${reason}` : "session-actor";
await syncSessionActorBlacklist(triggerLabel);
const refreshReason = "session-actor-ready";
if (typeof this.refreshVisibleModerationUi === "function") {
try {
this.refreshVisibleModerationUi({ reason: refreshReason });
} catch (error) {
devLogger.warn(
"[app.init()] Failed to refresh moderation UI after session actor:",
"[app.init()] Failed to refresh moderation UI after admin blacklist change:",
error,
);
}
} else {
this.refreshAllVideoGrids({
reason: refreshReason,
forceMainReload: true,
}).catch((error) => {
devLogger.warn(
"[app.init()] Failed to refresh video grids after session actor:",
error,
);
});
}
};
if (typeof nostrClient.onSessionActorChange === "function") {
nostrClient.onSessionActorChange((detail) => {
handleSessionActorReady(detail).catch((error) => {
devLogger.warn(
"[app.init()] Failed to process session actor change:",
error,
);
});
});
}
if (typeof accessControl.onWhitelistChange === "function") {
accessControl.onWhitelistChange(() => {
const refreshReason = "admin-whitelist-change";
this.refreshAllVideoGrids({
reason: refreshReason,
forceMainReload: true,
}).catch((error) => {
devLogger.warn(
"[app.init()] Failed to refresh video grids after admin whitelist change:",
error,
);
});
}
});
}
}
await syncSessionActorBlacklist("post-refresh");
_initUI() {
// Grab the "Subscriptions" link by its id in the sidebar
this.subscriptionsLink = document.getElementById("subscriptionsLink");
if (typeof accessControl.onBlacklistChange === "function") {
accessControl.onBlacklistChange(() => {
syncSessionActorBlacklist("blacklist-change");
if (this.isUserLoggedIn()) {
return;
}
this.syncAuthUiState();
const refreshReason = "admin-blacklist-change";
this.refreshAllVideoGrids({
reason: refreshReason,
forceMainReload: true,
}).catch((error) => {
devLogger.warn(
"[app.init()] Failed to refresh video grids after admin blacklist change:",
error,
);
});
if (typeof window !== "undefined") {
window.addEventListener("bitvid:auth-changed", (event) => {
const detail = event.detail || {};
const previousPubkey = detail.previousPubkey;
if (typeof this.refreshVisibleModerationUi === "function") {
try {
this.refreshVisibleModerationUi({ reason: refreshReason });
} catch (error) {
devLogger.warn(
"[app.init()] Failed to refresh moderation UI after admin blacklist change:",
error,
);
}
}
});
}
if (previousPubkey && typeof storageService !== "undefined" && typeof storageService.lock === "function") {
storageService.lock(previousPubkey);
}
if (typeof accessControl.onWhitelistChange === "function") {
accessControl.onWhitelistChange(() => {
const refreshReason = "admin-whitelist-change";
this.refreshAllVideoGrids({
reason: refreshReason,
forceMainReload: true,
}).catch((error) => {
devLogger.warn(
"[app.init()] Failed to refresh video grids after admin whitelist change:",
error,
);
});
});
}
// Grab the "Subscriptions" link by its id in the sidebar
this.subscriptionsLink = document.getElementById("subscriptionsLink");
this.syncAuthUiState();
if (typeof window !== "undefined") {
window.addEventListener("bitvid:auth-changed", (event) => {
const detail = event.detail || {};
const previousPubkey = detail.previousPubkey;
if (previousPubkey && typeof storageService !== "undefined" && typeof storageService.lock === "function") {
storageService.lock(previousPubkey);
}
if (this.uploadModal && typeof this.uploadModal.refreshState === "function") {
this.uploadModal.refreshState();
}
});
}
const savedPubKey = this.activeProfilePubkey;
if (savedPubKey) {
// Auto-login if a pubkey was saved
try {
await this.authService.login(savedPubKey, { persistActive: false });
} catch (error) {
devLogger.error("Auto-login failed:", error);
if (error && error.code === "site-lockdown") {
const message = this.describeLoginError(
error,
"Auto-login failed. Please sign in again once lockdown ends.",
);
this.showStatus(message, { autoHideMs: 12000 });
}
if (this.uploadModal && typeof this.uploadModal.refreshState === "function") {
this.uploadModal.refreshState();
}
});
}
}
async _initAutoLogin() {
const savedPubKey = this.activeProfilePubkey;
if (savedPubKey) {
// Auto-login if a pubkey was saved
try {
await this.authService.login(savedPubKey, { persistActive: false });
} catch (error) {
devLogger.error("Auto-login failed:", error);
if (error && error.code === "site-lockdown") {
const message = this.describeLoginError(
error,
"Auto-login failed. Please sign in again once lockdown ends.",
);
this.showStatus(message, { autoHideMs: 12000 });
}
}
}
}
async _initWatchHistory() {
const watchHistoryInitPromise =
this.watchHistoryTelemetry?.initPreferenceSync?.().catch((error) => {
devLogger.warn(
"[app.init()] Failed to initialize watch history metadata sync:",
error
);
}) || Promise.resolve();
await watchHistoryInitPromise;
}
async init() {
try {
await this._initViewManager();
this._warmupNostrExtension();
this._initServiceWorker();
this._initAuthAndModeration();
const modalBootstrapPromise = this._initModals();
await this._initNostr();
await modalBootstrapPromise;
await this._initTrustedSeeds();
this._initViewCounter();
this._initAccessControl();
await this._initSessionActor();
this._initAccessControlListeners();
this._initUI();
await this._initAutoLogin();
// 5. Setup general event listeners
this.setupEventListeners();
const watchHistoryInitPromise =
this.watchHistoryTelemetry?.initPreferenceSync?.().catch((error) => {
devLogger.warn(
"[app.init()] Failed to initialize watch history metadata sync:",
error
);
}) || Promise.resolve();
await watchHistoryInitPromise;
await this._initWatchHistory();
// 9. Check URL ?v= param
this.checkUrlParams();
@@ -2659,9 +2715,10 @@ class Application {
return this.authService.fetchAndRenderProfile(pubkey, forceRefresh);
}
async batchFetchProfiles(authorSet) {
async batchFetchProfiles(authorSet, { forceRefresh = false } = {}) {
return batchFetchProfilesFromRelays({
authorSet,
forceRefresh,
getProfileCacheEntry: (pubkey) => this.getProfileCacheEntry(pubkey),
setProfileCacheEntry: (pubkey, profile) =>
this.setProfileCacheEntry(pubkey, profile),
@@ -3945,22 +4002,22 @@ class Application {
async refreshForYouFeed(...args) {
this._initCoordinators();
return this._feed.refreshForYouFeed(...args);
return this._feed.refreshFeed(FEED_TYPES.FOR_YOU, ...args);
}
refreshKidsFeed(...args) {
this._initCoordinators();
return this._feed.refreshKidsFeed(...args);
return this._feed.refreshFeed(FEED_TYPES.KIDS, ...args);
}
refreshExploreFeed(...args) {
this._initCoordinators();
return this._feed.refreshExploreFeed(...args);
return this._feed.refreshFeed(FEED_TYPES.EXPLORE, ...args);
}
refreshRecentFeed(...args) {
this._initCoordinators();
return this._feed.refreshRecentFeed(...args);
return this._feed.refreshFeed("recent", ...args);
}
checkRelayHealthWarning(...args) {
@@ -3968,6 +4025,11 @@ class Application {
return this._feed.checkRelayHealthWarning(...args);
}
async loadFeedVideos(...args) {
this._initCoordinators();
return this._feed.loadFeedVideos(...args);
}
/**
* Subscribe to videos (older + new) and render them as they come in.
*/
+179 -401
View File
@@ -785,48 +785,92 @@ export function createFeedCoordinator(deps) {
};
},
async refreshForYouFeed({ reason, fallbackVideos } = {}) {
let watchHistoryItems = [];
async refreshFeed(feedType, { reason, fallbackVideos } = {}) {
const normalizedReason = typeof reason === "string" ? reason : undefined;
const fallback = Array.isArray(fallbackVideos) ? fallbackVideos : [];
let runtime = {};
let metadataModifier = (m) => m;
// 1. Prepare Runtime
try {
watchHistoryItems = await watchHistoryService.loadLatest(undefined, {
allowStale: true,
});
} catch (error) {
devLogger.warn(
"[Application] Failed to preload watch history for For You feed:",
error,
switch (feedType) {
case FEED_TYPES.FOR_YOU: {
let watchHistoryItems = [];
try {
watchHistoryItems = await watchHistoryService.loadLatest(
undefined,
{ allowStale: true },
);
} catch (err) {
devLogger.warn(
"[Application] Failed to preload watch history for For You feed:",
err,
);
}
runtime = this.buildForYouFeedRuntime({ watchHistoryItems });
break;
}
case FEED_TYPES.KIDS:
runtime = this.buildKidsFeedRuntime();
metadataModifier = (m) => {
if (runtime?.ageGroup && !m.ageGroup)
m.ageGroup = runtime.ageGroup;
return m;
};
break;
case FEED_TYPES.EXPLORE:
runtime = this.buildExploreFeedRuntime();
metadataModifier = (m) => {
const next = { ...m };
if (!next.sortOrder) next.sortOrder = FEED_TYPES.EXPLORE;
next.preserveOrder = true;
return next;
};
break;
case "recent":
runtime = this.buildRecentFeedRuntime();
break;
default:
devLogger.warn(`[Application] Unknown feed type: ${feedType}`);
return Promise.resolve({
videos: fallback,
metadata: { reason: normalizedReason, engine: "unknown" },
});
}
} catch (err) {
devLogger.error(
`[Application] Failed to build runtime for ${feedType}:`,
err,
);
}
const runtime = this.buildForYouFeedRuntime({ watchHistoryItems });
const normalizedReason = typeof reason === "string" ? reason : undefined;
const fallback = Array.isArray(fallbackVideos) ? fallbackVideos : [];
// 2. Check Engine
if (!this.feedEngine || typeof this.feedEngine.run !== "function") {
const metadata = {
const metadata = metadataModifier({
reason: normalizedReason,
engine: "unavailable",
};
});
this.latestFeedMetadata = metadata;
this.videosMap = this.nostrService.getVideosMap();
if (this.videoListView) {
this.videoListView.state.videosMap = this.videosMap;
}
this.updateForYouTelemetryMetadata([], metadata);
// Telemetry update for unavailable engine?
if (feedType === FEED_TYPES.FOR_YOU || feedType === FEED_TYPES.KIDS) {
this.updateFeedTelemetryMetadata(feedType, [], metadata);
}
this.renderVideoList({ videos: fallback, metadata });
return Promise.resolve({ videos: fallback, metadata });
}
// 3. Run Engine
return this.feedEngine
.run(FEED_TYPES.FOR_YOU, { runtime })
.run(feedType, { runtime })
.then((result) => {
const videos = Array.isArray(result?.videos) ? result.videos : [];
const metadata = {
...(result?.metadata || {}),
};
if (normalizedReason) {
metadata.reason = normalizedReason;
}
let metadata = { ...(result?.metadata || {}) };
if (normalizedReason) metadata.reason = normalizedReason;
metadata = metadataModifier(metadata);
this.latestFeedMetadata = metadata;
this.videosMap = this.nostrService.getVideosMap();
@@ -835,141 +879,8 @@ export function createFeedCoordinator(deps) {
}
const items = Array.isArray(result?.items) ? result.items : [];
this.updateForYouTelemetryMetadata(items, metadata);
const payload = { videos, metadata };
this.renderVideoList(payload);
return payload;
})
.catch((error) => {
devLogger.error("[Application] Failed to run for-you feed:", error);
const metadata = {
reason: normalizedReason || "error:for-you-feed",
error: true,
};
this.latestFeedMetadata = metadata;
this.videosMap = this.nostrService.getVideosMap();
if (this.videoListView) {
this.videoListView.state.videosMap = this.videosMap;
}
this.updateForYouTelemetryMetadata([], metadata);
const payload = { videos: fallback, metadata };
this.renderVideoList(payload);
return payload;
});
},
refreshKidsFeed({ reason, fallbackVideos } = {}) {
const runtime = this.buildKidsFeedRuntime();
const normalizedReason = typeof reason === "string" ? reason : undefined;
const fallback = Array.isArray(fallbackVideos) ? fallbackVideos : [];
if (!this.feedEngine || typeof this.feedEngine.run !== "function") {
const metadata = {
reason: normalizedReason,
engine: "unavailable",
};
if (runtime?.ageGroup && !metadata.ageGroup) {
metadata.ageGroup = runtime.ageGroup;
}
this.latestFeedMetadata = metadata;
this.videosMap = this.nostrService.getVideosMap();
if (this.videoListView) {
this.videoListView.state.videosMap = this.videosMap;
}
this.updateFeedTelemetryMetadata(FEED_TYPES.KIDS, [], metadata);
this.renderVideoList({ videos: fallback, metadata });
return Promise.resolve({ videos: fallback, metadata });
}
return this.feedEngine
.run(FEED_TYPES.KIDS, { runtime })
.then((result) => {
const videos = Array.isArray(result?.videos) ? result.videos : [];
const metadata = {
...(result?.metadata || {}),
};
if (normalizedReason) {
metadata.reason = normalizedReason;
}
if (runtime?.ageGroup && !metadata.ageGroup) {
metadata.ageGroup = runtime.ageGroup;
}
this.latestFeedMetadata = metadata;
this.videosMap = this.nostrService.getVideosMap();
if (this.videoListView) {
this.videoListView.state.videosMap = this.videosMap;
}
const items = Array.isArray(result?.items) ? result.items : [];
this.updateFeedTelemetryMetadata(FEED_TYPES.KIDS, items, metadata);
const payload = { videos, metadata };
this.renderVideoList(payload);
return payload;
})
.catch((error) => {
devLogger.error("[Application] Failed to run kids feed:", error);
const metadata = {
reason: normalizedReason || "error:kids-feed",
error: true,
};
if (runtime?.ageGroup && !metadata.ageGroup) {
metadata.ageGroup = runtime.ageGroup;
}
this.latestFeedMetadata = metadata;
this.videosMap = this.nostrService.getVideosMap();
if (this.videoListView) {
this.videoListView.state.videosMap = this.videosMap;
}
this.updateFeedTelemetryMetadata(FEED_TYPES.KIDS, [], metadata);
const payload = { videos: fallback, metadata };
this.renderVideoList(payload);
return payload;
});
},
refreshExploreFeed({ reason, fallbackVideos } = {}) {
const runtime = this.buildExploreFeedRuntime();
const normalizedReason = typeof reason === "string" ? reason : undefined;
const fallback = Array.isArray(fallbackVideos) ? fallbackVideos : [];
const applyExploreOrderingMetadata = (source) => {
const next = source && typeof source === "object" ? { ...source } : {};
if (!next.sortOrder) {
next.sortOrder = FEED_TYPES.EXPLORE;
}
next.preserveOrder = true;
return next;
};
if (!this.feedEngine || typeof this.feedEngine.run !== "function") {
const metadata = applyExploreOrderingMetadata({
reason: normalizedReason,
engine: "unavailable",
});
this.latestFeedMetadata = metadata;
this.videosMap = this.nostrService.getVideosMap();
if (this.videoListView) {
this.videoListView.state.videosMap = this.videosMap;
}
this.renderVideoList({ videos: fallback, metadata });
return Promise.resolve({ videos: fallback, metadata });
}
return this.feedEngine
.run(FEED_TYPES.EXPLORE, { runtime })
.then((result) => {
const videos = Array.isArray(result?.videos) ? result.videos : [];
const metadata = applyExploreOrderingMetadata({
...(result?.metadata || {}),
...(normalizedReason ? { reason: normalizedReason } : {}),
});
this.latestFeedMetadata = metadata;
this.videosMap = this.nostrService.getVideosMap();
if (this.videoListView) {
this.videoListView.state.videosMap = this.videosMap;
if (feedType === FEED_TYPES.FOR_YOU || feedType === FEED_TYPES.KIDS) {
this.updateFeedTelemetryMetadata(feedType, items, metadata);
}
const payload = { videos, metadata };
@@ -977,73 +888,26 @@ export function createFeedCoordinator(deps) {
return payload;
})
.catch((error) => {
devLogger.error("[Application] Failed to run explore feed:", error);
const metadata = applyExploreOrderingMetadata({
reason: normalizedReason || "error:explore-feed",
error: true,
});
this.latestFeedMetadata = metadata;
this.videosMap = this.nostrService.getVideosMap();
if (this.videoListView) {
this.videoListView.state.videosMap = this.videosMap;
}
const payload = { videos: fallback, metadata };
this.renderVideoList(payload);
return payload;
});
},
refreshRecentFeed({ reason, fallbackVideos } = {}) {
const runtime = this.buildRecentFeedRuntime();
const normalizedReason = typeof reason === "string" ? reason : undefined;
const fallback = Array.isArray(fallbackVideos) ? fallbackVideos : [];
if (!this.feedEngine || typeof this.feedEngine.run !== "function") {
const metadata = {
reason: normalizedReason,
engine: "unavailable",
};
this.latestFeedMetadata = metadata;
this.videosMap = this.nostrService.getVideosMap();
if (this.videoListView) {
this.videoListView.state.videosMap = this.videosMap;
}
this.renderVideoList({ videos: fallback, metadata });
return Promise.resolve({ videos: fallback, metadata });
}
return this.feedEngine
.run("recent", { runtime })
.then((result) => {
const videos = Array.isArray(result?.videos) ? result.videos : [];
const metadata = {
...(result?.metadata || {}),
};
if (normalizedReason) {
metadata.reason = normalizedReason;
}
this.latestFeedMetadata = metadata;
this.videosMap = this.nostrService.getVideosMap();
if (this.videoListView) {
this.videoListView.state.videosMap = this.videosMap;
}
const payload = { videos, metadata };
this.renderVideoList(payload);
return payload;
})
.catch((error) => {
devLogger.error("[Application] Failed to run recent feed:", error);
const metadata = {
reason: normalizedReason || "error:recent-feed",
devLogger.error(
`[Application] Failed to run ${feedType} feed:`,
error,
);
let metadata = {
reason: normalizedReason || `error:${feedType}-feed`,
error: true,
};
metadata = metadataModifier(metadata);
this.latestFeedMetadata = metadata;
this.videosMap = this.nostrService.getVideosMap();
if (this.videoListView) {
this.videoListView.state.videosMap = this.videosMap;
}
if (feedType === FEED_TYPES.FOR_YOU || feedType === FEED_TYPES.KIDS) {
this.updateFeedTelemetryMetadata(feedType, [], metadata);
}
const payload = { videos: fallback, metadata };
this.renderVideoList(payload);
return payload;
@@ -1085,6 +949,98 @@ export function createFeedCoordinator(deps) {
return true;
},
/**
* Unified loader for all feed types.
*/
async loadFeedVideos(feedType, forceFetch = false) {
devLogger.log(`Starting loadFeedVideos(${feedType})... (forceFetch =`, forceFetch, ")");
this.setFeedTelemetryContext(feedType);
let includeTags = false;
let loadingMessage = "Fetching videos\u2026";
let refreshMethod = null;
let shouldCheckRelayHealth = false;
switch (feedType) {
case "recent":
includeTags = true;
loadingMessage = "Fetching recent videos\u2026";
refreshMethod = (opts) => this.refreshFeed("recent", opts);
shouldCheckRelayHealth = true;
break;
case FEED_TYPES.FOR_YOU:
includeTags = false;
loadingMessage = "Fetching for-you videos\u2026";
refreshMethod = (opts) => this.refreshFeed(FEED_TYPES.FOR_YOU, opts);
shouldCheckRelayHealth = true;
break;
case FEED_TYPES.KIDS:
includeTags = true;
loadingMessage = "Fetching kids videos\u2026";
refreshMethod = (opts) => this.refreshFeed(FEED_TYPES.KIDS, opts);
shouldCheckRelayHealth = false;
break;
case FEED_TYPES.EXPLORE:
includeTags = false;
loadingMessage = "Fetching explore videos\u2026";
refreshMethod = (opts) => this.refreshFeed(FEED_TYPES.EXPLORE, opts);
shouldCheckRelayHealth = false;
break;
default:
devLogger.warn(
`[Application] Unknown feed type for loadFeedVideos: ${feedType}`,
);
return;
}
if (shouldCheckRelayHealth) {
this.checkRelayHealthWarning();
}
const container = this.mountVideoListView({ includeTags });
const hasCachedVideos =
this.nostrService &&
Array.isArray(this.nostrService.getFilteredActiveVideos()) &&
this.nostrService.getFilteredActiveVideos().length > 0;
if (!hasCachedVideos) {
if (this.videoListView && container) {
this.videoListView.showLoading(loadingMessage);
} else if (container) {
container.innerHTML = getSidebarLoadingMarkup(loadingMessage);
}
}
let initialRefreshPromise = null;
const videos = await this.nostrService.loadVideos({
forceFetch,
blacklistedEventIds: this.blacklistedEventIds,
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
onVideos: (payload, detail = {}) => {
const promise = refreshMethod({
reason: detail?.reason,
fallbackVideos: payload,
});
if (!initialRefreshPromise) {
initialRefreshPromise = promise;
}
},
});
if (initialRefreshPromise) {
await initialRefreshPromise;
} else if (!Array.isArray(videos) || videos.length === 0) {
await refreshMethod({ reason: "initial", fallbackVideos: [] });
}
this.videoSubscription = this.nostrService.getVideoSubscription() || null;
this.videosMap = this.nostrService.getVideosMap();
if (this.videoListView) {
this.videoListView.state.videosMap = this.videosMap;
}
},
/**
* Subscribe to videos (older + new) and render them as they come in.
*/
@@ -1102,52 +1058,7 @@ export function createFeedCoordinator(deps) {
this.lastLoadVideosTime = now;
this.loadVideosPromise = (async () => {
devLogger.log("Starting loadVideos... (forceFetch =", forceFetch, ")");
this.setFeedTelemetryContext("recent");
this.checkRelayHealthWarning();
const container = this.mountVideoListView();
const hasCachedVideos =
this.nostrService &&
Array.isArray(this.nostrService.getFilteredActiveVideos()) &&
this.nostrService.getFilteredActiveVideos().length > 0;
if (!hasCachedVideos) {
if (this.videoListView && container) {
this.videoListView.showLoading("Fetching recent videos\u2026");
} else if (container) {
container.innerHTML = getSidebarLoadingMarkup("Fetching recent videos\u2026");
}
}
let initialRefreshPromise = null;
const videos = await this.nostrService.loadVideos({
forceFetch,
blacklistedEventIds: this.blacklistedEventIds,
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
onVideos: (payload, detail = {}) => {
const promise = this.refreshRecentFeed({
reason: detail?.reason,
fallbackVideos: payload,
});
if (!initialRefreshPromise) {
initialRefreshPromise = promise;
}
},
});
if (initialRefreshPromise) {
await initialRefreshPromise;
} else if (!Array.isArray(videos) || videos.length === 0) {
await this.refreshRecentFeed({ reason: "initial", fallbackVideos: [] });
}
this.videoSubscription = this.nostrService.getVideoSubscription() || null;
this.videosMap = this.nostrService.getVideosMap();
if (this.videoListView) {
this.videoListView.state.videosMap = this.videosMap;
}
await this.loadFeedVideos("recent", forceFetch);
})();
try {
@@ -1158,148 +1069,15 @@ export function createFeedCoordinator(deps) {
},
async loadForYouVideos(forceFetch = false) {
devLogger.log("Starting loadForYouVideos... (forceFetch =", forceFetch, ")");
this.setFeedTelemetryContext(FEED_TYPES.FOR_YOU);
this.checkRelayHealthWarning();
const container = this.mountVideoListView({ includeTags: false });
const hasCachedVideos =
this.nostrService &&
Array.isArray(this.nostrService.getFilteredActiveVideos()) &&
this.nostrService.getFilteredActiveVideos().length > 0;
if (!hasCachedVideos) {
if (this.videoListView && container) {
this.videoListView.showLoading("Fetching for-you videos\u2026");
} else if (container) {
container.innerHTML = getSidebarLoadingMarkup("Fetching for-you videos\u2026");
}
}
let initialRefreshPromise = null;
const videos = await this.nostrService.loadVideos({
forceFetch,
blacklistedEventIds: this.blacklistedEventIds,
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
onVideos: (payload, detail = {}) => {
const promise = this.refreshForYouFeed({
reason: detail?.reason,
fallbackVideos: payload,
});
if (!initialRefreshPromise) {
initialRefreshPromise = promise;
}
},
});
if (initialRefreshPromise) {
await initialRefreshPromise;
} else if (!Array.isArray(videos) || videos.length === 0) {
await this.refreshForYouFeed({ reason: "initial", fallbackVideos: [] });
}
this.videoSubscription = this.nostrService.getVideoSubscription() || null;
this.videosMap = this.nostrService.getVideosMap();
if (this.videoListView) {
this.videoListView.state.videosMap = this.videosMap;
}
return this.loadFeedVideos(FEED_TYPES.FOR_YOU, forceFetch);
},
async loadKidsVideos(forceFetch = false) {
devLogger.log("Starting loadKidsVideos... (forceFetch =", forceFetch, ")");
this.setFeedTelemetryContext(FEED_TYPES.KIDS);
const container = this.mountVideoListView({ includeTags: true });
const hasCachedVideos =
this.nostrService &&
Array.isArray(this.nostrService.getFilteredActiveVideos()) &&
this.nostrService.getFilteredActiveVideos().length > 0;
if (!hasCachedVideos) {
if (this.videoListView && container) {
this.videoListView.showLoading("Fetching kids videos\u2026");
} else if (container) {
container.innerHTML = getSidebarLoadingMarkup("Fetching kids videos\u2026");
}
}
let initialRefreshPromise = null;
const videos = await this.nostrService.loadVideos({
forceFetch,
blacklistedEventIds: this.blacklistedEventIds,
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
onVideos: (payload, detail = {}) => {
const promise = this.refreshKidsFeed({
reason: detail?.reason,
fallbackVideos: payload,
});
if (!initialRefreshPromise) {
initialRefreshPromise = promise;
}
},
});
if (initialRefreshPromise) {
await initialRefreshPromise;
} else if (!Array.isArray(videos) || videos.length === 0) {
await this.refreshKidsFeed({ reason: "initial", fallbackVideos: [] });
}
this.videoSubscription = this.nostrService.getVideoSubscription() || null;
this.videosMap = this.nostrService.getVideosMap();
if (this.videoListView) {
this.videoListView.state.videosMap = this.videosMap;
}
return this.loadFeedVideos(FEED_TYPES.KIDS, forceFetch);
},
async loadExploreVideos(forceFetch = false) {
devLogger.log("Starting loadExploreVideos... (forceFetch =", forceFetch, ")");
this.setFeedTelemetryContext(FEED_TYPES.EXPLORE);
const container = this.mountVideoListView({ includeTags: false });
const hasCachedVideos =
this.nostrService &&
Array.isArray(this.nostrService.getFilteredActiveVideos()) &&
this.nostrService.getFilteredActiveVideos().length > 0;
if (!hasCachedVideos) {
if (this.videoListView && container) {
this.videoListView.showLoading("Fetching explore videos\u2026");
} else if (container) {
container.innerHTML = getSidebarLoadingMarkup("Fetching explore videos\u2026");
}
}
let initialRefreshPromise = null;
const videos = await this.nostrService.loadVideos({
forceFetch,
blacklistedEventIds: this.blacklistedEventIds,
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
onVideos: (payload, detail = {}) => {
const promise = this.refreshExploreFeed({
reason: detail?.reason,
fallbackVideos: payload,
});
if (!initialRefreshPromise) {
initialRefreshPromise = promise;
}
},
});
if (initialRefreshPromise) {
await initialRefreshPromise;
} else if (!Array.isArray(videos) || videos.length === 0) {
await this.refreshExploreFeed({ reason: "initial", fallbackVideos: [] });
}
this.videoSubscription = this.nostrService.getVideoSubscription() || null;
this.videosMap = this.nostrService.getVideosMap();
if (this.videoListView) {
this.videoListView.state.videosMap = this.videosMap;
}
return this.loadFeedVideos(FEED_TYPES.EXPLORE, forceFetch);
},
async loadOlderVideos(lastTimestamp) {
@@ -1313,7 +1091,7 @@ export function createFeedCoordinator(deps) {
return;
}
await this.refreshRecentFeed({ reason: "older-fetch" });
await this.refreshFeed("recent", { reason: "older-fetch" });
},
/**
+24
View File
@@ -0,0 +1,24 @@
export function createNip07SigningAdapter({ extension } = {}) {
const resolvedExtension =
extension ||
(typeof window !== "undefined" && window?.nostr ? window.nostr : null);
const getPubkey = async () => {
if (!resolvedExtension) {
throw new Error("NIP-07 extension unavailable.");
}
return resolvedExtension.getPublicKey();
};
const signEvent = async (event) => {
if (!resolvedExtension) {
throw new Error("NIP-07 extension unavailable.");
}
return resolvedExtension.signEvent(event);
};
return {
getPubkey,
signEvent,
};
}
+17 -10
View File
@@ -4366,10 +4366,23 @@ function getCachedChannelVideoEvents(pubkey) {
};
const rawMatches = collectMatches(nostrClient?.rawEvents, (callback) => {
if (
nostrClient?.rawEvents &&
typeof nostrClient.rawEvents.forEach === "function"
) {
if (!nostrClient?.rawEvents) {
return;
}
if (typeof nostrClient.rawEvents.getEventsByAuthor === "function") {
const candidates = nostrClient.rawEvents.getEventsByAuthor(normalized);
if (Array.isArray(candidates)) {
candidates.forEach((event) => {
if (event?.kind === 30078) {
callback(event);
}
});
return;
}
}
if (typeof nostrClient.rawEvents.forEach === "function") {
nostrClient.rawEvents.forEach((event) => {
if (event?.kind === 30078) {
callback(event);
@@ -4690,12 +4703,6 @@ export async function renderChannelVideosFromList({
typeof app?.deriveVideoPointerInfo === "function"
? app.deriveVideoPointerInfo(video)
: null;
if (
pointerInfo &&
typeof app?.persistWatchHistoryMetadataForVideo === "function"
) {
app.persistWatchHistoryMetadataForVideo(video, pointerInfo);
}
const shareUrl =
typeof app?.buildShareUrlFromEventId === "function"
+5 -134
View File
@@ -8,6 +8,11 @@ import {
removeTorrentProbeFromStorage,
writeTorrentProbeToStorage,
} from "./utils/storage.js";
import {
prioritizeEntries,
getViewportCenter,
PRIORITY_BASELINE,
} from "./gridHealthLogic.js";
const badgeUpdateListeners = new Set();
@@ -226,7 +231,6 @@ const PROBE_CACHE_TTL_MS = 5 * 60 * 1000;
const PROBE_TIMEOUT_MS = 20000;
const PROBE_CONCURRENCY = 96;
const PROBE_POLL_COUNT = 3;
const PRIORITY_BASELINE = 1_000_000;
class ProbeQueue {
constructor(max = 2) {
@@ -308,139 +312,6 @@ const probeQueue = new ProbeQueue(PROBE_CONCURRENCY);
const probeCache = new Map();
const probeInflight = new Map();
function getViewportCenter() {
if (typeof window === "undefined") {
return null;
}
const width = Number(window.innerWidth) || 0;
const height = Number(window.innerHeight) || 0;
if (width <= 0 && height <= 0) {
return null;
}
return {
x: width > 0 ? width / 2 : 0,
y: height > 0 ? height / 2 : 0,
};
}
function prioritizeEntries(entries, viewportCenter) {
if (!Array.isArray(entries) || entries.length === 0) {
return [];
}
const filtered = entries
.filter((entry) => entry.isIntersecting && entry.target instanceof HTMLElement)
.map((entry) => {
const rect = getIntersectionRect(entry);
if (!rect) {
return null;
}
const ratio =
typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0;
const centerY = rect.top + rect.height / 2;
const verticalDistance = viewportCenter
? Math.abs(centerY - viewportCenter.y)
: Number.POSITIVE_INFINITY;
return { entry, ratio, centerY, verticalDistance };
})
.filter(Boolean);
if (filtered.length === 0) {
return [];
}
if (!viewportCenter) {
return filtered
.sort((a, b) => {
if (b.ratio !== a.ratio) {
return b.ratio - a.ratio;
}
return a.centerY - b.centerY;
})
.map((item, index) => ({
entry: item.entry,
priority: PRIORITY_BASELINE - index,
}));
}
const ordered = filtered
.slice()
.sort((a, b) => a.centerY - b.centerY);
let centerIndex = 0;
let minDistance = Number.POSITIVE_INFINITY;
for (let i = 0; i < ordered.length; i += 1) {
const candidate = ordered[i];
if (candidate.verticalDistance < minDistance) {
minDistance = candidate.verticalDistance;
centerIndex = i;
}
}
const prioritized = [];
const pushCandidate = (candidate) => {
if (!candidate) {
return;
}
prioritized.push(candidate);
};
pushCandidate(ordered[centerIndex]);
let left = centerIndex - 1;
let right = centerIndex + 1;
while (left >= 0 || right < ordered.length) {
const leftCandidate = left >= 0 ? ordered[left] : null;
const rightCandidate = right < ordered.length ? ordered[right] : null;
if (leftCandidate && rightCandidate) {
const leftDistance = leftCandidate.verticalDistance;
const rightDistance = rightCandidate.verticalDistance;
const distanceDelta = Math.abs(leftDistance - rightDistance);
if (distanceDelta <= 0.5) {
if (rightCandidate.ratio > leftCandidate.ratio) {
pushCandidate(rightCandidate);
right += 1;
} else {
pushCandidate(leftCandidate);
left -= 1;
}
} else if (leftDistance < rightDistance) {
pushCandidate(leftCandidate);
left -= 1;
} else {
pushCandidate(rightCandidate);
right += 1;
}
} else if (rightCandidate) {
pushCandidate(rightCandidate);
right += 1;
} else if (leftCandidate) {
pushCandidate(leftCandidate);
left -= 1;
}
}
return prioritized.map((candidate, index) => ({
entry: candidate.entry,
priority: PRIORITY_BASELINE - index,
}));
}
function getIntersectionRect(entry) {
if (!entry) {
return null;
}
const rect = entry.intersectionRect;
if (rect && rect.width > 0 && rect.height > 0) {
return rect;
}
const fallback = entry.boundingClientRect;
if (fallback && fallback.width > 0 && fallback.height > 0) {
return fallback;
}
return rect || fallback || null;
}
function setBadge(card, state, details) {
if (!(card instanceof HTMLElement)) {
return;
+134
View File
@@ -0,0 +1,134 @@
export const PRIORITY_BASELINE = 1_000_000;
export function getViewportCenter() {
if (typeof window === "undefined") {
return null;
}
const width = Number(window.innerWidth) || 0;
const height = Number(window.innerHeight) || 0;
if (width <= 0 && height <= 0) {
return null;
}
return {
x: width > 0 ? width / 2 : 0,
y: height > 0 ? height / 2 : 0,
};
}
export function getIntersectionRect(entry) {
if (!entry) {
return null;
}
const rect = entry.intersectionRect;
if (rect && rect.width > 0 && rect.height > 0) {
return rect;
}
const fallback = entry.boundingClientRect;
if (fallback && fallback.width > 0 && fallback.height > 0) {
return fallback;
}
return null;
}
export function prioritizeEntries(entries, viewportCenter) {
if (!Array.isArray(entries) || entries.length === 0) {
return [];
}
const filtered = entries
.filter((entry) => entry.isIntersecting && entry.target instanceof HTMLElement)
.map((entry) => {
const rect = getIntersectionRect(entry);
if (!rect) {
return null;
}
const ratio =
typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0;
const centerY = rect.top + rect.height / 2;
const verticalDistance = viewportCenter
? Math.abs(centerY - viewportCenter.y)
: Number.POSITIVE_INFINITY;
return { entry, ratio, centerY, verticalDistance };
})
.filter(Boolean);
if (filtered.length === 0) {
return [];
}
if (!viewportCenter) {
return filtered
.sort((a, b) => {
if (b.ratio !== a.ratio) {
return b.ratio - a.ratio;
}
return a.centerY - b.centerY;
})
.map((item, index) => ({
entry: item.entry,
priority: PRIORITY_BASELINE - index,
}));
}
const ordered = filtered
.slice()
.sort((a, b) => a.centerY - b.centerY);
let centerIndex = 0;
let minDistance = Number.POSITIVE_INFINITY;
for (let i = 0; i < ordered.length; i += 1) {
const candidate = ordered[i];
if (candidate.verticalDistance < minDistance) {
minDistance = candidate.verticalDistance;
centerIndex = i;
}
}
const prioritized = [];
const pushCandidate = (candidate) => {
if (!candidate) {
return;
}
prioritized.push(candidate);
};
pushCandidate(ordered[centerIndex]);
let left = centerIndex - 1;
let right = centerIndex + 1;
while (left >= 0 || right < ordered.length) {
const leftCandidate = left >= 0 ? ordered[left] : null;
const rightCandidate = right < ordered.length ? ordered[right] : null;
if (leftCandidate && rightCandidate) {
const leftDistance = leftCandidate.verticalDistance;
const rightDistance = rightCandidate.verticalDistance;
const distanceDelta = Math.abs(leftDistance - rightDistance);
if (distanceDelta <= 0.5) {
if (rightCandidate.ratio > leftCandidate.ratio) {
pushCandidate(rightCandidate);
right += 1;
} else {
pushCandidate(leftCandidate);
left -= 1;
}
} else if (leftDistance < rightDistance) {
pushCandidate(leftCandidate);
left -= 1;
} else {
pushCandidate(rightCandidate);
right += 1;
}
} else if (rightCandidate) {
pushCandidate(rightCandidate);
right += 1;
} else if (leftCandidate) {
pushCandidate(leftCandidate);
left -= 1;
}
}
return prioritized.map((candidate, index) => ({
entry: candidate.entry,
priority: PRIORITY_BASELINE - index,
}));
}
+78 -30
View File
@@ -165,6 +165,7 @@ import {
sanitizeRelayList,
readStoredNip46Session,
writeStoredNip46Session,
decryptNip46Session,
clearStoredNip46Session,
parseNip46ConnectionString,
generateNip46Secret,
@@ -180,6 +181,8 @@ import {
decodeNpubToHex,
encodeHexToNpub,
normalizeNostrPubkey,
} from "./nip46Client.js";
import {
summarizeHexForLog,
summarizeSecretForLog,
summarizeMetadataForLog,
@@ -188,7 +191,7 @@ import {
summarizeRpcParamsForLog,
summarizeRpcResultForLog,
summarizeRelayPublishResultsForLog,
} from "./nip46Client.js";
} from "./nip46LoggingUtils.js";
import { profileCache } from "../state/profileCache.js";
import { createPrivateKeyCipherClosures } from "./signerHelpers.js";
import {
@@ -202,6 +205,7 @@ import { createNip07Adapter } from "./adapters/nip07Adapter.js";
import { createNsecAdapter } from "./adapters/nsecAdapter.js";
import { createNip46Adapter } from "./adapters/nip46Adapter.js";
import { queueSignEvent } from "./signRequestQueue.js";
import { EventsMap } from "./eventsMap.js";
function normalizeProfileFromEvent(event) {
if (!event || !event.content) return null;
@@ -1270,7 +1274,7 @@ export class NostrClient {
* Kept for signature verification and republishing (e.g. NIP-94 mirror).
* @public
*/
this.rawEvents = new Map();
this.rawEvents = new EventsMap();
/**
* @type {Map<string, object>}
@@ -1479,11 +1483,15 @@ export class NostrClient {
const remotePubkey = stored.remotePubkey || "";
const userPubkey = stored.userPubkey || "";
const canReconnect = Boolean(stored.clientPrivateKey && stored.secret);
const hasEncryptedSession =
typeof stored.encryptedSecrets === "string" && !!stored.encryptedSecrets;
const canReconnect =
Boolean(stored.clientPrivateKey && stored.secret) || hasEncryptedSession;
return {
hasSession: true,
canReconnect,
hasEncryptedSession,
remotePubkey,
remoteNpub: encodeHexToNpub(remotePubkey),
clientPublicKey: stored.clientPublicKey || "",
@@ -2234,6 +2242,7 @@ export class NostrClient {
onAuthUrl,
onStatus,
handshakeTimeoutMs,
passphrase,
} = {}) {
const parsed = parseNip46ConnectionString(connectionString);
if (!parsed) {
@@ -2552,19 +2561,26 @@ export class NostrClient {
encryption: client.encryptionAlgorithm || handshakeAlgorithm || "",
permissions: permissions || null,
});
writeStoredNip46Session({
version: 1,
clientPublicKey,
remotePubkey,
relays,
encryption: client.encryptionAlgorithm || handshakeAlgorithm || "",
permissions,
metadata,
userPubkey,
lastConnectedAt: Date.now(),
});
await writeStoredNip46Session(
{
version: 1,
clientPublicKey,
remotePubkey,
relays,
encryption: client.encryptionAlgorithm || handshakeAlgorithm || "",
permissions,
metadata,
userPubkey,
lastConnectedAt: Date.now(),
clientPrivateKey,
secret,
},
passphrase,
);
} else {
devLogger.debug("[nostr] Clearing stored remote signer session per request");
devLogger.debug(
"[nostr] Clearing stored remote signer session per request",
);
clearStoredNip46Session();
}
@@ -2637,15 +2653,40 @@ export class NostrClient {
options && typeof options === "object" ? options : {};
const silent = normalizedOptions.silent === true;
const forgetOnError = normalizedOptions.forgetOnError === true;
const validator = typeof normalizedOptions.validator === "function" ? normalizedOptions.validator : null;
const passphrase =
typeof normalizedOptions.passphrase === "string"
? normalizedOptions.passphrase
: null;
const validator =
typeof normalizedOptions.validator === "function"
? normalizedOptions.validator
: null;
const stored = readStoredNip46Session();
let stored = readStoredNip46Session();
if (!stored) {
const error = new Error("No remote signer session is stored on this device.");
const error = new Error(
"No remote signer session is stored on this device.",
);
error.code = "no-stored-session";
throw error;
}
if (stored.encryptedSecrets) {
if (!passphrase) {
const error = new Error("Passphrase required to unlock session.");
error.code = "passphrase-required";
throw error;
}
try {
stored = await decryptNip46Session(stored, passphrase);
} catch (decryptError) {
const error = new Error("Failed to decrypt stored session.");
error.code = "decrypt-failed";
error.cause = decryptError;
throw error;
}
}
const relays = resolveNip46Relays(stored.relays, this.relays);
devLogger.debug("[nostr] Reconnecting to stored remote signer", {
@@ -2664,7 +2705,9 @@ export class NostrClient {
});
if (!stored.clientPrivateKey || !stored.secret) {
const error = new Error("Stored remote signer credentials are unavailable.");
const error = new Error(
"Stored remote signer credentials are unavailable.",
);
error.code = "stored-session-missing-credentials";
throw error;
}
@@ -2708,17 +2751,22 @@ export class NostrClient {
client.metadata = stored.metadata;
const signer = await this.installNip46Client(client, { userPubkey });
writeStoredNip46Session({
version: 1,
clientPublicKey: stored.clientPublicKey,
remotePubkey: stored.remotePubkey,
relays: stored.relays,
encryption: client.encryptionAlgorithm || stored.encryption || "",
permissions: stored.permissions,
metadata: stored.metadata,
userPubkey,
lastConnectedAt: Date.now(),
});
await writeStoredNip46Session(
{
version: 1,
clientPublicKey: stored.clientPublicKey,
remotePubkey: stored.remotePubkey,
relays: stored.relays,
encryption: client.encryptionAlgorithm || stored.encryption || "",
permissions: stored.permissions,
metadata: stored.metadata,
userPubkey,
lastConnectedAt: Date.now(),
clientPrivateKey: stored.clientPrivateKey,
secret: stored.secret,
},
passphrase,
);
devLogger.debug("[nostr] Stored remote signer session refreshed", {
remotePubkey: summarizeHexForLog(stored.remotePubkey || ""),
+90
View File
@@ -0,0 +1,90 @@
/**
* A Map extension that maintains an author-based index for fast lookups.
* Used for `rawEvents` in NostrClient.
*/
export class EventsMap extends Map {
constructor(entries) {
super(entries);
this.authorIndex = new Map(); // pubkey -> Set<Event>
// If entries provided, index them
if (entries) {
for (const [key, value] of this.entries()) {
this._addToIndex(value);
}
}
}
_normalizePubkey(pubkey) {
if (typeof pubkey !== "string") return "";
return pubkey.trim().toLowerCase();
}
_addToIndex(event) {
if (!event || typeof event !== "object") return;
const pubkey = this._normalizePubkey(event.pubkey);
if (!pubkey) return;
let set = this.authorIndex.get(pubkey);
if (!set) {
set = new Set();
this.authorIndex.set(pubkey, set);
}
set.add(event);
}
_removeFromIndex(event) {
if (!event || typeof event !== "object") return;
const pubkey = this._normalizePubkey(event.pubkey);
if (!pubkey) return;
const set = this.authorIndex.get(pubkey);
if (set) {
set.delete(event);
if (set.size === 0) {
this.authorIndex.delete(pubkey);
}
}
}
set(key, value) {
const existing = this.get(key);
// If the value is identical, we still set it in the map,
// but the index doesn't strictly need updating.
// However, if the key was mapped to a DIFFERENT object with the SAME pubkey,
// we must update the Set (remove old object, add new object).
if (existing) {
if (existing === value) {
// No index change needed
return super.set(key, value);
}
this._removeFromIndex(existing);
}
super.set(key, value);
this._addToIndex(value);
return this;
}
delete(key) {
const value = this.get(key);
const result = super.delete(key);
if (result && value) {
this._removeFromIndex(value);
}
return result;
}
clear() {
super.clear();
this.authorIndex.clear();
}
getEventsByAuthor(pubkey) {
const normalized = this._normalizePubkey(pubkey);
if (!normalized) return [];
const set = this.authorIndex.get(normalized);
// Return array to allow iteration
return set ? Array.from(set) : [];
}
}
+2 -2
View File
@@ -15,7 +15,7 @@ const MAX_LISTENER_SNIPPETS = [
const MAX_LISTENER_CODES = new Set(["MaxListenersExceededWarning"]);
function collectCandidateStrings(value) {
export function collectCandidateStrings(value) {
const candidates = [];
if (!value) {
@@ -40,7 +40,7 @@ function collectCandidateStrings(value) {
return candidates;
}
function shouldSuppressWarning(...args) {
export function shouldSuppressWarning(...args) {
if (isVerboseDiagnosticsEnabled()) {
return false;
}
+108 -280
View File
@@ -14,6 +14,11 @@
*/
import { isDevMode } from "../config.js";
import {
encryptSessionPrivateKey,
decryptSessionPrivateKey,
normalizeStoredEncryptionMetadata,
} from "./sessionActor.js";
import {
DEFAULT_RELAY_URLS,
ensureNostrTools,
@@ -27,6 +32,16 @@ import {
import { devLogger, userLogger } from "../utils/logger.js";
import { Nip46RequestQueue, NIP46_PRIORITY } from "./nip46Queue.js";
import { HEX64_REGEX } from "../utils/hex.js";
import {
summarizeHexForLog,
summarizeSecretForLog,
summarizeMetadataForLog,
summarizeUrlForLog,
summarizePayloadPreviewForLog,
summarizeRpcParamsForLog,
summarizeRpcResultForLog,
summarizeRelayPublishResultsForLog,
} from "./nip46LoggingUtils.js";
export const NIP46_RPC_KIND = 24_133;
const NIP46_SESSION_STORAGE_KEY = "bitvid:nip46:session:v1";
@@ -223,6 +238,11 @@ export function sanitizeStoredNip46Session(candidate) {
lastConnectedAt: Number.isFinite(candidate.lastConnectedAt)
? candidate.lastConnectedAt
: Date.now(),
encryptedSecrets:
typeof candidate.encryptedSecrets === "string"
? candidate.encryptedSecrets.trim()
: "",
keyEncryption: normalizeStoredEncryptionMetadata(candidate.keyEncryption),
};
}
@@ -251,7 +271,7 @@ export function readStoredNip46Session() {
(typeof parsed?.clientPrivateKey === "string" ||
typeof parsed?.secret === "string")
) {
writeStoredNip46Session(sanitized);
writeStoredNip46SessionSync(sanitized);
}
return sanitized;
} catch (error) {
@@ -267,7 +287,7 @@ export function readStoredNip46Session() {
}
}
export function writeStoredNip46Session(payload) {
export function writeStoredNip46SessionSync(payload) {
const storage = getNip46Storage();
if (!storage) {
return;
@@ -285,6 +305,8 @@ export function writeStoredNip46Session(payload) {
metadata: payload.metadata,
userPubkey: payload.userPubkey,
lastConnectedAt: payload.lastConnectedAt,
encryptedSecrets: payload.encryptedSecrets,
keyEncryption: payload.keyEncryption,
}
: payload;
const normalized = sanitizeStoredNip46Session(sanitizedInput);
@@ -304,6 +326,90 @@ export function writeStoredNip46Session(payload) {
}
}
export async function writeStoredNip46Session(payload, passphrase) {
if (!payload || typeof payload !== "object") {
return;
}
const update = { ...payload };
if (
passphrase &&
typeof passphrase === "string" &&
passphrase.trim() &&
typeof update.clientPrivateKey === "string" &&
update.clientPrivateKey &&
typeof update.secret === "string" &&
update.secret
) {
const secrets = JSON.stringify({
clientPrivateKey: update.clientPrivateKey,
secret: update.secret,
});
try {
const encrypted = await encryptSessionPrivateKey(
secrets,
passphrase.trim(),
);
update.encryptedSecrets = encrypted.ciphertext;
update.keyEncryption = {
salt: encrypted.salt,
iv: encrypted.iv,
iterations: encrypted.iterations,
hash: encrypted.hash,
algorithm: encrypted.algorithm,
version: encrypted.version,
};
} catch (error) {
devLogger.warn("[nostr] Failed to encrypt remote signer session:", error);
// Fall through to write sanitized (non-sensitive) data only
}
}
// Ensure plain text keys are never persisted
delete update.clientPrivateKey;
delete update.secret;
writeStoredNip46SessionSync(update);
}
export async function decryptNip46Session(session, passphrase) {
if (!session || typeof session !== "object") {
return null;
}
if (
!session.encryptedSecrets ||
typeof session.encryptedSecrets !== "string"
) {
return { ...session };
}
if (!passphrase || typeof passphrase !== "string" || !passphrase.trim()) {
throw new Error("Passphrase required to decrypt session.");
}
const payload = {
privateKeyEncrypted: session.encryptedSecrets,
encryption: session.keyEncryption,
};
const decryptedJson = await decryptSessionPrivateKey(payload, passphrase);
let secrets = null;
try {
secrets = JSON.parse(decryptedJson);
} catch (error) {
throw new Error("Decrypted session secrets are malformed.");
}
return {
...session,
clientPrivateKey: secrets.clientPrivateKey,
secret: secrets.secret,
};
}
export function clearStoredNip46Session() {
const storage = getNip46Storage();
if (!storage) {
@@ -317,125 +423,6 @@ export function clearStoredNip46Session() {
}
}
const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
const BECH32_CHARSET_MAP = (() => {
const map = new Map();
for (let index = 0; index < BECH32_CHARSET.length; index += 1) {
map.set(BECH32_CHARSET[index], index);
}
return map;
})();
function bech32Polymod(values) {
const GENERATORS = [
0x3b6a57b2,
0x26508e6d,
0x1ea119fa,
0x3d4233dd,
0x2a1462b3,
];
let chk = 1;
for (const value of values) {
const top = chk >> 25;
chk = ((chk & 0x1ffffff) << 5) ^ value;
for (let i = 0; i < GENERATORS.length; i += 1) {
if ((top >> i) & 1) {
chk ^= GENERATORS[i];
}
}
}
return chk;
}
function bech32HrpExpand(hrp) {
const result = [];
for (let i = 0; i < hrp.length; i += 1) {
result.push(hrp.charCodeAt(i) >> 5);
}
result.push(0);
for (let i = 0; i < hrp.length; i += 1) {
result.push(hrp.charCodeAt(i) & 31);
}
return result;
}
function bech32VerifyChecksum(hrp, data) {
return bech32Polymod([...bech32HrpExpand(hrp), ...data]) === 1;
}
function convertBits(data, fromBits, toBits) {
let accumulator = 0;
let bits = 0;
const result = [];
const maxValue = (1 << toBits) - 1;
const maxAccumulator = (1 << (fromBits + toBits - 1)) - 1;
for (const value of data) {
if (value < 0 || (value >> fromBits) !== 0) {
return null;
}
accumulator = ((accumulator << fromBits) | value) & maxAccumulator;
bits += fromBits;
while (bits >= toBits) {
bits -= toBits;
result.push((accumulator >> bits) & maxValue);
}
}
if (bits > 0) {
result.push((accumulator << (toBits - bits)) & maxValue);
}
return result;
}
function decodeBech32Npub(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
if (!trimmed) {
return "";
}
const normalized = trimmed.toLowerCase();
const separatorIndex = normalized.lastIndexOf("1");
if (separatorIndex <= 0 || separatorIndex + 7 > normalized.length) {
return "";
}
const hrp = normalized.slice(0, separatorIndex);
if (hrp !== "npub") {
return "";
}
const dataPart = normalized.slice(separatorIndex + 1);
const values = [];
for (let i = 0; i < dataPart.length; i += 1) {
const mapped = BECH32_CHARSET_MAP.get(dataPart[i]);
if (typeof mapped !== "number") {
return "";
}
values.push(mapped);
}
if (values.length < 7 || !bech32VerifyChecksum(hrp, values)) {
return "";
}
const words = values.slice(0, -6);
const bytes = convertBits(words, 5, 8);
if (!bytes || bytes.length !== 32) {
return "";
}
return Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
export function decodeNpubToHex(npub) {
if (typeof npub !== "string") {
return "";
@@ -480,11 +467,6 @@ export function decodeNpubToHex(npub) {
}
}
const manualDecoded = decodeBech32Npub(trimmed);
if (manualDecoded) {
return manualDecoded;
}
if (isDevMode && warnableNpub) {
userLogger.warn(
`[nostr] Failed to decode npub: ${trimmed}`,
@@ -1203,160 +1185,6 @@ export async function decryptNip46PayloadWithKeys(privateKey, remotePubkey, ciph
throw failure;
}
export function summarizeHexForLog(value) {
if (typeof value !== "string" || !value.trim()) {
return "";
}
const normalized = value.trim().toLowerCase();
if (normalized.length <= 12) {
return `${normalized} (len:${normalized.length})`;
}
return `${normalized.slice(0, 8)}…${normalized.slice(-4)} (len:${normalized.length})`;
}
export function summarizeSecretForLog(value) {
if (typeof value !== "string" || !value.trim()) {
return "<empty>";
}
const trimmed = value.trim();
const visible = trimmed.length <= 4 ? "*".repeat(trimmed.length) : `${"*".repeat(3)}…`;
return `${visible} (len:${trimmed.length})`;
}
export function summarizeMetadataForLog(metadata) {
if (!metadata || typeof metadata !== "object") {
return [];
}
return Object.keys(metadata).slice(0, 12);
}
export function summarizeUrlForLog(value) {
if (typeof value !== "string" || !value.trim()) {
return "";
}
const trimmed = value.trim();
try {
const url = new URL(trimmed);
return {
origin: url.origin,
pathname: url.pathname,
hasQuery: Boolean(url.search),
hasHash: Boolean(url.hash),
length: trimmed.length,
};
} catch (error) {
const length = trimmed.length;
if (length <= 64) {
return `${trimmed} (len:${length})`;
}
return `${trimmed.slice(0, 32)}…${trimmed.slice(-8)} (len:${length})`;
}
}
export function summarizePayloadPreviewForLog(value) {
if (typeof value !== "string") {
return { type: typeof value };
}
const trimmed = value.trim();
if (!trimmed) {
return { type: "string", length: 0 };
}
return {
type: "string",
length: trimmed.length,
preview: trimmed.length <= 96 ? trimmed : `${trimmed.slice(0, 64)}…`,
};
}
export function summarizeRpcParamsForLog(method, params) {
if (!Array.isArray(params)) {
return [];
}
return params.map((param, index) => {
if (typeof param === "string") {
if (method === "connect" && index === 1) {
return { index, secret: summarizeSecretForLog(param) };
}
const trimmed = param.trim();
if (!trimmed) {
return { index, type: "string", length: 0 };
}
if (method === "sign_event") {
return { index, type: "string", length: trimmed.length };
}
if (trimmed.length <= 64) {
return { index, value: trimmed, length: trimmed.length };
}
return {
index,
type: "string",
length: trimmed.length,
preview: `${trimmed.slice(0, 32)}…${trimmed.slice(-8)}`,
};
}
if (param && typeof param === "object") {
return {
index,
type: Array.isArray(param) ? "array" : "object",
keys: Object.keys(param).slice(0, 6),
};
}
return { index, type: typeof param };
});
}
export function summarizeRpcResultForLog(method, result) {
if (typeof result === "string") {
const trimmed = result.trim();
if (!trimmed) {
return { type: "string", length: 0 };
}
if (method === "connect") {
return { type: "string", length: trimmed.length, secret: summarizeSecretForLog(trimmed) };
}
if (trimmed.length <= 96) {
return { type: "string", length: trimmed.length, value: trimmed };
}
return {
type: "string",
length: trimmed.length,
preview: `${trimmed.slice(0, 48)}…${trimmed.slice(-12)}`,
};
}
if (!result) {
return { type: typeof result };
}
if (typeof result === "object") {
return {
type: Array.isArray(result) ? "array" : "object",
keys: Object.keys(result).slice(0, 6),
};
}
return { type: typeof result };
}
export function summarizeRelayPublishResultsForLog(results) {
if (!Array.isArray(results)) {
return [];
}
return results.map((entry) => ({
relay: entry?.relay || "",
success: Boolean(entry?.ok),
reason: entry?.error ? entry.error?.message || String(entry.error) : null,
}));
}
export function createNip46RequestId() {
try {
+158
View File
@@ -0,0 +1,158 @@
/**
* Logging utilities for NIP-46 (Nostr Connect) client and diagnostics.
*/
export function summarizeHexForLog(value) {
if (typeof value !== "string" || !value.trim()) {
return "";
}
const normalized = value.trim().toLowerCase();
if (normalized.length <= 12) {
return `${normalized} (len:${normalized.length})`;
}
return `${normalized.slice(0, 8)}…${normalized.slice(-4)} (len:${normalized.length})`;
}
export function summarizeSecretForLog(value) {
if (typeof value !== "string" || !value.trim()) {
return "<empty>";
}
const trimmed = value.trim();
const visible = trimmed.length <= 4 ? "*".repeat(trimmed.length) : `${"*".repeat(3)}…`;
return `${visible} (len:${trimmed.length})`;
}
export function summarizeMetadataForLog(metadata) {
if (!metadata || typeof metadata !== "object") {
return [];
}
return Object.keys(metadata).slice(0, 12);
}
export function summarizeUrlForLog(value) {
if (typeof value !== "string" || !value.trim()) {
return "";
}
const trimmed = value.trim();
try {
const url = new URL(trimmed);
return {
origin: url.origin,
pathname: url.pathname,
hasQuery: Boolean(url.search),
hasHash: Boolean(url.hash),
length: trimmed.length,
};
} catch (error) {
const length = trimmed.length;
if (length <= 64) {
return `${trimmed} (len:${length})`;
}
return `${trimmed.slice(0, 32)}…${trimmed.slice(-8)} (len:${length})`;
}
}
export function summarizePayloadPreviewForLog(value) {
if (typeof value !== "string") {
return { type: typeof value };
}
const trimmed = value.trim();
if (!trimmed) {
return { type: "string", length: 0 };
}
return {
type: "string",
length: trimmed.length,
preview: trimmed.length <= 96 ? trimmed : `${trimmed.slice(0, 64)}…`,
};
}
export function summarizeRpcParamsForLog(method, params) {
if (!Array.isArray(params)) {
return [];
}
return params.map((param, index) => {
if (typeof param === "string") {
if (method === "connect" && index === 1) {
return { index, secret: summarizeSecretForLog(param) };
}
const trimmed = param.trim();
if (!trimmed) {
return { index, type: "string", length: 0 };
}
if (method === "sign_event") {
return { index, type: "string", length: trimmed.length };
}
if (trimmed.length <= 64) {
return { index, value: trimmed, length: trimmed.length };
}
return {
index,
type: "string",
length: trimmed.length,
preview: `${trimmed.slice(0, 32)}…${trimmed.slice(-8)}`,
};
}
if (param && typeof param === "object") {
return {
index,
type: Array.isArray(param) ? "array" : "object",
keys: Object.keys(param).slice(0, 6),
};
}
return { index, type: typeof param };
});
}
export function summarizeRpcResultForLog(method, result) {
if (typeof result === "string") {
const trimmed = result.trim();
if (!trimmed) {
return { type: "string", length: 0 };
}
if (method === "connect") {
return { type: "string", length: trimmed.length, secret: summarizeSecretForLog(trimmed) };
}
if (trimmed.length <= 96) {
return { type: "string", length: trimmed.length, value: trimmed };
}
return {
type: "string",
length: trimmed.length,
preview: `${trimmed.slice(0, 48)}…${trimmed.slice(-12)}`,
};
}
if (!result) {
return { type: typeof result };
}
if (typeof result === "object") {
return {
type: Array.isArray(result) ? "array" : "object",
keys: Object.keys(result).slice(0, 6),
};
}
return { type: typeof result };
}
export function summarizeRelayPublishResultsForLog(results) {
if (!Array.isArray(results)) {
return [];
}
return results.map((entry) => ({
relay: entry?.relay || "",
success: Boolean(entry?.ok),
reason: entry?.error ? entry.error?.message || String(entry.error) : null,
}));
}
+87 -51
View File
@@ -6,7 +6,6 @@ import {
ADMIN_COMMUNITY_BLACKLIST_PREFIX,
isDevMode,
WATCH_HISTORY_KIND,
WATCH_HISTORY_LIST_IDENTIFIER,
WATCH_HISTORY_VERSION_TAG_VALUE,
} from "./config.js";
import {
@@ -2107,57 +2106,46 @@ export function buildReactionEvent(params) {
return event;
}
export function buildCommentEvent(params) {
const {
pubkey,
created_at,
videoEventId = "",
videoEventRelay = "",
videoDefinitionAddress = "",
videoDefinitionRelay = "",
rootIdentifier = "",
rootIdentifierRelay = "",
parentCommentId = "",
parentCommentRelay = "",
threadParticipantPubkey = "",
threadParticipantRelay = "",
rootKind,
rootAuthorPubkey = "",
rootAuthorRelay = "",
parentKind,
parentAuthorPubkey = "",
parentAuthorRelay = "",
parentIdentifier = "",
parentIdentifierRelay = "",
additionalTags = [],
content = "",
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT);
const tags = [];
function normalizeCommentInputs(params) {
const normalizeString = (value) =>
typeof value === "string" ? value.trim() : "";
const normalizedVideoEventId = normalizeString(videoEventId);
const normalizedVideoEventRelay = normalizeString(videoEventRelay);
const normalizedVideoDefinitionAddress = normalizeString(videoDefinitionAddress);
const normalizedVideoDefinitionRelay = normalizeString(videoDefinitionRelay);
const normalizedRootIdentifier = normalizeString(rootIdentifier);
const normalizedRootIdentifierRelay = normalizeString(rootIdentifierRelay);
const normalizedParentCommentId = normalizeString(parentCommentId);
const normalizedParentCommentRelay = normalizeString(parentCommentRelay);
const normalizedParentIdentifier = normalizeString(parentIdentifier);
const normalizedParentIdentifierRelay = normalizeString(parentIdentifierRelay);
const normalizedThreadParticipantPubkey = normalizeString(
threadParticipantPubkey,
);
const normalizedThreadParticipantRelay = normalizeString(
threadParticipantRelay,
);
return {
normalizedVideoEventId: normalizeString(params.videoEventId),
normalizedVideoEventRelay: normalizeString(params.videoEventRelay),
normalizedVideoDefinitionAddress: normalizeString(params.videoDefinitionAddress),
normalizedVideoDefinitionRelay: normalizeString(params.videoDefinitionRelay),
normalizedRootIdentifier: normalizeString(params.rootIdentifier),
normalizedRootIdentifierRelay: normalizeString(params.rootIdentifierRelay),
normalizedParentCommentId: normalizeString(params.parentCommentId),
normalizedParentCommentRelay: normalizeString(params.parentCommentRelay),
normalizedThreadParticipantPubkey: normalizeString(params.threadParticipantPubkey),
normalizedThreadParticipantRelay: normalizeString(params.threadParticipantRelay),
normalizedParentIdentifier: normalizeString(params.parentIdentifier),
normalizedParentIdentifierRelay: normalizeString(params.parentIdentifierRelay),
resolvedRootKind: normalizeString(params.rootKind),
resolvedRootAuthorPubkey: normalizeString(params.rootAuthorPubkey),
resolvedRootAuthorRelay: normalizeString(params.rootAuthorRelay),
resolvedParentAuthorPubkey: normalizeString(params.parentAuthorPubkey),
resolvedParentAuthorRelay: normalizeString(params.parentAuthorRelay),
resolvedParentKind: normalizeString(params.parentKind),
};
}
let resolvedRootKind = normalizeString(rootKind);
let resolvedRootAuthorPubkey = normalizeString(rootAuthorPubkey);
let resolvedRootAuthorRelay = normalizeString(rootAuthorRelay);
function resolveCommentContext(inputs, schema) {
let {
resolvedRootKind,
resolvedRootAuthorPubkey,
resolvedRootAuthorRelay,
resolvedParentAuthorPubkey,
resolvedParentAuthorRelay,
resolvedParentKind,
normalizedVideoDefinitionAddress,
normalizedVideoDefinitionRelay,
normalizedThreadParticipantPubkey,
normalizedThreadParticipantRelay,
normalizedParentCommentId,
} = inputs;
if (normalizedVideoDefinitionAddress) {
const definitionSegments = normalizedVideoDefinitionAddress.split(":");
@@ -2172,12 +2160,10 @@ export function buildCommentEvent(params) {
}
}
let resolvedParentAuthorPubkey = normalizeString(parentAuthorPubkey);
if (!resolvedParentAuthorPubkey) {
resolvedParentAuthorPubkey = normalizedThreadParticipantPubkey;
}
let resolvedParentAuthorRelay = normalizeString(parentAuthorRelay);
if (!resolvedParentAuthorRelay) {
resolvedParentAuthorRelay = normalizedThreadParticipantRelay;
}
@@ -2196,7 +2182,6 @@ export function buildCommentEvent(params) {
resolvedParentAuthorRelay = resolvedRootAuthorRelay;
}
let resolvedParentKind = normalizeString(parentKind);
if (!resolvedParentKind) {
if (normalizedParentCommentId) {
resolvedParentKind = String(schema?.kind ?? 1111);
@@ -2209,6 +2194,41 @@ export function buildCommentEvent(params) {
resolvedRootKind = resolvedParentKind;
}
return {
resolvedRootKind,
resolvedRootAuthorPubkey,
resolvedRootAuthorRelay,
resolvedParentAuthorPubkey,
resolvedParentAuthorRelay,
resolvedParentKind,
};
}
function buildCommentTags(schema, inputs, context) {
const {
normalizedVideoEventId,
normalizedVideoEventRelay,
normalizedVideoDefinitionAddress,
normalizedVideoDefinitionRelay,
normalizedRootIdentifier,
normalizedRootIdentifierRelay,
normalizedParentCommentId,
normalizedParentCommentRelay,
normalizedParentIdentifier,
normalizedParentIdentifierRelay,
} = inputs;
const {
resolvedRootKind,
resolvedRootAuthorPubkey,
resolvedRootAuthorRelay,
resolvedParentAuthorPubkey,
resolvedParentAuthorRelay,
resolvedParentKind,
} = context;
const tags = [];
const rootDefinitionPointerTagName = schema?.rootDefinitionPointerTagName || "A";
const rootEventPointerTagName = schema?.rootEventPointerTagName || "E";
const rootIdentifierPointerTagName = schema?.rootIdentifierPointerTagName || "I";
@@ -2334,6 +2354,22 @@ export function buildCommentEvent(params) {
}
}
return tags;
}
export function buildCommentEvent(params) {
const {
pubkey,
created_at,
additionalTags = [],
content = "",
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT);
const inputs = normalizeCommentInputs(params || {});
const context = resolveCommentContext(inputs, schema);
const tags = buildCommentTags(schema, inputs, context);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
+4 -22
View File
@@ -1,27 +1,9 @@
import { devLogger } from "../../utils/logger.js";
import { accessControl } from "../../accessControl.js";
function summarizeHexForLog(value) {
if (typeof value !== "string" || !value.trim()) {
return "";
}
const normalized = value.trim().toLowerCase();
if (normalized.length <= 12) {
return `${normalized} (len:${normalized.length})`;
}
return `${normalized.slice(0, 8)}…${normalized.slice(-4)} (len:${normalized.length})`;
}
function summarizeSecretForLog(value) {
if (typeof value !== "string" || !value.trim()) {
return "<empty>";
}
const trimmed = value.trim();
const visible = trimmed.length <= 4 ? "*".repeat(trimmed.length) : `${"*".repeat(3)}…`;
return `${visible} (len:${trimmed.length})`;
}
import {
summarizeHexForLog,
summarizeSecretForLog,
} from "../../nostr/nip46LoggingUtils.js";
function sanitizeNip46UriForLog(value) {
if (typeof value !== "string" || !value) {
+33
View File
@@ -10,6 +10,7 @@ import { userLogger } from "../utils/logger.js";
import { requestDefaultExtensionPermissions } from "../nostr/defaultClient.js";
import {
getSavedProfiles,
setSavedProfiles,
getActiveProfilePubkey,
setActiveProfilePubkey,
mutateSavedProfiles,
@@ -417,6 +418,38 @@ export default class AuthService {
return publish(payload);
}
get pubkey() {
return getPubkey();
}
set pubkey(value) {
setPubkey(value ?? null);
}
get currentUserNpub() {
return getCurrentUserNpub();
}
set currentUserNpub(value) {
setCurrentUserNpub(value ?? null);
}
get activeProfilePubkey() {
return getActiveProfilePubkey();
}
setActiveProfilePubkey(pubkey, options = {}) {
return setActiveProfilePubkey(pubkey, options);
}
get savedProfiles() {
return getSavedProfiles();
}
setSavedProfiles(profiles, options = {}) {
return setSavedProfiles(profiles, options);
}
getActivePubkey() {
return getPubkey();
}
+5 -8
View File
@@ -352,18 +352,15 @@ class HashtagPreferencesService {
eventId: this.eventId,
createdAt: this.eventCreatedAt,
};
// Use the note type key directly, though "interests" maps to HASHTAG_PREFERENCES
// in profileCache. Using NOTE_TYPES.HASHTAG_PREFERENCES explicitly is safer if we import it,
// but relying on the mapped key "interests" is also consistent with current usage.
// However, the fix requires us to ensure we use the unified path.
// We'll stick to "interests" which maps to NOTE_TYPES.HASHTAG_PREFERENCES in profileCache.js
profileCache.set("interests", payload);
// Use the note type key directly. This maps to the same storage key as the legacy "interests"
// mapping in profileCache, ensuring backward compatibility while using the unified path.
profileCache.set(NOTE_TYPES.HASHTAG_PREFERENCES, payload);
}
loadFromCache(pubkey) {
// profileCache internally uses activePubkey, but we should verify if it matches
try {
let cached = profileCache.getProfileData(pubkey, "interests");
let cached = profileCache.getProfileData(pubkey, NOTE_TYPES.HASHTAG_PREFERENCES);
// Legacy fallback migration
if (!cached && typeof localStorage !== "undefined") {
@@ -372,7 +369,7 @@ class HashtagPreferencesService {
if (legacy) {
const parsed = JSON.parse(legacy);
// Migrate to unified profile cache
profileCache.set("interests", parsed);
profileCache.set(NOTE_TYPES.HASHTAG_PREFERENCES, parsed);
localStorage.removeItem(HASHTAG_IDENTIFIER);
cached = parsed;
devLogger.log(`${LOG_PREFIX} Migrated legacy preferences to profileCache.`);
+9
View File
@@ -1,6 +1,7 @@
import { userLogger } from "../utils/logger.js";
import { safeDecodeURIComponent } from "../utils/safeDecode.js";
import { PLAYBACK_START_TIMEOUT } from "../constants.js";
import { getCurrentVideo, setCurrentVideo } from "../state/appState.js";
// js/services/playbackService.js
/**
@@ -285,6 +286,14 @@ export class PlaybackService {
this.probeCacheTtlMs = PROBE_CACHE_TTL_MS;
}
get currentVideo() {
return getCurrentVideo();
}
set currentVideo(value) {
setCurrentVideo(value);
}
log(...args) {
try {
this.logger(...args);
+11 -1
View File
@@ -3,6 +3,7 @@ import { devLogger, userLogger } from "../utils/logger.js";
const TELEMETRY_STORAGE_KEY = "bitvid:relay-health-telemetry-opt-in";
const PERSISTENT_FAILURE_THRESHOLD = 3;
const USER_LOG_COOLDOWN_MS = 5 * 60 * 1000;
const DEFAULT_TIMEOUT_MS = 5000;
function resolveLogger(logger) {
if (logger && logger.dev && logger.user) {
@@ -202,7 +203,16 @@ class RelayHealthService {
const start = nowMs();
try {
const relay = await this.nostrClient.pool.ensureRelay(relayUrl);
const relay = await Promise.race([
this.nostrClient.pool.ensureRelay(relayUrl),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error("relay-connection-timeout")),
DEFAULT_TIMEOUT_MS,
),
),
]);
if (!relay) {
throw new Error("relay-unavailable");
}
+142 -127
View File
@@ -30,7 +30,6 @@ const URL_PROBE_TIMEOUT_RETRY_MS = 15 * 1000; // 15 seconds
const MODERATION_OVERRIDE_STORAGE_KEY = "bitvid:moderationOverrides:v2";
const LEGACY_MODERATION_OVERRIDE_STORAGE_KEY = "bitvid:moderationOverrides:v1";
const MODERATION_OVERRIDE_STORAGE_VERSION = 2;
const MODERATION_SETTINGS_STORAGE_KEY = "bitvid:moderationSettings:v1";
const MODERATION_SETTINGS_STORAGE_VERSION = 3;
@@ -505,6 +504,133 @@ export function persistSavedProfiles({ persistActive = true } = {}) {
writeSavedProfilesPayloadToStorage(payload);
}
function processSavedProfilesPayload(parsed) {
const result = {
profiles: [],
activePubkey: null,
hasExplicitActiveProfile: false,
needsRewrite: false,
};
if (
!parsed ||
typeof parsed !== "object" ||
parsed.version !== SAVED_PROFILES_STORAGE_VERSION
) {
result.needsRewrite = true;
return result;
}
const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
const seenPubkeys = new Set();
for (const candidate of entries) {
if (!candidate || typeof candidate !== "object") {
continue;
}
const normalizedPubkey = normalizeHexPubkey(candidate.pubkey);
if (!normalizedPubkey || seenPubkeys.has(normalizedPubkey)) {
result.needsRewrite = true;
continue;
}
seenPubkeys.add(normalizedPubkey);
const rawAuthType =
typeof candidate.authType === "string" ? candidate.authType : null;
let storedAuthType = null;
if (rawAuthType) {
const trimmedAuthType = rawAuthType.trim();
if (trimmedAuthType) {
storedAuthType = trimmedAuthType;
if (trimmedAuthType !== rawAuthType) {
result.needsRewrite = true;
}
}
}
if (!storedAuthType) {
storedAuthType = "nip07";
if (rawAuthType !== "nip07") {
result.needsRewrite = true;
}
}
const rawProviderId =
typeof candidate.providerId === "string" ? candidate.providerId : null;
let storedProviderId = null;
if (rawProviderId) {
const trimmedProviderId = rawProviderId.trim();
if (trimmedProviderId) {
storedProviderId = trimmedProviderId;
if (trimmedProviderId !== rawProviderId) {
result.needsRewrite = true;
}
}
}
if (!storedProviderId && storedAuthType) {
storedProviderId = storedAuthType;
if (rawProviderId !== storedProviderId) {
result.needsRewrite = true;
}
}
const npub =
typeof candidate.npub === "string" && candidate.npub.trim()
? candidate.npub.trim()
: null;
const entry = {
pubkey: normalizedPubkey,
npub,
name: typeof candidate.name === "string" ? candidate.name : "",
picture: typeof candidate.picture === "string" ? candidate.picture : "",
authType: storedAuthType,
providerId: storedProviderId,
};
if (
typeof candidate.npub === "string" &&
candidate.npub.trim() !== candidate.npub
) {
result.needsRewrite = true;
}
if (
entry.npub &&
typeof candidate.npub !== "string" &&
entry.npub !== candidate.npub
) {
result.needsRewrite = true;
}
result.profiles.push(entry);
}
let hasActiveField = false;
let activeCandidate;
if (Object.prototype.hasOwnProperty.call(parsed, "activePubkey")) {
hasActiveField = true;
activeCandidate = parsed.activePubkey;
} else if (Object.prototype.hasOwnProperty.call(parsed, "activePubKey")) {
hasActiveField = true;
activeCandidate = parsed.activePubKey;
}
if (hasActiveField) {
result.hasExplicitActiveProfile = true;
if (typeof activeCandidate === "string") {
const normalizedActive = normalizeHexPubkey(activeCandidate);
if (normalizedActive && seenPubkeys.has(normalizedActive)) {
result.activePubkey = normalizedActive;
} else if (activeCandidate) {
result.needsRewrite = true;
}
} else if (activeCandidate !== null && activeCandidate !== undefined) {
result.needsRewrite = true;
}
}
return result;
}
export function loadSavedProfilesFromStorage() {
savedProfiles = [];
activeProfilePubkey = null;
@@ -530,117 +656,24 @@ export function loadSavedProfilesFromStorage() {
let needsRewrite = false;
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object" && parsed.version === SAVED_PROFILES_STORAGE_VERSION) {
const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
const seenPubkeys = new Set();
for (const candidate of entries) {
if (!candidate || typeof candidate !== "object") {
continue;
}
const normalizedPubkey = normalizeHexPubkey(candidate.pubkey);
if (!normalizedPubkey || seenPubkeys.has(normalizedPubkey)) {
needsRewrite = true;
continue;
}
seenPubkeys.add(normalizedPubkey);
const rawAuthType =
typeof candidate.authType === "string" ? candidate.authType : null;
let storedAuthType = null;
if (rawAuthType) {
const trimmedAuthType = rawAuthType.trim();
if (trimmedAuthType) {
storedAuthType = trimmedAuthType;
if (trimmedAuthType !== rawAuthType) {
needsRewrite = true;
}
}
}
if (!storedAuthType) {
storedAuthType = "nip07";
if (rawAuthType !== "nip07") {
needsRewrite = true;
}
}
const rawProviderId =
typeof candidate.providerId === "string" ? candidate.providerId : null;
let storedProviderId = null;
if (rawProviderId) {
const trimmedProviderId = rawProviderId.trim();
if (trimmedProviderId) {
storedProviderId = trimmedProviderId;
if (trimmedProviderId !== rawProviderId) {
needsRewrite = true;
}
}
}
if (!storedProviderId && storedAuthType) {
storedProviderId = storedAuthType;
if (rawProviderId !== storedProviderId) {
needsRewrite = true;
}
}
const npub =
typeof candidate.npub === "string" && candidate.npub.trim()
? candidate.npub.trim()
: null;
const entry = {
pubkey: normalizedPubkey,
npub,
name: typeof candidate.name === "string" ? candidate.name : "",
picture: typeof candidate.picture === "string" ? candidate.picture : "",
authType: storedAuthType,
providerId: storedProviderId,
};
if (
typeof candidate.npub === "string" && candidate.npub.trim() !== candidate.npub
) {
needsRewrite = true;
}
if (entry.npub && typeof candidate.npub !== "string" && entry.npub !== candidate.npub) {
needsRewrite = true;
}
savedProfiles.push(entry);
}
let hasActiveField = false;
let activeCandidate;
if (Object.prototype.hasOwnProperty.call(parsed, "activePubkey")) {
hasActiveField = true;
activeCandidate = parsed.activePubkey;
} else if (Object.prototype.hasOwnProperty.call(parsed, "activePubKey")) {
hasActiveField = true;
activeCandidate = parsed.activePubKey;
}
if (hasActiveField) {
hadExplicitActiveProfile = true;
if (typeof activeCandidate === "string") {
const normalizedActive = normalizeHexPubkey(activeCandidate);
if (normalizedActive && seenPubkeys.has(normalizedActive)) {
activeProfilePubkey = normalizedActive;
} else if (activeCandidate) {
needsRewrite = true;
}
} else if (activeCandidate !== null && activeCandidate !== undefined) {
needsRewrite = true;
}
}
} else if (raw) {
needsRewrite = true;
}
const result = processSavedProfilesPayload(parsed);
savedProfiles = result.profiles;
activeProfilePubkey = result.activePubkey;
hadExplicitActiveProfile = result.hasExplicitActiveProfile;
needsRewrite = result.needsRewrite;
} catch (error) {
userLogger.warn("[cache.loadSavedProfilesFromStorage] Failed to parse payload:", error);
userLogger.warn(
"[cache.loadSavedProfilesFromStorage] Failed to parse payload:",
error,
);
needsRewrite = true;
}
if (!activeProfilePubkey && savedProfiles.length && !hadExplicitActiveProfile) {
if (
!activeProfilePubkey &&
savedProfiles.length &&
!hadExplicitActiveProfile
) {
activeProfilePubkey = savedProfiles[0].pubkey;
needsRewrite = true;
}
@@ -1496,9 +1529,7 @@ export function loadModerationOverridesFromStorage() {
return;
}
const raw =
localStorage.getItem(MODERATION_OVERRIDE_STORAGE_KEY) ||
localStorage.getItem(LEGACY_MODERATION_OVERRIDE_STORAGE_KEY);
const raw = localStorage.getItem(MODERATION_OVERRIDE_STORAGE_KEY);
if (!raw) {
return;
}
@@ -1519,20 +1550,6 @@ export function loadModerationOverridesFromStorage() {
} else {
shouldRewrite = true;
}
} else if (payload.version === 1) {
const legacyEntries =
payload.entries && typeof payload.entries === "object"
? payload.entries
: {};
Object.entries(legacyEntries).forEach(([eventId, entry]) => {
entries.push({
eventId,
authorPubkey: "",
showAnyway: entry?.showAnyway === true,
updatedAt: entry?.updatedAt,
});
});
shouldRewrite = true;
} else {
return;
}
@@ -1599,7 +1616,6 @@ export function persistModerationOverridesToStorage() {
if (entries.length === 0) {
try {
localStorage.removeItem(MODERATION_OVERRIDE_STORAGE_KEY);
localStorage.removeItem(LEGACY_MODERATION_OVERRIDE_STORAGE_KEY);
} catch (error) {
userLogger.warn(
"[cache.persistModerationOverridesToStorage] Failed to clear overrides:",
@@ -1620,7 +1636,6 @@ export function persistModerationOverridesToStorage() {
MODERATION_OVERRIDE_STORAGE_KEY,
JSON.stringify(payload),
);
localStorage.removeItem(LEGACY_MODERATION_OVERRIDE_STORAGE_KEY);
} catch (error) {
userLogger.warn(
"[cache.persistModerationOverridesToStorage] Failed to persist overrides:",
-25
View File
@@ -867,31 +867,6 @@ export default class ApplicationBootstrap {
}
};
app.boundNwcSettingsToastHandler = null;
Object.defineProperty(app, "savedProfiles", {
configurable: false,
enumerable: false,
get() {
return getSavedProfiles();
},
set(next) {
setSavedProfiles(Array.isArray(next) ? next : [], {
persist: false,
persistActive: false,
});
},
});
Object.defineProperty(app, "activeProfilePubkey", {
configurable: false,
enumerable: false,
get() {
return getActiveProfilePubkey();
},
set(value) {
setStoredActiveProfilePubkey(value, { persist: false });
},
});
Object.defineProperty(app, "profileCache", {
configurable: false,
enumerable: false,
+17
View File
@@ -1561,6 +1561,23 @@ export default class LoginModalController {
if (!pendingAuthUrl) {
return;
}
let isValid = false;
try {
const parsed = new URL(pendingAuthUrl);
isValid = parsed.protocol === "http:" || parsed.protocol === "https:";
} catch (error) {
isValid = false;
}
if (!isValid) {
devLogger.warn(
"[LoginModalController] Blocked unsafe authentication URL:",
pendingAuthUrl,
);
return;
}
if (this.window && typeof this.window.open === "function") {
this.window.open(pendingAuthUrl, "_blank", "noopener,noreferrer");
} else if (this.document && this.document.location) {
+4
View File
@@ -10,6 +10,7 @@ const DEFAULT_PROFILE_IMAGE = "assets/svg/default-profile.svg";
export async function batchFetchProfilesFromRelays({
authorSet,
forceRefresh = false,
getProfileCacheEntry,
setProfileCacheEntry,
updateProfileInDOM,
@@ -50,6 +51,9 @@ export async function batchFetchProfilesFromRelays({
const cacheEntry = getProfileCacheEntry(normalizedPubkey);
if (cacheEntry) {
updateProfileInDOM(normalizedPubkey, cacheEntry.profile);
if (forceRefresh) {
toFetch.push(normalizedPubkey);
}
} else {
toFetch.push(normalizedPubkey);
}
+48 -75
View File
@@ -706,34 +706,44 @@ export class TorrentClient {
});
}
// Handle Chrome-based browsers
handleChromeTorrent(torrent, videoElement, resolve, reject) {
// Prune demo web seeds/trackers that chronically trip Chromium CORS and
// deliberately mutate `torrent._opts` as a sanctioned WebTorrent workaround.
torrent.on("warning", (err) => {
if (err && typeof err.message === "string") {
if (
err.message.includes("CORS") ||
err.message.includes("Access-Control-Allow-Origin")
) {
userLogger.warn(
"CORS warning detected. Attempting to remove the failing webseed/tracker."
);
if (torrent._opts?.urlList?.length) {
torrent._opts.urlList = torrent._opts.urlList.filter((url) => {
return !url.includes("distribution.bbb3d.renderfarming.net");
});
userLogger.warn("Cleaned up webseeds =>", torrent._opts.urlList);
}
if (torrent._opts?.announce?.length) {
torrent._opts.announce = torrent._opts.announce.filter((url) => {
return !url.includes("fastcast.nz");
});
userLogger.warn("Cleaned up trackers =>", torrent._opts.announce);
handleTorrentStream(
torrent,
videoElement,
resolve,
reject,
context = "chrome"
) {
const isChrome = context === "chrome";
const isFirefox = context === "firefox";
// Chrome-specific: Prune demo web seeds/trackers that chronically trip Chromium CORS
// and deliberately mutate `torrent._opts` as a sanctioned WebTorrent workaround.
if (isChrome) {
torrent.on("warning", (err) => {
if (err && typeof err.message === "string") {
if (
err.message.includes("CORS") ||
err.message.includes("Access-Control-Allow-Origin")
) {
userLogger.warn(
"CORS warning detected. Attempting to remove the failing webseed/tracker."
);
if (torrent._opts?.urlList?.length) {
torrent._opts.urlList = torrent._opts.urlList.filter((url) => {
return !url.includes("distribution.bbb3d.renderfarming.net");
});
userLogger.warn("Cleaned up webseeds =>", torrent._opts.urlList);
}
if (torrent._opts?.announce?.length) {
torrent._opts.announce = torrent._opts.announce.filter((url) => {
return !url.includes("fastcast.nz");
});
userLogger.warn("Cleaned up trackers =>", torrent._opts.announce);
}
}
}
}
});
});
}
const file = torrent.files.find((f) => /\.(mp4|webm|mkv)$/i.test(f.name));
if (!file) {
@@ -744,18 +754,23 @@ export class TorrentClient {
videoElement.crossOrigin = "anonymous";
videoElement.addEventListener("error", (e) => {
this.log("Video error:", e.target.error);
this.log(`Video error (${context} path):`, e.target.error);
});
const tryStart = () => {
this.attemptAutoplay(videoElement, "chrome");
this.attemptAutoplay(videoElement, context);
};
videoElement.addEventListener("canplay", tryStart, { once: true });
videoElement.addEventListener("loadeddata", tryStart, { once: true });
try {
file.streamTo(videoElement);
const streamOptions = {};
if (isFirefox) {
streamOptions.highWaterMark = 256 * 1024;
}
file.streamTo(videoElement, streamOptions);
// If the video is already ready (e.g. from cache or fast load), try starting immediately
if (videoElement.readyState >= 3) {
tryStart();
@@ -763,54 +778,12 @@ export class TorrentClient {
this.currentTorrent = torrent;
resolve(torrent);
} catch (err) {
this.log("Streaming error (Chrome path):", err);
this.log(`Streaming error (${context} path):`, err);
reject(err);
}
torrent.on("error", (err) => {
this.log("Torrent error (Chrome path):", err);
reject(err);
});
}
// Handle Firefox-based browsers
handleFirefoxTorrent(torrent, videoElement, resolve, reject) {
const file = torrent.files.find((f) =>
/\.(mp4|webm|mkv)$/.test(f.name.toLowerCase())
);
if (!file) {
return reject(new Error("No compatible video file found in torrent"));
}
// Satisfy autoplay requirements and keep cross-origin chunks usable (e.g., for snapshots).
videoElement.crossOrigin = "anonymous";
videoElement.addEventListener("error", (e) => {
this.log("Video error (Firefox path):", e.target.error);
});
const tryStart = () => {
this.attemptAutoplay(videoElement, "firefox");
};
videoElement.addEventListener("canplay", tryStart, { once: true });
videoElement.addEventListener("loadeddata", tryStart, { once: true });
try {
file.streamTo(videoElement, { highWaterMark: 256 * 1024 });
// If the video is already ready (e.g. from cache or fast load), try starting immediately
if (videoElement.readyState >= 3) {
tryStart();
}
this.currentTorrent = torrent;
resolve(torrent);
} catch (err) {
this.log("Streaming error (Firefox path):", err);
reject(err);
}
torrent.on("error", (err) => {
this.log("Torrent error (Firefox path):", err);
this.log(`Torrent error (${context} path):`, err);
reject(err);
});
}
@@ -932,14 +905,14 @@ export class TorrentClient {
{ ...chromeOptions, maxWebConns: 4 },
(torrent) => {
this.log("Torrent added (Firefox path):", torrent.name);
this.handleFirefoxTorrent(torrent, videoElement, resolve, reject);
this.handleTorrentStream(torrent, videoElement, resolve, reject, "firefox");
}
);
} else {
this.log("Starting torrent download (Chrome path)");
this.client.add(magnetURI, chromeOptions, (torrent) => {
this.log("Torrent added (Chrome path):", torrent.name);
this.handleChromeTorrent(torrent, videoElement, resolve, reject);
this.handleTorrentStream(torrent, videoElement, resolve, reject, "chrome");
});
}
});
+94
View File
@@ -0,0 +1,94 @@
import { JSDOM } from 'jsdom';
import { performance } from 'perf_hooks';
import * as NostrTools from 'nostr-tools';
const dom = new JSDOM(`<!DOCTYPE html><body><div id="channelVideoList"></div></body>`, {
url: "http://localhost/#view=channel-profile&npub=npub1test"
});
global.window = dom.window;
global.self = dom.window;
global.document = dom.window.document;
global.HTMLElement = dom.window.HTMLElement;
global.NodeList = dom.window.NodeList;
global.URLSearchParams = dom.window.URLSearchParams;
global.URL = dom.window.URL;
global.Blob = dom.window.Blob;
global.TextEncoder = dom.window.TextEncoder;
global.TextDecoder = dom.window.TextDecoder;
// global.crypto = dom.window.crypto; // Node has crypto
global.window.NostrTools = NostrTools;
// Mock requestIdleCallback
global.requestIdleCallback = (cb) => setTimeout(cb, 0);
global.cancelIdleCallback = (id) => clearTimeout(id);
global.localStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {}
};
// We need to mock AccessControl because it's called in refreshActiveChannelVideoGrid
// accessControl.ensureReady()
// It imports accessControl from js/accessControl.js
// We can try to rely on its default behavior or mock the module if necessary.
// But importing real modules is easier if they don't have heavy side effects.
// Import nostrClient
const { nostrClient } = await import('../js/nostrClientFacade.js');
// Import channelProfile
const channelProfile = await import('../js/channelProfile.js');
async function runBenchmark() {
console.log("Setting up benchmark...");
nostrClient.pool = {
list: async () => [],
ensureRelay: async () => {},
get: async () => null,
};
nostrClient.relays = ["wss://relay.example.com"];
// Populate rawEvents with many events
const eventCount = 1000000;
const targetPubkey = "0000000000000000000000000000000000000000000000000000000000000001";
console.log(`Populating ${eventCount} events...`);
const rawEvents = nostrClient.rawEvents;
for (let i = 0; i < eventCount; i++) {
const id = i.toString(16).padStart(64, '0');
// Mix of target events and noise
const isTarget = i % 1000 === 0;
const pubkey = isTarget ? targetPubkey : id; // Unique pubkey for noise
const event = {
id,
pubkey,
kind: 30078,
created_at: Math.floor(Date.now() / 1000) - i,
content: "{}",
tags: []
};
rawEvents.set(id, event);
}
console.log(`Populated. Raw events size: ${rawEvents.size}`);
// Setup test state
channelProfile.__setChannelProfileTestState({ pubkey: targetPubkey });
// Benchmark
console.log("Starting benchmark...");
const start = performance.now();
// We call refreshActiveChannelVideoGrid which triggers the scan
await channelProfile.refreshActiveChannelVideoGrid();
const end = performance.now();
console.log(`refreshActiveChannelVideoGrid took ${(end - start).toFixed(2)}ms`);
}
runBenchmark().catch(console.error);
+76
View File
@@ -133,3 +133,79 @@ test("batchFetchProfiles handles fast and failing relays", async () => {
{ pubkey: VALID_PUBKEY, profile: { name: "Fast", picture: "fast.png" } },
]);
});
test("batchFetchProfiles respects forceRefresh", async () => {
const cache = new Map();
const updates = [];
const setCalls = [];
const authorSet = new Set([
CACHED_PUBKEY,
]);
const getProfileCacheEntry = (pubkey) => {
if (pubkey === CACHED_PUBKEY) {
return { profile: { name: "Cached", picture: "cached.png" } };
}
return null;
};
const setProfileCacheEntry = (pubkey, profile) => {
cache.set(pubkey, { profile });
setCalls.push({ pubkey, profile });
};
const updateProfileInDOM = (pubkey, profile) => {
updates.push({ pubkey, profile });
};
const originalPool = nostrClient.pool;
const poolListCalls = [];
nostrClient.pool = {
list: (relays, filters) => {
poolListCalls.push({ relays, filters });
return Promise.resolve([
createProfileEvent({
pubkey: CACHED_PUBKEY,
createdAt: 300,
content: { name: "Refreshed", picture: "refreshed.png" },
}),
]);
},
};
try {
await batchFetchProfilesFromRelays({
authorSet,
forceRefresh: true,
getProfileCacheEntry,
setProfileCacheEntry,
updateProfileInDOM,
});
} finally {
nostrClient.pool = originalPool;
}
// Should fetch despite cache presence
assert.ok(poolListCalls.length > 0, "should query relays when forceRefresh is true");
assert.deepEqual(
poolListCalls[0].filters[0].authors,
[CACHED_PUBKEY],
"should include cached pubkey in fetch list"
);
// Should still optimistic update from cache first
assert.deepEqual(
updates[0],
{ pubkey: CACHED_PUBKEY, profile: { name: "Cached", picture: "cached.png" } },
"cached profiles should hydrate immediately even with forceRefresh"
);
// Then update with fresh data
const lastUpdate = updates[updates.length - 1];
assert.deepEqual(
lastUpdate,
{ pubkey: CACHED_PUBKEY, profile: { name: "Refreshed", picture: "refreshed.png" } },
"should update with refreshed data"
);
});
+87
View File
@@ -0,0 +1,87 @@
import { test, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createFeedCoordinator } from '../../js/app/feedCoordinator.js';
import { FEED_TYPES } from '../../js/constants.js';
test('createFeedCoordinator - loadForYouVideos', async (t) => {
const mockDeps = {
devLogger: { log: mock.fn(), warn: mock.fn() },
userLogger: { warn: mock.fn() },
nostrClient: {},
getSidebarLoadingMarkup: mock.fn(() => '<div>Loading...</div>'),
watchHistoryService: {},
subscriptions: {},
createActiveNostrSource: mock.fn(),
createBlacklistFilterStage: mock.fn(),
createDisinterestFilterStage: mock.fn(),
createDedupeByRootStage: mock.fn(),
createExploreDiversitySorter: mock.fn(),
createExploreScorerStage: mock.fn(),
createKidsAudienceFilterStage: mock.fn(),
createKidsScorerStage: mock.fn(),
createKidsScoreSorter: mock.fn(),
createModerationStage: mock.fn(),
createResolvePostedAtStage: mock.fn(),
createTagPreferenceFilterStage: mock.fn(),
createWatchHistorySuppressionStage: mock.fn(),
createChronologicalSorter: mock.fn(),
createSubscriptionAuthorsSource: mock.fn(),
registerWatchHistoryFeed: mock.fn(),
};
const coordinator = createFeedCoordinator(mockDeps);
// Mock application context
const app = {
...coordinator,
nostrService: {
getFilteredActiveVideos: mock.fn(() => []), // Simulate no cached videos
loadVideos: mock.fn(async ({ onVideos }) => {
// Simulate fetch completing with videos
onVideos([], { reason: 'test-reason' });
return [];
}),
getVideoSubscription: mock.fn(() => 'mock-subscription'),
getVideosMap: mock.fn(() => new Map()),
},
videoListView: {
showLoading: mock.fn(),
state: {},
},
mountVideoListView: mock.fn(() => ({ innerHTML: '' })),
checkRelayHealthWarning: mock.fn(),
setFeedTelemetryContext: mock.fn(),
isAuthorBlocked: mock.fn(() => false),
refreshFeed: mock.fn(async () => {}),
blacklistedEventIds: new Set(),
};
// Bind methods to app context
const loadForYouVideos = app.loadForYouVideos.bind(app);
await t.test('loadForYouVideos executes successfully', async () => {
await loadForYouVideos(false);
// Verify devLogger was called
assert.strictEqual(mockDeps.devLogger.log.mock.callCount() > 0, true, 'devLogger.log should be called');
// Verify telemetry context set
assert.strictEqual(app.setFeedTelemetryContext.mock.calls[0].arguments[0], FEED_TYPES.FOR_YOU, 'Should set telemetry context to for-you');
// Verify mountVideoListView called with includeTags: false
assert.deepStrictEqual(app.mountVideoListView.mock.calls[0].arguments[0], { includeTags: false }, 'Should mount list view with includeTags: false');
// Verify checkRelayHealthWarning called
assert.strictEqual(app.checkRelayHealthWarning.mock.callCount(), 1, 'Should check relay health warning');
// Verify nostrService.loadVideos called
assert.strictEqual(app.nostrService.loadVideos.mock.callCount(), 1, 'Should call nostrService.loadVideos');
// Verify refreshFeed called via callback
assert.strictEqual(app.refreshFeed.mock.callCount(), 1, 'Should call refreshFeed via callback');
assert.strictEqual(app.refreshFeed.mock.calls[0].arguments[0], FEED_TYPES.FOR_YOU, 'Should call refreshFeed with FOR_YOU');
// Verify subscription updated
assert.strictEqual(app.videoSubscription, 'mock-subscription', 'Should update videoSubscription');
});
});
+91
View File
@@ -0,0 +1,91 @@
import { test, describe, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
import { createNip07SigningAdapter } from "../../js/auth/signingAdapter.js";
describe("createNip07SigningAdapter", () => {
let originalWindow;
beforeEach(() => {
// Preserve original window if it exists (for safety, though usually undefined in node)
if (typeof globalThis.window !== "undefined") {
originalWindow = globalThis.window;
}
});
afterEach(() => {
// Restore window
if (originalWindow) {
globalThis.window = originalWindow;
} else {
delete globalThis.window;
}
});
test("uses explicit extension if provided", async () => {
const mockExtension = {
getPublicKey: mock.fn(async () => "explicit_pubkey"),
signEvent: mock.fn(async (event) => ({ ...event, sig: "signature" })),
};
const adapter = createNip07SigningAdapter({ extension: mockExtension });
const pubkey = await adapter.getPubkey();
assert.strictEqual(pubkey, "explicit_pubkey");
assert.strictEqual(mockExtension.getPublicKey.mock.calls.length, 1);
const event = { id: "123" };
const signed = await adapter.signEvent(event);
assert.strictEqual(signed.sig, "signature");
assert.strictEqual(mockExtension.signEvent.mock.calls.length, 1);
assert.deepStrictEqual(mockExtension.signEvent.mock.calls[0].arguments[0], event);
});
test("falls back to window.nostr if no extension provided", async () => {
const mockWindowNostr = {
getPublicKey: mock.fn(async () => "window_pubkey"),
signEvent: mock.fn(async (event) => ({ ...event, sig: "window_sig" })),
};
globalThis.window = { nostr: mockWindowNostr };
const adapter = createNip07SigningAdapter();
const pubkey = await adapter.getPubkey();
assert.strictEqual(pubkey, "window_pubkey");
assert.strictEqual(mockWindowNostr.getPublicKey.mock.calls.length, 1);
});
test("throws error if no extension available", async () => {
globalThis.window = {}; // No nostr
const adapter = createNip07SigningAdapter();
await assert.rejects(
async () => adapter.getPubkey(),
{ message: "NIP-07 extension unavailable." }
);
await assert.rejects(
async () => adapter.signEvent({}),
{ message: "NIP-07 extension unavailable." }
);
});
test("prioritizes explicit extension over window.nostr", async () => {
const mockExplicit = {
getPublicKey: mock.fn(async () => "explicit"),
};
const mockWindow = {
getPublicKey: mock.fn(async () => "window"),
};
globalThis.window = { nostr: mockWindow };
const adapter = createNip07SigningAdapter({ extension: mockExplicit });
const pubkey = await adapter.getPubkey();
assert.strictEqual(pubkey, "explicit");
assert.strictEqual(mockExplicit.getPublicKey.mock.calls.length, 1);
assert.strictEqual(mockWindow.getPublicKey.mock.calls.length, 0);
});
});
+276
View File
@@ -0,0 +1,276 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { JSDOM } from 'jsdom';
import {
registerScope,
setVariables,
releaseScope,
getScopeAttributeName
} from '../../js/designSystem/dynamicStyles.js';
describe('js/designSystem/dynamicStyles.js', () => {
let dom;
let document;
let window;
let originalCSSStyleSheet;
beforeEach(() => {
dom = new JSDOM('<!DOCTYPE html><html><head></head><body></body></html>', {
url: 'http://localhost/',
pretendToBeVisual: true,
});
window = dom.window;
document = window.document;
originalCSSStyleSheet = globalThis.CSSStyleSheet;
// Polyfill global CSSStyleSheet for constructable stylesheets support if needed
// JSDOM supports it, but we need to expose it globally for the module to detect it
if (!globalThis.CSSStyleSheet) {
globalThis.CSSStyleSheet = window.CSSStyleSheet;
}
});
afterEach(() => {
// Clean up
dom = null;
document = null;
window = null;
globalThis.CSSStyleSheet = originalCSSStyleSheet;
});
describe('registerScope', () => {
it('should register a scope and return a string ID', () => {
const scopeId = registerScope('test-scope', [], { documentRef: document });
assert.equal(typeof scopeId, 'string');
assert.ok(scopeId.startsWith('test-scope'));
});
it('should create a style element or use adoptedStyleSheets', () => {
const scopeId = registerScope('test-scope', [], { documentRef: document });
// Check for style element fallback or adoptedStyleSheets
const styleElement = document.querySelector('style[data-ds-dynamic="true"]');
const adopted = document.adoptedStyleSheets && document.adoptedStyleSheets.length > 0;
assert.ok(styleElement || adopted, 'Should use either <style> or adoptedStyleSheets');
});
it('should insert rules with the correct selector', () => {
const scopeId = registerScope('test-scope', [':scope', '.child'], { documentRef: document });
const attr = getScopeAttributeName();
// Helper to find rule
const findRule = (selectorPart) => {
// Check style element
const styleElement = document.querySelector('style[data-ds-dynamic="true"]');
if (styleElement && styleElement.sheet) {
for (const rule of styleElement.sheet.cssRules) {
if (rule.selectorText.includes(selectorPart)) return rule;
}
}
// Check adopted sheets
if (document.adoptedStyleSheets) {
for (const sheet of document.adoptedStyleSheets) {
for (const rule of sheet.cssRules) {
if (rule.selectorText.includes(selectorPart)) return rule;
}
}
}
return null;
};
const scopeSelector = `[${attr}="${scopeId}"]`;
assert.ok(findRule(scopeSelector), 'Should have rule for :scope');
assert.ok(findRule(`${scopeSelector} .child`), 'Should have rule for .child');
});
it('should handle "&" in selectors', () => {
const scopeId = registerScope('amp-test', ['&.active'], { documentRef: document });
const attr = getScopeAttributeName();
const expectedSelector = `[${attr}="${scopeId}"].active`;
// Verify rule existence
let found = false;
const checkSheet = (sheet) => {
for (const rule of sheet.cssRules) {
// Note: selectorText might be normalized by JSDOM/browser
if (rule.selectorText === expectedSelector) found = true;
}
};
const styleElement = document.querySelector('style[data-ds-dynamic="true"]');
if (styleElement?.sheet) checkSheet(styleElement.sheet);
if (document.adoptedStyleSheets) document.adoptedStyleSheets.forEach(checkSheet);
assert.ok(found, `Rule for ${expectedSelector} should be found`);
});
});
describe('setVariables', () => {
it('should update CSS variables on the scope', () => {
const scopeId = registerScope('var-test', [], { documentRef: document });
const vars = { '--color': 'red', '--size': '10px' };
const success = setVariables(scopeId, vars);
assert.equal(success, true, 'setVariables should return true');
// Verify style property
// We need to access the rule.
// Since we can't easily export the internal registry, we inspect the DOM/OM.
let ruleStyle = null;
const checkSheet = (sheet) => {
for (const rule of sheet.cssRules) {
if (rule.selectorText.includes(scopeId)) {
ruleStyle = rule['style'];
}
}
};
const styleElement = document.querySelector('style[data-ds-dynamic="true"]');
if (styleElement?.sheet) checkSheet(styleElement.sheet);
if (document.adoptedStyleSheets) document.adoptedStyleSheets.forEach(checkSheet);
assert.ok(ruleStyle, 'Rule style should be accessible');
assert.equal(ruleStyle.getPropertyValue('--color'), 'red');
assert.equal(ruleStyle.getPropertyValue('--size'), '10px');
});
it('should handle removing variables', () => {
const scopeId = registerScope('var-remove', [], { documentRef: document });
setVariables(scopeId, { '--temp': '123' });
setVariables(scopeId, { '--temp': null });
let ruleStyle = null;
// ... find rule style again (reuse helper logic ideally)
const checkSheet = (sheet) => {
for (const rule of sheet.cssRules) {
if (rule.selectorText.includes(scopeId)) {
ruleStyle = rule['style'];
}
}
};
const styleElement = document.querySelector('style[data-ds-dynamic="true"]');
if (styleElement?.sheet) checkSheet(styleElement.sheet);
if (document.adoptedStyleSheets) document.adoptedStyleSheets.forEach(checkSheet);
assert.equal(ruleStyle.getPropertyValue('--temp'), '');
});
});
describe('releaseScope', () => {
it('should remove rules and clean up', () => {
const scopeId = registerScope('release-test', [], { documentRef: document });
// Verify exists
let foundBefore = false;
const checkSheet = (sheet) => {
for (const rule of sheet.cssRules) {
if (rule.selectorText.includes(scopeId)) foundBefore = true;
}
};
const styleElement = document.querySelector('style[data-ds-dynamic="true"]');
if (styleElement?.sheet) checkSheet(styleElement.sheet);
if (document.adoptedStyleSheets) document.adoptedStyleSheets.forEach(checkSheet);
assert.ok(foundBefore, 'Rule should exist before release');
const released = releaseScope(scopeId);
assert.equal(released, true, 'releaseScope should return true');
// Verify removed
let foundAfter = false;
const checkSheetAfter = (sheet) => {
for (const rule of sheet.cssRules) {
if (rule.selectorText.includes(scopeId)) foundAfter = true;
}
};
if (styleElement?.sheet) checkSheetAfter(styleElement.sheet);
if (document.adoptedStyleSheets) document.adoptedStyleSheets.forEach(checkSheetAfter);
assert.equal(foundAfter, false, 'Rule should be removed after release');
});
it('should return false for non-existent scope', () => {
const result = releaseScope('non-existent-scope');
assert.equal(result, false);
});
});
describe('Isolation', () => {
it('should manage scopes independently for different documents', () => {
const dom2 = new JSDOM('<!DOCTYPE html><html><body></body></html>');
const doc2 = dom2.window.document;
// Polyfill if needed for doc2 environment
// But since we run in Node, global CSSStyleSheet is shared if we set it on globalThis.
const id1 = registerScope('iso', [], { documentRef: document });
const id2 = registerScope('iso', [], { documentRef: doc2 });
// They might get different IDs because registerScope checks global SCOPE_REGISTRY.
// But we just want to ensure they don't crash and rules are in respective docs.
// Check doc1
const style1 = document.querySelector('style[data-ds-dynamic="true"]');
const adopted1 = document.adoptedStyleSheets?.length > 0;
assert.ok(style1 || adopted1, 'Doc1 should have styles');
// Check doc2
const style2 = doc2.querySelector('style[data-ds-dynamic="true"]');
const adopted2 = doc2.adoptedStyleSheets?.length > 0;
assert.ok(style2 || adopted2, 'Doc2 should have styles');
// Clean up
releaseScope(id1);
releaseScope(id2);
});
});
describe('Edge cases', () => {
it('should handle invalid selectors gracefully', () => {
// It might just produce invalid CSS which insertRule might throw or ignore.
// dynamicStyles.js catches errors in insertEmptyRule.
const scopeId = registerScope('invalid', ['::::invalid'], { documentRef: document });
assert.equal(typeof scopeId, 'string');
// No crash
});
it('should handle missing document gracefully', () => {
// Passing null documentRef, falls back to global document.
// In test env, global document might be undefined unless set.
// If undefined, ensureManager returns null.
// registerScope returns null.
// Temporarily hide global document if it exists
const originalDoc = globalThis.document;
delete globalThis.document;
const result = registerScope('no-doc', [], { documentRef: null });
assert.equal(result, null);
globalThis.document = originalDoc;
});
it('should fallback to <style> element if CSSStyleSheet is missing', () => {
// Unset CSSStyleSheet to force fallback
globalThis.CSSStyleSheet = undefined;
// Also ensure JSDOM instance simulates lack of support if possible?
// dynamicStyles.js checks globalThis.CSSStyleSheet.
// We need a fresh document because ensureManager caches the manager per document.
const domFallback = new JSDOM('<!DOCTYPE html><html><head></head><body></body></html>');
const docFallback = domFallback.window.document;
const scopeId = registerScope('fallback', [], { documentRef: docFallback });
assert.ok(scopeId);
const styleElement = docFallback.querySelector('style[data-ds-dynamic="true"]');
assert.ok(styleElement, 'Should use <style> element');
assert.ok(styleElement.sheet, 'Style element should have a sheet');
// adoptedStyleSheets should not be used (or empty/not modified by us)
const adopted = docFallback.adoptedStyleSheets;
// JSDOM might initialize it as empty array.
// We just ensure we found the style element.
});
});
});
+221
View File
@@ -0,0 +1,221 @@
import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import { prioritizeEntries } from "../js/gridHealthLogic.js";
function setupDom() {
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
globalThis.window = dom.window;
globalThis.HTMLElement = dom.window.HTMLElement;
return dom;
}
function teardownDom(dom) {
dom.window.close();
delete globalThis.window;
delete globalThis.HTMLElement;
}
test("prioritizeEntries input validation", (t) => {
const dom = setupDom();
t.after(() => teardownDom(dom));
assert.deepEqual(prioritizeEntries(null), [], "should return empty array for null entries");
assert.deepEqual(prioritizeEntries(undefined), [], "should return empty array for undefined entries");
assert.deepEqual(prioritizeEntries([]), [], "should return empty array for empty entries");
});
test("prioritizeEntries filtering", (t) => {
const dom = setupDom();
t.after(() => teardownDom(dom));
const entries = [
{ isIntersecting: false, target: dom.window.document.createElement("div") },
{ isIntersecting: true, target: {} }, // Not an HTMLElement
{ isIntersecting: true, target: dom.window.document.createElement("div"), intersectionRect: { width: 0, height: 0 } }, // Invalid rect
];
const result = prioritizeEntries(entries, { x: 500, y: 500 });
assert.deepEqual(result, [], "should filter out invalid entries");
});
test("prioritizeEntries without viewport center", (t) => {
const dom = setupDom();
t.after(() => teardownDom(dom));
// prioritizeEntries sorts by ratio desc, then centerY asc
// Note: the implementation sorts by ratio desc (b.ratio - a.ratio), then a.centerY - b.centerY
const entries = [
{
isIntersecting: true,
target: dom.window.document.createElement("div"),
intersectionRect: { top: 100, height: 100, width: 100 }, // centerY = 150
intersectionRatio: 0.5,
id: 1
},
{
isIntersecting: true,
target: dom.window.document.createElement("div"),
intersectionRect: { top: 200, height: 100, width: 100 }, // centerY = 250
intersectionRatio: 0.8,
id: 2
},
{
isIntersecting: true,
target: dom.window.document.createElement("div"),
intersectionRect: { top: 0, height: 100, width: 100 }, // centerY = 50
intersectionRatio: 0.5,
id: 3
},
];
const result = prioritizeEntries(entries, null);
assert.equal(result.length, 3);
// Highest ratio first (id: 2)
assert.equal(result[0].entry.id, 2);
// Equal ratio (0.5), sort by centerY asc (id: 3 (50) < id: 1 (150))
assert.equal(result[1].entry.id, 3);
assert.equal(result[2].entry.id, 1);
});
test("prioritizeEntries with viewport center", (t) => {
const dom = setupDom();
t.after(() => teardownDom(dom));
const viewportCenter = { x: 500, y: 500 };
const entries = [
{
isIntersecting: true,
target: dom.window.document.createElement("div"),
intersectionRect: { top: 450, height: 100, width: 100 }, // centerY = 500 (distance 0)
intersectionRatio: 1.0,
id: "center"
},
{
isIntersecting: true,
target: dom.window.document.createElement("div"),
intersectionRect: { top: 350, height: 100, width: 100 }, // centerY = 400 (distance 100)
intersectionRatio: 1.0,
id: "above"
},
{
isIntersecting: true,
target: dom.window.document.createElement("div"),
intersectionRect: { top: 550, height: 100, width: 100 }, // centerY = 600 (distance 100)
intersectionRatio: 1.0,
id: "below"
},
{
isIntersecting: true,
target: dom.window.document.createElement("div"),
intersectionRect: { top: 0, height: 100, width: 100 }, // centerY = 50 (distance 450)
intersectionRatio: 0.5,
id: "far_above"
}
];
const result = prioritizeEntries(entries, viewportCenter);
assert.equal(result.length, 4);
// The closest to center should be first
assert.equal(result[0].entry.id, "center");
// The logic for left/right (above/below) depends on implementation details
// but "above" and "below" are equidistant.
// The implementation sorts by centerY first: above (400), center (500), below (600), far_above(50) -> far_above, above, center, below
// centerIndex is found (min distance).
// Then it expands left/right.
// Let's trace:
// Ordered by Y: far_above (50), above (400), center (500), below (600)
// minDistance is center (0). centerIndex = 2.
// result: [center]
// left = 1 (above), right = 3 (below)
// leftDist = 100, rightDist = 100.
// delta = 0 <= 0.5.
// rightRatio (1.0) > leftRatio (1.0) -> False.
// else -> push left (above), left--
// result: [center, above]
// left = 0 (far_above), right = 3 (below)
// leftDist = 450, rightDist = 100.
// leftDist > rightDist -> push right (below), right++
// result: [center, above, below]
// left = 0 (far_above), right = 4 (out)
// push left (far_above)
// result: [center, above, below, far_above]
assert.equal(result[1].entry.id, "above");
assert.equal(result[2].entry.id, "below");
assert.equal(result[3].entry.id, "far_above");
});
test("prioritizeEntries ratio prioritization when distances are similar", (t) => {
const dom = setupDom();
t.after(() => teardownDom(dom));
const viewportCenter = { x: 500, y: 500 };
const entries = [
{
isIntersecting: true,
target: dom.window.document.createElement("div"),
intersectionRect: { top: 450, height: 100, width: 100 }, // centerY = 500 (distance 0)
intersectionRatio: 1.0,
id: "center"
},
{
isIntersecting: true,
target: dom.window.document.createElement("div"),
intersectionRect: { top: 350, height: 100, width: 100 }, // centerY = 400 (distance 100)
intersectionRatio: 0.5,
id: "above_low_ratio"
},
{
isIntersecting: true,
target: dom.window.document.createElement("div"),
intersectionRect: { top: 550, height: 100, width: 100 }, // centerY = 600 (distance 100)
intersectionRatio: 0.9,
id: "below_high_ratio"
}
];
// Logic check:
// Ordered by Y: above_low_ratio (400), center (500), below_high_ratio (600)
// centerIndex = 1 (center)
// left = 0 (above), right = 2 (below)
// Distances: 100 vs 100. Delta = 0.
// rightRatio (0.9) > leftRatio (0.5) -> True.
// Push right (below)
const result = prioritizeEntries(entries, viewportCenter);
assert.equal(result[0].entry.id, "center");
assert.equal(result[1].entry.id, "below_high_ratio");
assert.equal(result[2].entry.id, "above_low_ratio");
});
test("prioritizeEntries uses boundingClientRect fallback", (t) => {
const dom = setupDom();
t.after(() => teardownDom(dom));
const viewportCenter = { x: 500, y: 500 };
const entries = [
{
isIntersecting: true,
target: dom.window.document.createElement("div"),
// No intersectionRect
boundingClientRect: { top: 450, height: 100, width: 100 },
intersectionRatio: 1.0,
id: "fallback"
}
];
const result = prioritizeEntries(entries, viewportCenter);
assert.equal(result.length, 1);
assert.equal(result[0].entry.id, "fallback");
});
+41
View File
@@ -203,6 +203,47 @@ export async function createModerationAppHarness(options = {}) {
const Application = await getApplicationClass();
const app = Object.create(Application.prototype);
app.playbackService = {
_currentVideo: null,
get currentVideo() {
return this._currentVideo;
},
set currentVideo(value) {
this._currentVideo = value;
},
};
app.authService = {
_pubkey: null,
_currentUserNpub: null,
_activeProfilePubkey: null,
_savedProfiles: [],
get pubkey() {
return this._pubkey;
},
set pubkey(value) {
this._pubkey = value;
},
get currentUserNpub() {
return this._currentUserNpub;
},
set currentUserNpub(value) {
this._currentUserNpub = value;
},
get activeProfilePubkey() {
return this._activeProfilePubkey;
},
setActiveProfilePubkey(value) {
this._activeProfilePubkey = value;
},
get savedProfiles() {
return this._savedProfiles;
},
setSavedProfiles(value) {
this._savedProfiles = value;
},
};
Object.defineProperty(app, "__profiles", {
value: new Map(),
writable: true,
+182
View File
@@ -0,0 +1,182 @@
import { describe, it, before, after, afterEach, beforeEach } from "node:test";
import assert from "node:assert/strict";
import "../test-setup.mjs";
describe("countDiagnostics", () => {
let countDiagnostics;
let warnCalls = [];
let originalConsoleWarn;
before(async () => {
// Setup environment before importing modules
globalThis.__BITVID_DEV_MODE_OVERRIDE__ = true;
globalThis.__BITVID_VERBOSE_DEV_MODE_OVERRIDE__ = true;
// Mock console.warn
originalConsoleWarn = console.warn;
console.warn = (...args) => {
warnCalls.push(args);
// originalConsoleWarn(...args); // Uncomment to see logs
};
// Dynamic import to pick up the overrides
countDiagnostics = await import("../../js/nostr/countDiagnostics.js");
});
after(() => {
if (originalConsoleWarn) {
console.warn = originalConsoleWarn;
}
});
afterEach(() => {
warnCalls = [];
// Reset window flags between tests if needed, though most tests set them explicitly
if (typeof window !== "undefined") {
delete window.__BITVID_VERBOSE_DEV_MODE__;
}
});
describe("isVerboseDiagnosticsEnabled", () => {
it("should return true by default (due to override)", () => {
assert.equal(countDiagnostics.isVerboseDiagnosticsEnabled(), true);
});
it("should return false if window.__BITVID_VERBOSE_DEV_MODE__ is false", () => {
window.__BITVID_VERBOSE_DEV_MODE__ = false;
assert.equal(countDiagnostics.isVerboseDiagnosticsEnabled(), false);
});
it("should return true if window.__BITVID_VERBOSE_DEV_MODE__ is true", () => {
window.__BITVID_VERBOSE_DEV_MODE__ = true;
assert.equal(countDiagnostics.isVerboseDiagnosticsEnabled(), true);
});
it("should fall back to isVerboseDevMode if window flag is not boolean", () => {
window.__BITVID_VERBOSE_DEV_MODE__ = "invalid";
assert.equal(countDiagnostics.isVerboseDiagnosticsEnabled(), true);
window.__BITVID_VERBOSE_DEV_MODE__ = 123;
assert.equal(countDiagnostics.isVerboseDiagnosticsEnabled(), true);
});
});
describe("logRelayCountFailure", () => {
it("should log warning for new relay URL", () => {
const relayUrl = "wss://relay.example.com";
const error = new Error("Connection failed");
countDiagnostics.logRelayCountFailure(relayUrl, error);
assert.equal(warnCalls.length, 1);
assert.match(warnCalls[0][0], /COUNT request failed on wss:\/\/relay.example.com/);
assert.deepEqual(warnCalls[0][1], error);
});
it("should suppress 'Failed to connect to relay' errors", () => {
const relayUrl = "wss://relay.bad.com";
const error = new Error("Failed to connect to relay wss://relay.bad.com");
countDiagnostics.logRelayCountFailure(relayUrl, error);
assert.equal(warnCalls.length, 0);
});
it("should throttle duplicate warnings for the same relay", () => {
const relayUrl = "wss://relay.throttled.com";
const error = new Error("Timeout");
// First call logs
countDiagnostics.logRelayCountFailure(relayUrl, error);
assert.equal(warnCalls.length, 1);
// Second call suppresses
countDiagnostics.logRelayCountFailure(relayUrl, error);
assert.equal(warnCalls.length, 1);
});
it("should handle empty or non-string relay URLs", () => {
const error = new Error("Unknown error");
countDiagnostics.logRelayCountFailure(null, error);
assert.equal(warnCalls.length, 1);
assert.match(warnCalls[0][0], /COUNT request failed on \(unknown relay\)/);
warnCalls = [];
// This call should be throttled because both null and "" map to "(unknown relay)"
countDiagnostics.logRelayCountFailure("", error);
assert.equal(warnCalls.length, 0);
});
});
describe("Other Loggers", () => {
// These use fixed keys so we can only test them once for "logging" behavior per process/suite run
it("should log timeout cleanup failure once", () => {
const error = new Error("Cleanup failed");
countDiagnostics.logCountTimeoutCleanupFailure(error);
assert.equal(warnCalls.length, 1);
assert.match(warnCalls[0][0], /COUNT timeout cleanup failed/);
// Verify throttling immediately
countDiagnostics.logCountTimeoutCleanupFailure(error);
assert.equal(warnCalls.length, 1);
});
it("should log rebroadcast failure once", () => {
const error = new Error("Rebroadcast failed");
countDiagnostics.logRebroadcastCountFailure(error);
assert.equal(warnCalls.length, 1);
assert.match(warnCalls[0][0], /COUNT request for rebroadcast failed/);
// Verify throttling
countDiagnostics.logRebroadcastCountFailure(error);
assert.equal(warnCalls.length, 1);
});
it("should log view count failure once", () => {
const error = new Error("View count failed");
countDiagnostics.logViewCountFailure(error);
assert.equal(warnCalls.length, 1);
assert.match(warnCalls[0][0], /COUNT view request failed/);
// Verify throttling
countDiagnostics.logViewCountFailure(error);
assert.equal(warnCalls.length, 1);
});
});
describe("Verbose Mode Disabled", () => {
beforeEach(() => {
window.__BITVID_VERBOSE_DEV_MODE__ = false;
});
it("should not log even for new keys when disabled", () => {
const relayUrl = "wss://relay.silent.com";
const error = new Error("Silent error");
countDiagnostics.logRelayCountFailure(relayUrl, error);
assert.equal(warnCalls.length, 0);
});
it("should not consume throttle key when disabled", () => {
const relayUrl = "wss://relay.delayed.com";
const error = new Error("Delayed error");
// Disabled: no log, key not added
countDiagnostics.logRelayCountFailure(relayUrl, error);
assert.equal(warnCalls.length, 0);
// Enable verbose mode
window.__BITVID_VERBOSE_DEV_MODE__ = true;
// Should log now because previous call didn't register key
countDiagnostics.logRelayCountFailure(relayUrl, error);
assert.equal(warnCalls.length, 1);
assert.match(warnCalls[0][0], /COUNT request failed on wss:\/\/relay.delayed.com/);
});
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, it, after } from "node:test";
import assert from "node:assert/strict";
import "../test-setup.mjs";
// Mock dependencies before importing the module under test
if (!globalThis.WebSocket) {
globalThis.WebSocket = class MockWebSocket {
send() {}
close() {}
};
}
if (!globalThis.localStorage) {
globalThis.localStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {},
};
}
// Set dev mode override
globalThis.__BITVID_DEV_MODE_OVERRIDE__ = true;
// Mock console to suppress logs during tests
const originalConsole = { ...console };
globalThis.console = {
...console,
log: () => {},
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
};
// Import modules dynamically after setting up mocks
const { nostrClient } = await import("../../js/nostr/defaultClient.js");
const { DEFAULT_RELAY_URLS } = await import("../../js/nostr/toolkit.js");
const { getRegisteredNostrClient } = await import("../../js/nostrClientRegistry.js");
const { NostrClient } = await import("../../js/nostr/client.js");
describe("Default NostrClient", () => {
after(() => {
// Restore console
globalThis.console = originalConsole;
// Clean up globals if desired, though process isolation makes this optional
delete globalThis.__BITVID_DEV_MODE_OVERRIDE__;
});
it("should be an instance of NostrClient", () => {
assert.ok(nostrClient instanceof NostrClient);
});
it("should be configured with default relays", () => {
const expectedRelays = Array.from(DEFAULT_RELAY_URLS);
assert.deepEqual(nostrClient.relays, expectedRelays);
});
it("should be initialized with default read relays", () => {
const expectedRelays = Array.from(DEFAULT_RELAY_URLS);
assert.deepEqual(nostrClient.readRelays, expectedRelays);
});
it("should be initialized with default write relays", () => {
const expectedRelays = Array.from(DEFAULT_RELAY_URLS);
assert.deepEqual(nostrClient.writeRelays, expectedRelays);
});
it("should be registered as the default client", () => {
const registered = getRegisteredNostrClient();
assert.strictEqual(registered, nostrClient);
});
});
+149
View File
@@ -0,0 +1,149 @@
import assert from "node:assert/strict";
import { test, describe, it } from "node:test";
import { summarizeDmEventForLog, sanitizeDecryptError } from "../../js/nostr/dmDecryptDiagnostics.js";
describe("dmDecryptDiagnostics", () => {
describe("summarizeDmEventForLog", () => {
it("should return a default object when event is null or undefined", () => {
const expected = {
kind: null,
createdAt: null,
hasContent: false,
contentLength: 0,
tagCount: 0,
};
assert.deepEqual(summarizeDmEventForLog(null), expected);
assert.deepEqual(summarizeDmEventForLog(undefined), expected);
});
it("should return a default object when event is not an object", () => {
const expected = {
kind: null,
createdAt: null,
hasContent: false,
contentLength: 0,
tagCount: 0,
};
assert.deepEqual(summarizeDmEventForLog("invalid"), expected);
assert.deepEqual(summarizeDmEventForLog(123), expected);
});
it("should summarize a valid event correctly", () => {
const event = {
kind: 4,
created_at: 1678901234.567,
content: "test content",
tags: [["p", "pubkey"], ["e", "eventid"]],
};
const expected = {
kind: 4,
createdAt: 1678901234, // Math.floor applied
hasContent: true,
contentLength: 12,
tagCount: 2,
};
assert.deepEqual(summarizeDmEventForLog(event), expected);
});
it("should handle non-finite created_at", () => {
const event = {
kind: 4,
created_at: NaN,
content: "test",
tags: [],
};
const summary = summarizeDmEventForLog(event);
assert.strictEqual(summary.createdAt, null);
});
it("should handle non-finite kind", () => {
const event = {
kind: Infinity,
created_at: 1000,
content: "test",
tags: [],
};
const summary = summarizeDmEventForLog(event);
assert.strictEqual(summary.kind, null);
});
it("should handle missing or invalid content", () => {
const event1 = { kind: 4, created_at: 1000, tags: [] };
const summary1 = summarizeDmEventForLog(event1);
assert.strictEqual(summary1.hasContent, false);
assert.strictEqual(summary1.contentLength, 0);
const event2 = { kind: 4, created_at: 1000, content: 123, tags: [] };
const summary2 = summarizeDmEventForLog(event2);
assert.strictEqual(summary2.hasContent, false);
assert.strictEqual(summary2.contentLength, 0);
});
it("should handle missing or invalid tags", () => {
const event1 = { kind: 4, created_at: 1000, content: "test" };
const summary1 = summarizeDmEventForLog(event1);
assert.strictEqual(summary1.tagCount, 0);
const event2 = { kind: 4, created_at: 1000, content: "test", tags: "invalid" };
const summary2 = summarizeDmEventForLog(event2);
assert.strictEqual(summary2.tagCount, 0);
});
});
describe("sanitizeDecryptError", () => {
it("should return null when error is null or undefined", () => {
assert.strictEqual(sanitizeDecryptError(null), null);
assert.strictEqual(sanitizeDecryptError(undefined), null);
});
it("should handle string errors", () => {
const error = "Something went wrong";
const expected = {
name: "",
code: "",
message: "Something went wrong",
};
assert.deepEqual(sanitizeDecryptError(error), expected);
});
it("should handle Error objects with standard properties", () => {
const error = {
name: "Error",
code: "ERR_CODE",
message: "Error message",
};
const expected = {
name: "Error",
code: "ERR_CODE",
message: "Error message",
};
assert.deepEqual(sanitizeDecryptError(error), expected);
});
it("should handle Error objects with missing properties", () => {
const error = {
message: "Just a message",
};
const expected = {
name: "",
code: "",
message: "Just a message",
};
assert.deepEqual(sanitizeDecryptError(error), expected);
});
it("should handle Error objects with non-string properties", () => {
const error = {
name: 123,
code: null,
message: ["not a string"],
};
const expected = {
name: "",
code: "",
message: "",
};
assert.deepEqual(sanitizeDecryptError(error), expected);
});
});
});
+79
View File
@@ -0,0 +1,79 @@
import { test } from 'node:test';
import assert from 'node:assert';
import { EventsMap } from '../../js/nostr/eventsMap.js';
test('EventsMap functionality', async (t) => {
await t.test('indexes events by author on set', () => {
const map = new EventsMap();
const event1 = { id: '1', pubkey: 'abc', kind: 1 };
const event2 = { id: '2', pubkey: 'abc', kind: 1 };
const event3 = { id: '3', pubkey: 'def', kind: 1 };
map.set(event1.id, event1);
map.set(event2.id, event2);
map.set(event3.id, event3);
const abcEvents = map.getEventsByAuthor('abc');
assert.strictEqual(abcEvents.length, 2);
assert.ok(abcEvents.includes(event1));
assert.ok(abcEvents.includes(event2));
const defEvents = map.getEventsByAuthor('def');
assert.strictEqual(defEvents.length, 1);
assert.ok(defEvents.includes(event3));
});
await t.test('updates index on delete', () => {
const map = new EventsMap();
const event1 = { id: '1', pubkey: 'abc', kind: 1 };
map.set(event1.id, event1);
assert.strictEqual(map.getEventsByAuthor('abc').length, 1);
map.delete(event1.id);
assert.strictEqual(map.getEventsByAuthor('abc').length, 0);
});
await t.test('updates index on overwrite with different object', () => {
const map = new EventsMap();
const event1 = { id: '1', pubkey: 'abc', kind: 1 };
const event1Update = { id: '1', pubkey: 'abc', kind: 1, content: 'update' };
map.set(event1.id, event1);
assert.ok(map.getEventsByAuthor('abc').includes(event1));
map.set(event1.id, event1Update);
const abcEvents = map.getEventsByAuthor('abc');
assert.strictEqual(abcEvents.length, 1);
assert.ok(abcEvents.includes(event1Update));
assert.ok(!abcEvents.includes(event1));
});
await t.test('clears index on clear', () => {
const map = new EventsMap();
const event1 = { id: '1', pubkey: 'abc', kind: 1 };
map.set(event1.id, event1);
map.clear();
assert.strictEqual(map.getEventsByAuthor('abc').length, 0);
assert.strictEqual(map.size, 0);
});
await t.test('normalizes pubkeys', () => {
const map = new EventsMap();
const event1 = { id: '1', pubkey: 'ABC', kind: 1 };
map.set(event1.id, event1);
assert.strictEqual(map.getEventsByAuthor('abc').length, 1);
assert.strictEqual(map.getEventsByAuthor('ABC').length, 1);
assert.strictEqual(map.getEventsByAuthor(' abc ').length, 1);
});
await t.test('handles non-event objects gracefully', () => {
const map = new EventsMap();
map.set('1', null);
map.set('2', { foo: 'bar' }); // No pubkey
assert.strictEqual(map.getEventsByAuthor('undefined').length, 0);
});
});
+114
View File
@@ -0,0 +1,114 @@
import assert from "node:assert/strict";
import { test, describe, it, before, after, beforeEach } from "node:test";
import { shouldSuppressWarning, collectCandidateStrings } from "../../js/nostr/maxListenerDiagnostics.js";
// Helper to control verbose mode mock
function setVerboseMode(enabled) {
if (!globalThis.window) {
globalThis.window = {};
}
globalThis.window.__BITVID_VERBOSE_DEV_MODE__ = enabled;
}
describe("maxListenerDiagnostics", () => {
let originalWindow;
before(() => {
originalWindow = globalThis.window;
});
after(() => {
globalThis.window = originalWindow;
});
beforeEach(() => {
// Default to non-verbose for most tests
setVerboseMode(false);
});
describe("collectCandidateStrings", () => {
it("should return empty array for null/undefined/falsy", () => {
assert.deepEqual(collectCandidateStrings(null), []);
assert.deepEqual(collectCandidateStrings(undefined), []);
assert.deepEqual(collectCandidateStrings(false), []);
assert.deepEqual(collectCandidateStrings(0), []);
});
it("should return array with string for string input", () => {
assert.deepEqual(collectCandidateStrings("test warning"), ["test warning"]);
});
it("should extract relevant fields from object", () => {
const errorObj = {
name: "ErrorName",
message: "ErrorMessage",
code: "ErrorCode",
type: "ErrorType",
other: "OtherField"
};
const result = collectCandidateStrings(errorObj);
assert.ok(result.includes("ErrorName"));
assert.ok(result.includes("ErrorMessage"));
assert.ok(result.includes("ErrorCode"));
assert.ok(result.includes("ErrorType"));
assert.ok(!result.includes("OtherField"));
assert.equal(result.length, 4);
});
it("should ignore non-string fields in object", () => {
const errorObj = {
message: 123,
code: null
};
assert.deepEqual(collectCandidateStrings(errorObj), []);
});
});
describe("shouldSuppressWarning", () => {
it("should NOT suppress anything if verbose mode is enabled", () => {
setVerboseMode(true);
assert.equal(shouldSuppressWarning("MaxListenersExceededWarning"), false);
assert.equal(shouldSuppressWarning({ code: "MaxListenersExceededWarning" }), false);
});
it("should suppress warning by code string", () => {
setVerboseMode(false);
assert.equal(shouldSuppressWarning("MaxListenersExceededWarning"), true);
});
it("should suppress warning by object code property", () => {
setVerboseMode(false);
assert.equal(shouldSuppressWarning({ code: "MaxListenersExceededWarning" }), true);
});
it("should suppress warning by message snippet", () => {
setVerboseMode(false);
assert.equal(shouldSuppressWarning("Possible EventEmitter memory leak detected. 11 listeners added."), true);
});
it("should suppress warning by object message property snippet", () => {
setVerboseMode(false);
assert.equal(shouldSuppressWarning({ message: "Possible EventEmitter memory leak detected" }), true);
});
it("should NOT suppress unrelated warnings", () => {
setVerboseMode(false);
assert.equal(shouldSuppressWarning("Some other warning"), false);
assert.equal(shouldSuppressWarning({ message: "Some other warning" }), false);
});
it("should handle multiple arguments", () => {
setVerboseMode(false);
// First arg unrelated, second arg matching
assert.equal(shouldSuppressWarning("unrelated", { code: "MaxListenersExceededWarning" }), true);
// Both unrelated
assert.equal(shouldSuppressWarning("unrelated", "also unrelated"), false);
});
});
describe("process.emitWarning patch", () => {
it("should have patched process.emitWarning", () => {
assert.ok(process.emitWarning.__BITVID_MAX_LISTENER_PATCHED__, "process.emitWarning should be patched");
});
});
});
@@ -0,0 +1,192 @@
import { describe, test, mock, before, after, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { createUiDom } from "../ui/helpers/jsdom-test-helpers.mjs";
import DiscussionCountService from "../../js/services/discussionCountService.js";
describe("DiscussionCountService", () => {
let dom;
let document;
let window;
before(() => {
dom = createUiDom();
window = dom.window;
document = dom.document;
});
after(() => {
dom.cleanup();
});
test("initialization sets defaults", () => {
const service = new DiscussionCountService();
assert.equal(service.maxVideos, 24);
assert.ok(service.logger);
assert.equal(service.videoDiscussionCountCache.size, 0);
assert.equal(service.inFlightDiscussionCounts.size, 0);
});
test("refreshCounts returns early if videos array is empty or invalid", () => {
const service = new DiscussionCountService();
// mock logger to ensure no warnings
const logger = { warn: mock.fn() };
service.logger = logger;
service.refreshCounts([], {});
service.refreshCounts(null, {});
assert.equal(service.inFlightDiscussionCounts.size, 0);
assert.equal(logger.warn.mock.callCount(), 0);
});
test("refreshCounts returns early if dependencies are missing", () => {
const service = new DiscussionCountService();
const video = { id: "video1" };
// Missing nostrClient
service.refreshCounts([video], { videoListRoot: document.createElement("div") });
assert.equal(service.inFlightDiscussionCounts.size, 0);
// Missing videoListRoot
service.refreshCounts([video], { nostrClient: {} });
assert.equal(service.inFlightDiscussionCounts.size, 0);
});
test("refreshCounts handles happy path (fetches and updates DOM)", async () => {
const service = new DiscussionCountService();
const video = { id: "video1", kind: 30078, pubkey: "pubkey1" };
const root = document.createElement("div");
const container = document.createElement("div");
container.dataset.discussionCount = "video1";
const valueEl = document.createElement("span");
valueEl.dataset.discussionCountValue = "true";
container.appendChild(valueEl);
root.appendChild(container);
const nostrClient = {
pool: {},
countEventsAcrossRelays: mock.fn(async () => ({
total: 42,
ok: true,
perRelay: [{ ok: true }]
}))
};
service.refreshCounts([video], { videoListRoot: root, nostrClient });
// Wait for promise resolution (microtasks)
await new Promise(resolve => process.nextTick(resolve));
await new Promise(resolve => process.nextTick(resolve));
assert.equal(nostrClient.countEventsAcrossRelays.mock.callCount(), 1);
assert.equal(valueEl.textContent, "42");
assert.equal(container.dataset.countState, "ready");
assert.equal(service.videoDiscussionCountCache.get("video1"), 42);
});
test("refreshCounts uses cached count if available", async () => {
const service = new DiscussionCountService();
const video = { id: "video1" };
service.videoDiscussionCountCache.set("video1", 100);
const root = document.createElement("div");
const container = document.createElement("div");
container.dataset.discussionCount = "video1";
const valueEl = document.createElement("span");
valueEl.dataset.discussionCountValue = "true";
container.appendChild(valueEl);
root.appendChild(container);
const nostrClient = {
pool: {},
countEventsAcrossRelays: mock.fn()
};
service.refreshCounts([video], { videoListRoot: root, nostrClient });
assert.equal(nostrClient.countEventsAcrossRelays.mock.callCount(), 0);
assert.equal(valueEl.textContent, "100");
});
test("refreshCounts handles API errors gracefully", async () => {
const logger = { warn: mock.fn() };
const service = new DiscussionCountService({ logger });
const video = { id: "video1" };
const root = document.createElement("div");
const container = document.createElement("div");
container.dataset.discussionCount = "video1";
const valueEl = document.createElement("span");
valueEl.dataset.discussionCountValue = "true";
container.appendChild(valueEl);
root.appendChild(container);
const error = new Error("Network error");
const nostrClient = {
pool: {},
countEventsAcrossRelays: mock.fn(async () => { throw error; })
};
service.refreshCounts([video], { videoListRoot: root, nostrClient });
const requestPromise = service.inFlightDiscussionCounts.get("video1");
await assert.rejects(requestPromise, error);
assert.equal(logger.warn.mock.callCount(), 1);
assert.equal(container.dataset.countState, "error");
assert.equal(valueEl.textContent, "—");
});
test("refreshCounts handles partial results", async () => {
const service = new DiscussionCountService();
const video = { id: "video1" };
const root = document.createElement("div");
const container = document.createElement("div");
container.dataset.discussionCount = "video1";
const valueEl = document.createElement("span");
valueEl.dataset.discussionCountValue = "true";
container.appendChild(valueEl);
root.appendChild(container);
const nostrClient = {
pool: {},
countEventsAcrossRelays: mock.fn(async () => ({ total: 5, partial: true, ok: true, perRelay: [{ ok: true }] }))
};
service.refreshCounts([video], { videoListRoot: root, nostrClient });
await new Promise(resolve => process.nextTick(resolve));
await new Promise(resolve => process.nextTick(resolve));
assert.equal(container.dataset.countState, "partial");
assert.equal(valueEl.textContent, "5");
});
test("refreshCounts handles unsupported relays (empty perRelay)", async () => {
const service = new DiscussionCountService();
const video = { id: "video1" };
const root = document.createElement("div");
const container = document.createElement("div");
container.dataset.discussionCount = "video1";
const valueEl = document.createElement("span");
valueEl.dataset.discussionCountValue = "true";
container.appendChild(valueEl);
root.appendChild(container);
const nostrClient = {
pool: {},
countEventsAcrossRelays: mock.fn(async () => ({ total: 0, perRelay: [] }))
};
service.refreshCounts([video], { videoListRoot: root, nostrClient });
await new Promise(resolve => process.nextTick(resolve));
await new Promise(resolve => process.nextTick(resolve));
assert.equal(container.dataset.countState, "unsupported");
assert.equal(valueEl.textContent, "—");
});
});
+167
View File
@@ -0,0 +1,167 @@
import { describe, it, before, beforeEach, afterEach, after, mock } from 'node:test';
import assert from 'node:assert';
// Set Dev Mode Override to ensure logs are emitted
globalThis.__BITVID_DEV_MODE_OVERRIDE__ = true;
// Mock Worker globally before importing the service
class MockWorker {
constructor(scriptURL) {
this.scriptURL = scriptURL;
this.listeners = {};
MockWorker.instances.push(this);
}
addEventListener(event, handler) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(handler);
}
removeEventListener(event, handler) {
if (this.listeners[event]) {
this.listeners[event] = this.listeners[event].filter((h) => h !== handler);
}
}
postMessage(data) {
// Simulate async processing
setImmediate(() => {
if (MockWorker.onPostMessage) {
MockWorker.onPostMessage(this, data);
}
});
}
// Helper to simulate incoming message from worker
emitMessage(data) {
if (this.listeners['message']) {
const event = { data };
this.listeners['message'].forEach((handler) => handler(event));
}
}
}
MockWorker.instances = [];
MockWorker.onPostMessage = null;
globalThis.Worker = MockWorker;
describe('exploreDataService - buildWatchHistoryTagCounts', () => {
let buildWatchHistoryTagCounts;
let consoleWarnMock;
let initialCallCount = 0;
before(async () => {
// Mock console.warn BEFORE import so logger.js captures the mock
// if console.warn is already mocked (e.g. by other tests? unlikely in isolated process), restore it first
if (console.warn.mock) console.warn.mock.restore();
consoleWarnMock = mock.method(console, 'warn', () => {});
// Dynamic import
const mod = await import('../../js/services/exploreDataService.js');
buildWatchHistoryTagCounts = mod.buildWatchHistoryTagCounts;
});
after(() => {
if (consoleWarnMock) consoleWarnMock.mock.restore();
});
beforeEach(() => {
MockWorker.instances = [];
MockWorker.onPostMessage = null;
initialCallCount = consoleWarnMock.mock.calls.length;
});
it('should return empty Map if watchHistoryService is missing', async () => {
const result = await buildWatchHistoryTagCounts({});
assert.ok(result instanceof Map);
assert.strictEqual(result.size, 0);
});
it('should handle missing loadLatest method gracefully', async () => {
const watchHistoryService = {};
const result = await buildWatchHistoryTagCounts({ watchHistoryService });
assert.strictEqual(result.size, 0);
});
it('should handle loadLatest failure gracefully', async () => {
const watchHistoryService = {
loadLatest: mock.fn(async () => {
throw new Error('Load failed');
}),
};
// Even if load fails, it should continue with empty items
MockWorker.onPostMessage = (worker, data) => {
assert.deepStrictEqual(data.payload.items, []);
worker.emitMessage({ id: data.id, result: new Map() });
};
const result = await buildWatchHistoryTagCounts({ watchHistoryService });
assert.ok(result instanceof Map);
assert.strictEqual(result.size, 0);
assert.strictEqual(watchHistoryService.loadLatest.mock.callCount(), 1);
// Verify warning was logged
const newCalls = consoleWarnMock.mock.calls.slice(initialCallCount);
const warning = newCalls.find(c => c.arguments[0] && String(c.arguments[0]).includes('Failed to load watch history entries'));
assert.ok(warning, 'Should log warning about load failure');
});
it('should return counts from worker on success', async () => {
const watchHistoryService = {
loadLatest: mock.fn(async () => ['item1']),
};
const nostrService = {
getVideosMap: mock.fn(() => new Map([['v1', { tags: ['t1'] }]])),
};
MockWorker.onPostMessage = (worker, data) => {
assert.strictEqual(data.type, 'CALC_HISTORY_COUNTS');
assert.deepStrictEqual(data.payload.items, ['item1']);
// Verify worker receives correct payload structure
assert.ok(data.payload.videosMap instanceof Map);
// Simulate successful result
worker.emitMessage({
id: data.id,
result: new Map([['t1', 5]])
});
};
const result = await buildWatchHistoryTagCounts({
watchHistoryService,
nostrService,
actor: 'user1'
});
assert.strictEqual(result.get('t1'), 5);
});
it('should handle worker error gracefully', async () => {
const watchHistoryService = {
loadLatest: mock.fn(async () => ['item1']),
};
MockWorker.onPostMessage = (worker, data) => {
worker.emitMessage({
id: data.id,
error: 'Worker exploded'
});
};
const result = await buildWatchHistoryTagCounts({ watchHistoryService });
assert.ok(result instanceof Map);
assert.strictEqual(result.size, 0);
const newCalls = consoleWarnMock.mock.calls.slice(initialCallCount);
const warning = newCalls.find(c => c.arguments[0] && String(c.arguments[0]).includes('Worker failed to calculate history counts'));
assert.ok(warning, 'Should log warning about worker failure');
});
});
+143 -1
View File
@@ -2,7 +2,7 @@
import "../test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import test, { mock } from "node:test";
import RelayHealthService from "../../js/services/relayHealthService.js";
// Mock SimpleEventEmitter for tests
@@ -166,3 +166,145 @@ test("RelayHealthService: emits telemetry if opted in", async () => {
assert.equal(telemetryPayload.relays.length, 1);
assert.equal(telemetryPayload.relays[0].url, "wss://t1.com");
});
test("RelayHealthService: checkRelay times out after DEFAULT_TIMEOUT_MS", async () => {
const originalSetTimeout = globalThis.setTimeout;
let timeoutCallback;
// Manually mock setTimeout to capture callback
globalThis.setTimeout = (cb, delay) => {
if (delay === 5000) {
timeoutCallback = cb;
return 123; // dummy timer id
}
return originalSetTimeout(cb, delay);
};
try {
const nostrClient = {
ensurePool: async () => {},
pool: {
ensureRelay: () => new Promise(() => {}), // Never resolves
},
};
const service = new RelayHealthService({ nostrClient });
const checkPromise = service.checkRelay("wss://timeout.com");
// Wait for the promise race to register the timeout
await new Promise((resolve) => originalSetTimeout(resolve, 0));
// Trigger the timeout manually
if (timeoutCallback) {
timeoutCallback();
} else {
throw new Error("setTimeout was not called with 5000ms delay");
}
await checkPromise;
const state = service.ensureRelayState("wss://timeout.com");
assert.equal(state.connected, false);
assert.equal(state.errorCount, 1);
assert.equal(state.consecutiveFailures, 1);
} finally {
globalThis.setTimeout = originalSetTimeout;
}
});
test("RelayHealthService: failure threshold triggers user warning", () => {
const userLogger = { warn: mock.fn() };
const devLogger = { warn: mock.fn() };
const logger = { user: userLogger, dev: devLogger };
const service = new RelayHealthService({ logger });
const url = "wss://fail.com";
// 1st failure
service.recordRelayFailure(url, new Error("fail 1"));
assert.equal(userLogger.warn.mock.callCount(), 0);
// 2nd failure
service.recordRelayFailure(url, new Error("fail 2"));
assert.equal(userLogger.warn.mock.callCount(), 0);
// 3rd failure (Threshold is 3)
service.recordRelayFailure(url, new Error("fail 3"));
assert.equal(userLogger.warn.mock.callCount(), 1);
});
test("RelayHealthService: user warning respects cooldown", () => {
const originalDateNow = Date.now;
let currentTime = 1000000;
Date.now = () => currentTime;
try {
const userLogger = { warn: mock.fn() };
const devLogger = { warn: mock.fn() };
const logger = { user: userLogger, dev: devLogger };
const service = new RelayHealthService({ logger });
const url = "wss://cooldown.com";
// Trigger 3 failures to reach threshold and get first warning
service.recordRelayFailure(url, new Error("1"));
service.recordRelayFailure(url, new Error("2"));
service.recordRelayFailure(url, new Error("3"));
assert.equal(userLogger.warn.mock.callCount(), 1);
// Advance 1 minute (less than 5 mins cooldown)
currentTime += 60 * 1000;
service.recordRelayFailure(url, new Error("4"));
assert.equal(userLogger.warn.mock.callCount(), 1); // Still 1
// Advance 5 minutes (more than cooldown)
currentTime += 5 * 60 * 1000;
service.recordRelayFailure(url, new Error("5"));
assert.equal(userLogger.warn.mock.callCount(), 2); // Now 2
} finally {
Date.now = originalDateNow;
}
});
test("RelayHealthService: relay disconnect/error events trigger failure", () => {
const service = new RelayHealthService();
const url = "wss://events.com";
// Mock relay
const relay = new SimpleEventEmitter();
// Attach listeners manually for this test case (usually done via checkRelay)
service.attachRelayListeners(url, relay);
// Simulate connect
relay.emit("connect");
assert.equal(service.ensureRelayState(url).connected, true);
// Simulate disconnect
relay.emit("disconnect");
const stateDisconnect = service.ensureRelayState(url);
assert.equal(stateDisconnect.connected, false);
assert.equal(stateDisconnect.errorCount, 1);
// Simulate connect again
relay.emit("connect");
assert.equal(service.ensureRelayState(url).connected, true);
// Simulate error
relay.emit("error", new Error("boom"));
const stateError = service.ensureRelayState(url);
assert.equal(stateError.connected, false);
assert.equal(stateError.errorCount, 2);
});
test("RelayHealthService: integrates with nostrClient.markRelayUnreachable", () => {
const markRelayUnreachable = mock.fn();
const nostrClient = { markRelayUnreachable };
const service = new RelayHealthService({ nostrClient });
service.recordRelayFailure("wss://unreachable.com", new Error("fail"));
assert.equal(markRelayUnreachable.mock.callCount(), 1);
const args = markRelayUnreachable.mock.calls[0].arguments;
assert.equal(args[0], "wss://unreachable.com");
assert.equal(args[1], 60000); // Verify duration
});
+52
View File
@@ -12,6 +12,9 @@ import {
getModerationSettings,
setModerationSettings,
urlHealthConstants,
loadModerationOverridesFromStorage,
getModerationOverridesList,
clearModerationOverride,
} from "../../js/state/cache.js";
// Ensure localStorage is mocked
@@ -102,4 +105,53 @@ test("js/state/cache.js", async (t) => {
assert.strictEqual(updated.blurThreshold, 1);
assert.strictEqual(updated.autoplayBlockThreshold, 2);
});
await t.test("Legacy Moderation Overrides Support", async (t) => {
// Reset internal state by clearing overrides
const current = getModerationOverridesList();
for (const entry of current) {
clearModerationOverride(entry, { persist: false });
}
const VALID_HEX_ID_1 = "0000000000000000000000000000000000000000000000000000000000000001";
const VALID_HEX_ID_2 = "0000000000000000000000000000000000000000000000000000000000000002";
await t.test("ignores legacy v1 overrides", () => {
const legacyData = {
version: 1,
entries: {
[VALID_HEX_ID_1]: { showAnyway: true, updatedAt: 1234567890 },
}
};
localStorage.setItem("bitvid:moderationOverrides:v1", JSON.stringify(legacyData));
loadModerationOverridesFromStorage();
const overrides = getModerationOverridesList();
assert.strictEqual(overrides.length, 0, "Should not load legacy overrides");
// Check if it migrated to v2 key
const v2Data = localStorage.getItem("bitvid:moderationOverrides:v2");
assert.strictEqual(v2Data, null, "Should not migrate/persist v2 data");
// Check if legacy key is NOT removed
assert.ok(localStorage.getItem("bitvid:moderationOverrides:v1"), "Should not remove legacy key");
});
await t.test("loads v2 overrides", () => {
const v2Data = {
version: 2,
entries: [
{ eventId: VALID_HEX_ID_2, authorPubkey: "", showAnyway: true, updatedAt: 1234567890 }
]
};
localStorage.setItem("bitvid:moderationOverrides:v2", JSON.stringify(v2Data));
loadModerationOverridesFromStorage();
const overrides = getModerationOverridesList();
assert.strictEqual(overrides.length, 1);
assert.strictEqual(overrides[0].eventId, VALID_HEX_ID_2);
});
});
});
@@ -0,0 +1,208 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { JSDOM } from 'jsdom';
import {
createVideoMoreMenuPanel,
createVideoShareMenuPanel,
createChannelProfileMenuPanel,
createVideoSettingsMenuPanel,
} from '../../../js/ui/components/videoMenuRenderers.js';
function setupDom() {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://example.com',
});
return { window: dom.window, document: dom.window.document };
}
test('createVideoMoreMenuPanel - basic rendering', () => {
const { document } = setupDom();
const video = {
id: 'event-123',
pubkey: 'pubkey-123',
title: 'Test Video',
description: 'Test Description',
thumbnail: 'thumbnail.jpg',
isPrivate: false,
kind: 30078,
};
const panel = createVideoMoreMenuPanel({
document,
video,
context: 'card',
});
assert.ok(panel, 'Panel should be created');
assert.equal(panel.dataset.menu, 'video-more');
assert.equal(panel.dataset.menuContext, 'card');
// Check common actions
const openChannel = panel.querySelector('[data-action="open-channel"]');
assert.ok(openChannel, 'Open channel button exists');
assert.equal(openChannel.dataset.author, video.pubkey);
const copyLink = panel.querySelector('[data-action="copy-link"]');
assert.ok(copyLink, 'Copy link button exists');
assert.equal(copyLink.dataset.eventId, video.id);
// Check Boost heading
const headings = Array.from(panel.querySelectorAll('.menu__heading'));
assert.ok(headings.some(h => h.textContent === 'Boost on Nostr…'), 'Boost heading exists');
// Check Repost
const repost = panel.querySelector('[data-action="repost-event"]');
assert.ok(repost, 'Repost button exists');
assert.equal(repost.dataset.eventId, video.id);
// Check Mute/Block/Report
assert.ok(panel.querySelector('[data-action="mute-author"]'), 'Mute button exists');
assert.ok(panel.querySelector('[data-action="block-author"]'), 'Block button exists');
assert.ok(panel.querySelector('[data-action="report"]'), 'Report button exists');
});
test('createVideoMoreMenuPanel - pointer info rendering', () => {
const { document } = setupDom();
const video = { id: 'v1', pubkey: 'p1' };
const pointerInfo = {
key: 'history-key',
pointer: ['a', 'value', 'relay'],
};
const panel = createVideoMoreMenuPanel({
document,
video,
pointerInfo,
});
const removeHistory = panel.querySelector('[data-action="remove-history"]');
assert.ok(removeHistory, 'Remove from history button exists');
assert.equal(removeHistory.dataset.pointerKey, 'history-key');
assert.equal(removeHistory.dataset.pointerValue, 'value');
assert.equal(removeHistory.dataset.pointerRelay, 'relay');
});
test('createVideoMoreMenuPanel - mirror logic', () => {
const { document } = setupDom();
const video = { id: 'v1', isPrivate: false };
const playbackUrl = 'https://example.com/video.mp4';
const playbackMagnet = 'magnet:?xt=urn:btih:123';
const panel = createVideoMoreMenuPanel({
document,
video,
playbackUrl,
playbackMagnet,
});
const mirror = panel.querySelector('[data-action="mirror-video"]');
assert.ok(mirror, 'Mirror button exists');
assert.equal(mirror.dataset.url, playbackUrl);
assert.equal(mirror.dataset.magnet, playbackMagnet);
assert.equal(mirror.dataset.isPrivate, 'false');
});
test('createVideoMoreMenuPanel - blacklist logic', () => {
const { document } = setupDom();
const video = { id: 'v1', pubkey: 'p1' };
const panel = createVideoMoreMenuPanel({
document,
video,
canManageBlacklist: true,
});
const blacklist = panel.querySelector('[data-action="blacklist-author"]');
assert.ok(blacklist, 'Blacklist button exists');
assert.equal(blacklist.dataset.variant, 'critical');
});
test('createVideoShareMenuPanel - permission logic', () => {
const { document } = setupDom();
const video = { id: 'v1' };
// Case 1: Not logged in
const panel1 = createVideoShareMenuPanel({
document,
video,
isLoggedIn: false,
hasSigner: false,
});
const nostrBtn1 = panel1.querySelector('[data-action="share-nostr"]');
assert.ok(nostrBtn1.disabled, 'Share on Nostr should be disabled when not logged in');
// Case 2: Logged in but no signer
const panel2 = createVideoShareMenuPanel({
document,
video,
isLoggedIn: true,
hasSigner: false,
});
const nostrBtn2 = panel2.querySelector('[data-action="share-nostr"]');
assert.ok(nostrBtn2.disabled, 'Share on Nostr should be disabled without signer');
// Case 3: Logged in with signer
const panel3 = createVideoShareMenuPanel({
document,
video,
isLoggedIn: true,
hasSigner: true,
});
const nostrBtn3 = panel3.querySelector('[data-action="share-nostr"]');
assert.ok(!nostrBtn3.disabled, 'Share on Nostr should be enabled with signer');
});
test('createVideoShareMenuPanel - magnet/cdn logic', () => {
const { document } = setupDom();
const video = { id: 'v1' };
const panel = createVideoShareMenuPanel({
document,
video,
hasMagnet: true,
hasCdn: false,
});
const magnetBtn = panel.querySelector('[data-action="copy-magnet"]');
assert.ok(!magnetBtn.disabled, 'Magnet button should be enabled');
const cdnBtn = panel.querySelector('[data-action="copy-cdn"]');
assert.ok(cdnBtn.disabled, 'CDN button should be disabled');
});
test('createChannelProfileMenuPanel - basic actions', () => {
const { document } = setupDom();
const panel = createChannelProfileMenuPanel({
document,
context: 'profile',
});
assert.equal(panel.dataset.menu, 'channel-profile');
assert.ok(panel.querySelector('[data-action="copy-npub"]'), 'Copy npub exists');
assert.ok(panel.querySelector('[data-action="mute-author"]'), 'Mute author exists');
assert.ok(panel.querySelector('[data-action="report"]'), 'Report exists');
});
test('createVideoSettingsMenuPanel - capabilities', () => {
const { document } = setupDom();
const video = { id: 'v1' };
// Case 1: Edit only
const panel1 = createVideoSettingsMenuPanel({
document,
video,
capabilities: { canEdit: true },
});
assert.ok(panel1.querySelector('[data-action="edit"]'), 'Edit button exists');
assert.ok(!panel1.querySelector('[data-action="revert"]'), 'Revert button absent');
assert.ok(!panel1.querySelector('[data-action="delete"]'), 'Delete button absent');
// Case 2: Full capabilities
const panel2 = createVideoSettingsMenuPanel({
document,
video,
capabilities: { canEdit: true, canRevert: true, canDelete: true },
});
assert.ok(panel2.querySelector('[data-action="revert"]'), 'Revert button exists');
assert.ok(panel2.querySelector('[data-action="delete"]'), 'Delete button exists');
});
+136
View File
@@ -0,0 +1,136 @@
import assert from "node:assert/strict";
import { test, describe, it } from "node:test";
import {
formatZapAmount,
aggregateZapTotals,
normalizeZapReceipt,
} from "../../../js/ui/dm/zapHelpers.js";
describe("formatZapAmount", () => {
it("formats standard amounts correctly", () => {
assert.equal(formatZapAmount(100), "100 sats");
assert.equal(formatZapAmount(1000), "1,000 sats");
assert.equal(formatZapAmount(1234567), "1,234,567 sats");
});
it("formats compact amounts correctly", () => {
// With maximumFractionDigits: 0, compact notation rounds to nearest integer
assert.equal(formatZapAmount(100, { compact: true }), "100 sats");
assert.equal(formatZapAmount(1000, { compact: true }), "1K sats");
assert.equal(formatZapAmount(1200, { compact: true }), "1K sats");
assert.equal(formatZapAmount(1500, { compact: true }), "2K sats");
assert.equal(formatZapAmount(1234567, { compact: true }), "1M sats");
});
it("handles zero and small numbers", () => {
assert.equal(formatZapAmount(0), "0 sats");
// normalizeAmount uses Math.round
assert.equal(formatZapAmount(0.5), "1 sats");
assert.equal(formatZapAmount(0.4), "0 sats");
});
it("handles invalid inputs gracefully", () => {
assert.equal(formatZapAmount(NaN), "0 sats");
assert.equal(formatZapAmount(null), "0 sats");
assert.equal(formatZapAmount(undefined), "0 sats");
assert.equal(formatZapAmount("invalid"), "0 sats");
});
it("handles string number inputs", () => {
assert.equal(formatZapAmount("100"), "100 sats");
});
});
describe("aggregateZapTotals", () => {
it("aggregates totals correctly", () => {
const receipts = [
{ amountSats: 100, conversationId: "c1", profileId: "p1" },
{ amountSats: 200, conversationId: "c1", profileId: "p2" },
{ amountSats: 50, conversationId: "c2", profileId: "p1" },
];
const result = aggregateZapTotals(receipts);
assert.equal(result.overallSats, 350);
assert.equal(result.totalsByConversation.get("c1"), 300);
assert.equal(result.totalsByConversation.get("c2"), 50);
assert.equal(result.totalsByProfile.get("p1"), 150);
assert.equal(result.totalsByProfile.get("p2"), 200);
});
it("handles empty input", () => {
const result = aggregateZapTotals([]);
assert.equal(result.overallSats, 0);
assert.equal(result.totalsByConversation.size, 0);
assert.equal(result.totalsByProfile.size, 0);
});
it("handles non-array input", () => {
const result = aggregateZapTotals(null);
assert.equal(result.overallSats, 0);
assert.equal(result.totalsByConversation.size, 0);
assert.equal(result.totalsByProfile.size, 0);
});
it("handles missing or invalid amounts", () => {
const receipts = [
{ amountSats: 100, conversationId: "c1" },
{ conversationId: "c1" }, // missing amount
{ amountSats: "invalid", conversationId: "c1" },
{ amount: 50, conversationId: "c1" }, // fallback to amount property
];
const result = aggregateZapTotals(receipts);
// 100 + 0 + 0 + 50 = 150
assert.equal(result.overallSats, 150);
assert.equal(result.totalsByConversation.get("c1"), 150);
});
it("handles missing IDs", () => {
const receipts = [
{ amountSats: 100 },
{ amountSats: 200, conversationId: "c1" },
{ amountSats: 300, profileId: "p1" },
];
const result = aggregateZapTotals(receipts);
assert.equal(result.overallSats, 600);
assert.equal(result.totalsByConversation.get("c1"), 200);
assert.equal(result.totalsByConversation.size, 1);
assert.equal(result.totalsByProfile.get("p1"), 300);
assert.equal(result.totalsByProfile.size, 1);
});
});
describe("normalizeZapReceipt", () => {
it("normalizes valid receipt", () => {
const receipt = { amountSats: 100, other: "field" };
const normalized = normalizeZapReceipt(receipt);
assert.equal(normalized.amountSats, 100);
assert.equal(normalized.other, "field");
});
it("normalizes receipt with amount fallback", () => {
const receipt = { amount: 100 };
const normalized = normalizeZapReceipt(receipt);
assert.equal(normalized.amountSats, 100);
// It should preserve original fields
assert.equal(normalized.amount, 100);
});
it("normalizes empty receipt", () => {
const normalized = normalizeZapReceipt({});
assert.equal(normalized.amountSats, 0);
});
it("normalizes undefined receipt", () => {
// The function default param is {} so calling with undefined works
const normalized = normalizeZapReceipt(undefined);
assert.equal(normalized.amountSats, 0);
});
it("normalizes invalid amount", () => {
const normalized = normalizeZapReceipt({ amountSats: "invalid" });
assert.equal(normalized.amountSats, 0);
});
});
+176
View File
@@ -0,0 +1,176 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
normalizeCommentAvatarKey,
resolveCommentAvatarAsset,
registerCommentAvatarFailure,
} from "../../js/ui/components/video-modal/utils/commentAvatar.js";
test("normalizeCommentAvatarKey handles inputs correctly", () => {
// Non-string inputs
assert.equal(normalizeCommentAvatarKey(null), "");
assert.equal(normalizeCommentAvatarKey(undefined), "");
assert.equal(normalizeCommentAvatarKey(123), "");
assert.equal(normalizeCommentAvatarKey({}), "");
// Whitespace
assert.equal(normalizeCommentAvatarKey(" ABC "), "abc");
assert.equal(normalizeCommentAvatarKey("\tXYZ\n"), "xyz");
// Empty string
assert.equal(normalizeCommentAvatarKey(""), "");
assert.equal(normalizeCommentAvatarKey(" "), "");
// Lowercase
assert.equal(normalizeCommentAvatarKey("HeLLo"), "hello");
});
test("resolveCommentAvatarAsset resolves avatars correctly", () => {
const defaultAvatar = "default.png";
const pubkey = "pk1";
const sanitizedPicture = "https://example.com/avatar.png";
// Scenario 1: Basic resolution (no cache, no failures)
let result = resolveCommentAvatarAsset({
cache: new Map(),
failures: new Set(),
defaultAvatar,
pubkey,
sanitizedPicture,
});
assert.deepEqual(result, { url: sanitizedPicture, source: sanitizedPicture });
// Scenario 2: Missing picture
result = resolveCommentAvatarAsset({
cache: new Map(),
failures: new Set(),
defaultAvatar,
pubkey,
sanitizedPicture: null,
});
assert.deepEqual(result, { url: defaultAvatar, source: "" });
// Scenario 3: Known failure
const failures = new Set([sanitizedPicture]);
result = resolveCommentAvatarAsset({
cache: new Map(),
failures,
defaultAvatar,
pubkey,
sanitizedPicture,
});
assert.deepEqual(result, { url: defaultAvatar, source: "" });
// Scenario 4: Cache hit (matching source)
const cache = new Map();
cache.set(pubkey, { url: sanitizedPicture, source: sanitizedPicture });
result = resolveCommentAvatarAsset({
cache,
failures: new Set(),
defaultAvatar,
pubkey,
sanitizedPicture,
});
assert.deepEqual(result, { url: sanitizedPicture, source: sanitizedPicture });
// Scenario 5: Cache hit (source mismatch - new avatar)
const newPicture = "https://example.com/new.png";
result = resolveCommentAvatarAsset({
cache, // cache has old picture
failures: new Set(),
defaultAvatar,
pubkey,
sanitizedPicture: newPicture,
});
assert.deepEqual(result, { url: newPicture, source: newPicture });
// Verify cache update
assert.deepEqual(cache.get(pubkey), { url: newPicture, source: newPicture });
// Scenario 6: Cache hit (source mismatch - removed avatar)
// Note: Implementation preserves cached avatar if input is empty (persistence)
result = resolveCommentAvatarAsset({
cache, // cache has new picture
failures: new Set(),
defaultAvatar,
pubkey,
sanitizedPicture: "", // avatar removed or missing
});
assert.deepEqual(result, { url: newPicture, source: newPicture });
// Verify cache remains unchanged
assert.deepEqual(cache.get(pubkey), { url: newPicture, source: newPicture });
// Scenario 7: Cache hit (cached failure)
cache.set(pubkey, { url: defaultAvatar, source: "" });
result = resolveCommentAvatarAsset({
cache,
failures: new Set(),
defaultAvatar,
pubkey,
sanitizedPicture: "", // no new picture provided
});
assert.deepEqual(result, { url: defaultAvatar, source: "" });
});
test("registerCommentAvatarFailure registers failures correctly", () => {
const defaultAvatar = "default.png";
const sourceUrl = "https://example.com/bad.png";
// Scenario 1: Basic failure registration
const failures = new Set();
const cache = new Map();
registerCommentAvatarFailure({
cache,
failures,
defaultAvatar,
sourceUrl,
});
assert.ok(failures.has(sourceUrl));
// Scenario 2: Cache invalidation on failure
cache.set("pk1", { url: sourceUrl, source: sourceUrl });
cache.set("pk2", {
url: "https://example.com/good.png",
source: "https://example.com/good.png",
});
registerCommentAvatarFailure({
cache,
failures,
defaultAvatar,
sourceUrl,
});
// Should update pk1 to default
assert.deepEqual(cache.get("pk1"), { url: defaultAvatar, source: "" });
// Should keep pk2
assert.deepEqual(cache.get("pk2"), {
url: "https://example.com/good.png",
source: "https://example.com/good.png",
});
// Scenario 3: Ignore invalid inputs
const failures2 = new Set();
registerCommentAvatarFailure({
cache: new Map(),
failures: failures2,
defaultAvatar,
sourceUrl: "",
});
assert.equal(failures2.size, 0);
registerCommentAvatarFailure({
cache: new Map(),
failures: failures2,
defaultAvatar,
sourceUrl: defaultAvatar,
});
assert.equal(failures2.size, 0);
registerCommentAvatarFailure({
cache: new Map(),
failures: failures2,
defaultAvatar,
sourceUrl: null,
});
assert.equal(failures2.size, 0);
});
+144
View File
@@ -0,0 +1,144 @@
import { describe, it, beforeEach } from "node:test";
import assert from "node:assert/strict";
import "../test-setup.mjs";
import { updateVideoCardSourceVisibility } from "../../js/utils/cardSourceVisibility.js";
describe("cardSourceVisibility", () => {
let card;
beforeEach(() => {
card = document.createElement("div");
card.classList.add("card");
// Default state: not hidden, no health state set (so pending)
card.hidden = false;
// Clear any previous attributes
delete card.dataset.urlHealthState;
delete card.dataset.streamHealthState;
delete card.dataset.ownerIsViewer;
delete card.dataset.sourceVisibility;
});
describe("updateVideoCardSourceVisibility", () => {
it("should handle null or undefined input gracefully", () => {
assert.doesNotThrow(() => updateVideoCardSourceVisibility(null));
assert.doesNotThrow(() => updateVideoCardSourceVisibility(undefined));
});
it("should resolve card from object with .card property", () => {
const wrapper = { card: card };
card.dataset.urlHealthState = "failed";
card.dataset.streamHealthState = "failed";
// Should hide because both failed
updateVideoCardSourceVisibility(wrapper);
assert.equal(card.hidden, true);
});
it("should always show card if owner is viewer", () => {
card.dataset.ownerIsViewer = "true";
// Set to failed state which would normally hide it
card.dataset.urlHealthState = "failed";
card.dataset.streamHealthState = "failed";
card.hidden = true; // Start hidden to verify it unhides
updateVideoCardSourceVisibility(card);
assert.equal(card.hidden, false);
assert.equal(card.dataset.sourceVisibility, "visible");
});
it("should show card if at least one source is healthy", () => {
// Case 1: URL healthy
card.dataset.urlHealthState = "healthy";
card.dataset.streamHealthState = "failed";
card.hidden = true;
updateVideoCardSourceVisibility(card);
assert.equal(card.hidden, false);
assert.equal(card.dataset.sourceVisibility, "visible");
// Reset
card.hidden = true;
// Case 2: Stream healthy
card.dataset.urlHealthState = "failed";
card.dataset.streamHealthState = "healthy";
updateVideoCardSourceVisibility(card);
assert.equal(card.hidden, false);
assert.equal(card.dataset.sourceVisibility, "visible");
});
it("should show card if at least one source is checking/pending", () => {
// Case 1: URL checking
card.dataset.urlHealthState = "checking";
card.dataset.streamHealthState = "failed";
card.hidden = true;
updateVideoCardSourceVisibility(card);
assert.equal(card.hidden, false);
// Reset
card.hidden = true;
// Case 2: Stream checking
card.dataset.urlHealthState = "failed";
card.dataset.streamHealthState = "checking";
updateVideoCardSourceVisibility(card);
assert.equal(card.hidden, false);
// Reset
card.hidden = true;
// Case 3: Empty state (pending)
delete card.dataset.urlHealthState; // undefined/empty means pending
card.dataset.streamHealthState = "failed";
updateVideoCardSourceVisibility(card);
assert.equal(card.hidden, false);
});
it("should hide card ONLY if both sources are failed (not healthy and not pending)", () => {
card.dataset.urlHealthState = "failed";
card.dataset.streamHealthState = "failed";
card.hidden = false;
updateVideoCardSourceVisibility(card);
assert.equal(card.hidden, true);
assert.equal(card.dataset.sourceVisibility, "hidden");
});
it("should recover from hidden state when source becomes healthy", () => {
// First failed
card.dataset.urlHealthState = "failed";
card.dataset.streamHealthState = "failed";
updateVideoCardSourceVisibility(card);
assert.equal(card.hidden, true);
// Then healthy
card.dataset.urlHealthState = "healthy";
updateVideoCardSourceVisibility(card);
assert.equal(card.hidden, false);
assert.equal(card.dataset.sourceVisibility, "visible");
});
it("should check closest .card if element is not .card itself", () => {
const container = document.createElement("div");
container.classList.add("card");
const inner = document.createElement("div");
container.appendChild(inner);
// We need to attach to DOM or mock closest if not attached?
// JSDOM supports closest on detached elements too usually.
// Fail state on container
container.dataset.urlHealthState = "failed";
container.dataset.streamHealthState = "failed";
updateVideoCardSourceVisibility(inner);
assert.equal(container.hidden, true);
});
});
});
+109
View File
@@ -0,0 +1,109 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import "../test-setup.mjs"; // Setup JSDOM
import { escapeHTML, removeTrackingScripts } from "../../js/utils/domUtils.js";
describe("domUtils", () => {
describe("escapeHTML", () => {
it("should return empty string for null or undefined", () => {
assert.equal(escapeHTML(null), "");
assert.equal(escapeHTML(undefined), "");
});
it("should return the string as is if no special characters are present", () => {
assert.equal(escapeHTML("hello world"), "hello world");
assert.equal(escapeHTML("12345"), "12345");
});
it("should escape special characters", () => {
assert.equal(escapeHTML("&"), "&amp;");
assert.equal(escapeHTML("<"), "&lt;");
assert.equal(escapeHTML(">"), "&gt;");
assert.equal(escapeHTML('"'), "&quot;");
assert.equal(escapeHTML("'"), "&#039;");
});
it("should escape multiple occurrences of special characters", () => {
assert.equal(escapeHTML("<script>alert('xss')</script>"), "&lt;script&gt;alert(&#039;xss&#039;)&lt;/script&gt;");
assert.equal(escapeHTML("foo & bar & baz"), "foo &amp; bar &amp; baz");
});
it("should handle mixed content correctly", () => {
const input = '<div class="test">It\'s me & you</div>';
const expected = '&lt;div class=&quot;test&quot;&gt;It&#039;s me &amp; you&lt;/div&gt;';
assert.equal(escapeHTML(input), expected);
});
it("should convert non-string inputs to string and escape", () => {
assert.equal(escapeHTML(123), "123");
// This behavior depends on String(obj), but let's test a simple object if needed.
// Ideally escapeHTML expects a string, but the implementation does String(unsafe).
assert.equal(escapeHTML(true), "true");
});
});
describe("removeTrackingScripts", () => {
it("should do nothing if root is null or undefined", () => {
// Should not throw
removeTrackingScripts(null);
removeTrackingScripts(undefined);
});
it("should do nothing if root has no querySelectorAll", () => {
removeTrackingScripts({});
});
it("should remove scripts matching the tracking pattern", () => {
const container = document.createElement("div");
const trackScript = document.createElement("script");
trackScript.src = "https://example.com/tracking.js";
container.appendChild(trackScript);
const trackScript2 = document.createElement("script");
trackScript2.src = "/tracking.js?id=123";
container.appendChild(trackScript2);
const safeScript = document.createElement("script");
safeScript.src = "https://example.com/app.js";
container.appendChild(safeScript);
removeTrackingScripts(container);
assert.equal(container.querySelectorAll("script").length, 1);
assert.equal(container.querySelector("script").src, "https://example.com/app.js");
});
it("should not remove inline scripts (no src)", () => {
const container = document.createElement("div");
const inlineScript = document.createElement("script");
inlineScript.textContent = "console.log('hello')";
container.appendChild(inlineScript);
removeTrackingScripts(container);
assert.equal(container.querySelectorAll("script").length, 1);
assert.equal(container.querySelector("script").textContent, "console.log('hello')");
});
it("should remove scripts where src ends with tracking.js", () => {
const container = document.createElement("div");
const script = document.createElement("script");
script.src = "http://bad.com/tracking.js";
container.appendChild(script);
removeTrackingScripts(container);
assert.equal(container.querySelectorAll("script").length, 0);
});
it("should remove scripts where src contains /tracking.js", () => {
const container = document.createElement("div");
const script = document.createElement("script");
script.src = "http://bad.com/js/tracking.js?v=1";
container.appendChild(script);
removeTrackingScripts(container);
assert.equal(container.querySelectorAll("script").length, 0);
});
});
});
+152
View File
@@ -0,0 +1,152 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveVideoPointer, DEFAULT_VIDEO_KIND } from "../../js/utils/videoPointer.js";
test("resolveVideoPointer returns address pointer with dTag", () => {
const result = resolveVideoPointer({
kind: 30000,
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
dTag: "test-video",
});
assert.ok(result);
assert.equal(result.key, "a:30000:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234:test-video");
assert.deepEqual(result.pointer, [
"a",
"30000:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234:test-video",
]);
assert.equal(result.eventId, "");
});
test("resolveVideoPointer returns address pointer with videoRootId", () => {
const result = resolveVideoPointer({
kind: 30000,
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
videoRootId: "root-id",
});
assert.ok(result);
assert.equal(result.key, "a:30000:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234:root-id");
assert.deepEqual(result.pointer, [
"a",
"30000:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234:root-id",
]);
assert.equal(result.eventId, "");
});
test("resolveVideoPointer returns event pointer with fallbackEventId", () => {
const eventId = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
const result = resolveVideoPointer({
fallbackEventId: eventId,
});
assert.ok(result);
assert.equal(result.key, `e:${eventId}`);
assert.deepEqual(result.pointer, ["e", eventId]);
assert.equal(result.eventId, eventId);
});
test("resolveVideoPointer prioritizes dTag over videoRootId", () => {
const result = resolveVideoPointer({
kind: 30000,
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
dTag: "priority-dtag",
videoRootId: "ignored-root",
});
assert.ok(result);
assert.equal(result.key, "a:30000:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234:priority-dtag");
});
test("resolveVideoPointer prioritizes videoRootId over fallbackEventId", () => {
const result = resolveVideoPointer({
kind: 30000,
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
videoRootId: "priority-root",
fallbackEventId: "ignored-fallback",
});
assert.ok(result);
assert.equal(result.key, "a:30000:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234:priority-root");
});
test("resolveVideoPointer prioritizes dTag over fallbackEventId", () => {
const result = resolveVideoPointer({
kind: 30000,
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
dTag: "priority-dtag",
fallbackEventId: "ignored-fallback",
});
assert.ok(result);
assert.equal(result.key, "a:30000:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234:priority-dtag");
});
test("resolveVideoPointer includes relay in pointer", () => {
const result = resolveVideoPointer({
kind: 30000,
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
dTag: "test-video",
relay: "wss://relay.example.com",
});
assert.ok(result);
// Note: pointerArrayToKey includes relay if present
assert.equal(result.key, "a:30000:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234:test-video:wss://relay.example.com");
assert.deepEqual(result.pointer, [
"a",
"30000:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234:test-video",
"wss://relay.example.com",
]);
});
test("resolveVideoPointer normalizes inputs", () => {
const result = resolveVideoPointer({
kind: 30000,
pubkey: " ABCD1234ABCD1234ABCD1234ABCD1234ABCD1234ABCD1234ABCD1234ABCD1234 ",
dTag: " test-video ",
relay: " wss://relay.example.com ",
});
assert.ok(result);
assert.equal(result.key, "a:30000:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234:test-video:wss://relay.example.com");
});
test("resolveVideoPointer uses default kind when kind is missing or invalid", () => {
const pubkey = "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234";
// Missing kind
let result = resolveVideoPointer({
pubkey,
dTag: "test",
});
assert.equal(result.key, `a:${DEFAULT_VIDEO_KIND}:${pubkey}:test`);
// Invalid kind (string)
result = resolveVideoPointer({
kind: "30000",
pubkey,
dTag: "test",
});
assert.equal(result.key, `a:${DEFAULT_VIDEO_KIND}:${pubkey}:test`);
// Invalid kind (NaN)
result = resolveVideoPointer({
kind: NaN,
pubkey,
dTag: "test",
});
assert.equal(result.key, `a:${DEFAULT_VIDEO_KIND}:${pubkey}:test`);
});
test("resolveVideoPointer returns null for invalid inputs", () => {
assert.equal(resolveVideoPointer(), null);
assert.equal(resolveVideoPointer({}), null);
// Missing pubkey for address pointer
assert.equal(resolveVideoPointer({ dTag: "test" }), null);
assert.equal(resolveVideoPointer({ videoRootId: "test" }), null);
// Missing identifier for address pointer
assert.equal(resolveVideoPointer({ pubkey: "abcd" }), null);
});
+128
View File
@@ -0,0 +1,128 @@
import "./test-helpers/setup-localstorage.mjs";
import { test, describe, it, before, after } from "node:test";
import assert from "node:assert";
import EventEmitter from "events";
import { TorrentClient } from "../js/webtorrent.js";
// Mock JSDOM-like environment if needed
if (!global.window) {
global.window = new EventEmitter();
global.window.navigator = { userAgent: "Node" };
}
class MockFile {
constructor(name) {
this.name = name;
}
streamTo(elem, opts) {
elem.opts = opts; // Capture opts for verification
elem.src = "blob:mock-stream";
// Simulate ready state progression
setTimeout(() => {
elem.readyState = 3;
elem.emit('canplay');
elem.emit('loadeddata');
}, 10);
}
}
class MockTorrent extends EventEmitter {
constructor(magnet, opts) {
super();
this.magnet = magnet;
this.files = [new MockFile("video.mp4")];
this._opts = opts || {};
}
destroy() {}
}
class MockVideoElement extends EventEmitter {
constructor() {
super();
this.playCalled = false;
this.crossOrigin = null;
this.readyState = 0;
this.muted = false;
this.error = null;
}
play() {
this.playCalled = true;
return Promise.resolve();
}
addEventListener(event, handler, opts) {
this.on(event, handler);
}
removeEventListener(event, handler) {
this.off(event, handler);
}
}
describe("TorrentClient Handlers", () => {
let client;
let videoElement;
let torrent;
before(() => {
client = new TorrentClient();
// Silence logs for tests
client.log = () => {};
});
it("handleTorrentStream (chrome) should set up video correctly", async () => {
videoElement = new MockVideoElement();
torrent = new MockTorrent("magnet:?xt=urn:btih:test");
await new Promise((resolve, reject) => {
client.handleTorrentStream(torrent, videoElement, resolve, reject, "chrome");
});
// Wait for events to fire
await new Promise(r => setTimeout(r, 20));
assert.strictEqual(videoElement.crossOrigin, "anonymous", "crossOrigin should be anonymous");
assert.strictEqual(videoElement.playCalled, true, "play() should be called");
assert.ok(videoElement.src, "src should be set");
assert.strictEqual(client.currentTorrent, torrent, "currentTorrent should be set");
});
it("handleTorrentStream (firefox) should set up video correctly with highWaterMark", async () => {
videoElement = new MockVideoElement();
torrent = new MockTorrent("magnet:?xt=urn:btih:test");
await new Promise((resolve, reject) => {
client.handleTorrentStream(torrent, videoElement, resolve, reject, "firefox");
});
// Wait for events to fire
await new Promise(r => setTimeout(r, 20));
assert.strictEqual(videoElement.crossOrigin, "anonymous", "crossOrigin should be anonymous");
assert.strictEqual(videoElement.playCalled, true, "play() should be called");
assert.ok(videoElement.src, "src should be set");
// Check options directly on videoElement which our mock sets
assert.ok(videoElement.opts, "opts should be set");
assert.strictEqual(videoElement.opts.highWaterMark, 256 * 1024, "highWaterMark should be set for Firefox");
assert.strictEqual(client.currentTorrent, torrent, "currentTorrent should be set");
});
it("handleTorrentStream (chrome) should handle CORS warning logic", () => {
videoElement = new MockVideoElement();
torrent = new MockTorrent("magnet:?xt=urn:btih:test", {
urlList: ["http://distribution.bbb3d.renderfarming.net/video.mp4", "http://good.com/video.mp4"],
announce: ["ws://fastcast.nz", "ws://good.com"]
});
// We don't await because we are testing the event listener setup which happens synchronously
// But we need a dummy resolve/reject
client.handleTorrentStream(torrent, videoElement, () => {}, () => {}, "chrome");
// Emit warning
torrent.emit('warning', { message: 'CORS policy' });
assert.strictEqual(torrent._opts.urlList.length, 1, "Should filter urlList");
assert.strictEqual(torrent._opts.urlList[0], "http://good.com/video.mp4", "Should keep good url");
assert.strictEqual(torrent._opts.announce.length, 1, "Should filter announce");
assert.strictEqual(torrent._opts.announce[0], "ws://good.com", "Should keep good tracker");
});
});