From 55774f588e77ba5db8705971edd1bedeb12b99d4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 29 Sep 2025 13:52:34 -0400 Subject: [PATCH 01/20] Add UI affordance for managing multiple Nostr profiles --- components/profile-modal.html | 99 +++++++++++++++++++++++++++++++---- 1 file changed, 89 insertions(+), 10 deletions(-) diff --git a/components/profile-modal.html b/components/profile-modal.html index 6b0910fc..1b2b67dc 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -107,21 +107,100 @@ role="tabpanel" aria-labelledby="profileNavAccount" > -
- Profile -

- Loading... +

+
+
+

Switch profiles

+

+ Save multiple Nostr profiles so you can swap identities without leaving BitVid. +

+
+ + Preview + +
+
+ +
+ +
+

+ Profiles you add here will stay on this device so you can quickly switch before posting or moderating.

From c285bd7e9c05144e9b5bf38525c1c563db5a595b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 29 Sep 2025 13:59:07 -0400 Subject: [PATCH 02/20] Stack profile switcher list vertically --- components/profile-modal.html | 61 +++++++++++++++++------------------ 1 file changed, 29 insertions(+), 32 deletions(-) diff --git a/components/profile-modal.html b/components/profile-modal.html index 1b2b67dc..0b326bd1 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -121,54 +121,53 @@ Preview
-
+
From 198fd97a807fe9ddcb83e181b68d2634e8f6374f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:09:48 -0400 Subject: [PATCH 03/20] Adjust profile switcher layout --- components/profile-modal.html | 51 ++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/components/profile-modal.html b/components/profile-modal.html index 0b326bd1..e8a34595 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -122,38 +122,46 @@
- + + + Active profile avatar + +
-

Manage your account details and upcoming preferences here.

From d15c574b13ed586efd1fb6a247740cda194ce0b0 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:15:10 -0400 Subject: [PATCH 04/20] Restyle profile switcher active row --- components/profile-modal.html | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/components/profile-modal.html b/components/profile-modal.html index e8a34595..1e7e9de2 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -123,14 +123,21 @@
- + Active profile + npub1... - Date: Mon, 29 Sep 2025 14:23:18 -0400 Subject: [PATCH 05/20] Refine active profile layout in modal --- components/profile-modal.html | 40 ++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/components/profile-modal.html b/components/profile-modal.html index 1e7e9de2..c5254ca4 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -123,21 +123,33 @@
+ + Active profile avatar + - Active profile - +
+ Active profile + +
- - Active profile avatar -
Date: Mon, 29 Sep 2025 14:27:45 -0400 Subject: [PATCH 06/20] Ensure full npub wraps in profile modal --- components/profile-modal.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/profile-modal.html b/components/profile-modal.html index c5254ca4..cc96a919 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -158,7 +158,7 @@ npub1... From 4266d0bd18fc2f5efd064a7dbd7e826cb4214045 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:39:23 -0400 Subject: [PATCH 07/20] Refine account cards in profile modal --- components/profile-modal.html | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/components/profile-modal.html b/components/profile-modal.html index cc96a919..1c6d12d5 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -107,7 +107,7 @@ role="tabpanel" aria-labelledby="profileNavAccount" > -
+

Switch profiles

@@ -123,7 +123,7 @@
-
+
Active profile @@ -173,7 +173,7 @@
-
- -
-

+ + + + + + 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 @@
- + - - - + + - - + + + + + +
From db408f4b06162b96c6e35481c5cd78ad0f8ac9d4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 09:05:03 -0400 Subject: [PATCH 20/20] Raise notification portal above modals --- css/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/css/style.css b/css/style.css index 27da150e..de146c6e 100644 --- a/css/style.css +++ b/css/style.css @@ -824,7 +824,7 @@ button:not(.icon-button):focus { top: calc(env(safe-area-inset-top, 0px) + 1.5rem); left: 50%; transform: translateX(-50%); - z-index: 100; + z-index: 400; /* Keep notifications above the highest modal overlays */ width: min(90vw, 32rem); display: flex; flex-direction: column;