mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-04 11:52:25 +00:00
Finalize NIP-46 remote signer integration
This commit is contained in:
1 parent
6047ffc22e
commit
d3c3ac1ee7
7 files changed
+1979
-24
No files matched your search
@@ -202,6 +202,10 @@ export default class LoginModalController {
|
||||
services.authService && typeof services.authService === "object"
|
||||
? services.authService
|
||||
: null,
|
||||
nostrClient:
|
||||
services.nostrClient && typeof services.nostrClient === "object"
|
||||
? services.nostrClient
|
||||
: null,
|
||||
};
|
||||
|
||||
this.callbacks = {
|
||||
@@ -233,11 +237,18 @@ export default class LoginModalController {
|
||||
this.modalElement?.querySelector(".modal-body") || null;
|
||||
this.nsecTemplate =
|
||||
this.modalElement?.querySelector("template[data-login-nsec-dialog]") || null;
|
||||
this.nip46Template =
|
||||
this.modalElement?.querySelector("template[data-login-nip46-dialog]") || null;
|
||||
this.template = null;
|
||||
this.providerEntries = new Map();
|
||||
this.slowTimers = new Map();
|
||||
this.boundClickHandler = (event) => this.handleContainerClick(event);
|
||||
this.activeNsecForm = null;
|
||||
this.activeNip46Form = null;
|
||||
this.remoteSignerUnsubscribe = null;
|
||||
this.lastRemoteSignerStatus = null;
|
||||
|
||||
this.initializeRemoteSignerStatus();
|
||||
this.initialized = false;
|
||||
|
||||
this.initialize();
|
||||
@@ -260,11 +271,20 @@ export default class LoginModalController {
|
||||
|
||||
this.renderProviders();
|
||||
|
||||
if (typeof this.lastRemoteSignerStatus !== "undefined") {
|
||||
this.applyRemoteSignerStatus(this.lastRemoteSignerStatus);
|
||||
}
|
||||
|
||||
this.providerContainer.addEventListener("click", this.boundClickHandler);
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
getNostrClient() {
|
||||
const direct = this.services?.nostrClient;
|
||||
if (direct && typeof direct === "object") {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const service = this.services?.authService;
|
||||
if (!service || typeof service !== "object") {
|
||||
return null;
|
||||
@@ -288,6 +308,143 @@ export default class LoginModalController {
|
||||
return null;
|
||||
}
|
||||
|
||||
getStoredNip46Metadata() {
|
||||
const nostrClient = this.getNostrClient();
|
||||
if (!nostrClient || typeof nostrClient.getStoredNip46Metadata !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return nostrClient.getStoredNip46Metadata();
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[LoginModalController] Failed to inspect stored NIP-46 session:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
initializeRemoteSignerStatus() {
|
||||
const nostrClient = this.getNostrClient();
|
||||
if (!nostrClient) {
|
||||
this.applyRemoteSignerStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof nostrClient.getRemoteSignerStatus === "function") {
|
||||
try {
|
||||
const status = nostrClient.getRemoteSignerStatus();
|
||||
this.applyRemoteSignerStatus(status);
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[LoginModalController] Failed to read initial remote signer status:",
|
||||
error,
|
||||
);
|
||||
this.applyRemoteSignerStatus(null);
|
||||
}
|
||||
} else {
|
||||
this.applyRemoteSignerStatus(null);
|
||||
}
|
||||
|
||||
if (typeof nostrClient.onRemoteSignerChange === "function") {
|
||||
try {
|
||||
this.remoteSignerUnsubscribe = nostrClient.onRemoteSignerChange((status) => {
|
||||
this.applyRemoteSignerStatus(status);
|
||||
});
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[LoginModalController] Failed to subscribe to remote signer updates:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
formatRemoteSignerLabel(status) {
|
||||
if (!status || typeof status !== "object") {
|
||||
return "remote signer";
|
||||
}
|
||||
|
||||
const { label, remoteNpub, remotePubkey } = status;
|
||||
if (typeof label === "string" && label.trim()) {
|
||||
return label.trim();
|
||||
}
|
||||
|
||||
if (typeof remoteNpub === "string" && remoteNpub.trim()) {
|
||||
return remoteNpub.trim();
|
||||
}
|
||||
|
||||
if (typeof remotePubkey === "string" && remotePubkey.trim()) {
|
||||
const trimmed = remotePubkey.trim();
|
||||
if (trimmed.length <= 16) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed.slice(0, 8)}…${trimmed.slice(-6)}`;
|
||||
}
|
||||
|
||||
return "remote signer";
|
||||
}
|
||||
|
||||
applyRemoteSignerStatus(status) {
|
||||
this.lastRemoteSignerStatus = status || null;
|
||||
const storedMetadata = this.getStoredNip46Metadata();
|
||||
const hasStoredSession = storedMetadata?.hasSession === true;
|
||||
|
||||
const normalizedState =
|
||||
typeof status?.state === "string" && status.state.trim()
|
||||
? status.state.trim()
|
||||
: hasStoredSession
|
||||
? "stored"
|
||||
: "idle";
|
||||
|
||||
let message = "";
|
||||
let showDisconnect = false;
|
||||
|
||||
switch (normalizedState) {
|
||||
case "connected": {
|
||||
if (typeof status?.userNpub === "string" && status.userNpub.trim()) {
|
||||
message = `Connected as ${status.userNpub.trim()}`;
|
||||
} else {
|
||||
const label = this.formatRemoteSignerLabel(status);
|
||||
message = `Connected to ${label}`;
|
||||
}
|
||||
showDisconnect = true;
|
||||
break;
|
||||
}
|
||||
case "connecting": {
|
||||
message = "Connecting to remote signer…";
|
||||
break;
|
||||
}
|
||||
case "error": {
|
||||
message =
|
||||
typeof status?.message === "string" && status.message.trim()
|
||||
? status.message.trim()
|
||||
: "Remote signer unavailable.";
|
||||
break;
|
||||
}
|
||||
case "stored": {
|
||||
if (hasStoredSession) {
|
||||
const label = this.formatRemoteSignerLabel(status || storedMetadata);
|
||||
message = `Saved remote signer (${label}) ready to connect.`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
message = "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.setProviderStatus("nip46", {
|
||||
message,
|
||||
reset: !message,
|
||||
showDisconnect,
|
||||
statusState: normalizedState,
|
||||
});
|
||||
}
|
||||
|
||||
async promptForNsecOptions() {
|
||||
if (!this.modalBody || !this.nsecTemplate) {
|
||||
userLogger.warn("[LoginModalController] Direct key login is unavailable in this build.");
|
||||
@@ -552,6 +709,160 @@ export default class LoginModalController {
|
||||
});
|
||||
}
|
||||
|
||||
async promptForNip46Options() {
|
||||
if (!this.modalBody || !this.nip46Template) {
|
||||
userLogger.warn(
|
||||
"[LoginModalController] Remote signer login is unavailable in this build.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.activeNip46Form) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fragment = this.nip46Template.content
|
||||
? this.nip46Template.content.cloneNode(true)
|
||||
: null;
|
||||
if (!fragment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const form = fragment.querySelector("[data-nip46-form]");
|
||||
if (!(form instanceof HTMLFormElement)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const connectInput = form.querySelector("[data-nip46-connect-uri]");
|
||||
const rememberCheckbox = form.querySelector("[data-nip46-remember]");
|
||||
const reuseButton = form.querySelector("[data-nip46-reuse]");
|
||||
const cancelButton = form.querySelector("[data-nip46-cancel]");
|
||||
const errorNode = form.querySelector("[data-nip46-error]");
|
||||
|
||||
const storedMetadata = this.getStoredNip46Metadata();
|
||||
const hasStoredSession = storedMetadata?.hasSession === true;
|
||||
|
||||
if (reuseButton instanceof HTMLButtonElement) {
|
||||
reuseButton.classList.toggle("hidden", !hasStoredSession);
|
||||
}
|
||||
|
||||
const elementsToHide = [];
|
||||
for (const child of Array.from(this.modalBody.children)) {
|
||||
if (child instanceof HTMLElement && child !== form) {
|
||||
if (child.tagName === "TEMPLATE") {
|
||||
continue;
|
||||
}
|
||||
const wasHidden = child.classList.contains("hidden");
|
||||
if (!wasHidden) {
|
||||
child.classList.add("hidden");
|
||||
}
|
||||
elementsToHide.push({ element: child, wasHidden });
|
||||
}
|
||||
}
|
||||
|
||||
this.modalBody.appendChild(form);
|
||||
this.activeNip46Form = form;
|
||||
|
||||
const setError = (message) => {
|
||||
if (!(errorNode instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const normalized = typeof message === "string" ? message.trim() : "";
|
||||
if (normalized) {
|
||||
errorNode.textContent = normalized;
|
||||
errorNode.classList.remove("hidden");
|
||||
} else {
|
||||
errorNode.textContent = "";
|
||||
errorNode.classList.add("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
setError("");
|
||||
if (connectInput instanceof HTMLTextAreaElement) {
|
||||
connectInput.focus();
|
||||
}
|
||||
|
||||
let submitHandler = null;
|
||||
let cancelHandler = null;
|
||||
let reuseHandler = null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (submitHandler) {
|
||||
form.removeEventListener("submit", submitHandler);
|
||||
}
|
||||
if (cancelHandler && cancelButton instanceof HTMLButtonElement) {
|
||||
cancelButton.removeEventListener("click", cancelHandler);
|
||||
}
|
||||
if (reuseHandler && reuseButton instanceof HTMLButtonElement) {
|
||||
reuseButton.removeEventListener("click", reuseHandler);
|
||||
}
|
||||
|
||||
if (form.parentElement) {
|
||||
form.parentElement.removeChild(form);
|
||||
}
|
||||
for (const entry of elementsToHide) {
|
||||
if (!(entry?.element instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
if (!entry.wasHidden) {
|
||||
entry.element.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
this.activeNip46Form = null;
|
||||
};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (detail) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(detail);
|
||||
};
|
||||
|
||||
submitHandler = (event) => {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!(connectInput instanceof HTMLTextAreaElement)) {
|
||||
setError("Enter the remote signer connection link to continue.");
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = connectInput.value.trim();
|
||||
if (!raw) {
|
||||
setError("Paste the nostrconnect:// or bunker:// link from your signer.");
|
||||
connectInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const remember =
|
||||
rememberCheckbox instanceof HTMLInputElement ? rememberCheckbox.checked : true;
|
||||
finish({ connectionString: raw, remember: remember !== false });
|
||||
};
|
||||
|
||||
form.addEventListener("submit", submitHandler);
|
||||
|
||||
if (cancelButton instanceof HTMLButtonElement) {
|
||||
cancelHandler = (event) => {
|
||||
event.preventDefault();
|
||||
finish(null);
|
||||
};
|
||||
cancelButton.addEventListener("click", cancelHandler);
|
||||
}
|
||||
|
||||
if (reuseButton instanceof HTMLButtonElement) {
|
||||
reuseHandler = (event) => {
|
||||
event.preventDefault();
|
||||
finish({ reuseStored: true });
|
||||
};
|
||||
reuseButton.addEventListener("click", reuseHandler);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resolveTemplate() {
|
||||
if (!this.modalElement) {
|
||||
return null;
|
||||
@@ -660,6 +971,17 @@ export default class LoginModalController {
|
||||
statusNode.classList.add("hidden");
|
||||
}
|
||||
|
||||
const statusRow = button.querySelector("[data-provider-status-row]");
|
||||
if (statusRow instanceof HTMLElement) {
|
||||
statusRow.classList.add("hidden");
|
||||
}
|
||||
|
||||
const disconnectButton = button.querySelector("[data-provider-disconnect]");
|
||||
if (disconnectButton instanceof HTMLButtonElement) {
|
||||
disconnectButton.dataset.providerId = provider.id;
|
||||
disconnectButton.classList.add("hidden");
|
||||
}
|
||||
|
||||
const capabilitiesNode = button.querySelector(
|
||||
"[data-provider-capabilities]",
|
||||
);
|
||||
@@ -702,6 +1024,9 @@ export default class LoginModalController {
|
||||
button,
|
||||
descriptionNode: descriptionNode instanceof HTMLElement ? descriptionNode : null,
|
||||
statusNode: statusNode instanceof HTMLElement ? statusNode : null,
|
||||
statusRow: statusRow instanceof HTMLElement ? statusRow : null,
|
||||
disconnectButton:
|
||||
disconnectButton instanceof HTMLButtonElement ? disconnectButton : null,
|
||||
defaultDescription: provider.description,
|
||||
},
|
||||
};
|
||||
@@ -713,6 +1038,17 @@ export default class LoginModalController {
|
||||
return;
|
||||
}
|
||||
|
||||
const disconnectButton = target.closest("[data-provider-disconnect]");
|
||||
if (disconnectButton instanceof HTMLButtonElement) {
|
||||
const providerId = disconnectButton.dataset.providerId;
|
||||
if (providerId) {
|
||||
this.handleProviderDisconnect(providerId);
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
const button = target.closest("[data-provider-button]");
|
||||
if (!(button instanceof HTMLButtonElement)) {
|
||||
return;
|
||||
@@ -726,10 +1062,60 @@ export default class LoginModalController {
|
||||
this.handleProviderSelection(providerId);
|
||||
}
|
||||
|
||||
async handleProviderDisconnect(providerId) {
|
||||
if (providerId !== "nip46") {
|
||||
return;
|
||||
}
|
||||
|
||||
const nostrClient = this.getNostrClient();
|
||||
if (!nostrClient || typeof nostrClient.disconnectRemoteSigner !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await nostrClient.disconnectRemoteSigner({ keepStored: false });
|
||||
} catch (error) {
|
||||
devLogger.warn("[LoginModalController] Failed to disconnect remote signer:", error);
|
||||
}
|
||||
}
|
||||
|
||||
getProviderEntry(providerId) {
|
||||
return this.providerEntries.get(providerId) || null;
|
||||
}
|
||||
|
||||
setProviderStatus(providerId, options = {}) {
|
||||
const entry = this.getProviderEntry(providerId);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
message = "",
|
||||
reset = false,
|
||||
showDisconnect = false,
|
||||
disableDisconnect = false,
|
||||
statusState = null,
|
||||
} = options || {};
|
||||
|
||||
if (statusState) {
|
||||
entry.statusState = statusState;
|
||||
} else {
|
||||
delete entry.statusState;
|
||||
}
|
||||
|
||||
this.updateStatusMessage(entry, message, reset);
|
||||
|
||||
const shouldShowRow = !reset && typeof message === "string" && message.trim();
|
||||
if (entry.statusRow instanceof HTMLElement) {
|
||||
entry.statusRow.classList.toggle("hidden", !shouldShowRow);
|
||||
}
|
||||
|
||||
if (entry.disconnectButton instanceof HTMLButtonElement) {
|
||||
entry.disconnectButton.classList.toggle("hidden", !showDisconnect);
|
||||
entry.disconnectButton.disabled = !!disableDisconnect;
|
||||
}
|
||||
}
|
||||
|
||||
setLoadingState(providerId, isLoading) {
|
||||
const entry = this.getProviderEntry(providerId);
|
||||
if (!entry) {
|
||||
@@ -741,10 +1127,14 @@ export default class LoginModalController {
|
||||
entry.button.dataset.state = "loading";
|
||||
entry.button.setAttribute("aria-busy", "true");
|
||||
|
||||
this.updateStatusMessage(
|
||||
entry,
|
||||
entry.provider.messages.loading || "Connecting…",
|
||||
);
|
||||
const loadingMessage = entry.provider.messages.loading || "Connecting…";
|
||||
this.setProviderStatus(providerId, {
|
||||
message: loadingMessage,
|
||||
reset: false,
|
||||
showDisconnect: false,
|
||||
disableDisconnect: true,
|
||||
statusState: "loading",
|
||||
});
|
||||
this.startSlowTimer(providerId, entry);
|
||||
} else {
|
||||
const shouldRemainDisabled = entry.provider.disabled === true;
|
||||
@@ -758,7 +1148,7 @@ export default class LoginModalController {
|
||||
delete entry.button.dataset.state;
|
||||
entry.button.removeAttribute("aria-busy");
|
||||
this.stopSlowTimer(providerId);
|
||||
this.updateStatusMessage(entry, "", true);
|
||||
this.setProviderStatus(providerId, { message: "", reset: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -812,7 +1202,13 @@ export default class LoginModalController {
|
||||
|
||||
const slowMessage =
|
||||
entry.provider.messages.slow || "Waiting for the provider…";
|
||||
this.updateStatusMessage(entry, slowMessage);
|
||||
this.setProviderStatus(providerId, {
|
||||
message: slowMessage,
|
||||
reset: false,
|
||||
showDisconnect: false,
|
||||
statusState: "waiting",
|
||||
disableDisconnect: true,
|
||||
});
|
||||
}, timeout);
|
||||
|
||||
this.slowTimers.set(providerId, timerId);
|
||||
@@ -877,6 +1273,26 @@ export default class LoginModalController {
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (entry.provider.id === "nip46") {
|
||||
try {
|
||||
const nip46Options = await this.promptForNip46Options();
|
||||
if (!nip46Options) {
|
||||
devLogger.log(
|
||||
"[LoginModalController] Remote signer login cancelled by the user.",
|
||||
);
|
||||
if (entry.button instanceof HTMLButtonElement) {
|
||||
entry.button.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
providerOptions = nip46Options;
|
||||
} catch (promptError) {
|
||||
devLogger.error(
|
||||
"[LoginModalController] Failed to collect remote signer connection:",
|
||||
promptError,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.setLoadingState(providerId, true);
|
||||
@@ -942,6 +1358,18 @@ export default class LoginModalController {
|
||||
this.stopSlowTimer(providerId);
|
||||
}
|
||||
|
||||
if (typeof this.remoteSignerUnsubscribe === "function") {
|
||||
try {
|
||||
this.remoteSignerUnsubscribe();
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[LoginModalController] Remote signer unsubscribe handler threw:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.remoteSignerUnsubscribe = null;
|
||||
|
||||
this.providerEntries.clear();
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user