mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-03 11:22:25 +00:00
Merge pull request #265 from PR0M3TH3AN/codex/create-global-state-and-cache-management-modules
Fix duplicate url health reader in cache module
This commit is contained in:
3 files changed
+994
-542
No files matched your search
@@ -62,10 +62,35 @@ import {
|
||||
removeTrackingScripts,
|
||||
} from "./utils/domUtils.js";
|
||||
import {
|
||||
readUrlHealthFromStorage,
|
||||
removeUrlHealthFromStorage,
|
||||
writeUrlHealthToStorage,
|
||||
} from "./utils/storage.js";
|
||||
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,
|
||||
loadSavedProfilesFromStorage as hydrateSavedProfilesFromStorage,
|
||||
syncSavedProfileFromCache as syncSavedProfileFromCacheState,
|
||||
loadProfileCacheFromStorage as hydrateProfileCacheFromStorage,
|
||||
persistProfileCacheToStorage as persistProfileCacheState,
|
||||
getProfileCacheEntry as getCachedProfileEntry,
|
||||
setProfileCacheEntry as setCachedProfileEntry,
|
||||
getProfileCacheMap,
|
||||
getCachedUrlHealth as readCachedUrlHealth,
|
||||
storeUrlHealth as persistUrlHealth,
|
||||
setInFlightUrlProbe,
|
||||
getInFlightUrlProbe,
|
||||
URL_PROBE_TIMEOUT_MS,
|
||||
urlHealthConstants,
|
||||
} from "./state/cache.js";
|
||||
|
||||
function pointerArrayToKey(pointer) {
|
||||
if (!Array.isArray(pointer) || pointer.length < 2) {
|
||||
@@ -108,147 +133,7 @@ const BITVID_WEBSITE_URL = "https://bitvid.network/";
|
||||
const MAX_DISCUSSION_COUNT_VIDEOS = 24;
|
||||
const VIDEO_EVENT_KIND = 30078;
|
||||
const EMPTY_VIDEO_LIST_SIGNATURE = "__EMPTY__";
|
||||
/**
|
||||
* Local storage keys for cached profile metadata and saved authentication
|
||||
* sessions. `SAVED_PROFILES_STORAGE_KEY` stores objects shaped like
|
||||
* `{ version: 1, entries: [{ pubkey, npub, name, picture, authType }],
|
||||
* activePubkey?: string | null }`. `authType` currently supports `"nip07"`
|
||||
* and reserves `"nsec"` for future direct key flows. See docs/nostr-auth.md
|
||||
* for extension guidance.
|
||||
*/
|
||||
const PROFILE_CACHE_STORAGE_KEY = "bitvid:profileCache:v1";
|
||||
const SAVED_PROFILES_STORAGE_KEY = "bitvid:savedProfiles:v1";
|
||||
const SAVED_PROFILES_STORAGE_VERSION = 1;
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
const PROFILE_CACHE_VERSION = 1;
|
||||
const PROFILE_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
// We probe hosted URLs often enough that a naive implementation would spam
|
||||
// remote CDNs. A medium-lived cache (roughly 45 minutes) keeps us from
|
||||
// hammering dead hosts while still giving the UI timely updates when a CDN
|
||||
// recovers. Results are stored both in-memory and in localStorage so other
|
||||
// views can reuse them without re-probing.
|
||||
const URL_HEALTH_TTL_MS = 45 * 60 * 1000; // 45 minutes
|
||||
const URL_HEALTH_TIMEOUT_RETRY_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const URL_PROBE_TIMEOUT_MS = 8 * 1000; // 8 seconds
|
||||
const URL_PROBE_TIMEOUT_RETRY_MS = 15 * 1000; // 15 seconds
|
||||
const urlHealthCache = new Map();
|
||||
const urlHealthInFlight = new Map();
|
||||
|
||||
function isUrlHealthEntryFresh(entry, url) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (typeof entry.expiresAt !== "number" || entry.expiresAt <= now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (url && entry.url && entry.url !== url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached health result for a given event ID when the entry is
|
||||
* still "fresh". Entries are evicted eagerly once their TTL expires or when
|
||||
* the associated URL changes (e.g. a user edits the post to point elsewhere).
|
||||
*/
|
||||
function readUrlHealthFromCache(eventId, url) {
|
||||
if (!eventId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = urlHealthCache.get(eventId);
|
||||
if (isUrlHealthEntryFresh(entry, url)) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
if (entry) {
|
||||
urlHealthCache.delete(eventId);
|
||||
}
|
||||
|
||||
const stored = readUrlHealthFromStorage(eventId);
|
||||
if (!isUrlHealthEntryFresh(stored, url)) {
|
||||
if (stored) {
|
||||
removeUrlHealthFromStorage(eventId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
urlHealthCache.set(eventId, stored);
|
||||
return stored;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a health result and calculates the expiry moment. The TTL lasts for
|
||||
* roughly three quarters of an hour so we recover quickly when a CDN returns
|
||||
* while still avoiding redundant probes during render thrash.
|
||||
*/
|
||||
function writeUrlHealthToCache(eventId, url, result, ttlMs = URL_HEALTH_TTL_MS) {
|
||||
if (!eventId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ttl = typeof ttlMs === "number" && ttlMs > 0 ? ttlMs : URL_HEALTH_TTL_MS;
|
||||
const now = Date.now();
|
||||
const entry = {
|
||||
status: result?.status || "checking",
|
||||
message: result?.message || "Checking hosted URL…",
|
||||
url: url || result?.url || "",
|
||||
expiresAt: now + ttl,
|
||||
lastCheckedAt: now,
|
||||
};
|
||||
urlHealthCache.set(eventId, entry);
|
||||
writeUrlHealthToStorage(eventId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks in-flight probe promises so that multiple cards rendering the same
|
||||
* event simultaneously do not all fire off duplicate requests. The stored
|
||||
* metadata includes the URL so edits invalidate the old request immediately.
|
||||
*/
|
||||
function buildUrlProbeKey(url, options = {}) {
|
||||
const trimmed = typeof url === "string" ? url : "";
|
||||
const mode = options?.confirmPlayable ? "playable" : "basic";
|
||||
return `${trimmed}::${mode}`;
|
||||
}
|
||||
|
||||
function setInFlightUrlProbe(eventId, url, promise, options = {}) {
|
||||
if (!eventId || !promise) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = buildUrlProbeKey(url, options);
|
||||
urlHealthInFlight.set(eventId, { promise, key });
|
||||
promise.finally(() => {
|
||||
const current = urlHealthInFlight.get(eventId);
|
||||
if (current && current.promise === promise) {
|
||||
urlHealthInFlight.delete(eventId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getInFlightUrlProbe(eventId, url, options = {}) {
|
||||
if (!eventId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = urlHealthInFlight.get(eventId);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = buildUrlProbeKey(url, options);
|
||||
if (entry.key && entry.key !== key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.promise;
|
||||
}
|
||||
// NOTE: The modal uses a "please stand by" animated poster while the torrent
|
||||
// or direct URL boots up. We've regressed multiple times by forgetting to clear
|
||||
// that poster once playback starts, which leaves the loading GIF covering the
|
||||
@@ -457,6 +342,30 @@ class MediaLoader {
|
||||
}
|
||||
|
||||
class bitvidApp {
|
||||
get pubkey() {
|
||||
return getStoredPubkey();
|
||||
}
|
||||
|
||||
set pubkey(value) {
|
||||
setStoredPubkey(value ?? null);
|
||||
}
|
||||
|
||||
get currentUserNpub() {
|
||||
return getStoredCurrentUserNpub();
|
||||
}
|
||||
|
||||
set currentUserNpub(value) {
|
||||
setStoredCurrentUserNpub(value ?? null);
|
||||
}
|
||||
|
||||
get currentVideo() {
|
||||
return getStoredCurrentVideo();
|
||||
}
|
||||
|
||||
set currentVideo(value) {
|
||||
setStoredCurrentVideo(value ?? null);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Basic auth/display elements
|
||||
this.loginButton = document.getElementById("loginButton") || null;
|
||||
@@ -668,123 +577,39 @@ class bitvidApp {
|
||||
this.moreMenuGlobalHandlerBound = false;
|
||||
this.boundMoreMenuDocumentClick = null;
|
||||
this.boundMoreMenuDocumentKeydown = null;
|
||||
// Simple cache for user profiles
|
||||
this.profileCache = new Map();
|
||||
this.savedProfiles = [];
|
||||
this.activeProfilePubkey = null;
|
||||
this.readSavedProfilesPayloadFromStorage = () => {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return null;
|
||||
}
|
||||
Object.defineProperty(this, "savedProfiles", {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
get() {
|
||||
return getSavedProfiles();
|
||||
},
|
||||
set(next) {
|
||||
setSavedProfiles(Array.isArray(next) ? next : [], {
|
||||
persist: false,
|
||||
persistActive: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const raw = localStorage.getItem(SAVED_PROFILES_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
Object.defineProperty(this, "activeProfilePubkey", {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
get() {
|
||||
return getActiveProfilePubkey();
|
||||
},
|
||||
set(value) {
|
||||
setStoredActiveProfilePubkey(value, { persist: false });
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
console.warn("[readSavedProfilesPayloadFromStorage] Failed to parse payload:", err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
this.writeSavedProfilesPayloadToStorage = (payload) => {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
Object.defineProperty(this, "profileCache", {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
get() {
|
||||
return getProfileCacheMap();
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
localStorage.setItem(
|
||||
SAVED_PROFILES_STORAGE_KEY,
|
||||
JSON.stringify(payload)
|
||||
);
|
||||
} catch (err) {
|
||||
const isQuotaError =
|
||||
err &&
|
||||
(err.name === "QuotaExceededError" ||
|
||||
err.name === "NS_ERROR_DOM_QUOTA_REACHED" ||
|
||||
err.code === 22 ||
|
||||
err.code === 1014);
|
||||
if (isQuotaError) {
|
||||
console.warn(
|
||||
"[writeSavedProfilesPayloadToStorage] Storage quota exceeded while saving profiles; keeping in-memory copy only.",
|
||||
err
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
"[writeSavedProfilesPayloadToStorage] Failed to persist saved profiles:",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
this.persistSavedProfiles = ({ persistActive = true } = {}) => {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.savedProfiles.length && !this.activeProfilePubkey) {
|
||||
try {
|
||||
localStorage.removeItem(SAVED_PROFILES_STORAGE_KEY);
|
||||
} catch (err) {
|
||||
console.warn("[persistSavedProfiles] Failed to remove empty payload:", err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let activePubkeyToPersist = this.activeProfilePubkey || null;
|
||||
if (!persistActive) {
|
||||
const storedPayload = this.readSavedProfilesPayloadFromStorage();
|
||||
if (storedPayload && typeof storedPayload === "object") {
|
||||
const candidate =
|
||||
typeof storedPayload.activePubkey === "string"
|
||||
? storedPayload.activePubkey
|
||||
: typeof storedPayload.activePubKey === "string"
|
||||
? storedPayload.activePubKey
|
||||
: null;
|
||||
const normalizedStored = this.normalizeHexPubkey(candidate);
|
||||
if (normalizedStored) {
|
||||
activePubkeyToPersist = normalizedStored;
|
||||
} else if (candidate === null) {
|
||||
activePubkeyToPersist = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
version: SAVED_PROFILES_STORAGE_VERSION,
|
||||
entries: this.savedProfiles.map((entry) => ({
|
||||
pubkey: entry.pubkey,
|
||||
npub:
|
||||
typeof entry.npub === "string" && entry.npub.trim()
|
||||
? entry.npub.trim()
|
||||
: null,
|
||||
name: typeof entry.name === "string" ? entry.name : "",
|
||||
picture:
|
||||
typeof entry.picture === "string" ? entry.picture : "",
|
||||
authType: entry.authType === "nsec" ? "nsec" : "nip07",
|
||||
})),
|
||||
activePubkey: activePubkeyToPersist,
|
||||
};
|
||||
|
||||
this.writeSavedProfilesPayloadToStorage(payload);
|
||||
};
|
||||
this.persistActiveProfileSelection = (
|
||||
pubkey,
|
||||
{ persist = true } = {}
|
||||
) => {
|
||||
const normalized = this.normalizeHexPubkey(pubkey);
|
||||
this.activeProfilePubkey = normalized;
|
||||
if (persist) {
|
||||
this.persistSavedProfiles({ persistActive: true });
|
||||
}
|
||||
this.renderSavedProfiles();
|
||||
};
|
||||
this.lastRenderedVideoSignature = null;
|
||||
this._lastRenderedVideoListElement = null;
|
||||
this.renderedVideoIds = new Set();
|
||||
@@ -813,303 +638,61 @@ class bitvidApp {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.unsubscribeFromPubkeyState = subscribeToAppStateKey(
|
||||
"pubkey",
|
||||
(next, previous) => {
|
||||
if (next !== previous) {
|
||||
this.renderSavedProfiles();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
this.unsubscribeFromCurrentUserState = subscribeToAppStateKey(
|
||||
"currentUserNpub",
|
||||
(next) => {
|
||||
if (this.userPubKey) {
|
||||
this.userPubKey.textContent = next || "";
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
loadSavedProfilesFromStorage() {
|
||||
this.savedProfiles = [];
|
||||
this.activeProfilePubkey = null;
|
||||
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
const { profiles } = hydrateSavedProfilesFromStorage();
|
||||
if (!getActiveProfilePubkey() && Array.isArray(profiles) && profiles.length) {
|
||||
setStoredActiveProfilePubkey(profiles[0].pubkey, { persist: true });
|
||||
}
|
||||
|
||||
const seenPubkeys = new Set();
|
||||
let needsRewrite = false;
|
||||
let parsed = null;
|
||||
const raw = localStorage.getItem(SAVED_PROFILES_STORAGE_KEY);
|
||||
if (raw) {
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
console.warn("Failed to parse saved profiles payload:", err);
|
||||
needsRewrite = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
parsed.version === SAVED_PROFILES_STORAGE_VERSION &&
|
||||
Array.isArray(parsed.entries)
|
||||
) {
|
||||
for (const candidate of parsed.entries) {
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
needsRewrite = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedPubkey = this.normalizeHexPubkey(candidate.pubkey);
|
||||
if (!normalizedPubkey) {
|
||||
needsRewrite = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seenPubkeys.has(normalizedPubkey)) {
|
||||
needsRewrite = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
seenPubkeys.add(normalizedPubkey);
|
||||
|
||||
const storedAuthType =
|
||||
candidate.authType === "nsec" ? "nsec" : "nip07";
|
||||
if (candidate.authType !== storedAuthType) {
|
||||
needsRewrite = true;
|
||||
}
|
||||
|
||||
const entry = {
|
||||
pubkey: normalizedPubkey,
|
||||
npub:
|
||||
typeof candidate.npub === "string" && candidate.npub.trim()
|
||||
? candidate.npub.trim()
|
||||
: this.safeEncodeNpub(normalizedPubkey),
|
||||
name:
|
||||
typeof candidate.name === "string" ? candidate.name : "",
|
||||
picture:
|
||||
typeof candidate.picture === "string" ? candidate.picture : "",
|
||||
authType: storedAuthType,
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
this.savedProfiles.push(entry);
|
||||
}
|
||||
|
||||
const activeCandidate =
|
||||
typeof parsed.activePubkey === "string"
|
||||
? parsed.activePubkey
|
||||
: typeof parsed.activePubKey === "string"
|
||||
? parsed.activePubKey
|
||||
: null;
|
||||
const normalizedActive = this.normalizeHexPubkey(activeCandidate);
|
||||
if (normalizedActive && seenPubkeys.has(normalizedActive)) {
|
||||
this.activeProfilePubkey = normalizedActive;
|
||||
} else if (activeCandidate) {
|
||||
needsRewrite = true;
|
||||
}
|
||||
} else if (raw) {
|
||||
needsRewrite = true;
|
||||
}
|
||||
|
||||
if (!this.activeProfilePubkey && this.savedProfiles.length) {
|
||||
this.activeProfilePubkey = this.savedProfiles[0].pubkey;
|
||||
needsRewrite = true;
|
||||
}
|
||||
|
||||
if (needsRewrite) {
|
||||
this.persistSavedProfiles();
|
||||
}
|
||||
|
||||
this.renderSavedProfiles();
|
||||
}
|
||||
|
||||
syncSavedProfileFromCache(pubkey, { persist = false } = {}) {
|
||||
const normalized = this.normalizeHexPubkey(pubkey);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
const updated = syncSavedProfileFromCacheState(pubkey, { persist });
|
||||
if (updated) {
|
||||
this.renderSavedProfiles();
|
||||
}
|
||||
|
||||
const cacheEntry = this.profileCache.get(normalized);
|
||||
if (!cacheEntry || typeof cacheEntry !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const index = this.savedProfiles.findIndex(
|
||||
(entry) => entry && entry.pubkey === normalized
|
||||
);
|
||||
if (index < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existing = this.savedProfiles[index] || {};
|
||||
const profile = cacheEntry.profile || {};
|
||||
const nextEntry = {
|
||||
...existing,
|
||||
name: profile.name || existing.name || "",
|
||||
picture: profile.picture || existing.picture || "",
|
||||
};
|
||||
|
||||
const changed =
|
||||
existing.name !== nextEntry.name || existing.picture !== nextEntry.picture;
|
||||
|
||||
if (!changed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.savedProfiles[index] = nextEntry;
|
||||
|
||||
if (persist) {
|
||||
this.persistSavedProfiles({ persistActive: false });
|
||||
}
|
||||
|
||||
this.renderSavedProfiles();
|
||||
return true;
|
||||
return updated;
|
||||
}
|
||||
|
||||
loadProfileCacheFromStorage() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const raw = localStorage.getItem(PROFILE_CACHE_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.version !== PROFILE_CACHE_VERSION) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = parsed.entries;
|
||||
if (!entries || typeof entries !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [pubkey, entry] of Object.entries(entries)) {
|
||||
if (!pubkey || !entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0;
|
||||
if (!timestamp || now - timestamp > PROFILE_CACHE_TTL_MS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const profile = entry.profile;
|
||||
if (!profile || typeof profile !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
name: profile.name || profile.display_name || "Unknown",
|
||||
picture: profile.picture || "assets/svg/default-profile.svg",
|
||||
};
|
||||
|
||||
this.profileCache.set(pubkey, {
|
||||
profile: normalized,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to parse stored profile cache:", err);
|
||||
}
|
||||
hydrateProfileCacheFromStorage();
|
||||
}
|
||||
|
||||
persistProfileCacheToStorage() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const entries = {};
|
||||
|
||||
for (const [pubkey, entry] of this.profileCache.entries()) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0;
|
||||
if (!timestamp || now - timestamp > PROFILE_CACHE_TTL_MS) {
|
||||
this.profileCache.delete(pubkey);
|
||||
continue;
|
||||
}
|
||||
|
||||
entries[pubkey] = {
|
||||
profile: entry.profile,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const payload = {
|
||||
version: PROFILE_CACHE_VERSION,
|
||||
savedAt: now,
|
||||
entries,
|
||||
};
|
||||
|
||||
if (Object.keys(entries).length === 0) {
|
||||
try {
|
||||
localStorage.removeItem(PROFILE_CACHE_STORAGE_KEY);
|
||||
} catch (err) {
|
||||
console.warn("Failed to clear profile cache storage:", err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(PROFILE_CACHE_STORAGE_KEY, JSON.stringify(payload));
|
||||
} catch (err) {
|
||||
console.warn("Failed to persist profile cache:", err);
|
||||
}
|
||||
persistProfileCacheState();
|
||||
}
|
||||
|
||||
getProfileCacheEntry(pubkey) {
|
||||
const normalized = this.normalizeHexPubkey(pubkey);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
return getCachedProfileEntry(pubkey);
|
||||
}
|
||||
|
||||
const entry = this.profileCache.get(normalized);
|
||||
setProfileCacheEntry(pubkey, profile) {
|
||||
const entry = setCachedProfileEntry(pubkey, profile);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0;
|
||||
if (!timestamp || Date.now() - timestamp > PROFILE_CACHE_TTL_MS) {
|
||||
this.profileCache.delete(normalized);
|
||||
this.persistProfileCacheToStorage();
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
setProfileCacheEntry(pubkey, profile) {
|
||||
const normalizedPubkey = this.normalizeHexPubkey(pubkey);
|
||||
if (!normalizedPubkey || !profile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
name: profile.name || profile.display_name || "Unknown",
|
||||
picture: profile.picture || "assets/svg/default-profile.svg",
|
||||
};
|
||||
|
||||
const entry = {
|
||||
profile: normalized,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.profileCache.set(normalizedPubkey, entry);
|
||||
this.persistProfileCacheToStorage();
|
||||
const didUpdateSavedProfile = this.syncSavedProfileFromCache(normalizedPubkey, {
|
||||
persist: true,
|
||||
});
|
||||
@@ -1119,6 +702,11 @@ class bitvidApp {
|
||||
return entry;
|
||||
}
|
||||
|
||||
persistActiveProfileSelection(pubkey, { persist = true } = {}) {
|
||||
setStoredActiveProfilePubkey(pubkey, { persist });
|
||||
this.renderSavedProfiles();
|
||||
}
|
||||
|
||||
prepareForViewLoad() {
|
||||
if (this._videoListElement && this._videoListClickHandler) {
|
||||
try {
|
||||
@@ -1465,6 +1053,7 @@ class bitvidApp {
|
||||
this.playerModal;
|
||||
scrollRegion.scrollTop = 0;
|
||||
}
|
||||
setGlobalModalState("player", true);
|
||||
this.applyModalLoadingPoster();
|
||||
}
|
||||
|
||||
@@ -1638,6 +1227,7 @@ class bitvidApp {
|
||||
this.closeUploadModalBtn.addEventListener("click", () => {
|
||||
if (this.uploadModal) {
|
||||
this.uploadModal.classList.add("hidden");
|
||||
setGlobalModalState("upload", false);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1831,6 +1421,7 @@ class bitvidApp {
|
||||
showEditVideoModal() {
|
||||
if (this.editVideoModal) {
|
||||
this.editVideoModal.classList.remove("hidden");
|
||||
setGlobalModalState("editVideo", true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1838,6 +1429,7 @@ class bitvidApp {
|
||||
if (this.editVideoModal) {
|
||||
this.editVideoModal.classList.add("hidden");
|
||||
}
|
||||
setGlobalModalState("editVideo", false);
|
||||
this.resetEditVideoForm();
|
||||
}
|
||||
|
||||
@@ -2006,6 +1598,7 @@ class bitvidApp {
|
||||
showRevertVideoModal() {
|
||||
if (this.revertVideoModal) {
|
||||
this.revertVideoModal.classList.remove("hidden");
|
||||
setGlobalModalState("revertVideo", true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2013,6 +1606,7 @@ class bitvidApp {
|
||||
if (this.revertVideoModal) {
|
||||
this.revertVideoModal.classList.add("hidden");
|
||||
}
|
||||
setGlobalModalState("revertVideo", false);
|
||||
this.resetRevertVideoModal();
|
||||
}
|
||||
|
||||
@@ -4493,6 +4087,7 @@ class bitvidApp {
|
||||
|
||||
this.profileModal.classList.remove("hidden");
|
||||
this.profileModal.setAttribute("aria-hidden", "false");
|
||||
setGlobalModalState("profile", true);
|
||||
|
||||
this.lastFocusedBeforeProfileModal =
|
||||
document.activeElement instanceof HTMLElement
|
||||
@@ -4591,6 +4186,7 @@ class bitvidApp {
|
||||
this.profileModal.classList.add("hidden");
|
||||
}
|
||||
this.profileModal.setAttribute("aria-hidden", "true");
|
||||
setGlobalModalState("profile", false);
|
||||
|
||||
if (this.boundProfileModalKeydown) {
|
||||
this.profileModal.removeEventListener(
|
||||
@@ -5137,7 +4733,7 @@ class bitvidApp {
|
||||
authType: "nip07",
|
||||
});
|
||||
|
||||
this.persistSavedProfiles({ persistActive: false });
|
||||
persistSavedProfiles({ persistActive: false });
|
||||
this.renderSavedProfiles();
|
||||
|
||||
this.showSuccess("Profile added. Select it when you're ready to switch.");
|
||||
@@ -5895,6 +5491,7 @@ class bitvidApp {
|
||||
this.uploadButton.addEventListener("click", () => {
|
||||
if (this.uploadModal) {
|
||||
this.uploadModal.classList.remove("hidden");
|
||||
setGlobalModalState("upload", true);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -5906,6 +5503,7 @@ class bitvidApp {
|
||||
const loginModal = document.getElementById("loginModal");
|
||||
if (loginModal) {
|
||||
loginModal.classList.remove("hidden");
|
||||
setGlobalModalState("login", true);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -5917,6 +5515,7 @@ class bitvidApp {
|
||||
const loginModal = document.getElementById("loginModal");
|
||||
if (loginModal) {
|
||||
loginModal.classList.add("hidden");
|
||||
setGlobalModalState("login", false);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -5952,6 +5551,7 @@ class bitvidApp {
|
||||
const loginModal = document.getElementById("loginModal");
|
||||
if (loginModal) {
|
||||
loginModal.classList.add("hidden");
|
||||
setGlobalModalState("login", false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[NIP-07 login error]", err);
|
||||
@@ -6006,6 +5606,7 @@ class bitvidApp {
|
||||
const loginModal = document.getElementById("loginModal");
|
||||
if (loginModal) {
|
||||
loginModal.classList.add("hidden");
|
||||
setGlobalModalState("login", false);
|
||||
}
|
||||
// Show the application modal
|
||||
const appModal = document.getElementById("nostrFormModal");
|
||||
@@ -6311,6 +5912,7 @@ class bitvidApp {
|
||||
}
|
||||
if (suppressModalClose !== true && this.uploadModal) {
|
||||
this.uploadModal.classList.add("hidden");
|
||||
setGlobalModalState("upload", false);
|
||||
}
|
||||
await this.loadVideos();
|
||||
this.showSuccess("Video shared successfully!");
|
||||
@@ -6528,7 +6130,7 @@ class bitvidApp {
|
||||
this.activeProfilePubkey = null;
|
||||
}
|
||||
|
||||
this.persistSavedProfiles();
|
||||
persistSavedProfiles();
|
||||
this.renderSavedProfiles();
|
||||
}
|
||||
|
||||
@@ -6584,7 +6186,7 @@ class bitvidApp {
|
||||
}
|
||||
|
||||
if (reordered) {
|
||||
this.persistSavedProfiles({ persistActive: true });
|
||||
persistSavedProfiles({ persistActive: true });
|
||||
}
|
||||
|
||||
this.renderSavedProfiles();
|
||||
@@ -6749,7 +6351,7 @@ class bitvidApp {
|
||||
});
|
||||
|
||||
if (!persistActive && savedProfilesMutated) {
|
||||
this.persistSavedProfiles({ persistActive: false });
|
||||
persistSavedProfiles({ persistActive: false });
|
||||
}
|
||||
} else {
|
||||
if (persistActive) {
|
||||
@@ -6760,7 +6362,7 @@ class bitvidApp {
|
||||
}
|
||||
|
||||
if (!normalizedPubkey && savedProfilesMutated) {
|
||||
this.persistSavedProfiles({ persistActive: false });
|
||||
persistSavedProfiles({ persistActive: false });
|
||||
}
|
||||
|
||||
await this.applyPostLoginState();
|
||||
@@ -8117,6 +7719,7 @@ class bitvidApp {
|
||||
}
|
||||
document.body.classList.remove("modal-open");
|
||||
document.documentElement.classList.remove("modal-open");
|
||||
setGlobalModalState("player", false);
|
||||
if (typeof this.modalPosterCleanup === "function") {
|
||||
this.modalPosterCleanup();
|
||||
this.modalPosterCleanup = null;
|
||||
@@ -8332,11 +7935,11 @@ class bitvidApp {
|
||||
}
|
||||
|
||||
getCachedUrlHealth(eventId, url) {
|
||||
return readUrlHealthFromCache(eventId, url);
|
||||
return readCachedUrlHealth(eventId, url);
|
||||
}
|
||||
|
||||
storeUrlHealth(eventId, url, result, ttlMs) {
|
||||
return writeUrlHealthToCache(eventId, url, result, ttlMs);
|
||||
return persistUrlHealth(eventId, url, result, ttlMs);
|
||||
}
|
||||
|
||||
updateUrlHealthBadge(badgeEl, state, videoId) {
|
||||
@@ -8498,7 +8101,7 @@ class bitvidApp {
|
||||
|
||||
const ttlOverride =
|
||||
entry.status === "timeout" || entry.status === "unknown"
|
||||
? URL_HEALTH_TIMEOUT_RETRY_MS
|
||||
? urlHealthConstants.URL_HEALTH_TIMEOUT_RETRY_MS
|
||||
: undefined;
|
||||
|
||||
return this.storeUrlHealth(eventId, trimmedUrl, entry, ttlOverride);
|
||||
@@ -10397,10 +10000,12 @@ class bitvidApp {
|
||||
if (
|
||||
result &&
|
||||
result.outcome === "timeout" &&
|
||||
Number.isFinite(URL_PROBE_TIMEOUT_RETRY_MS) &&
|
||||
URL_PROBE_TIMEOUT_RETRY_MS > initialTimeout
|
||||
Number.isFinite(urlHealthConstants.URL_PROBE_TIMEOUT_RETRY_MS) &&
|
||||
urlHealthConstants.URL_PROBE_TIMEOUT_RETRY_MS > initialTimeout
|
||||
) {
|
||||
const retryResult = await attemptWithTimeout(URL_PROBE_TIMEOUT_RETRY_MS);
|
||||
const retryResult = await attemptWithTimeout(
|
||||
urlHealthConstants.URL_PROBE_TIMEOUT_RETRY_MS
|
||||
);
|
||||
if (retryResult) {
|
||||
result = { ...retryResult, retriedAfterTimeout: true };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
const state = {
|
||||
pubkey: null,
|
||||
currentUserNpub: null,
|
||||
currentVideo: null,
|
||||
modals: Object.create(null),
|
||||
};
|
||||
|
||||
const globalSubscribers = new Set();
|
||||
const keySubscribers = new Map();
|
||||
const modalSubscribers = new Map();
|
||||
|
||||
function createSnapshot() {
|
||||
return {
|
||||
pubkey: state.pubkey,
|
||||
currentUserNpub: state.currentUserNpub,
|
||||
currentVideo: state.currentVideo,
|
||||
modals: { ...state.modals },
|
||||
};
|
||||
}
|
||||
|
||||
function notifyKey(key, value, previousValue) {
|
||||
const subscribers = keySubscribers.get(key);
|
||||
if (subscribers) {
|
||||
for (const callback of subscribers) {
|
||||
try {
|
||||
callback(value, previousValue, createSnapshot());
|
||||
} catch (error) {
|
||||
console.warn(`[appState] subscriber for key "${key}" threw:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (globalSubscribers.size) {
|
||||
const snapshot = createSnapshot();
|
||||
for (const callback of globalSubscribers) {
|
||||
try {
|
||||
callback(snapshot, { key, value, previousValue });
|
||||
} catch (error) {
|
||||
console.warn("[appState] global subscriber threw:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeToKey(key, callback) {
|
||||
if (typeof callback !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (!keySubscribers.has(key)) {
|
||||
keySubscribers.set(key, new Set());
|
||||
}
|
||||
const subscribers = keySubscribers.get(key);
|
||||
subscribers.add(callback);
|
||||
|
||||
return () => {
|
||||
subscribers.delete(callback);
|
||||
if (subscribers.size === 0) {
|
||||
keySubscribers.delete(key);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function subscribeToAppState(callback) {
|
||||
if (typeof callback !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
globalSubscribers.add(callback);
|
||||
return () => {
|
||||
globalSubscribers.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
export function subscribeToAppStateKey(key, callback) {
|
||||
return subscribeToKey(key, callback);
|
||||
}
|
||||
|
||||
function updateKey(key, value) {
|
||||
const previousValue = state[key];
|
||||
if (previousValue === value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
state[key] = value;
|
||||
notifyKey(key, value, previousValue);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getAppState() {
|
||||
return createSnapshot();
|
||||
}
|
||||
|
||||
export function getPubkey() {
|
||||
return state.pubkey;
|
||||
}
|
||||
|
||||
export function setPubkey(value) {
|
||||
return updateKey("pubkey", typeof value === "string" ? value : value ?? null);
|
||||
}
|
||||
|
||||
export function getCurrentUserNpub() {
|
||||
return state.currentUserNpub;
|
||||
}
|
||||
|
||||
export function setCurrentUserNpub(value) {
|
||||
return updateKey(
|
||||
"currentUserNpub",
|
||||
typeof value === "string" && value ? value : value ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function getCurrentVideo() {
|
||||
return state.currentVideo;
|
||||
}
|
||||
|
||||
export function setCurrentVideo(value) {
|
||||
return updateKey("currentVideo", value ?? null);
|
||||
}
|
||||
|
||||
export function getModalState(name) {
|
||||
if (typeof name !== "string" || !name) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(state.modals[name]);
|
||||
}
|
||||
|
||||
function notifyModal(name, value, previousValue) {
|
||||
const subscribers = modalSubscribers.get(name);
|
||||
if (!subscribers || subscribers.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const callback of subscribers) {
|
||||
try {
|
||||
callback(value, previousValue, createSnapshot());
|
||||
} catch (error) {
|
||||
console.warn(`[appState] modal subscriber for "${name}" threw:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setModalState(name, isOpen) {
|
||||
if (typeof name !== "string" || !name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = Boolean(isOpen);
|
||||
const previous = Boolean(state.modals[name]);
|
||||
if (previous === normalized) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const previousModals = { ...state.modals };
|
||||
state.modals[name] = normalized;
|
||||
notifyModal(name, normalized, previous);
|
||||
notifyKey("modals", { ...state.modals }, previousModals);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function subscribeToModalState(name, callback) {
|
||||
if (typeof name !== "string" || !name || typeof callback !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (!modalSubscribers.has(name)) {
|
||||
modalSubscribers.set(name, new Set());
|
||||
}
|
||||
const subscribers = modalSubscribers.get(name);
|
||||
subscribers.add(callback);
|
||||
|
||||
return () => {
|
||||
subscribers.delete(callback);
|
||||
if (subscribers.size === 0) {
|
||||
modalSubscribers.delete(name);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function resetAppState() {
|
||||
const previous = createSnapshot();
|
||||
state.pubkey = null;
|
||||
state.currentUserNpub = null;
|
||||
state.currentVideo = null;
|
||||
state.modals = Object.create(null);
|
||||
notifyKey("pubkey", state.pubkey, previous.pubkey);
|
||||
notifyKey("currentUserNpub", state.currentUserNpub, previous.currentUserNpub);
|
||||
notifyKey("currentVideo", state.currentVideo, previous.currentVideo);
|
||||
notifyKey("modals", { ...state.modals }, previous.modals);
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
import {
|
||||
readUrlHealthFromStorage,
|
||||
removeUrlHealthFromStorage,
|
||||
writeUrlHealthToStorage,
|
||||
} from "../utils/storage.js";
|
||||
|
||||
const PROFILE_CACHE_STORAGE_KEY = "bitvid:profileCache:v1";
|
||||
const PROFILE_CACHE_VERSION = 1;
|
||||
const PROFILE_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
const SAVED_PROFILES_STORAGE_KEY = "bitvid:savedProfiles:v1";
|
||||
const SAVED_PROFILES_STORAGE_VERSION = 1;
|
||||
|
||||
const URL_HEALTH_TTL_MS = 45 * 60 * 1000; // 45 minutes
|
||||
const URL_HEALTH_TIMEOUT_RETRY_MS = 5 * 60 * 1000; // 5 minutes
|
||||
export const URL_PROBE_TIMEOUT_MS = 8 * 1000; // 8 seconds
|
||||
const URL_PROBE_TIMEOUT_RETRY_MS = 15 * 1000; // 15 seconds
|
||||
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
|
||||
let savedProfiles = [];
|
||||
let activeProfilePubkey = null;
|
||||
const profileCache = new Map();
|
||||
const urlHealthCache = new Map();
|
||||
const urlHealthInFlight = new Map();
|
||||
|
||||
function hasSavedProfilesChanged(previousProfiles, nextProfiles) {
|
||||
if (previousProfiles === nextProfiles) {
|
||||
return false;
|
||||
}
|
||||
if (!Array.isArray(previousProfiles) || !Array.isArray(nextProfiles)) {
|
||||
return true;
|
||||
}
|
||||
if (previousProfiles.length !== nextProfiles.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (let index = 0; index < previousProfiles.length; index += 1) {
|
||||
const prev = previousProfiles[index];
|
||||
const next = nextProfiles[index];
|
||||
if (prev === next) {
|
||||
continue;
|
||||
}
|
||||
if (!prev || !next || typeof prev !== "object" || typeof next !== "object") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
|
||||
for (const key of keys) {
|
||||
if (prev[key] !== next[key]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function safeDecodeNpub(npub) {
|
||||
if (typeof npub !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = npub.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = window?.NostrTools?.nip19?.decode(trimmed);
|
||||
if (decoded && decoded.type === "npub" && typeof decoded.data === "string") {
|
||||
return decoded.data;
|
||||
}
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeHexPubkey(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = pubkey.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (HEX64_REGEX.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("npub1")) {
|
||||
const decoded = safeDecodeNpub(trimmed);
|
||||
if (decoded && HEX64_REGEX.test(decoded)) {
|
||||
return decoded.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getSavedProfiles() {
|
||||
return savedProfiles;
|
||||
}
|
||||
|
||||
export function getProfileCacheMap() {
|
||||
return profileCache;
|
||||
}
|
||||
|
||||
export function getActiveProfilePubkey() {
|
||||
return activeProfilePubkey;
|
||||
}
|
||||
|
||||
export function setActiveProfilePubkey(pubkey, { persist = true } = {}) {
|
||||
const normalized = normalizeHexPubkey(pubkey);
|
||||
const nextValue =
|
||||
normalized || (typeof pubkey === "string" && pubkey.trim() ? pubkey.trim() : null);
|
||||
if (activeProfilePubkey === nextValue) {
|
||||
if (persist) {
|
||||
persistSavedProfiles({ persistActive: true });
|
||||
}
|
||||
return activeProfilePubkey;
|
||||
}
|
||||
|
||||
activeProfilePubkey = nextValue;
|
||||
if (persist) {
|
||||
persistSavedProfiles({ persistActive: true });
|
||||
}
|
||||
return activeProfilePubkey;
|
||||
}
|
||||
|
||||
export function mutateSavedProfiles(mutator, { persist = true, persistActive = true } = {}) {
|
||||
if (typeof mutator !== "function") {
|
||||
return { changed: false, profiles: getSavedProfiles() };
|
||||
}
|
||||
|
||||
const draft = savedProfiles.slice();
|
||||
const result = mutator(draft);
|
||||
const nextProfiles = Array.isArray(result) ? result : draft;
|
||||
const changed = hasSavedProfilesChanged(savedProfiles, nextProfiles);
|
||||
|
||||
if (changed) {
|
||||
savedProfiles = nextProfiles;
|
||||
if (persist) {
|
||||
persistSavedProfiles({ persistActive });
|
||||
}
|
||||
} else if (persist && persistActive === false) {
|
||||
persistSavedProfiles({ persistActive: false });
|
||||
}
|
||||
|
||||
return { changed, profiles: getSavedProfiles() };
|
||||
}
|
||||
|
||||
export function setSavedProfiles(nextProfiles, options) {
|
||||
return mutateSavedProfiles(
|
||||
() => (Array.isArray(nextProfiles) ? nextProfiles.slice() : []),
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
export function readSavedProfilesPayloadFromStorage() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = localStorage.getItem(SAVED_PROFILES_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[cache.readSavedProfilesPayloadFromStorage] Failed to parse payload:",
|
||||
error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSavedProfilesPayloadToStorage(payload) {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(SAVED_PROFILES_STORAGE_KEY, JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
const isQuotaError =
|
||||
error &&
|
||||
(error.name === "QuotaExceededError" ||
|
||||
error.name === "NS_ERROR_DOM_QUOTA_REACHED" ||
|
||||
error.code === 22 ||
|
||||
error.code === 1014);
|
||||
if (isQuotaError) {
|
||||
console.warn(
|
||||
"[cache.writeSavedProfilesPayloadToStorage] Storage quota exceeded; keeping in-memory copy only.",
|
||||
error
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
"[cache.writeSavedProfilesPayloadToStorage] Failed to persist saved profiles:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function persistSavedProfiles({ persistActive = true } = {}) {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!savedProfiles.length && !activeProfilePubkey) {
|
||||
try {
|
||||
localStorage.removeItem(SAVED_PROFILES_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[cache.persistSavedProfiles] Failed to remove empty payload:",
|
||||
error
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let activePubkeyToPersist = activeProfilePubkey || null;
|
||||
if (!persistActive) {
|
||||
const storedPayload = readSavedProfilesPayloadFromStorage();
|
||||
if (storedPayload && typeof storedPayload === "object") {
|
||||
const candidate =
|
||||
typeof storedPayload.activePubkey === "string"
|
||||
? storedPayload.activePubkey
|
||||
: typeof storedPayload.activePubKey === "string"
|
||||
? storedPayload.activePubKey
|
||||
: null;
|
||||
const normalizedStored = normalizeHexPubkey(candidate);
|
||||
if (normalizedStored) {
|
||||
activePubkeyToPersist = normalizedStored;
|
||||
} else if (candidate === null) {
|
||||
activePubkeyToPersist = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
version: SAVED_PROFILES_STORAGE_VERSION,
|
||||
entries: savedProfiles.map((entry) => ({
|
||||
pubkey: entry.pubkey,
|
||||
npub:
|
||||
typeof entry.npub === "string" && entry.npub.trim() ? entry.npub.trim() : null,
|
||||
name: typeof entry.name === "string" ? entry.name : "",
|
||||
picture: typeof entry.picture === "string" ? entry.picture : "",
|
||||
authType: entry.authType === "nsec" ? "nsec" : "nip07",
|
||||
})),
|
||||
activePubkey: activePubkeyToPersist,
|
||||
};
|
||||
|
||||
writeSavedProfilesPayloadToStorage(payload);
|
||||
}
|
||||
|
||||
export function loadSavedProfilesFromStorage() {
|
||||
savedProfiles = [];
|
||||
activeProfilePubkey = null;
|
||||
|
||||
if (typeof localStorage === "undefined") {
|
||||
return { profiles: getSavedProfiles(), activePubkey: activeProfilePubkey };
|
||||
}
|
||||
|
||||
const raw = localStorage.getItem(SAVED_PROFILES_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return { profiles: getSavedProfiles(), activePubkey: activeProfilePubkey };
|
||||
}
|
||||
|
||||
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 storedAuthType = candidate.authType === "nsec" ? "nsec" : "nip07";
|
||||
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,
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const activeCandidate =
|
||||
typeof parsed.activePubkey === "string"
|
||||
? parsed.activePubkey
|
||||
: typeof parsed.activePubKey === "string"
|
||||
? parsed.activePubKey
|
||||
: null;
|
||||
const normalizedActive = normalizeHexPubkey(activeCandidate);
|
||||
if (normalizedActive && seenPubkeys.has(normalizedActive)) {
|
||||
activeProfilePubkey = normalizedActive;
|
||||
} else if (activeCandidate) {
|
||||
needsRewrite = true;
|
||||
}
|
||||
} else if (raw) {
|
||||
needsRewrite = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[cache.loadSavedProfilesFromStorage] Failed to parse payload:", error);
|
||||
needsRewrite = true;
|
||||
}
|
||||
|
||||
if (!activeProfilePubkey && savedProfiles.length) {
|
||||
activeProfilePubkey = savedProfiles[0].pubkey;
|
||||
needsRewrite = true;
|
||||
}
|
||||
|
||||
if (needsRewrite) {
|
||||
persistSavedProfiles();
|
||||
}
|
||||
|
||||
return { profiles: getSavedProfiles(), activePubkey: activeProfilePubkey };
|
||||
}
|
||||
|
||||
export function syncSavedProfileFromCache(pubkey, { persist = false } = {}) {
|
||||
const normalized = normalizeHexPubkey(pubkey);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cacheEntry = profileCache.get(normalized);
|
||||
if (!cacheEntry || typeof cacheEntry !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let updated = false;
|
||||
mutateSavedProfiles((profiles) => {
|
||||
const next = profiles.slice();
|
||||
const index = next.findIndex((entry) => entry && entry.pubkey === normalized);
|
||||
if (index < 0) {
|
||||
return profiles;
|
||||
}
|
||||
|
||||
const existing = next[index] || {};
|
||||
const profile = cacheEntry.profile || {};
|
||||
const nextEntry = {
|
||||
...existing,
|
||||
name: profile.name || existing.name || "",
|
||||
picture: profile.picture || existing.picture || "",
|
||||
};
|
||||
|
||||
if (
|
||||
existing.name === nextEntry.name &&
|
||||
existing.picture === nextEntry.picture
|
||||
) {
|
||||
return profiles;
|
||||
}
|
||||
|
||||
next[index] = nextEntry;
|
||||
updated = true;
|
||||
return next;
|
||||
}, { persist, persistActive: false });
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function loadProfileCacheFromStorage() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const raw = localStorage.getItem(PROFILE_CACHE_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.version !== PROFILE_CACHE_VERSION) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = parsed.entries;
|
||||
if (!entries || typeof entries !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [pubkey, entry] of Object.entries(entries)) {
|
||||
if (!pubkey || !entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0;
|
||||
if (!timestamp || now - timestamp > PROFILE_CACHE_TTL_MS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const profile = entry.profile;
|
||||
if (!profile || typeof profile !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
name: profile.name || profile.display_name || "Unknown",
|
||||
picture: profile.picture || "assets/svg/default-profile.svg",
|
||||
};
|
||||
|
||||
profileCache.set(pubkey, {
|
||||
profile: normalized,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[cache.loadProfileCacheFromStorage] Failed to parse payload:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function persistProfileCacheToStorage() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const entries = {};
|
||||
|
||||
for (const [pubkey, entry] of profileCache.entries()) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0;
|
||||
if (!timestamp || now - timestamp > PROFILE_CACHE_TTL_MS) {
|
||||
profileCache.delete(pubkey);
|
||||
continue;
|
||||
}
|
||||
|
||||
entries[pubkey] = {
|
||||
profile: entry.profile,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const payload = {
|
||||
version: PROFILE_CACHE_VERSION,
|
||||
savedAt: now,
|
||||
entries,
|
||||
};
|
||||
|
||||
if (Object.keys(entries).length === 0) {
|
||||
try {
|
||||
localStorage.removeItem(PROFILE_CACHE_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[cache.persistProfileCacheToStorage] Failed to clear storage:",
|
||||
error
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(PROFILE_CACHE_STORAGE_KEY, JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
console.warn("[cache.persistProfileCacheToStorage] Failed to persist cache:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function getProfileCacheEntry(pubkey) {
|
||||
const normalized = normalizeHexPubkey(pubkey);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = profileCache.get(normalized);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0;
|
||||
if (!timestamp || Date.now() - timestamp > PROFILE_CACHE_TTL_MS) {
|
||||
profileCache.delete(normalized);
|
||||
persistProfileCacheToStorage();
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function setProfileCacheEntry(pubkey, profile, { persist = true } = {}) {
|
||||
const normalizedPubkey = normalizeHexPubkey(pubkey);
|
||||
if (!normalizedPubkey || !profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
name: profile.name || profile.display_name || "Unknown",
|
||||
picture: profile.picture || "assets/svg/default-profile.svg",
|
||||
};
|
||||
|
||||
const entry = {
|
||||
profile: normalized,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
profileCache.set(normalizedPubkey, entry);
|
||||
if (persist) {
|
||||
persistProfileCacheToStorage();
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
function buildUrlProbeKey(url, options = {}) {
|
||||
const trimmed = typeof url === "string" ? url : "";
|
||||
const mode = options?.confirmPlayable ? "playable" : "basic";
|
||||
return `${trimmed}::${mode}`;
|
||||
}
|
||||
|
||||
function isUrlHealthEntryFresh(entry, url) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (typeof entry.expiresAt !== "number" || entry.expiresAt <= now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (url && entry.url && entry.url !== url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getCachedUrlHealth(eventId, url) {
|
||||
if (!eventId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = urlHealthCache.get(eventId);
|
||||
if (isUrlHealthEntryFresh(entry, url)) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
if (entry) {
|
||||
urlHealthCache.delete(eventId);
|
||||
}
|
||||
|
||||
const stored = readUrlHealthFromStorage(eventId);
|
||||
if (!isUrlHealthEntryFresh(stored, url)) {
|
||||
if (stored) {
|
||||
removeUrlHealthFromStorage(eventId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
urlHealthCache.set(eventId, stored);
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function storeUrlHealth(eventId, url, result, ttlMs = URL_HEALTH_TTL_MS) {
|
||||
if (!eventId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ttl = typeof ttlMs === "number" && ttlMs > 0 ? ttlMs : URL_HEALTH_TTL_MS;
|
||||
const now = Date.now();
|
||||
const entry = {
|
||||
status: result?.status || "checking",
|
||||
message: result?.message || "Checking hosted URL…",
|
||||
url: url || result?.url || "",
|
||||
expiresAt: now + ttl,
|
||||
lastCheckedAt: now,
|
||||
};
|
||||
urlHealthCache.set(eventId, entry);
|
||||
writeUrlHealthToStorage(eventId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function setInFlightUrlProbe(eventId, url, promise, options = {}) {
|
||||
if (!eventId || !promise) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = buildUrlProbeKey(url, options);
|
||||
urlHealthInFlight.set(eventId, { promise, key });
|
||||
promise.finally(() => {
|
||||
const current = urlHealthInFlight.get(eventId);
|
||||
if (current && current.promise === promise) {
|
||||
urlHealthInFlight.delete(eventId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function getInFlightUrlProbe(eventId, url, options = {}) {
|
||||
if (!eventId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = urlHealthInFlight.get(eventId);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = buildUrlProbeKey(url, options);
|
||||
if (entry.key && entry.key !== key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
export const urlHealthConstants = {
|
||||
URL_HEALTH_TTL_MS,
|
||||
URL_HEALTH_TIMEOUT_RETRY_MS,
|
||||
URL_PROBE_TIMEOUT_RETRY_MS,
|
||||
};
|
||||
Reference in new issue
Block a user