Files
bitvid/js/app/uiCoordinator.js
T
thePR0M3TH3ANandClaude Opus 5 d141c316a9 [ui] Make "Share on Nostr" a working button
The share menu's "Share on Nostr" entry was never clickable, no matter who was
logged in -- it rendered as inert, dimmed text.

VideoModal initialized `shareNostrAuthState` to { isLoggedIn: false, hasSigner:
false } and nothing ever wrote to it. createVideoShareMenuPanel gates the item
on `isLoggedIn && hasSigner`, so it was always disabled, and the click
delegation in bindShareMenuActions() bails on `button.disabled` -- so the whole
share-to-Nostr flow behind it was unreachable.

The renderer and the publish path were both already correct; only the gate was
missing:

- VideoModal.setShareNostrAuthState() records the state and closes an already
  open share menu when it changes, so a menu opened before login does not keep
  showing the stale disabled item. It is a no-op when nothing changed, to avoid
  slamming the menu shut on every sync.
- syncAuthUiState() computes the gate from isUserLoggedIn() plus a
  signing-capable active signer, mirroring ShareNostrController.handleShare():
  a signer without signEvent would only produce an error toast on click.
- syncAuthUiState() was only ever called at bootstrap, so logging in
  mid-session left the gate closed. It now self-installs a bitvid:auth-changed
  listener on first run (bound once, re-entrancy safe) rather than adding a
  call site in js/app.js, which is at its file-size cap.

Verified in a browser that the rendered item is disabled when logged out or
signer-less and enabled + click-dispatching with both; the new tests fail 5/9
with the gate push removed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:16:18 -04:00

370 lines
12 KiB
JavaScript

