-
-
Switch profiles
-
+
+
+
+
+
-
-
-
+
+
-
-
-
-
+
+
+
+
+
+ Add profile
+ Link another npub
+
+
+
+
Profiles you add here will stay on this device so you can quickly switch before posting or moderating.
diff --git a/css/style.css b/css/style.css
index d36859e4..43d90bef 100644
--- a/css/style.css
+++ b/css/style.css
@@ -269,6 +269,280 @@ header img {
-webkit-backdrop-filter: blur(0.625rem); /* For Safari */
}
+/* --------------------------------------------------------------------------
+ Profile modal account pane
+ --------------------------------------------------------------------------
+ The modal previously mixed Tailwind-style utility classes with our global
+ stylesheet. Those utilities are not always present in the production build,
+ so important layout rules (borders, gaps, flex behaviour) were getting
+ dropped. This block consolidates the account tab styling in one predictable
+ place so future tweaks only require CSS updates here instead of scattered
+ inline class lists.
+--------------------------------------------------------------------------- */
+#profileModal .profile-pane {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+#profileModal .profile-switcher {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+ padding: clamp(1.25rem, 2vw, 1.5rem);
+ border-radius: 1.25rem;
+ border: 0.0625rem solid rgba(148, 163, 184, 0.28);
+ background: linear-gradient(145deg, rgba(15, 23, 42, 0.94), rgba(15, 23, 42, 0.72));
+ box-shadow: 0 1.75rem 3.5rem rgba(8, 11, 30, 0.45);
+}
+
+#profileModal .profile-switcher__header {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+#profileModal .profile-switcher__intro {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+#profileModal .profile-switcher__title {
+ margin: 0;
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--color-text);
+}
+
+#profileModal .profile-switcher__subtitle {
+ margin: 0;
+ font-size: 0.875rem;
+ color: var(--color-muted);
+}
+
+#profileModal .profile-switcher__badge {
+ align-self: flex-start;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.35rem 0.85rem;
+ font-size: 0.625rem;
+ font-weight: 600;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ border-radius: 9999px;
+ background: rgba(59, 130, 246, 0.18);
+ color: rgba(191, 219, 254, 0.95);
+ border: 0.0625rem solid rgba(96, 165, 250, 0.25);
+}
+
+#profileModal .profile-card {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 0.85rem;
+ padding: 1rem;
+ border-radius: 1rem;
+ border: 0.0625rem solid rgba(148, 163, 184, 0.24);
+ background: rgba(15, 23, 42, 0.72);
+ box-shadow: inset 0 1px 0 rgba(148, 163, 184, 0.08);
+ color: var(--color-text);
+ cursor: pointer;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
+ outline: none;
+}
+
+#profileModal .profile-card:hover {
+ border-color: rgba(148, 163, 184, 0.45);
+}
+
+#profileModal .profile-card:focus-visible {
+ border-color: rgba(96, 165, 250, 0.6);
+ box-shadow: 0 0 0 0.1875rem rgba(37, 99, 235, 0.25);
+}
+
+#profileModal .profile-card--active {
+ border-color: rgba(59, 130, 246, 0.55);
+ background: linear-gradient(160deg, rgba(37, 99, 235, 0.22), rgba(15, 23, 42, 0.78));
+ box-shadow: 0 1.35rem 2.8rem rgba(37, 99, 235, 0.22);
+}
+
+#profileModal .profile-card__avatar {
+ flex: 0 0 auto;
+ width: 3rem;
+ height: 3rem;
+ display: grid;
+ place-items: center;
+ border-radius: 9999px;
+ overflow: hidden;
+ border: 0.1875rem solid rgba(148, 163, 184, 0.3);
+ background: rgba(15, 23, 42, 0.95);
+}
+
+#profileModal .profile-card__avatar img {
+ width: 100%;
+ height: 100%;
+ border-radius: inherit;
+ object-fit: cover;
+}
+
+#profileModal .profile-card--active .profile-card__avatar {
+ border-color: rgba(96, 165, 250, 0.7);
+ box-shadow: 0 0 0 0.1875rem rgba(59, 130, 246, 0.18);
+}
+
+#profileModal .profile-card__meta {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+#profileModal .profile-card__topline {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ align-items: flex-start;
+}
+
+#profileModal .profile-card__label {
+ font-size: 0.7rem;
+ font-weight: 600;
+ letter-spacing: 0.2em;
+ text-transform: uppercase;
+ color: rgba(191, 219, 254, 0.9);
+}
+
+#profileModal .profile-card__action {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.3rem 0.8rem;
+ border-radius: 9999px;
+ border: 0.0625rem solid rgba(59, 130, 246, 0.4);
+ background: rgba(59, 130, 246, 0.14);
+ color: rgba(191, 219, 254, 0.95);
+ font-size: 0.65rem;
+ font-weight: 600;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ text-decoration: none;
+ transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
+}
+
+#profileModal .profile-card__action:hover,
+#profileModal .profile-card__action:focus-visible {
+ background: rgba(59, 130, 246, 0.24);
+ border-color: rgba(59, 130, 246, 0.6);
+ color: #ffffff;
+}
+
+#profileModal .profile-card__name {
+ font-size: 0.95rem;
+ font-weight: 600;
+ color: var(--color-text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+#profileModal .profile-card__npub {
+ font-size: 0.75rem;
+ font-family: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ color: rgba(148, 163, 184, 0.85);
+ word-break: break-all;
+}
+
+#profileModal .profile-switcher__list {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+#profileModal .profile-switcher__add {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 1rem;
+ border-radius: 1rem;
+ border: 0.125rem dashed rgba(148, 163, 184, 0.35);
+ background: rgba(15, 23, 42, 0.55);
+ color: rgba(226, 232, 240, 0.85);
+ text-align: left;
+ cursor: pointer;
+ transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
+}
+
+#profileModal .profile-switcher__add:hover,
+#profileModal .profile-switcher__add:focus-visible {
+ border-color: rgba(96, 165, 250, 0.65);
+ background: rgba(59, 130, 246, 0.18);
+ color: #ffffff;
+ outline: none;
+}
+
+#profileModal .profile-switcher__addAvatar {
+ flex: 0 0 auto;
+ width: 3rem;
+ height: 3rem;
+ display: grid;
+ place-items: center;
+ border-radius: 9999px;
+ border: 0.125rem dashed currentColor;
+ color: inherit;
+ background: rgba(15, 23, 42, 0.75);
+}
+
+#profileModal .profile-switcher__addAvatar svg {
+ width: 1.25rem;
+ height: 1.25rem;
+}
+
+#profileModal .profile-switcher__addCopy {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+#profileModal .profile-switcher__addTitle {
+ font-size: 0.9rem;
+ font-weight: 600;
+ color: inherit;
+}
+
+#profileModal .profile-switcher__addHint {
+ font-size: 0.75rem;
+ color: rgba(148, 163, 184, 0.85);
+}
+
+#profileModal .profile-switcher__helper {
+ margin: 0;
+ font-size: 0.75rem;
+ color: rgba(148, 163, 184, 0.7);
+}
+
+@media (min-width: 640px) {
+ #profileModal .profile-switcher__header {
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ #profileModal .profile-card {
+ flex-direction: row;
+ align-items: center;
+ }
+
+ #profileModal .profile-card__topline {
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.75rem;
+ }
+}
+
/* If you ever want to show it, add ".flex" class dynamically */
#playerModal.flex {
display: flex;
From 2b1d5488d67bf8fba72be2407e7cb83622e35c9e Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Mon, 29 Sep 2025 15:58:12 -0400
Subject: [PATCH 10/20] Restore hidden profile panes
---
css/style.css | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/css/style.css b/css/style.css
index 43d90bef..53523690 100644
--- a/css/style.css
+++ b/css/style.css
@@ -285,6 +285,16 @@ header img {
gap: 1.5rem;
}
+/*
+ Ensure the global `.hidden` helper keeps working for the tab panes. Our
+ profile-pane declaration above sets a `display` value with higher
+ specificity than `.hidden`, so we explicitly restore the hidden state when
+ both classes are present.
+*/
+#profileModal .profile-pane.hidden {
+ display: none;
+}
+
#profileModal .profile-switcher {
display: flex;
flex-direction: column;
From 5cfdd79a1872995e47409178afc7575a8ed6931e Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Mon, 29 Sep 2025 17:24:25 -0400
Subject: [PATCH 11/20] Add saved Nostr profile storage helpers
---
docs/nostr-auth.md | 73 ++++++++++
js/app.js | 322 +++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 384 insertions(+), 11 deletions(-)
create mode 100644 docs/nostr-auth.md
diff --git a/docs/nostr-auth.md b/docs/nostr-auth.md
new file mode 100644
index 00000000..c54554d4
--- /dev/null
+++ b/docs/nostr-auth.md
@@ -0,0 +1,73 @@
+# Nostr authentication storage
+
+Bitvid keeps lightweight state in `localStorage` so the UI can remember which
+accounts signed in recently. This document explains the storage schema so future
+updates can evolve it without breaking existing users.
+
+## Storage keys
+
+- `bitvid:profileCache:v1` – short-lived metadata cache for profile names and
+ avatars. See `js/app.js` for TTL and eviction rules.
+- `bitvid:savedProfiles:v1` – persistent list of accounts the user authenticated
+ with in this browser.
+
+Both keys live in the main origin scope. Clearing one should not affect the
+other.
+
+## Saved profile schema
+
+`bitvid:savedProfiles:v1` is a JSON object with the shape:
+
+```json
+{
+ "version": 1,
+ "entries": [
+ {
+ "pubkey": "
",
+ "npub": "",
+ "name": "",
+ "picture": "",
+ "authType": ""
+ }
+ ],
+ "activePubkey": ""
+}
+```
+
+Notes:
+
+- `pubkey` always stores a lowercase 64-character hex string and is the primary
+ dedupe key.
+- `npub` is optional. When omitted, the UI regenerates it on load using
+ `NostrTools.nip19.npubEncode`.
+- `name` and `picture` are cached hints that keep the profile switcher snappy.
+ They may be empty strings and should be treated as hints, not the source of
+ truth.
+- `activePubkey` tracks the last account that successfully authenticated in this
+ browser. It may be `null` when the user logs out but keeps saved entries.
+
+### `authType` enum
+
+`authType` describes how the profile authenticated:
+
+- `"nip07"` – Browser extension flow (current default).
+- `"nsec"` – Reserved for future direct key import or signer integrations. New
+ auth strategies should pick a distinct string and document how migration works
+ alongside any recovery tooling.
+
+When the app reads stored entries it normalises unknown values back to
+`"nip07"` but keeps recognised alternatives intact.
+
+## Migration notes
+
+Earlier builds only persisted a single `userPubKey` string. During startup the
+app now:
+
+1. Attempts to read `bitvid:savedProfiles:v1` and validate the payload.
+2. If the key is missing (or malformed) but a legacy `userPubKey` entry exists,
+ it seeds `savedProfiles` with that value and writes the new structure.
+3. Once the JSON payload is written successfully, the legacy `userPubKey` entry
+ is removed.
+
+Future migrations should follow the same pattern: validate, normalise, write the
+new format, then clean up legacy keys to avoid data loss.
diff --git a/js/app.js b/js/app.js
index 909b6635..8bf1fad4 100644
--- a/js/app.js
+++ b/js/app.js
@@ -70,7 +70,18 @@ const ADMIN_DM_IMAGE_URL =
const BITVID_WEBSITE_URL = "https://bitvid.network/";
const TRACKING_SCRIPT_PATTERN = /(?:^|\/)tracking\.js(?:$|\?)/;
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
@@ -665,6 +676,68 @@ class bitvidApp {
this.boundMoreMenuDocumentKeydown = null;
// Simple cache for user profiles
this.profileCache = new Map();
+ this.savedProfiles = [];
+ this.activeProfilePubkey = null;
+ this.persistSavedProfiles = () => {
+ 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;
+ }
+
+ 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: this.activeProfilePubkey || null,
+ };
+
+ try {
+ localStorage.setItem(
+ SAVED_PROFILES_STORAGE_KEY,
+ JSON.stringify(payload)
+ );
+ try {
+ localStorage.removeItem("userPubKey");
+ } catch (legacyErr) {
+ console.warn(
+ "[persistSavedProfiles] Failed to remove legacy userPubKey entry:",
+ legacyErr
+ );
+ }
+ } 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(
+ "[persistSavedProfiles] Storage quota exceeded while saving profiles; keeping in-memory copy only.",
+ err
+ );
+ } else {
+ console.warn("[persistSavedProfiles] Failed to persist saved profiles:", err);
+ }
+ }
+ };
this.lastRenderedVideoSignature = null;
this._lastRenderedVideoListElement = null;
this.renderedVideoIds = new Set();
@@ -695,6 +768,133 @@ class bitvidApp {
}
}
+ loadSavedProfilesFromStorage() {
+ this.savedProfiles = [];
+ this.activeProfilePubkey = null;
+
+ if (typeof localStorage === "undefined") {
+ return;
+ }
+
+ 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.savedProfiles.length) {
+ const legacyPubkey = localStorage.getItem("userPubKey");
+ const normalizedLegacy = this.normalizeHexPubkey(legacyPubkey);
+ if (normalizedLegacy) {
+ const legacyEntry = {
+ pubkey: normalizedLegacy,
+ npub: this.safeEncodeNpub(normalizedLegacy),
+ name: "",
+ picture: "",
+ authType: "nip07",
+ };
+ this.savedProfiles = [legacyEntry];
+ this.activeProfilePubkey = normalizedLegacy;
+ this.persistSavedProfiles();
+ return;
+ }
+ }
+
+ if (!this.activeProfilePubkey && this.savedProfiles.length) {
+ this.activeProfilePubkey = this.savedProfiles[0].pubkey;
+ needsRewrite = true;
+ }
+
+ if (needsRewrite) {
+ this.persistSavedProfiles();
+ }
+ }
+
loadProfileCacheFromStorage() {
if (typeof localStorage === "undefined") {
return;
@@ -886,6 +1086,7 @@ class bitvidApp {
}
this.loadProfileCacheFromStorage();
+ this.loadSavedProfilesFromStorage();
// 1. Initialize the video modal (components/video-modal.html)
await this.initModal();
@@ -913,7 +1114,8 @@ class bitvidApp {
// Grab the "Subscriptions" link by its id in the sidebar
this.subscriptionsLink = document.getElementById("subscriptionsLink");
- const savedPubKey = localStorage.getItem("userPubKey");
+ const savedPubKey =
+ this.activeProfilePubkey || localStorage.getItem("userPubKey");
if (savedPubKey) {
// Auto-login if a pubkey was saved
try {
@@ -5236,23 +5438,92 @@ class bitvidApp {
});
}
+ /**
+ * Removes a saved profile entry and clears the active pointer when it matches
+ * the removed pubkey. Intended for the profile-switcher UI to prune old
+ * accounts without touching cached avatars.
+ */
+ removeSavedProfile(pubkey) {
+ const normalized = this.normalizeHexPubkey(pubkey);
+ const target = normalized || (typeof pubkey === "string" ? pubkey.trim() : "");
+ if (!target) {
+ return;
+ }
+
+ const nextProfiles = this.savedProfiles.filter(
+ (entry) => entry.pubkey !== target
+ );
+ if (nextProfiles.length === this.savedProfiles.length) {
+ return;
+ }
+
+ this.savedProfiles = nextProfiles;
+
+ if (this.activeProfilePubkey === target) {
+ this.activeProfilePubkey = null;
+ }
+
+ this.persistSavedProfiles();
+ }
+
/**
* Called upon successful login.
*/
async login(pubkey, saveToStorage = true) {
console.log("[app.js] login() called with pubkey =", pubkey);
- this.pubkey = pubkey;
- this.currentUserNpub = this.safeEncodeNpub(pubkey);
+ const normalizedPubkey = this.normalizeHexPubkey(pubkey);
+ if (normalizedPubkey) {
+ this.pubkey = normalizedPubkey;
+ } else {
+ this.pubkey = pubkey;
+ }
+ this.currentUserNpub = this.safeEncodeNpub(this.pubkey);
let reloadScheduled = false;
- if (saveToStorage) {
- try {
- localStorage.setItem("userPubKey", pubkey);
- reloadScheduled = true;
- } catch (err) {
- console.warn("[app.js] Failed to persist pubkey:", err);
+ if (normalizedPubkey) {
+ if (saveToStorage) {
+ const profileCacheEntry = this.profileCache.get(normalizedPubkey);
+ const existingIndex = this.savedProfiles.findIndex(
+ (candidate) => candidate.pubkey === normalizedPubkey
+ );
+ const existingAuthType =
+ existingIndex >= 0
+ ? this.savedProfiles[existingIndex].authType || "nip07"
+ : "nip07";
+ const entry = {
+ pubkey: normalizedPubkey,
+ npub: this.safeEncodeNpub(normalizedPubkey),
+ name: profileCacheEntry?.profile?.name || "",
+ picture: profileCacheEntry?.profile?.picture || "",
+ authType: existingAuthType,
+ };
+
+ if (existingIndex >= 0) {
+ this.savedProfiles[existingIndex] = {
+ ...this.savedProfiles[existingIndex],
+ ...entry,
+ };
+ } else {
+ this.savedProfiles.push(entry);
+ }
}
+
+ if (this.activeProfilePubkey !== normalizedPubkey) {
+ this.activeProfilePubkey = normalizedPubkey;
+ if (!saveToStorage && this.savedProfiles.length) {
+ this.persistSavedProfiles();
+ }
+ }
+
+ if (saveToStorage) {
+ this.persistSavedProfiles();
+ reloadScheduled = true;
+ }
+ } else if (saveToStorage) {
+ console.warn(
+ "[app.js] login() requested storage persistence but pubkey was invalid; skipping saved profile update."
+ );
}
if (reloadScheduled) {
@@ -5263,7 +5534,7 @@ class bitvidApp {
await this.refreshAdminPaneState();
try {
- await userBlocks.loadBlocks(pubkey);
+ await userBlocks.loadBlocks(this.pubkey);
} catch (error) {
console.warn("Failed to load personal block list:", error);
}
@@ -5302,7 +5573,7 @@ class bitvidApp {
}
// (Optional) load the user's own Nostr profile
- this.loadOwnProfile(pubkey);
+ this.loadOwnProfile(this.pubkey);
// Refresh the video list so the user sees any private videos, etc.
await this.loadVideos();
@@ -5321,6 +5592,11 @@ class bitvidApp {
this.pubkey = null;
this.currentUserNpub = null;
+ if (this.activeProfilePubkey) {
+ this.activeProfilePubkey = null;
+ }
+ this.persistSavedProfiles();
+
userBlocks.reset();
this.populateBlockedList();
@@ -8789,6 +9065,30 @@ class bitvidApp {
return null;
}
+ 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 = this.safeDecodeNpub(trimmed);
+ if (decoded && HEX64_REGEX.test(decoded)) {
+ return decoded.toLowerCase();
+ }
+ }
+
+ return null;
+ }
+
/**
* Attempts to fetch an older event by its ID if we can't find it in
* this.videosMap or from a bulk fetch. Uses nostrClient.getEventById.
From ec83b1222feef0cdf81637bef9543431de66ab30 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Mon, 29 Sep 2025 22:55:40 -0400
Subject: [PATCH 12/20] Refine login flow and profile storage
---
js/app.js | 523 ++++++++++++++++++++++++++++++++++++++++--------------
1 file changed, 391 insertions(+), 132 deletions(-)
diff --git a/js/app.js b/js/app.js
index 8bf1fad4..e5e16c48 100644
--- a/js/app.js
+++ b/js/app.js
@@ -509,6 +509,7 @@ class bitvidApp {
this.profileLogoutBtn = null;
this.profileModalAvatar = null;
this.profileModalName = null;
+ this.profileModalNpub = null;
this.profileChannelLink = null;
this.profileNavButtons = {
account: null,
@@ -549,6 +550,7 @@ class bitvidApp {
this.boundProfileModalKeydown = null;
this.boundProfileModalFocusIn = null;
this.profileModalFocusables = [];
+ this.profileSwitcherList = null;
this.currentUserNpub = null;
// Upload modal elements
@@ -678,7 +680,58 @@ class bitvidApp {
this.profileCache = new Map();
this.savedProfiles = [];
this.activeProfilePubkey = null;
- this.persistSavedProfiles = () => {
+ this.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 (err) {
+ console.warn("[readSavedProfilesPayloadFromStorage] Failed to parse payload:", err);
+ return null;
+ }
+ };
+ this.writeSavedProfilesPayloadToStorage = (payload) => {
+ if (typeof localStorage === "undefined") {
+ return;
+ }
+
+ 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;
}
@@ -692,6 +745,25 @@ class bitvidApp {
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) => ({
@@ -705,37 +777,19 @@ class bitvidApp {
typeof entry.picture === "string" ? entry.picture : "",
authType: entry.authType === "nsec" ? "nsec" : "nip07",
})),
- activePubkey: this.activeProfilePubkey || null,
+ activePubkey: activePubkeyToPersist,
};
- try {
- localStorage.setItem(
- SAVED_PROFILES_STORAGE_KEY,
- JSON.stringify(payload)
- );
- try {
- localStorage.removeItem("userPubKey");
- } catch (legacyErr) {
- console.warn(
- "[persistSavedProfiles] Failed to remove legacy userPubKey entry:",
- legacyErr
- );
- }
- } 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(
- "[persistSavedProfiles] Storage quota exceeded while saving profiles; keeping in-memory copy only.",
- err
- );
- } else {
- console.warn("[persistSavedProfiles] Failed to persist saved profiles:", err);
- }
+ this.writeSavedProfilesPayloadToStorage(payload);
+ };
+ this.persistActiveProfileSelection = (
+ pubkey,
+ { persist = true } = {}
+ ) => {
+ const normalized = this.normalizeHexPubkey(pubkey);
+ this.activeProfilePubkey = normalized;
+ if (persist) {
+ this.persistSavedProfiles({ persistActive: true });
}
};
this.lastRenderedVideoSignature = null;
@@ -867,24 +921,6 @@ class bitvidApp {
needsRewrite = true;
}
- if (!this.savedProfiles.length) {
- const legacyPubkey = localStorage.getItem("userPubKey");
- const normalizedLegacy = this.normalizeHexPubkey(legacyPubkey);
- if (normalizedLegacy) {
- const legacyEntry = {
- pubkey: normalizedLegacy,
- npub: this.safeEncodeNpub(normalizedLegacy),
- name: "",
- picture: "",
- authType: "nip07",
- };
- this.savedProfiles = [legacyEntry];
- this.activeProfilePubkey = normalizedLegacy;
- this.persistSavedProfiles();
- return;
- }
- }
-
if (!this.activeProfilePubkey && this.savedProfiles.length) {
this.activeProfilePubkey = this.savedProfiles[0].pubkey;
needsRewrite = true;
@@ -893,6 +929,51 @@ class bitvidApp {
if (needsRewrite) {
this.persistSavedProfiles();
}
+
+ this.renderSavedProfilesList();
+ }
+
+ syncSavedProfileFromCache(pubkey, { persist = false } = {}) {
+ const normalized = this.normalizeHexPubkey(pubkey);
+ if (!normalized) {
+ return false;
+ }
+
+ 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.renderSavedProfilesList();
+ return true;
}
loadProfileCacheFromStorage() {
@@ -1034,6 +1115,12 @@ class bitvidApp {
this.profileCache.set(pubkey, entry);
this.persistProfileCacheToStorage();
+ const didUpdateSavedProfile = this.syncSavedProfileFromCache(pubkey, {
+ persist: true,
+ });
+ if (!didUpdateSavedProfile) {
+ this.renderSavedProfilesList();
+ }
return entry;
}
@@ -1114,12 +1201,11 @@ class bitvidApp {
// Grab the "Subscriptions" link by its id in the sidebar
this.subscriptionsLink = document.getElementById("subscriptionsLink");
- const savedPubKey =
- this.activeProfilePubkey || localStorage.getItem("userPubKey");
+ const savedPubKey = this.activeProfilePubkey;
if (savedPubKey) {
// Auto-login if a pubkey was saved
try {
- await this.login(savedPubKey, false);
+ await this.login(savedPubKey, { persistActive: false });
} catch (error) {
console.error("Auto-login failed:", error);
}
@@ -3462,6 +3548,156 @@ class bitvidApp {
}
}
+ renderSavedProfilesList() {
+ const normalizedActive = this.normalizeHexPubkey(
+ this.activeProfilePubkey
+ );
+ const fallbackAvatar = "assets/svg/default-profile.svg";
+
+ const resolveMeta = (entry) => {
+ if (!entry || typeof entry !== "object") {
+ return {
+ name: "",
+ picture: fallbackAvatar,
+ npub: null,
+ };
+ }
+
+ const cacheEntry = this.profileCache.get(entry.pubkey);
+ const cachedProfile = cacheEntry?.profile || {};
+ const resolvedName = cachedProfile.name || entry.name || "Unknown";
+ const resolvedPicture =
+ cachedProfile.picture || entry.picture || fallbackAvatar;
+ let resolvedNpub =
+ typeof entry.npub === "string" && entry.npub.trim()
+ ? entry.npub.trim()
+ : null;
+ if (!resolvedNpub && entry.pubkey) {
+ resolvedNpub = this.safeEncodeNpub(entry.pubkey);
+ }
+
+ return {
+ name: resolvedName,
+ picture: resolvedPicture,
+ npub: resolvedNpub,
+ };
+ };
+
+ const savedEntries = Array.isArray(this.savedProfiles)
+ ? this.savedProfiles
+ : [];
+ const activeEntry = normalizedActive
+ ? savedEntries.find((entry) => entry.pubkey === normalizedActive)
+ : null;
+ const activeMeta = activeEntry ? resolveMeta(activeEntry) : null;
+
+ if (this.profileModalName) {
+ this.profileModalName.textContent = activeMeta
+ ? activeMeta.name || "Unnamed profile"
+ : "No active profile";
+ }
+
+ if (this.profileModalAvatar) {
+ const avatarSrc = activeMeta?.picture || fallbackAvatar;
+ if (this.profileModalAvatar.src !== avatarSrc) {
+ this.profileModalAvatar.src = avatarSrc;
+ }
+ this.profileModalAvatar.alt = activeMeta
+ ? `${activeMeta.name || "Saved profile"} avatar`
+ : "Inactive profile avatar";
+ }
+
+ if (this.profileModalNpub) {
+ if (activeMeta?.npub) {
+ this.profileModalNpub.textContent = truncateMiddle(
+ activeMeta.npub,
+ 48
+ );
+ } else {
+ this.profileModalNpub.textContent = "Not signed in";
+ }
+ }
+
+ if (this.profileChannelLink) {
+ if (activeMeta?.npub) {
+ this.profileChannelLink.href = `#view=channel-profile&npub=${encodeURIComponent(
+ activeMeta.npub
+ )}`;
+ this.profileChannelLink.dataset.targetNpub = activeMeta.npub;
+ this.profileChannelLink.classList.remove("hidden");
+ } else {
+ this.profileChannelLink.classList.add("hidden");
+ this.profileChannelLink.removeAttribute("href");
+ delete this.profileChannelLink.dataset.targetNpub;
+ }
+ }
+
+ const listEl = this.profileSwitcherList;
+ if (!(listEl instanceof HTMLElement)) {
+ return;
+ }
+
+ const otherEntries = savedEntries.filter(
+ (entry) => entry && entry.pubkey && entry.pubkey !== normalizedActive
+ );
+
+ if (!otherEntries.length) {
+ listEl.innerHTML = `
+
+ No other profiles saved yet.
+
+ `;
+ this.updateProfileModalFocusables();
+ return;
+ }
+
+ const markup = otherEntries
+ .map((entry) => {
+ const meta = resolveMeta(entry);
+ const truncatedNpub = meta.npub
+ ? truncateMiddle(meta.npub, 48)
+ : "npub unavailable";
+ const authLabel =
+ entry.authType === "nsec" ? "Direct key" : "Saved profile";
+ const npubAttribute =
+ meta.npub && typeof meta.npub === "string"
+ ? ` data-profile-npub="${this.escapeHTML(meta.npub)}"`
+ : "";
+ return `
+
+ `;
+ })
+ .join("\n");
+
+ listEl.innerHTML = markup;
+ this.updateProfileModalFocusables();
+ }
+
/**
* (Optional) Initialize a separate profile modal (profile-modal.html).
*/
@@ -3494,8 +3730,12 @@ class bitvidApp {
document.getElementById("profileModalAvatar") || null;
this.profileModalName =
document.getElementById("profileModalName") || null;
+ this.profileModalNpub =
+ document.getElementById("profileModalNpub") || null;
this.profileChannelLink =
document.getElementById("profileChannelLink") || null;
+ this.profileSwitcherList =
+ document.getElementById("profileSwitcherList") || null;
this.profileNavButtons.account =
document.getElementById("profileNavAccount") || null;
this.profileNavButtons.relays =
@@ -3702,6 +3942,8 @@ class bitvidApp {
this.populateBlockedList();
await this.refreshAdminPaneState();
+ this.renderSavedProfilesList();
+
console.log("Profile modal initialization successful");
return true;
} catch (error) {
@@ -4912,7 +5154,7 @@ class bitvidApp {
try {
const pubkey = await nostrClient.login(); // call the extension
console.log("[NIP-07] login returned pubkey:", pubkey);
- this.login(pubkey, true);
+ await this.login(pubkey, { persistActive: true });
// Hide the login modal
const loginModal = document.getElementById("loginModal");
@@ -5055,6 +5297,12 @@ class bitvidApp {
if (this.profileModalAvatar) {
this.profileModalAvatar.src = picture;
}
+ if (this.profileModalNpub) {
+ const encoded = this.safeEncodeNpub(pubkey);
+ this.profileModalNpub.textContent = encoded
+ ? truncateMiddle(encoded, 48)
+ : "Not signed in";
+ }
if (this.profileChannelLink) {
const targetNpub = this.safeEncodeNpub(pubkey);
if (targetNpub) {
@@ -5464,73 +5712,10 @@ class bitvidApp {
}
this.persistSavedProfiles();
+ this.renderSavedProfilesList();
}
- /**
- * Called upon successful login.
- */
- async login(pubkey, saveToStorage = true) {
- console.log("[app.js] login() called with pubkey =", pubkey);
-
- const normalizedPubkey = this.normalizeHexPubkey(pubkey);
- if (normalizedPubkey) {
- this.pubkey = normalizedPubkey;
- } else {
- this.pubkey = pubkey;
- }
- this.currentUserNpub = this.safeEncodeNpub(this.pubkey);
-
- let reloadScheduled = false;
- if (normalizedPubkey) {
- if (saveToStorage) {
- const profileCacheEntry = this.profileCache.get(normalizedPubkey);
- const existingIndex = this.savedProfiles.findIndex(
- (candidate) => candidate.pubkey === normalizedPubkey
- );
- const existingAuthType =
- existingIndex >= 0
- ? this.savedProfiles[existingIndex].authType || "nip07"
- : "nip07";
- const entry = {
- pubkey: normalizedPubkey,
- npub: this.safeEncodeNpub(normalizedPubkey),
- name: profileCacheEntry?.profile?.name || "",
- picture: profileCacheEntry?.profile?.picture || "",
- authType: existingAuthType,
- };
-
- if (existingIndex >= 0) {
- this.savedProfiles[existingIndex] = {
- ...this.savedProfiles[existingIndex],
- ...entry,
- };
- } else {
- this.savedProfiles.push(entry);
- }
- }
-
- if (this.activeProfilePubkey !== normalizedPubkey) {
- this.activeProfilePubkey = normalizedPubkey;
- if (!saveToStorage && this.savedProfiles.length) {
- this.persistSavedProfiles();
- }
- }
-
- if (saveToStorage) {
- this.persistSavedProfiles();
- reloadScheduled = true;
- }
- } else if (saveToStorage) {
- console.warn(
- "[app.js] login() requested storage persistence but pubkey was invalid; skipping saved profile update."
- );
- }
-
- if (reloadScheduled) {
- window.location.reload();
- return;
- }
-
+ async applyPostLoginState() {
await this.refreshAdminPaneState();
try {
@@ -5541,49 +5726,128 @@ class bitvidApp {
this.populateBlockedList();
- // Hide login button if present
if (this.loginButton) {
this.loginButton.classList.add("hidden");
this.loginButton.setAttribute("hidden", "");
this.loginButton.style.display = "none";
}
- // Optionally hide logout or userStatus
+
if (this.logoutButton) {
this.logoutButton.classList.remove("hidden");
}
+
if (this.userStatus) {
this.userStatus.classList.add("hidden");
}
- // Show the upload button, profile button, etc.
if (this.uploadButton) {
this.uploadButton.classList.remove("hidden");
this.uploadButton.removeAttribute("hidden");
this.uploadButton.style.display = "inline-flex";
}
+
if (this.profileButton) {
this.profileButton.classList.remove("hidden");
this.profileButton.removeAttribute("hidden");
this.profileButton.style.display = "inline-flex";
}
- // Show the "Subscriptions" link if it exists
if (this.subscriptionsLink) {
this.subscriptionsLink.classList.remove("hidden");
}
- // (Optional) load the user's own Nostr profile
- this.loadOwnProfile(this.pubkey);
+ if (this.pubkey) {
+ this.loadOwnProfile(this.pubkey);
+ }
- // Refresh the video list so the user sees any private videos, etc.
await this.loadVideos();
- // Force a fresh fetch of all profile pictures/names
this.forceRefreshAllProfiles();
await this.updateCloudflareBucketPreview();
}
+ /**
+ * Called upon successful login.
+ */
+ async login(pubkey, options = {}) {
+ console.log("[app.js] login() called with pubkey =", pubkey);
+
+ let normalizedOptions = options;
+ if (typeof normalizedOptions === "boolean") {
+ normalizedOptions = { persistActive: normalizedOptions };
+ } else if (!normalizedOptions || typeof normalizedOptions !== "object") {
+ normalizedOptions = {};
+ }
+
+ const { persistActive = true } = normalizedOptions;
+
+ const normalizedPubkey = this.normalizeHexPubkey(pubkey);
+ if (normalizedPubkey) {
+ this.pubkey = normalizedPubkey;
+ } else {
+ this.pubkey = pubkey;
+ }
+ this.currentUserNpub = this.safeEncodeNpub(this.pubkey);
+
+ let savedProfilesMutated = false;
+ if (normalizedPubkey) {
+ const cacheEntry = this.profileCache.get(normalizedPubkey);
+ const cachedProfile = cacheEntry?.profile || {};
+ const existingIndex = this.savedProfiles.findIndex(
+ (candidate) => candidate.pubkey === normalizedPubkey
+ );
+ const existingEntry =
+ existingIndex >= 0 ? this.savedProfiles[existingIndex] : null;
+ const npub = this.safeEncodeNpub(normalizedPubkey);
+ const nextEntry = {
+ pubkey: normalizedPubkey,
+ npub: npub || existingEntry?.npub || null,
+ name: cachedProfile.name || existingEntry?.name || "",
+ picture: cachedProfile.picture || existingEntry?.picture || "",
+ authType: "nip07",
+ };
+
+ if (existingIndex >= 0) {
+ const currentEntry = this.savedProfiles[existingIndex];
+ const changed =
+ currentEntry.npub !== nextEntry.npub ||
+ currentEntry.name !== nextEntry.name ||
+ currentEntry.picture !== nextEntry.picture ||
+ currentEntry.authType !== nextEntry.authType;
+ if (changed) {
+ this.savedProfiles[existingIndex] = nextEntry;
+ savedProfilesMutated = true;
+ }
+ } else {
+ this.savedProfiles.push(nextEntry);
+ savedProfilesMutated = true;
+ }
+
+ this.persistActiveProfileSelection(normalizedPubkey, {
+ persist: persistActive,
+ });
+
+ if (!persistActive && savedProfilesMutated) {
+ this.persistSavedProfiles({ persistActive: false });
+ }
+ } else {
+ if (persistActive) {
+ console.warn(
+ "[app.js] login() requested storage persistence but pubkey was invalid; skipping saved profile update."
+ );
+ }
+ }
+
+ if (!normalizedPubkey && savedProfilesMutated) {
+ this.persistSavedProfiles({ persistActive: false });
+ }
+
+ this.renderSavedProfilesList();
+
+ await this.applyPostLoginState();
+ }
+
/**
* Logout logic
*/
@@ -5592,10 +5856,8 @@ class bitvidApp {
this.pubkey = null;
this.currentUserNpub = null;
- if (this.activeProfilePubkey) {
- this.activeProfilePubkey = null;
- }
- this.persistSavedProfiles();
+ this.persistActiveProfileSelection(null, { persist: true });
+ this.renderSavedProfilesList();
userBlocks.reset();
this.populateBlockedList();
@@ -5640,9 +5902,6 @@ class bitvidApp {
this.subscriptionsLink.classList.add("hidden");
}
- // Clear localStorage
- localStorage.removeItem("userPubKey");
-
await this.refreshAdminPaneState();
// Refresh the video list so user sees only public videos again
From 622aa756cc9a8f39653cbca158347445efef1901 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Mon, 29 Sep 2025 23:07:45 -0400
Subject: [PATCH 13/20] Refine saved profile rendering
---
js/app.js | 269 ++++++++++++++++++++++++++++++++++--------------------
1 file changed, 170 insertions(+), 99 deletions(-)
diff --git a/js/app.js b/js/app.js
index e5e16c48..3982afa1 100644
--- a/js/app.js
+++ b/js/app.js
@@ -551,6 +551,8 @@ class bitvidApp {
this.boundProfileModalFocusIn = null;
this.profileModalFocusables = [];
this.profileSwitcherList = null;
+ this.profileAddAccountBtn = null;
+ this.profileSwitcherSelectionPubkey = null;
this.currentUserNpub = null;
// Upload modal elements
@@ -791,6 +793,7 @@ class bitvidApp {
if (persist) {
this.persistSavedProfiles({ persistActive: true });
}
+ this.renderSavedProfiles();
};
this.lastRenderedVideoSignature = null;
this._lastRenderedVideoListElement = null;
@@ -930,7 +933,7 @@ class bitvidApp {
this.persistSavedProfiles();
}
- this.renderSavedProfilesList();
+ this.renderSavedProfiles();
}
syncSavedProfileFromCache(pubkey, { persist = false } = {}) {
@@ -972,7 +975,7 @@ class bitvidApp {
this.persistSavedProfiles({ persistActive: false });
}
- this.renderSavedProfilesList();
+ this.renderSavedProfiles();
return true;
}
@@ -1119,7 +1122,7 @@ class bitvidApp {
persist: true,
});
if (!didUpdateSavedProfile) {
- this.renderSavedProfilesList();
+ this.renderSavedProfiles();
}
return entry;
}
@@ -3548,11 +3551,11 @@ class bitvidApp {
}
}
- renderSavedProfilesList() {
+ renderSavedProfiles() {
+ const fallbackAvatar = "assets/svg/default-profile.svg";
const normalizedActive = this.normalizeHexPubkey(
this.activeProfilePubkey
);
- const fallbackAvatar = "assets/svg/default-profile.svg";
const resolveMeta = (entry) => {
if (!entry || typeof entry !== "object") {
@@ -3565,9 +3568,7 @@ class bitvidApp {
const cacheEntry = this.profileCache.get(entry.pubkey);
const cachedProfile = cacheEntry?.profile || {};
- const resolvedName = cachedProfile.name || entry.name || "Unknown";
- const resolvedPicture =
- cachedProfile.picture || entry.picture || fallbackAvatar;
+
let resolvedNpub =
typeof entry.npub === "string" && entry.npub.trim()
? entry.npub.trim()
@@ -3577,125 +3578,191 @@ class bitvidApp {
}
return {
- name: resolvedName,
- picture: resolvedPicture,
+ name: cachedProfile.name || entry.name || "",
+ picture: cachedProfile.picture || entry.picture || fallbackAvatar,
npub: resolvedNpub,
};
};
const savedEntries = Array.isArray(this.savedProfiles)
- ? this.savedProfiles
+ ? this.savedProfiles.filter((entry) => entry && entry.pubkey)
: [];
- const activeEntry = normalizedActive
- ? savedEntries.find((entry) => entry.pubkey === normalizedActive)
- : null;
- const activeMeta = activeEntry ? resolveMeta(activeEntry) : null;
- if (this.profileModalName) {
- this.profileModalName.textContent = activeMeta
- ? activeMeta.name || "Unnamed profile"
- : "No active profile";
+ let activeEntry = null;
+ if (normalizedActive) {
+ activeEntry = savedEntries.find(
+ (entry) => this.normalizeHexPubkey(entry.pubkey) === normalizedActive
+ );
+ }
+ if (!activeEntry && savedEntries.length) {
+ activeEntry = savedEntries[0];
}
- if (this.profileModalAvatar) {
- const avatarSrc = activeMeta?.picture || fallbackAvatar;
- if (this.profileModalAvatar.src !== avatarSrc) {
- this.profileModalAvatar.src = avatarSrc;
+ const activeMeta = activeEntry ? resolveMeta(activeEntry) : null;
+ const hasActiveProfile = Boolean(activeEntry && activeMeta);
+ const activeNameFallback = activeMeta?.npub
+ ? truncateMiddle(activeMeta.npub, 32)
+ : "Saved profile";
+ const activeDisplayName = hasActiveProfile
+ ? activeMeta.name?.trim() || activeNameFallback
+ : "No active profile";
+ const activeAvatarSrc = hasActiveProfile
+ ? activeMeta.picture || fallbackAvatar
+ : fallbackAvatar;
+
+ if (this.profileModalName) {
+ this.profileModalName.textContent = activeDisplayName;
+ }
+
+ if (this.profileModalAvatar instanceof HTMLImageElement) {
+ if (this.profileModalAvatar.src !== activeAvatarSrc) {
+ this.profileModalAvatar.src = activeAvatarSrc;
}
- this.profileModalAvatar.alt = activeMeta
- ? `${activeMeta.name || "Saved profile"} avatar`
- : "Inactive profile avatar";
+ this.profileModalAvatar.alt = hasActiveProfile
+ ? `${activeDisplayName} avatar`
+ : "Default profile avatar";
+ } else if (this.profileModalAvatar) {
+ this.profileModalAvatar.setAttribute("data-avatar-src", activeAvatarSrc);
}
if (this.profileModalNpub) {
- if (activeMeta?.npub) {
- this.profileModalNpub.textContent = truncateMiddle(
- activeMeta.npub,
- 48
- );
+ if (hasActiveProfile && activeMeta?.npub) {
+ this.profileModalNpub.textContent = truncateMiddle(activeMeta.npub, 48);
+ } else if (hasActiveProfile) {
+ this.profileModalNpub.textContent = "npub unavailable";
} else {
- this.profileModalNpub.textContent = "Not signed in";
+ this.profileModalNpub.textContent = "Link a profile to get started";
}
}
if (this.profileChannelLink) {
- if (activeMeta?.npub) {
+ if (hasActiveProfile && activeMeta?.npub) {
+ const encodedNpub = activeMeta.npub;
this.profileChannelLink.href = `#view=channel-profile&npub=${encodeURIComponent(
- activeMeta.npub
+ encodedNpub
)}`;
- this.profileChannelLink.dataset.targetNpub = activeMeta.npub;
+ this.profileChannelLink.dataset.targetNpub = encodedNpub;
this.profileChannelLink.classList.remove("hidden");
+ this.profileChannelLink.setAttribute("aria-hidden", "false");
} else {
this.profileChannelLink.classList.add("hidden");
this.profileChannelLink.removeAttribute("href");
delete this.profileChannelLink.dataset.targetNpub;
+ this.profileChannelLink.setAttribute("aria-hidden", "true");
}
}
+ if (this.profileAvatar instanceof HTMLImageElement) {
+ if (this.profileAvatar.src !== activeAvatarSrc) {
+ this.profileAvatar.src = activeAvatarSrc;
+ }
+ this.profileAvatar.alt = hasActiveProfile
+ ? `${activeDisplayName} avatar`
+ : this.profileAvatar.alt || "Profile avatar";
+ }
+
const listEl = this.profileSwitcherList;
- if (!(listEl instanceof HTMLElement)) {
- return;
- }
+ if (listEl instanceof HTMLElement) {
+ listEl.innerHTML = "";
+ let normalizedSelection = this.normalizeHexPubkey(
+ this.profileSwitcherSelectionPubkey
+ );
+ if (normalizedSelection && normalizedSelection === normalizedActive) {
+ normalizedSelection = null;
+ this.profileSwitcherSelectionPubkey = null;
+ }
+ const entriesToRender = savedEntries.filter((entry) => {
+ const normalized = this.normalizeHexPubkey(entry.pubkey);
+ return normalized && normalized !== normalizedActive;
+ });
- const otherEntries = savedEntries.filter(
- (entry) => entry && entry.pubkey && entry.pubkey !== normalizedActive
- );
+ if (!entriesToRender.length) {
+ listEl.setAttribute("data-profile-switcher-empty", "true");
+ const helper = document.createElement("p");
+ helper.className = "profile-switcher__empty text-sm text-gray-400";
+ helper.textContent = "No other profiles saved yet.";
+ helper.setAttribute("role", "note");
+ listEl.appendChild(helper);
+ } else {
+ listEl.removeAttribute("data-profile-switcher-empty");
+
+ entriesToRender.forEach((entry) => {
+ const meta = resolveMeta(entry);
+ const button = document.createElement("button");
+ button.type = "button";
+ button.classList.add("profile-card");
+ button.dataset.pubkey = entry.pubkey;
+ if (meta.npub) {
+ button.dataset.npub = meta.npub;
+ }
+ if (entry.authType) {
+ button.dataset.authType = entry.authType;
+ }
+
+ const normalizedPubkey = this.normalizeHexPubkey(entry.pubkey);
+ const isSelected =
+ normalizedSelection && normalizedPubkey === normalizedSelection;
+ if (isSelected) {
+ button.classList.add("profile-card--active");
+ button.setAttribute("aria-pressed", "true");
+ } else {
+ button.setAttribute("aria-pressed", "false");
+ }
+
+ const avatarSpan = document.createElement("span");
+ avatarSpan.className = "profile-card__avatar";
+ const avatarImg = document.createElement("img");
+ avatarImg.src = meta.picture || fallbackAvatar;
+ const cardDisplayName =
+ meta.name?.trim() ||
+ (meta.npub ? truncateMiddle(meta.npub, 32) : "Saved profile");
+ avatarImg.alt = `${cardDisplayName} avatar`;
+ avatarSpan.appendChild(avatarImg);
+
+ const metaSpan = document.createElement("span");
+ metaSpan.className = "profile-card__meta";
+
+ const topLine = document.createElement("span");
+ topLine.className = "profile-card__topline";
+
+ const label = document.createElement("span");
+ label.className = "profile-card__label";
+ label.textContent =
+ entry.authType === "nsec" ? "Direct key" : "Saved profile";
+
+ const action = document.createElement("span");
+ action.className = "profile-card__action";
+ action.setAttribute("aria-hidden", "true");
+ action.textContent = isSelected ? "Selected" : "Switch";
+
+ topLine.append(label, action);
+
+ const nameSpan = document.createElement("span");
+ nameSpan.className = "profile-card__name";
+ nameSpan.textContent = cardDisplayName;
+
+ const npubSpan = document.createElement("span");
+ npubSpan.className = "profile-card__npub";
+ npubSpan.textContent = meta.npub
+ ? truncateMiddle(meta.npub, 48)
+ : "npub unavailable";
+
+ metaSpan.append(topLine, nameSpan, npubSpan);
+ button.append(avatarSpan, metaSpan);
+
+ const ariaLabel = isSelected
+ ? `${cardDisplayName} selected`
+ : `Switch to ${cardDisplayName}`;
+ button.setAttribute("aria-label", ariaLabel);
+
+ listEl.appendChild(button);
+ });
+ }
- if (!otherEntries.length) {
- listEl.innerHTML = `
-
- No other profiles saved yet.
-
- `;
this.updateProfileModalFocusables();
- return;
+ } else {
+ this.updateProfileModalFocusables();
}
-
- const markup = otherEntries
- .map((entry) => {
- const meta = resolveMeta(entry);
- const truncatedNpub = meta.npub
- ? truncateMiddle(meta.npub, 48)
- : "npub unavailable";
- const authLabel =
- entry.authType === "nsec" ? "Direct key" : "Saved profile";
- const npubAttribute =
- meta.npub && typeof meta.npub === "string"
- ? ` data-profile-npub="${this.escapeHTML(meta.npub)}"`
- : "";
- return `
-
- `;
- })
- .join("\n");
-
- listEl.innerHTML = markup;
- this.updateProfileModalFocusables();
}
/**
@@ -3736,6 +3803,13 @@ class bitvidApp {
document.getElementById("profileChannelLink") || null;
this.profileSwitcherList =
document.getElementById("profileSwitcherList") || null;
+ this.profileAddAccountBtn =
+ document.getElementById("profileAddAccountBtn") || null;
+ const topLevelProfileAvatar =
+ document.getElementById("profileAvatar") || null;
+ if (topLevelProfileAvatar) {
+ this.profileAvatar = topLevelProfileAvatar;
+ }
this.profileNavButtons.account =
document.getElementById("profileNavAccount") || null;
this.profileNavButtons.relays =
@@ -3942,7 +4016,7 @@ class bitvidApp {
this.populateBlockedList();
await this.refreshAdminPaneState();
- this.renderSavedProfilesList();
+ this.renderSavedProfiles();
console.log("Profile modal initialization successful");
return true;
@@ -5712,7 +5786,7 @@ class bitvidApp {
}
this.persistSavedProfiles();
- this.renderSavedProfilesList();
+ this.renderSavedProfiles();
}
async applyPostLoginState() {
@@ -5843,8 +5917,6 @@ class bitvidApp {
this.persistSavedProfiles({ persistActive: false });
}
- this.renderSavedProfilesList();
-
await this.applyPostLoginState();
}
@@ -5857,7 +5929,6 @@ class bitvidApp {
this.currentUserNpub = null;
this.persistActiveProfileSelection(null, { persist: true });
- this.renderSavedProfilesList();
userBlocks.reset();
this.populateBlockedList();
From ee09b723aff324d9cc7f240d4a6a069cb6c92ef8 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Mon, 29 Sep 2025 23:14:52 -0400
Subject: [PATCH 14/20] Add profile switching support for NIP-07 accounts
---
js/app.js | 123 ++++++++++++++++++++++++++++++++++++++++++++++++++++
js/nostr.js | 42 +++++++++++++++++-
2 files changed, 164 insertions(+), 1 deletion(-)
diff --git a/js/app.js b/js/app.js
index 3982afa1..ae25716d 100644
--- a/js/app.js
+++ b/js/app.js
@@ -3897,6 +3897,16 @@ class bitvidApp {
});
}
+ if (
+ this.profileAddAccountBtn &&
+ this.profileAddAccountBtn.dataset.bound !== "true"
+ ) {
+ this.profileAddAccountBtn.dataset.bound = "true";
+ this.profileAddAccountBtn.addEventListener("click", () => {
+ this.handleAddProfile();
+ });
+ }
+
Object.entries(this.profileNavButtons).forEach(([name, button]) => {
if (!button || button.dataset.navBound === "true") {
return;
@@ -4440,6 +4450,119 @@ class bitvidApp {
});
}
+ async handleAddProfile() {
+ if (!this.profileAddAccountBtn) {
+ return;
+ }
+
+ const button = this.profileAddAccountBtn;
+ if (button.dataset.loading === "true") {
+ return;
+ }
+
+ const titleEl = button.querySelector(".profile-switcher__addTitle");
+ const hintEl = button.querySelector(".profile-switcher__addHint");
+ const originalTitle = titleEl ? titleEl.textContent : "";
+ const originalHint = hintEl ? hintEl.textContent : "";
+ const originalAriaLabel = button.getAttribute("aria-label");
+ const originalDisabled = button.disabled;
+
+ const setLoadingState = (isLoading) => {
+ button.disabled = isLoading ? true : originalDisabled;
+ button.dataset.loading = isLoading ? "true" : "false";
+ button.setAttribute("aria-busy", isLoading ? "true" : "false");
+ if (isLoading) {
+ button.setAttribute("aria-disabled", "true");
+ } else if (originalDisabled) {
+ button.setAttribute("aria-disabled", "true");
+ } else {
+ button.removeAttribute("aria-disabled");
+ }
+ if (isLoading) {
+ if (titleEl) {
+ titleEl.textContent = "Connecting...";
+ }
+ if (hintEl) {
+ hintEl.textContent = "Check your extension";
+ }
+ button.setAttribute(
+ "aria-label",
+ "Connecting to your Nostr extension"
+ );
+ } else {
+ if (titleEl) {
+ titleEl.textContent = originalTitle;
+ }
+ if (hintEl) {
+ hintEl.textContent = originalHint;
+ }
+ if (originalAriaLabel === null) {
+ button.removeAttribute("aria-label");
+ } else {
+ button.setAttribute("aria-label", originalAriaLabel);
+ }
+ }
+ };
+
+ setLoadingState(true);
+
+ try {
+ const pubkey = await nostrClient.login({
+ allowAccountSelection: true,
+ });
+
+ const normalizedPubkey = this.normalizeHexPubkey(pubkey);
+ if (!normalizedPubkey) {
+ throw new Error(
+ "Received an invalid public key from the Nostr extension."
+ );
+ }
+
+ const alreadySaved = this.savedProfiles.some(
+ (entry) =>
+ this.normalizeHexPubkey(entry.pubkey) === normalizedPubkey
+ );
+ if (alreadySaved) {
+ this.showSuccess("That profile is already saved on this device.");
+ return;
+ }
+
+ const npub = this.safeEncodeNpub(normalizedPubkey) || "";
+ let profileMeta = this.getProfileCacheEntry(normalizedPubkey)?.profile;
+
+ if (!profileMeta) {
+ await this.loadOwnProfile(normalizedPubkey);
+ profileMeta = this.getProfileCacheEntry(normalizedPubkey)?.profile;
+ }
+
+ const name = profileMeta?.name || "";
+ const picture =
+ profileMeta?.picture || "assets/svg/default-profile.svg";
+
+ this.savedProfiles.push({
+ pubkey: normalizedPubkey,
+ npub,
+ name,
+ picture,
+ authType: "nip07",
+ });
+
+ this.persistSavedProfiles({ persistActive: false });
+ this.renderSavedProfiles();
+
+ this.showSuccess("Profile added. Select it when you're ready to switch.");
+ } catch (error) {
+ console.error("Failed to add profile via NIP-07:", error);
+ const message =
+ error && typeof error.message === "string" && error.message.trim()
+ ? error.message.trim()
+ : "Couldn't add that profile. Please try again.";
+ this.showError(message);
+ } finally {
+ setLoadingState(false);
+ }
+ }
+
async handleAddBlockedCreator() {
if (!this.profileBlockedInput) {
return;
diff --git a/js/nostr.js b/js/nostr.js
index 2de5080a..126658c0 100644
--- a/js/nostr.js
+++ b/js/nostr.js
@@ -593,7 +593,7 @@ class NostrClient {
/**
* Attempt login with a Nostr extension
*/
- async login() {
+ async login(options = {}) {
try {
const extension = window.nostr;
if (!extension) {
@@ -603,6 +603,13 @@ class NostrClient {
);
}
+ const { allowAccountSelection = false, expectPubkey } =
+ typeof options === "object" && options !== null ? options : {};
+ const normalizedExpectedPubkey =
+ typeof expectPubkey === "string" && expectPubkey.trim()
+ ? expectPubkey.trim().toLowerCase()
+ : null;
+
if (typeof extension.getPublicKey !== "function") {
throw new Error(
"This NIP-07 extension is missing getPublicKey support. Please update the extension."
@@ -624,6 +631,30 @@ class NostrClient {
}
}
+ if (allowAccountSelection && typeof extension.selectAccounts === "function") {
+ try {
+ const selection = await extension.selectAccounts(
+ expectPubkey ? [expectPubkey] : undefined
+ );
+
+ const didCancelSelection =
+ selection === undefined ||
+ selection === null ||
+ selection === false ||
+ (Array.isArray(selection) && selection.length === 0);
+
+ if (didCancelSelection) {
+ throw new Error("Account selection was cancelled.");
+ }
+ } catch (selectionErr) {
+ const message =
+ selectionErr && typeof selectionErr.message === "string"
+ ? selectionErr.message
+ : "Account selection was cancelled.";
+ throw new Error(message);
+ }
+ }
+
let timeoutId;
const pubkey = await Promise.race([
extension.getPublicKey(),
@@ -646,6 +677,15 @@ class NostrClient {
"The NIP-07 extension did not return a public key. Please try again."
);
}
+
+ if (
+ normalizedExpectedPubkey &&
+ pubkey.toLowerCase() !== normalizedExpectedPubkey
+ ) {
+ throw new Error(
+ "The selected account doesn't match the expected profile. Please try again."
+ );
+ }
const npub = window.NostrTools.nip19.npubEncode(pubkey);
if (isDevMode) {
From edbc3786eba7620d658e9f339ea29c883d6fe16b Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Mon, 29 Sep 2025 23:18:37 -0400
Subject: [PATCH 15/20] Enhance profile switching interactions
---
js/app.js | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 90 insertions(+)
diff --git a/js/app.js b/js/app.js
index ae25716d..5bce857c 100644
--- a/js/app.js
+++ b/js/app.js
@@ -3755,6 +3755,37 @@ class bitvidApp {
: `Switch to ${cardDisplayName}`;
button.setAttribute("aria-label", ariaLabel);
+ const activateProfile = async (event) => {
+ if (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ if (button.dataset.loading === "true") {
+ return;
+ }
+
+ button.dataset.loading = "true";
+ button.setAttribute("aria-busy", "true");
+
+ try {
+ await this.switchProfile(entry.pubkey);
+ } catch (error) {
+ console.error("Failed to switch profile:", error);
+ } finally {
+ button.dataset.loading = "false";
+ button.setAttribute("aria-busy", "false");
+ }
+ };
+
+ button.addEventListener("click", activateProfile);
+ button.addEventListener("keydown", (event) => {
+ const key = event?.key;
+ if (key === "Enter" || key === " " || key === "Spacebar") {
+ activateProfile(event);
+ }
+ });
+
listEl.appendChild(button);
});
}
@@ -5912,6 +5943,65 @@ class bitvidApp {
this.renderSavedProfiles();
}
+ async switchProfile(pubkey) {
+ const normalizedTarget = this.normalizeHexPubkey(pubkey);
+ if (!normalizedTarget) {
+ this.showError("Unable to switch profiles: invalid account.");
+ return;
+ }
+
+ const normalizedActive = this.normalizeHexPubkey(this.activeProfilePubkey);
+ if (normalizedActive && normalizedActive === normalizedTarget) {
+ this.hideProfileModal();
+ return;
+ }
+
+ try {
+ await nostrClient.login({
+ allowAccountSelection: true,
+ expectPubkey: normalizedTarget,
+ });
+ } catch (error) {
+ const message =
+ error && typeof error.message === "string" && error.message.trim()
+ ? error.message.trim()
+ : "Profile switch was cancelled.";
+ this.showError(message);
+ return;
+ }
+
+ try {
+ await this.login(normalizedTarget, { persistActive: true });
+ } catch (error) {
+ console.error("Failed to finalize profile switch:", error);
+ const message =
+ error && typeof error.message === "string" && error.message.trim()
+ ? error.message.trim()
+ : "Failed to switch profiles. Please try again.";
+ this.showError(message);
+ return;
+ }
+
+ this.profileSwitcherSelectionPubkey = null;
+
+ let reordered = false;
+ const currentIndex = this.savedProfiles.findIndex(
+ (entry) => this.normalizeHexPubkey(entry.pubkey) === normalizedTarget
+ );
+ if (currentIndex > 0) {
+ const [moved] = this.savedProfiles.splice(currentIndex, 1);
+ this.savedProfiles.unshift(moved);
+ reordered = true;
+ }
+
+ if (reordered) {
+ this.persistSavedProfiles({ persistActive: true });
+ }
+
+ this.renderSavedProfiles();
+ this.hideProfileModal();
+ }
+
async applyPostLoginState() {
await this.refreshAdminPaneState();
From b50996b907f03228f97fe71b7a8e73bfb3c99551 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Mon, 29 Sep 2025 23:38:22 -0400
Subject: [PATCH 16/20] Auto-fetch saved profile metadata
---
docs/qa.md | 3 +++
js/app.js | 38 +++++++++++++++++++++++++++++++-------
2 files changed, 34 insertions(+), 7 deletions(-)
diff --git a/docs/qa.md b/docs/qa.md
index 2b785423..79447636 100644
--- a/docs/qa.md
+++ b/docs/qa.md
@@ -17,5 +17,8 @@ Run this checklist before publishing releases or merging changes that touch uplo
5. **Cross-Browser Verification**
- Spot-check Chromium and Firefox (desktop) for console warnings about CORS, Range requests, or tracker connectivity.
- Optionally validate on mobile Safari/Chrome if the change targets mobile UX.
+6. **Saved Profile Metadata Refresh**
+ - In the browser devtools console/storage panel, delete `bitvid:profileCache:v1` but keep `bitvid:savedProfiles:v1` populated with Nostr pubkeys that lack stored names/avatars.
+ - Reload the page and open the profile switcher; confirm avatars and display names populate automatically without manual refresh.
Document findings (pass/fail notes plus relevant screenshots or logs) so they can be attached to release or PR notes.
diff --git a/js/app.js b/js/app.js
index 5bce857c..f55d8776 100644
--- a/js/app.js
+++ b/js/app.js
@@ -1082,18 +1082,19 @@ class bitvidApp {
}
getProfileCacheEntry(pubkey) {
- if (!pubkey) {
+ const normalized = this.normalizeHexPubkey(pubkey);
+ if (!normalized) {
return null;
}
- const entry = this.profileCache.get(pubkey);
+ const entry = this.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) {
- this.profileCache.delete(pubkey);
+ this.profileCache.delete(normalized);
this.persistProfileCacheToStorage();
return null;
}
@@ -1102,7 +1103,8 @@ class bitvidApp {
}
setProfileCacheEntry(pubkey, profile) {
- if (!pubkey || !profile) {
+ const normalizedPubkey = this.normalizeHexPubkey(pubkey);
+ if (!normalizedPubkey || !profile) {
return;
}
@@ -1116,9 +1118,9 @@ class bitvidApp {
timestamp: Date.now(),
};
- this.profileCache.set(pubkey, entry);
+ this.profileCache.set(normalizedPubkey, entry);
this.persistProfileCacheToStorage();
- const didUpdateSavedProfile = this.syncSavedProfileFromCache(pubkey, {
+ const didUpdateSavedProfile = this.syncSavedProfileFromCache(normalizedPubkey, {
persist: true,
});
if (!didUpdateSavedProfile) {
@@ -3556,6 +3558,7 @@ class bitvidApp {
const normalizedActive = this.normalizeHexPubkey(
this.activeProfilePubkey
);
+ const entriesNeedingFetch = new Set();
const resolveMeta = (entry) => {
if (!entry || typeof entry !== "object") {
@@ -3566,9 +3569,26 @@ class bitvidApp {
};
}
- const cacheEntry = this.profileCache.get(entry.pubkey);
+ const normalizedPubkey = this.normalizeHexPubkey(entry.pubkey);
+ let cacheEntry = null;
+ if (normalizedPubkey) {
+ cacheEntry = this.getProfileCacheEntry(normalizedPubkey);
+ }
const cachedProfile = cacheEntry?.profile || {};
+ const hasStoredName =
+ typeof entry.name === "string" && entry.name.trim().length > 0;
+ const hasStoredPicture =
+ typeof entry.picture === "string" && entry.picture.trim().length > 0;
+
+ if (
+ !cacheEntry &&
+ normalizedPubkey &&
+ (!hasStoredName || !hasStoredPicture)
+ ) {
+ entriesNeedingFetch.add(normalizedPubkey);
+ }
+
let resolvedNpub =
typeof entry.npub === "string" && entry.npub.trim()
? entry.npub.trim()
@@ -3794,6 +3814,10 @@ class bitvidApp {
} else {
this.updateProfileModalFocusables();
}
+
+ if (entriesNeedingFetch.size) {
+ this.batchFetchProfiles(entriesNeedingFetch);
+ }
}
/**
From 233c50d238466440e4ad22e5b65a4a128a2c2107 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Mon, 29 Sep 2025 23:51:20 -0400
Subject: [PATCH 17/20] Show moderation filter loading status
---
css/style.css | 26 ++++++++++++++++++++++++++
index.html | 11 +++++++++++
js/app.js | 39 ++++++++++++++++++++++++++++++++++-----
3 files changed, 71 insertions(+), 5 deletions(-)
diff --git a/css/style.css b/css/style.css
index 53523690..ed102a28 100644
--- a/css/style.css
+++ b/css/style.css
@@ -834,6 +834,32 @@ button:not(.icon-button):focus {
display: flex;
}
+.status-banner {
+ align-items: center;
+ background-color: rgba(31, 41, 55, 0.9);
+ border-radius: 0.5rem;
+ color: #e5e7eb;
+ display: flex;
+ gap: 0.75rem;
+ margin-bottom: 1rem;
+ padding: 1rem;
+}
+
+.status-banner .status-spinner {
+ animation: spin 1s linear infinite;
+ border: 0.125rem solid rgba(229, 231, 235, 0.35);
+ border-radius: 9999px;
+ border-top-color: #f9fafb;
+ height: 1.25rem;
+ width: 1.25rem;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
/* Responsive Design */
@media (max-width: 640px) {
#videoList {
diff --git a/index.html b/index.html
index d62efcb8..71f4fe5c 100644
--- a/index.html
+++ b/index.html
@@ -155,6 +155,17 @@
class="hidden bg-red-100 text-red-900 p-4 rounded-md mb-4"
>
Date: Mon, 29 Sep 2025 23:59:52 -0400
Subject: [PATCH 18/20] Ensure moderation loading banner clears after sync
---
js/app.js | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/js/app.js b/js/app.js
index 12296c6d..e5ae412c 100644
--- a/js/app.js
+++ b/js/app.js
@@ -1204,6 +1204,15 @@ class bitvidApp {
console.warn("Failed to refresh admin lists after connecting to Nostr:", error);
}
+ try {
+ await this.refreshAdminPaneState();
+ } catch (error) {
+ console.warn(
+ "Failed to update admin pane after connecting to Nostr:",
+ error
+ );
+ }
+
// Grab the "Subscriptions" link by its id in the sidebar
this.subscriptionsLink = document.getElementById("subscriptionsLink");
From f832695a6afdf42c0ad660f6741bd2f979d65552 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 30 Sep 2025 08:59:18 -0400
Subject: [PATCH 19/20] Ensure notifications appear above modals
---
css/style.css | 21 +++++++++++++++++++++
index.html | 44 ++++++++++++++++++++++++++------------------
2 files changed, 47 insertions(+), 18 deletions(-)
diff --git a/css/style.css b/css/style.css
index ed102a28..27da150e 100644
--- a/css/style.css
+++ b/css/style.css
@@ -819,6 +819,27 @@ button:not(.icon-button):focus {
}
/* Notifications */
+#notificationPortal {
+ position: fixed;
+ top: calc(env(safe-area-inset-top, 0px) + 1.5rem);
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 100;
+ width: min(90vw, 32rem);
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ pointer-events: none;
+}
+
+#notificationPortal > * {
+ width: 100%;
+ margin-bottom: 0;
+ pointer-events: auto;
+ border-radius: 0.75rem;
+ box-shadow: 0 1.25rem 3rem rgba(15, 23, 42, 0.3);
+}
+
/* Base styling without a forced display */
#errorContainer,
#successContainer {
diff --git a/index.html b/index.html
index 71f4fe5c..2f8841ae 100644
--- a/index.html
+++ b/index.html
@@ -149,28 +149,36 @@