mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-29 17:02:28 +00:00
Merge branch 'unstable' into chore/code-health/remove-unused-promises-app-init-18262315909809877684
This commit is contained in:
46 files changed
+4751
-1541
No files matched your search
@@ -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`);
|
||||
@@ -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
@@ -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" });
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 || ""),
|
||||
|
||||
@@ -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) : [];
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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()));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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:",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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, "—");
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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("&"), "&");
|
||||
assert.equal(escapeHTML("<"), "<");
|
||||
assert.equal(escapeHTML(">"), ">");
|
||||
assert.equal(escapeHTML('"'), """);
|
||||
assert.equal(escapeHTML("'"), "'");
|
||||
});
|
||||
|
||||
it("should escape multiple occurrences of special characters", () => {
|
||||
assert.equal(escapeHTML("<script>alert('xss')</script>"), "<script>alert('xss')</script>");
|
||||
assert.equal(escapeHTML("foo & bar & baz"), "foo & bar & baz");
|
||||
});
|
||||
|
||||
it("should handle mixed content correctly", () => {
|
||||
const input = '<div class="test">It\'s me & you</div>';
|
||||
const expected = '<div class="test">It's me & you</div>';
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user