// js/app/uiCoordinator.js
/**
* UI State and Chrome management.
*
* All module-level dependencies are injected from the Application
* composition root rather than imported at module scope.
*
* Methods use `this` which is bound to the Application instance.
*/
import { SHORT_TIMEOUT_MS, DEBOUNCE_DELAY_MS } from "../constants.js";
import { getActiveSigner } from "../nostrClientRegistry.js";
export function createUiCoordinator(deps) {
const {
devLogger,
} = deps;
return {
dispatchAuthLoadingState(detail = {}) {
if (typeof window === "undefined" || typeof window.dispatchEvent !== "function") {
return false;
}
try {
const payload = { ...(typeof detail === "object" && detail ? detail : {}) };
window.dispatchEvent(new CustomEvent("bitvid:auth-loading-state", { detail: payload }));
return true;
} catch (error) {
devLogger.warn("[Application] Failed to dispatch auth loading state:", error);
return false;
}
},
updateAuthLoadingState(partial = {}) {
const baseState =
this.authLoadingState && typeof this.authLoadingState === "object"
? this.authLoadingState
: { profile: "idle", lists: "idle", dms: "idle" };
const nextState = {
...baseState,
...(partial && typeof partial === "object" ? partial : {}),
};
this.authLoadingState = nextState;
const root = typeof document !== "undefined" ? document.documentElement : null;
if (root) {
root.dataset.authProfileLoading = nextState.profile || "idle";
root.dataset.authListsLoading = nextState.lists || "idle";
root.dataset.authDmsLoading = nextState.dms || "idle";
}
this.dispatchAuthLoadingState(nextState);
return nextState;
},
updateActiveProfileUI(pubkey, profile = {}) {
if (this.profileController) {
this.profileController.handleProfileUpdated({
pubkey,
profile,
});
return;
}
const picture = profile.picture || "assets/svg/default-profile.svg";
if (this.profileAvatar) {
this.profileAvatar.src = picture;
}
const channelLink = document.getElementById("profileChannelLink");
if (channelLink instanceof HTMLElement) {
const targetNpub = this.safeEncodeNpub(pubkey);
if (targetNpub) {
channelLink.href = `#view=channel-profile&npub=${targetNpub}`;
channelLink.dataset.targetNpub = targetNpub;
channelLink.classList.remove("hidden");
} else {
channelLink.removeAttribute("href");
if (channelLink.dataset) {
delete channelLink.dataset.targetNpub;
}
channelLink.classList.add("hidden");
}
}
},
applyAuthenticatedUiState() {
const loginButton = this.loginButton || document.getElementById("loginButton");
if (loginButton) {
loginButton.classList.add("hidden");
loginButton.setAttribute("hidden", "");
}
const logoutButton = this.logoutButton || document.getElementById("logoutButton");
if (logoutButton) {
logoutButton.classList.remove("hidden");
}
const userStatus = this.userStatus || document.getElementById("userStatus");
if (userStatus) {
userStatus.classList.add("hidden");
}
const uploadButton = this.uploadButton || document.getElementById("uploadButton");
if (uploadButton) {
uploadButton.classList.remove("hidden");
uploadButton.removeAttribute("hidden");
}
const profileButton = this.profileButton || document.getElementById("profileButton");
if (profileButton) {
profileButton.classList.remove("hidden");
profileButton.removeAttribute("hidden");
}
const subscriptionsLink = this.resolveSubscriptionsLink();
if (subscriptionsLink) {
subscriptionsLink.classList.remove("hidden");
}
const forYouLink = this.resolveForYouLink();
if (forYouLink) {
forYouLink.classList.remove("hidden");
}
const exploreLink = this.resolveExploreLink();
if (exploreLink) {
exploreLink.classList.remove("hidden");
}
// Light the admin star/ring whenever the authenticated UI is applied —
// this covers session restore, not just a fresh login.
if (typeof this.refreshAdminStar === "function") {
this.refreshAdminStar();
}
},
applyLoggedOutUiState() {
const loginButton = this.loginButton || document.getElementById("loginButton");
if (loginButton) {
loginButton.classList.remove("hidden");
loginButton.removeAttribute("hidden");
}
const logoutButton = this.logoutButton || document.getElementById("logoutButton");
if (logoutButton) {
logoutButton.classList.add("hidden");
}
const userStatus = this.userStatus || document.getElementById("userStatus");
if (userStatus) {
userStatus.classList.add("hidden");
}
const userPubKey = this.userPubKey || document.getElementById("userPubKey");
if (userPubKey) {
userPubKey.textContent = "";
}
const uploadButton = this.uploadButton || document.getElementById("uploadButton");
if (uploadButton) {
uploadButton.classList.add("hidden");
uploadButton.setAttribute("hidden", "");
}
const profileButton = this.profileButton || document.getElementById("profileButton");
if (profileButton) {
profileButton.classList.add("hidden");
profileButton.setAttribute("hidden", "");
}
const subscriptionsLink = this.resolveSubscriptionsLink();
if (subscriptionsLink) {
subscriptionsLink.classList.add("hidden");
}
const forYouLink = this.resolveForYouLink();
if (forYouLink) {
forYouLink.classList.add("hidden");
}
const exploreLink = this.resolveExploreLink();
if (exploreLink) {
exploreLink.classList.add("hidden");
}
},
syncAuthUiState() {
// Auth-dependent chrome has to follow login/logout/account switches, but
// syncAuthUiState() was only ever called at bootstrap — so everything it
// gates (notably the share menu's "Share on Nostr" item) kept its
// logged-out state for the entire session. Self-install a listener on the
// first run rather than adding a call site in js/app.js, which is at its
// file-size cap.
if (
!this.__authUiSyncBound &&
typeof window !== "undefined" &&
typeof window?.addEventListener === "function"
) {
this.__authUiSyncBound = true;
window.addEventListener("bitvid:auth-changed", () => {
try {
this.syncAuthUiState();
} catch (error) {
devLogger.warn("[Application] Auth UI resync failed:", error);
}
});
}
const loggedIn = this.isUserLoggedIn();
if (loggedIn) {
this.applyAuthenticatedUiState();
} else {
this.applyLoggedOutUiState();
}
// Gate the share menu's "Share on Nostr" item. Without this the modal's
// shareNostrAuthState stays at its initial `false` forever and the item
// renders permanently disabled — indistinguishable from plain text.
if (typeof this.videoModal?.setShareNostrAuthState === "function") {
let signer = null;
try {
signer = getActiveSigner();
} catch (error) {
devLogger.warn("[Application] Failed to read active signer:", error);
}
this.videoModal.setShareNostrAuthState({
isLoggedIn: loggedIn,
// Mirrors ShareNostrController.handleShare(): a signer that cannot
// sign is no signer at all, and enabling the item would only produce
// an error toast on click.
hasSigner: !!signer && typeof signer.signEvent === "function",
});
}
},
refreshChromeElements() {
const doc = typeof document !== "undefined" ? document : null;
const findElement = (id) => {
if (!doc || typeof doc.getElementById !== "function") {
return null;
}
const element = doc.getElementById(id);
if (!element || typeof element.addEventListener !== "function") {
return null;
}
return element;
};
this.loginButton = findElement("loginButton");
this.logoutButton = findElement("logoutButton");
this.uploadButton = findElement("uploadButton");
this.profileButton = findElement("profileButton");
this.closeLoginModalBtn = findElement("closeLoginModal");
return {
loginButton: this.loginButton,
logoutButton: this.logoutButton,
uploadButton: this.uploadButton,
profileButton: this.profileButton,
closeLoginModalButton: this.closeLoginModalBtn,
};
},
resolveSubscriptionsLink() {
if (
this.subscriptionsLink instanceof HTMLElement &&
this.subscriptionsLink.isConnected
) {
return this.subscriptionsLink;
}
const linkCandidate = document.getElementById("subscriptionsLink");
if (linkCandidate instanceof HTMLElement) {
this.subscriptionsLink = linkCandidate;
return this.subscriptionsLink;
}
this.subscriptionsLink = null;
return null;
},
resolveForYouLink() {
if (this.forYouLink instanceof HTMLElement && this.forYouLink.isConnected) {
return this.forYouLink;
}
const linkCandidate = document.getElementById("forYouLink");
if (linkCandidate instanceof HTMLElement) {
this.forYouLink = linkCandidate;
return this.forYouLink;
}
this.forYouLink = null;
return null;
},
resolveExploreLink() {
if (
this.exploreLink instanceof HTMLElement &&
this.exploreLink.isConnected
) {
return this.exploreLink;
}
const linkCandidate = document.getElementById("exploreLink");
if (linkCandidate instanceof HTMLElement) {
this.exploreLink = linkCandidate;
return this.exploreLink;
}
this.exploreLink = null;
return null;
},
hydrateSidebarNavigation() {
const chromeElements = this.refreshChromeElements();
this.resolveSubscriptionsLink();
this.resolveForYouLink();
this.resolveExploreLink();
if (this.appChromeController) {
if (typeof this.appChromeController.setElements === "function") {
this.appChromeController.setElements(chromeElements);
} else {
if (this.appChromeController.elements) {
Object.assign(this.appChromeController.elements, chromeElements);
}
this.appChromeController.initialize();
}
}
this.syncAuthUiState();
},
maybeShowExperimentalLoginWarning(provider) {
const normalizedProvider =
typeof provider === "string" ? provider.trim().toLowerCase() : "";
if (normalizedProvider !== "nsec" && normalizedProvider !== "nip46") {
return;
}
const now = Date.now();
if (
this.lastExperimentalWarningKey === normalizedProvider &&
typeof this.lastExperimentalWarningAt === "number" &&
now - this.lastExperimentalWarningAt < DEBOUNCE_DELAY_MS
) {
return;
}
this.lastExperimentalWarningKey = normalizedProvider;
this.lastExperimentalWarningAt = now;
const providerLabel =
normalizedProvider === "nsec"
? "Direct nsec or seed"
: "NIP-46 remote signer";
this.showStatus(
`${providerLabel} logins are still in development and may not work well yet. We recommend using a NIP-07 browser extension for the most reliable experience.`,
{ showSpinner: false, autoHideMs: SHORT_TIMEOUT_MS },
);
}
};
}